把公司的缓存一致性搞砸之后,我翻遍了5种方案,这是我们的终极补救
兄弟们,你们经历过这种绝望吗?凌晨1点,业务方在群里疯狂@你:“用户刚充值的会员,一刷新就没了!”你赶紧查日志,发现缓存里读到的还是旧数据,数据库明明已经更新了。更诡异的是,过了几分钟再刷新,数据又对了。
这就是缓存与数据库数据不一致的典型症状。那天晚上我翻遍了5种补偿方案,从延时双删到Canal,才彻底搞明白这潭水有多深。今天就把这次翻车经历和完整补救方案复盘出来。
一、事故还原:缓存数据怎么就“穿越”了?
我们的架构是标准的读缓存→写数据库→删缓存模式。某天上线了一个会员充值功能,流程是这样的:

看起来没问题对吧?但现实是残酷的——第2步删除缓存和第5步读数据库之间,有一个时间窗口。
实际情况是这样的:

根因:MySQL主从复制有延迟(200ms),在缓存被删除后、从库同步完成前,有读请求打到了从库,读到了旧数据,然后把旧数据写回了缓存。缓存被“污染”了。
这就是缓存一致性问题最经典的坑——主从延迟 + 读写分离 + 缓存,三个东西凑在一起,数据就乱了。
二、为什么缓存一致性这么难?
2.1 数据流向的复杂性
在典型的读缓存→写数据库架构中,数据流向是这样的:

不一致产生的根本原因:写链路和读链路是异步的、并发的。主从同步有延迟,缓存操作和数据库操作不是原子的。
2.2 缓存一致性的“不可能三角”
在分布式环境下,缓存一致性面临一个“不可能三角”:
| 目标 | 说明 | 难度 |
|---|---|---|
| 强一致性 | 缓存和数据库任何时候都完全一致 | 极高(需要分布式事务) |
| 高性能 | 读写操作低延迟 | 容易(但牺牲一致性) |
| 高可用 | 系统持续可用 | 中等 |
现实选择:大多数业务选择最终一致性——允许短时间内数据不一致,但保证最终会一致。
三、方案一:延时双删
3.1 核心思想
延时双删通过两次删除 + 延时等待,在性能与一致性之间寻找平衡。
python 伪代码:
def update_data(key, obj): del_cache(key) # 第一次删除:删除脏数据 update_db(obj) # 更新数据库 logic_sleep(_time) # 延时等待(让主从同步完成) del_cache(key) # 第二次删除:保证最终一致
Go语言实现:
func UpdateWithDoubleDelete(db *gorm.DB, redis *redis.Client, user *User) error { cacheKey := fmt.Sprintf("user:%d", user.ID) // 第一次删除缓存(防止脏读) redis.Del(context.Background(), cacheKey) // 更新数据库 if err := db.Save(user).Error; err != nil { return err } // 延迟第二次删除(解决主从同步延迟期间的脏读) go func() { // 根据主从延迟调整,通常1-2秒 time.Sleep(1 * time.Second) redis.Del(context.Background(), cacheKey) }() return nil }
3.2 为什么要延时?
延时的核心目的是等待MySQL主从同步完成(延时双删(redis-mysql)数据一致性思考)。

3.3 延时双删能保证强一致吗?
不能。
| 限制 | 说明 |
|---|---|
| 主从同步时间不确定 | 负载高时同步可能超过延时时间 |
| 并发写场景复杂 | 多个写请求交错,两次删除可能不够 |
| 最终一致而非强一致 | 只能提高一致性概率,不能100%保证 |
3.4 为什么需要第一次删除?
第一次删除有两个作用:
-
删除脏数据:避免其他线程读到旧数据
-
提前实现最终一致:不用等四个步骤全部完成
四、方案二:先更新数据库,再删除缓存
这是最经典的方案,也是Cache-Aside Pattern的标准做法。
4.1 流程

4.2 存在的问题
问题一:删除缓存失败
如果第3步删除缓存失败,缓存中一直是旧数据。
解决方案:重试机制 + 监控告警。
问题二:读写并发导致脏数据

解决方案:使用延迟双删作为兜底。
五、方案三:先更新缓存,再更新数据库
5.1 流程

5.2 致命缺陷
如果第3步数据库更新失败,缓存中已经是新数据,但数据库还是旧数据——缓存和数据库永久不一致。
结论:这个方案不推荐,除非数据库更新失败的概率极低,且有完善的补偿机制。
六、方案四:MQ消息队列实现最终一致性
6.1 核心思想
将缓存更新操作异步化,通过MQ保证至少一次的执行。

6.2 关键设计
| 设计点 | 说明 |
|---|---|
| 消息可靠性 | MQ开启持久化 + 手动ACK |
| 重试机制 | 删除失败时重试(指数退避) |
| 死信队列 | 多次重试失败后进入死信,人工介入 |
| 幂等性 | 删除操作天然幂等 |
6.3 优势与局限
优势:
-
解耦:应用不直接操作缓存
-
可靠:MQ保证消息不丢失
-
可重试:失败自动重试
局限:
-
引入MQ增加系统复杂度
-
延迟略高(消息队列的延迟)
-
需要处理消息积压
七、方案五:Canal + Redis 实现数据双写一致性
7.1 什么是Canal?
Canal是阿里巴巴开源的一个MySQL binlog增量订阅&消费组件。它伪装成MySQL的Slave,订阅主库的binlog,解析后推送给下游。
7.2 架构设计

7.3 工作流程

7.4 核心优势
| 优势 | 说明 |
|---|---|
| 业务无侵入 | 应用只管写DB,不需要关心缓存 |
| 数据可靠 | 基于binlog,不丢数据 |
| 顺序保证 | binlog天然有序 |
| 支持多种目标 | Redis、ES、HBase、MQ等 |
7.5 注意事项
-
Canal自身高可用:需要部署Canal集群
-
消费延迟:binlog解析和消费有延迟(毫秒~秒级)
-
资源消耗:Canal需要解析binlog,有一定CPU消耗
八、方案对比与选型建议
| 方案 | 一致性级别 | 性能影响 | 实现复杂度 | 业务侵入 | 适用场景 |
|---|---|---|---|---|---|
| 延时双删 | 最终一致 | 低 | 低 | 中 | 大多数业务,能接受1-2秒不一致 |
| 先删缓存后更新 | 弱一致 | 低 | 低 | 中 | 读多写少 |
| 先更新后删缓存 | 最终一致 | 低 | 低 | 中 | 推荐方案 |
| MQ异步 | 最终一致 | 中 | 中 | 中 | 已有MQ infrastructure |
| Canal同步 | 最终一致 | 低 | 高 | 无 | 需要解耦、多目标同步 |
选型决策树

九、我们最终的补救方案
经过这次翻车,我们采用了分层防御的策略:

三层保障:
-
第一层(主链路):先更新数据库,再删除缓存(Cache-Aside)
-
第二层(兜底):Canal + MQ 异步删除,保证最终一致
-
第三层(补偿):延时双删作为最后的兜底
十、避坑总结
-
没有银弹:缓存一致性没有完美的解决方案,需要在一致性、性能、复杂度之间权衡
-
主从延迟是隐形杀手:读写分离场景下,延时双删的延时时间要大于主从同步延迟
-
监控比方案更重要:即使用了最好的方案,也要监控缓存命中率、数据一致性指标
-
业务兜底:核心业务(如金钱相关)建议读主库,不要依赖缓存
兄弟们,你们在生产环境中遇到过缓存一致性的坑吗?是用延时双删还是Canal?评论区聊聊,我帮你们分析分析。
浙公网安备 33010602011771号