分布式缓存一致性设计:缓存与数据库双写场景下的最终一致性方案复盘
一、缓存更新的经典陷阱:先删缓存再写库,为什么还有数据不一致
在引入 Redis 做 MySQL 的读缓存后,一个最简单的"先删缓存再更新数据库"策略在生产环境中出现了诡异的数据不一致——缓存中偶尔会出现旧数据,持续时间从几秒到几十秒不等。
问题的根因在并发时序上:线程 A 删除了缓存,但在更新数据库之前,线程 B 发起了读请求。线程 B 发现缓存未命中,从数据库读取了旧数据并回种到缓存。然后线程 A 才完成数据库更新。结果:数据库中是新数据,缓存中是旧数据。
另一种策略"先更新数据库再删缓存"也有自己的问题:在数据库更新完成、缓存删除完成之间的微小时间窗口内,读请求可能读到旧缓存。
二、延迟双删 + MQ 重试:最终一致性的工程解
方案"先删缓存 → 更新数据库 → 延迟双删"能覆盖绝大部分场景,但有以下边界条件:
- 第二次删除可能失败(网络抖动);
- 如果业务读的 P99 超过设定的等待时间,旧缓存仍可能存在。
引入 MQ 异步重试来保证第二次删除的可靠性:
// 缓存更新服务 —— 延迟双删 + MQ 保证最终一致 type CacheUpdateService struct { db *sql.DB cache *redis.Client mq MessageQueue // Kafka / Redis Stream } func (s *CacheUpdateService) UpdateWithCache(key string, newValue interface{}) error { // 步骤 1: 第一次删除缓存 if err := s.cache.Del(ctx, key).Err(); err != nil { log.Warnf("第一次删除缓存失败: %v,继续执行", err) // ⚠️ 第一次删除失败不中断流程,因为后面还有第二次删除兜底 } // 步骤 2: 更新数据库 if err := s.db.Update(key, newValue); err != nil { return fmt.Errorf("数据库更新失败: %w", err) } // 步骤 3: 延迟 500ms 后执行第二次删除 // 500ms 的选择依据:当前服务的读操作 P99 < 450ms // 二倍保险系数:确保 P99 内的并发读操作都已完成 time.Sleep(500 * time.Millisecond) if err := s.cache.Del(ctx, key).Err(); err != nil { // 步骤 4: 第二次删除失败 → 投递到 MQ 的重试队列 log.Errorf("第二次删除缓存失败,投递重试队列: %v", err) s.mq.Publish("cache:retry:delete", CacheDeleteMsg{ Key: key, RetryCount: 0, MaxRetries: 5, NextRetry: time.Now().Add(1 * time.Second), // 1 秒后重试 }) } return nil } // MQ 消费者 —— 保证最终删除成功 func (s *CacheUpdateService) HandleDeleteRetry(msg CacheDeleteMsg) { if err := s.cache.Del(ctx, msg.Key).Err(); err != nil { if msg.RetryCount < msg.MaxRetries { msg.RetryCount++ msg.NextRetry = time.Now().Add( time.Duration(msg.RetryCount*2) * time.Second, // 指数退避 ) s.mq.Publish("cache:retry:delete", msg) } } }三、Canal 监听 Binlog:最彻底的最终一致性方案
MQ 重试方案仍有"延迟双删等待时间"的不确定性。最彻底的一致性保证是通过 Canal 监听 MySQL Binlog,在数据库变更的第一时间同步删除/更新缓存:
// Canal Binlog 监听 —— 数据库变更时自动同步缓存 type BinlogSyncer struct { canal *canal.Canal cache *redis.Client } func (s *BinlogSyncer) Start() { // 监听指定数据库表的数据变更 s.canal.SetEventHandler(&EventHandler{ onRowUpdate: func(table string, before, after []interface{}) { // 数据库行更新 → 同步更新缓存 key := extractCacheKey(table, after) s.cache.Set(ctx, key, serializeRow(after), 10*time.Minute) }, onRowDelete: func(table string, row []interface{}) { // 数据库行删除 → 同步删除缓存 key := extractCacheKey(table, row) s.cache.Del(ctx, key) }, }) // 从指定 Binlog 位置开始同步 s.canal.RunFrom(mysql.Position{Name: binlogFile, Pos: binlogPos}) }Canal 方案的优势是数据库变更是唯一真理源(Single Source of Truth),缓存的同步由 Binlog 驱动,不存在"缓存和数据库不一致"的窗口期——只要 Binlog 已提交,缓存更新就一定会执行。但代价是引入了 Canal 组件的运维开销。
四、方案对比与选型矩阵
| 方案 | 一致性保证 | 延迟 | 引入组件 | 适用场景 |
|---|---|---|---|---|
| 先删后写(无保护) | 弱 | 低 | 无 | ❌ 不推荐 |
| 延迟双删 | 中(>99%) | 500ms+ | 无 | 一般业务 |
| 延迟双删 + MQ | 高(>99.9%) | 500ms+ | MQ | 核心业务 |
| Canal Binlog | 极高(>99.99%) | <100ms | Canal | 金融级一致性 |
在团队内部的选择策略:80% 的业务用"延迟双删"(成本最低),15% 的核心业务用"延迟双删 + MQ",5% 的支付/结算业务用 Canal。
五、总结
缓存一致性设计的关键决策点:
- 不存在绝对的强一致性:CAP 理论决定了在缓存和数据库双写场景下,最终一致性是唯一可行的目标;
- 延迟双删是成本收益的最优解:500ms 的等待窗口覆盖了 P99 的读延迟,双删失败率在生产环境中 < 0.3%;
- MQ 重试解决"最后一公里"的失败保障:0.3% 的双删失败在重试机制下降低到 < 0.01%,投入产出比极高;
- Canal 方案的一致性最强但运维成本最高:引入了新的组件,适合支付/交易等对一致性有硬性要求的场景。
排查工具:当遇到缓存不一致时,先用redis-cli GET和mysql SELECT对比值,再查 Binlog 时间戳确认哪个是"最新写入"。