Skip to content

Redis 中如何保证缓存与数据库的数据一致性? ​

  • 标签:后端、Redis

🏛 业务架构师 ​

缓存一致性先讲结论:没有强一致方案,只有"不一致窗口"长短和代价的权衡。业务架构的第一步是和业务方确认容忍度——商品详情晚 5 秒可接受,库存与余额不行。

主流方案按一致性递增:

  • Cache Aside(旁路缓存,默认选择):读— miss— 回源回填;写—先更新 DB 再删缓存。选"删"不选"改":改缓存有并发覆写问题,删让下次读自然回源。理论上存在"读线程回填旧值"的极小概率竞态,业务上可接受;
  • 延迟双删:写前删一次、写库后延迟几百毫秒再删一次,缓解"读旧值回填"。延迟时间拍脑袋是它的软肋,适合读并发高但精度要求中等的场景;
  • 订阅 binlog(Canal)异步失效:业务只写库,CDC 感知变更删缓存——业务代码零侵入、天然兜住所有写路径(包括定时任务、DBA 手工改数),是我做平台级治理的首选,代价是引入 CDC 链路的运维。

三条实操铁律:

  1. 缓存要设 TTL:任何方案都有漏网时刻,TTL 是最终自愈的保险丝;
  2. 删除失败要补偿:删缓存失败进重试队列或依赖 binlog 二次对账,不能静默吞掉;
  3. 强一致需求别走缓存:直接 DB 当前读。用"接受秒级不一致"换 90% 的读流量,是缓存架构的商业本质——想两头全要,成本会指数上升。

🏗 系统架构师 ​

缓存一致性没有完美方案,只有按业务选"可接受的窗口"。我按方案演进讲:

  • 先更新 DB 再删缓存(Cache Aside,推荐):读时回填,写时删缓存。为什么删而不是更新:更新缓存在并发写下容易乱序覆盖;删除天然幂等。缺点:删与下一次回填之间有窗口;极端时序(读旧值→写库→回填旧值)概率极低但存在;
  • 延迟双删:写库前删一次,写库后延迟几百毫秒再删一次,清理主从延迟窗口内的脏读回填。延迟时间拍脑袋是它的硬伤;
  • 订阅 binlog 异步删(Canal/订阅机制):删除动作由数据变更事件驱动,与业务代码解耦、可靠(有位点可重放),是我做大型系统的首选;
  • 强一致场景:加分布式锁串行化读写(性能差),或干脆别用缓存——缓存适用前提就是接受最终一致。

我的标准组合:Cache Aside + binlog 订阅删 + 过期时间兜底(key 设 TTL,即便删除失败也能自愈)+ 关键业务读时校验版本。目标是把不一致窗口压到毫秒级并保证最终一致,而不是追求理论上的强一致。

💻 全栈开发专家 ​

先说结论:无法强一致,只能最终一致。我标准方案是 Cache Aside + 先更新 DB 再删缓存:

java
@Transactional
public void updateProduct(Product p) {
    productMapper.update(p);       // 1. 更新数据库
    redis.delete(key(p.getId()));  // 2. 删除缓存(不是更新缓存)
}
public Product get(Long id) {
    Product p = redis.get(key(id));
    if (p == null) {
        p = productMapper.selectById(id);
        if (p != null) redis.setex(key(id), 300, p);  // 过期时间兜底
        // 极端并发下"旧值回填"仍有小概率,靠 TTL 兜底
    }
    return p;
}

为什么"删缓存"而不是"更新缓存":并发写时两次更新缓存的先后序可能颠倒,缓存留下旧值;删除让下次读触发回源,天然收敛。为什么先 DB 后删:反过来会出现"删缓存后、DB 提交前"的读把旧值又填回去。

更稳的两层加固(我在线上交易数据的做法):

  1. 延迟双删:更新后删一次,延迟 500ms~1s 再删一次(清掉主从延迟窗口内的脏读回填)。
  2. 订阅 binlog 删缓存(Canal):代码里不用管缓存失效,由 binlog 异步驱动删除,和业务解耦、不怕漏删——这是我用得最多的方案。

兜底:所有缓存必须带 TTL,即使逻辑漏删最多脏一个 TTL 周期。