在引入 Redis 缓存后,如何保证缓存和数据库(如 MySQL)的数据一致,是后端开发中最经典也最棘手的问题之一。
因为缓存和数据库是两套完全独立的存储系统,无法实现原生的跨系统分布式事务。在高并发下,只要读写请求的时序稍微错位,就可能导致两边数据不一致。
工业界通常不追求绝对的“强一致性”(因为会严重牺牲性能),而是追求“最终一致性”,即允许数据在极短的时间窗口内不一致,但能保证经过有限时间后,两边数据最终是对齐的。
下面为你拆解最主流的解决方案以及进阶的兜底策略:
🥇 行业标准方案:旁路缓存模式(Cache Aside Pattern)
这是目前 90% 以上互联网业务首选的方案,核心逻辑非常简单,分为读和写两个流程:
- 读流程(固定不变):
- 先查 Redis 缓存,如果命中,直接返回数据。
- 如果缓存未命中,再去查数据库。
- 将数据库查到的最新数据写入 Redis,并返回结果。
- 写流程(核心争议点):
先更新数据库,再删除缓存。
❓ 为什么是“删除缓存”而不是“更新缓存”?
- 懒加载,避免写放大:如果频繁修改数据但没人读取,更新缓存就是浪费计算资源。删除缓存后,只有下次真正有读请求时,才会触发数据库查询并回填缓存。
- 维护成本低:如果缓存是复杂的聚合对象(例如包含用户信息、订单数、等级等),每次更新都需要重新计算所有字段,而直接删除则简单高效。
❓ 为什么是“先更新数据库”而不是“先删缓存”?
如果是“先删缓存,再更新数据库”,在数据库还没更新完的间隙,如果有一个读请求进来,发现缓存没了,就会去数据库查到旧数据并回填到缓存。紧接着数据库更新完成,此时缓存里存的依然是旧数据,导致了长时间的脏数据。
⚠️ 标准方案的潜在风险与“延迟双删”
“先更新数据库,再删除缓存”虽然已经是最佳实践,但在极端并发或数据库主从架构下,依然存在极短的“不一致窗口”:
假设读请求在缓存失效的瞬间去查数据库,如果此时数据库主从同步有延迟,读请求可能从从库读到了旧值并回填到缓存。
为了缓解这个问题,衍生出了“延迟双删”策略:
- 先更新数据库。
- 删除一次缓存。
- 线程休眠一小段时间(例如 500ms)。
- 再次删除缓存。
第二次删除的目的,就是为了把“并发窗口期内,被其他读请求回填的旧缓存”再次清掉。
🛡️ 生产环境的终极兜底:Binlog 异步监听
“延迟双删”虽然有效,但休眠时间很难把控,且如果第二次删除因为网络抖动失败了怎么办?因此,在大型分布式系统中,通常会引入更可靠的兜底机制:监听数据库的 Binlog(变更日志)。
核心流程:
- 业务代码只负责更新数据库,不再直接操作缓存(或者只负责第一次删除)。
- 引入中间件(如阿里的 Canal 或 Debezium)伪装成 MySQL 的从库,实时订阅数据库的 Binlog。
- 当中间件监听到数据变更事件后,自动发送消息去删除或更新 Redis 中的对应缓存。
这种方案彻底解耦了业务代码和缓存逻辑,即使业务层删除缓存失败,Binlog 监听层也能保证最终把脏缓存清理掉。
📊 方案对比总结
为了让你更直观地选择,可以参考下表:
| 方案 | 一致性强度 | 实现复杂度 | 性能成本 | 适用场景 |
|---|---|---|---|---|
| 先更库后删缓存 | 最终一致性(绝大多数场景够用) | 低 | 低 | 默认首选,90%的业务场景 |
| 延迟双删 | 最终一致性(比标准方案更可靠) | 中 | 低 | 对一致性要求稍高,且能接受短暂延迟的场景 |
| Binlog 异步监听 | 最终一致性(极高可靠性) | 高(需维护中间件) | 中 | 系统复杂、微服务众多、追求业务纯净的大型系统 |
| 分布式锁强一致 | 强一致性 | 中 | 极高(串行化) | 金融核心交易、库存扣减等绝不容错场景 |
💡 避坑补充:防止缓存雪崩
在设置缓存过期时间时,千万不要给大批量的 Key 设置完全相同的过期时间(比如都设为凌晨 2 点过期)。一旦到达时间点,缓存集体失效,海量请求会瞬间击穿到数据库,导致数据库 CPU 飙升至 100% 甚至宕机(这就是缓存雪崩)。
最佳实践:在基础过期时间上,增加一个随机偏移量(例如基础 1 小时 + 随机 0~10 分钟),让缓存的失效时间均匀分散开。
