先删缓存还是先更新库
这是缓存面试题里出现频率最高的一道,但真正落到代码里,答案往往不是二选一。缓存和数据库是两个系统,没有跨系统的原子写,任何“先 A 后 B”的顺序都存在时间窗口。讨论顺序之前,先接受一个前提:缓存只保证最终一致,强一致要靠锁或分布式事务,代价通常不值得。
先看两种顺序各自的失败姿势。先删缓存、再更新数据库:删除后、事务提交前,读请求进来,缓存未命中,从数据库读到旧值并回填,等更新提交,缓存里已经是旧数据了。先更新数据库、再删缓存:更新提交后、删除前,读请求可能读到旧缓存;更隐蔽的是“读请求先查库拿到旧值,更新提交并删缓存后,读请求再把旧值写回缓存”。两种都会脏,但后者的窗口更短,出问题的条件更苛刻,所以工程上普遍采用“先更新库,提交后删缓存”。
为什么“更新缓存”通常不如“删缓存”
更新缓存看起来更直接,实际上要处理并发写的顺序问题:两个更新请求一前一后,后到的可能先写缓存,先到的反而覆盖回去,缓存里留下旧值。删缓存是幂等的,谁先谁后最终都会触发一次回源,逻辑简单得多。
另一个原因是缓存里的数据形态经常和数据库不一致。数据库存的是明细,缓存可能是聚合结果、脱敏后的 DTO、甚至多个表拼出来的视图。更新时很难准确算出新值,删除让下一次读自己去重建,反而可靠。
代码:提交后删除,再做一次延迟删除
关键点不是“删两次”这个动作,而是“删除必须发生在事务提交之后”。在事务方法里直接调用 redis.delete,事务一旦回滚,缓存已经被删了,虽然下次读会重建,但这次删除没有意义;更糟的是删除后、提交前有读请求回填旧值。
@Service
public class ProductCacheService {
private static final Duration TTL = Duration.ofMinutes(30);
private final StringRedisTemplate redis;
private final ProductMapper productMapper;
public ProductCacheService(StringRedisTemplate redis, ProductMapper productMapper) {
this.redis = redis;
this.productMapper = productMapper;
}
public Product get(Long id) {
String key = "product:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) {
return JsonUtils.parse(cached, Product.class);
}
Product product = productMapper.selectById(id);
if (product != null) {
redis.opsForValue().set(key, JsonUtils.toJson(product), TTL);
}
return product;
}
public void evict(Long id) {
redis.delete("product:" + id);
}
}
@Service
public class ProductService {
private final ProductMapper productMapper;
private final ProductCacheService cacheService;
private final TaskScheduler taskScheduler;
public ProductService(ProductMapper productMapper,
ProductCacheService cacheService,
TaskScheduler taskScheduler) {
this.productMapper = productMapper;
this.cacheService = cacheService;
this.taskScheduler = taskScheduler;
}
@Transactional(rollbackFor = Exception.class)
public void update(Product product) {
productMapper.updateById(product);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
cacheService.evict(product.getId());
taskScheduler.schedule(
() -> cacheService.evict(product.getId()),
Instant.now().plusMillis(500));
}
});
}
}
延迟时间一般取“一次读请求回源 + 写缓存”的耗时,几百毫秒到一秒都有人用。它只能降低概率,不能消灭不一致,所以缓存一定要有 TTL 兜底。
一致性还能怎么加固
如果业务对脏读很敏感,可以考虑订阅 MySQL binlog,用 Canal 之类的工具解析变更,异步删缓存。好处是缓存删除和业务代码解耦,坏处是链路更长,需要处理消费失败和重复消费。
读多写少的场景还可以用“版本号”做读时校验:缓存里同时存数据版本,回源时比较数据库版本,发现缓存版本落后就覆盖。这个方案能挡住“旧值回填”,但每次读都要多一次版本查询,适合热点 key 而不是全量数据。
排查缓存和数据库不一致时,先确认三件事:删除动作是不是在事务提交后执行的、Redis 是不是读写分离导致读到从库旧值、以及缓存 key 有没有被其他写路径绕过。命令层面可以先看 TTL 和内容:
redis-cli TTL product:1001
redis-cli GET product:1001
redis-cli --scan --pattern 'product:*' | head -n 20
常见坑
1. 在事务方法里直接 @CacheEvict
@CacheEvict 默认在方法返回后执行,但此时事务可能还没提交。AOP 顺序稍有变化,就会变成“删缓存 → 提交事务 → 读请求回填旧值”。要保证提交后删除,用 TransactionSynchronization#afterCommit 或 @TransactionalEventListener(AFTER_COMMIT)。
2. 以为延迟双删能解决所有问题
延迟双删只是把第二次删除推迟到大多数读请求完成之后,遇到长事务、主从延迟、缓存重建慢,仍然会脏。把它当成降低概率的手段,不要当成强一致方案。
3. 删除失败没有重试
Redis 网络抖动时 delete 返回 false 或被吞掉,缓存会一直脏到 TTL 过期。删除操作要记录日志,失败进入重试队列;对一致性要求高的 key,TTL 不要设得太长。
4. 读写分离放大不一致
写主库、读从库,主从延迟期间读请求拿到旧值并回填缓存,删了也会被重新写脏。要么让缓存回源走主库,要么接受延迟并用短 TTL,别在主从延迟上再加一层缓存延迟。
小结
工程上的默认答案是:先更新数据库,事务提交后删除缓存,配合 TTL 和必要的重试;热点场景再加延迟删除或 binlog 订阅。顺序只能缩小不一致窗口,真正的兜底永远是“缓存会过期,数据库才是事实来源”。
评论