ElIauk

Redis 缓存一致性:先删缓存还是先更新库

先删缓存还是先更新库 这是缓存面试题里出现频率最高的一道,但真正落到代码里,答案往往不是二选一。缓存和数据库是两个系统,没有跨系统的原子写,任何“先 A 后 B”的顺序都存在时间窗口。讨论顺序之前,先接受一个前提:缓存只保证最终一致,强一致要靠锁或分布式事务,代价通常不值得。 先看两种顺序各自的失败

评论
Redis 缓存一致性:先删缓存还是先更新库

先删缓存还是先更新库

这是缓存面试题里出现频率最高的一道,但真正落到代码里,答案往往不是二选一。缓存和数据库是两个系统,没有跨系统的原子写,任何“先 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 订阅。顺序只能缩小不一致窗口,真正的兜底永远是“缓存会过期,数据库才是事实来源”。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者