本文目录导读:

这是一个非常好的问题,在实际的分布式系统或高并发应用中,缓存(如Redis)与数据库(如MySQL)之间的数据一致性是核心难点,所谓的“统一缓存同步流程”,是指设计一套标准化的、可复用的模式,来确保缓存中的数据与数据库中的数据最终一致(或强一致),同时避免常见的坑(如缓存穿透、击穿、雪崩)。
下面从策略选择、标准化流程、代码实现和注意事项四个维度来拆解。
核心原则:选对策略
没有“万能”的统一流程,因为业务对一致性的要求不同,通常推荐以下两种主流、统一且工程化程度高的方案:
| 策略 | 核心思想 | 一致性等级 | 适用场景 | 缺点 |
|---|---|---|---|---|
| Cache Aside Pattern (旁路缓存) | 读的时候先读缓存,miss了查DB再写缓存;写的时候先更新DB,再删除缓存。 | 最终一致性 | 绝大多数业务场景(读多写少、允许短时间不一致) | 有并发写导致脏数据的极端情况 |
| 延迟双删 (优化版) | 在Cache Aside的基础上,更新DB后先删一次缓存,延迟一小段时间(如几百毫秒) 再删一次。 | 高最终一致性 | 对一致性要求较高,但不能接受分布式事务的场景 | 需要引入MQ或定时任务,实现稍复杂 |
关键结论:“先更新DB,后删除缓存” 是目前公认最简洁且有效的统一同步基石。不推荐“先删缓存,后更新DB”,因为极易导致并发场景下的脏数据。
统一的标准流程(以“旁路缓存 + 延迟双删”为例)
以下是一个高度抽象、可复用的公共流程,封装在服务层(Service Layer)中。
读请求(Query Flow)
graph TD
A[客户端请求] --> B{缓存中是否存在?};
B -- 存在 --> C[直接返回缓存数据];
B -- 不存在 --> D[加分布式锁,防止缓存击穿];
D --> E[再次检查缓存(Double Check)];
E -- 存在 --> C;
E -- 不存在 --> F[从数据库查询];
F --> G{数据库是否存在?};
G -- 是 --> H[将数据写入缓存,设置过期时间];
H --> I[释放锁,返回数据];
G -- 否 --> J[在缓存写入空值/布隆过滤器处理,防止穿透];
J --> I;
写请求(Write Flow)
graph TD
A[客户端写请求] --> B[操作数据库(新增/更新/删除)];
B --> C[事务提交成功];
C --> D[执行异步任务:第1次删除缓存(Key)];
D --> E[延迟N毫秒(如500ms)];
E --> F[执行异步任务:第2次删除缓存(Key)];
F --> G[结束];
Java 代码实现(统一封装)
为了做到“统一”,我们需要把流程抽象出来,通过一个统一的类(如 CacheSyncService)来管理。
核心接口定义
@Component
public class CacheSyncService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RabbitTemplate rabbitTemplate; // 或使用线程池
/**
* 统一的写操作同步方法(延迟双删)
* @param cacheKey 需要失效的缓存Key
* @param databaseOp 数据库操作函数(返回值影响是否执行后续操作)
* @param <T>
* @return
*/
public <T> T executeWriteWithCacheSync(String cacheKey, Supplier<T> databaseOp) {
// 1. 执行数据库操作
T result = databaseOp.get();
// 2. 异步删除缓存(第1次)
asyncDeleteCache(cacheKey);
// 3. 延迟删除缓存(第2次),为了处理并发读写的脏数据
scheduleDelayedDelete(cacheKey);
return result;
}
/**
* 统一的读操作同步方法
* @param cacheKey 缓存Key
* @param type 返回类型
* @param databaseLoader 数据库加载函数
* @param <T>
* @return
*/
public <T> T executeReadWithCacheSync(String cacheKey, Class<T> type, Supplier<T> databaseLoader) {
// 1. 从缓存查询
T cacheData = (T) redisTemplate.opsForValue().get(cacheKey);
if (cacheData != null) {
return cacheData;
}
// 2. 缓存Miss,加分布式锁(防击穿)
String lockKey = "lock:" + cacheKey;
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(2, 10, TimeUnit.SECONDS)) {
try {
// 3. 双检:防止当前线程加锁期间,其他线程已经写入缓存
cacheData = (T) redisTemplate.opsForValue().get(cacheKey);
if (cacheData != null) {
return cacheData;
}
// 4. 查数据库
T dbData = databaseLoader.get();
// 5. 处理缓存穿透
if (dbData == null) {
// 写入空值(过期时间短,如30秒)
redisTemplate.opsForValue().set(cacheKey, new NullValue(), 30, TimeUnit.SECONDS);
return null;
}
// 6. 写入缓存(设置随机过期时间,防雪崩)
long expireTime = getRandomExpireTime();
redisTemplate.opsForValue().set(cacheKey, dbData, expireTime, TimeUnit.SECONDS);
return dbData;
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 如果拿不到锁,走降级策略(例如再查一次DB,或者等待重试)
return databaseLoader.get();
}
// =============== 辅助方法 ===============
private void asyncDeleteCache(String key) {
// 方案1:线程池
// threadPoolTaskExecutor.execute(() -> redisTemplate.delete(key));
// 方案2:MQ(推荐,保证最终一致性)
rabbitTemplate.convertAndSend("cache.delete.exchange", "cache.delete.routing", key);
}
private void scheduleDelayedDelete(String key) {
// 延迟500ms后执行
CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS)
.execute(() -> {
// 第2次删除
redisTemplate.delete(key);
});
}
private long getRandomExpireTime() {
// 基础过期时间 + 随机值,防止大量缓存同时过期
return 60 * 60 + (long)(Math.random() * 600); // 1小时 + 随机0-10分钟
}
}
在业务层调用
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private CacheSyncService cacheSyncService;
private static final String USER_CACHE_PREFIX = "user:";
@Override
public User getUserById(Long id) {
String cacheKey = USER_CACHE_PREFIX + id;
return cacheSyncService.executeReadWithCacheSync(
cacheKey,
User.class,
() -> userMapper.selectById(id) // 数据库加载
);
}
@Override
@Transactional
public User updateUser(User user) {
// 使用统一封装,自动处理缓存
return cacheSyncService.executeWriteWithCacheSync(
USER_CACHE_PREFIX + user.getId(),
() -> {
userMapper.updateById(user);
return user;
}
);
}
}
统一流程中的关键注意事项
为了保证流程在各种极端情况下仍能工作,需要统一处理以下三个“顽疾”:
-
缓存穿透:
- 方案:在统一读流程中,若数据库返回空,写入一个短期的空值对象(Null Object),防止恶意Key压垮DB。
-
缓存击穿:
- 方案:在统一读流程中,使用分布式锁(如Redisson),注意要配合Double Check,避免加锁后重复查DB。
-
缓存雪崩:
- 方案:在统一写流程中,设置随机过期时间,不要在代码中写死固定的过期秒数。
优化与进阶(走向“绝对统一”)
如果想让整个流程更加自动化、无感,可以引入监听机制:
-
基于Canal(阿里开源):监听MySQL的
binlog变化。- 当数据库某张表发生变更时,Canal会推送该行数据。
- 统一流程:写操作只写DB,无需写代码去操作Redis,Canal监听到binlog后,自动删除或更新对应缓存。
- 优点:业务代码零侵入,彻底解耦,这是目前大型互联网公司最常用的方案。
-
基于注解的AOP:
- 定义一个
@CacheSync注解,包含key、expire等属性。 - 通过AOP切面拦截所有带此注解的方法,自动执行“查缓存 -> 没查到 -> 锁 -> 查DB -> 写缓存”的流程,这进一步统一了代码风格。
- 定义一个
Java缓存同步流程的统一,核心在于“固化最佳实践”。
- 最简方案(适合大部分项目):Cache Aside + 统一封装Service。
- 高一致方案:延迟双删 + MQ异步。
- 无侵入方案:Canal + Binlog监听。
无论选择哪种方案,关键在于:将缓存操作的细节(加锁、防穿透、延迟删、key拼装)都收归到一个统一的服务或切面中,业务代码只关心数据库操作本身。 这样就实现了真正的“统一流程”。