公司动态

分布式缓存一致性设计:缓存与数据库双写场景下的最终一致性方案复盘

📅 2026/7/24 15:07:47
分布式缓存一致性设计:缓存与数据库双写场景下的最终一致性方案复盘
分布式缓存一致性设计缓存与数据库双写场景下的最终一致性方案复盘一、缓存更新的经典陷阱先删缓存再写库为什么还有数据不一致在引入 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%500msMQ核心业务Canal Binlog极高99.99%100msCanal金融级一致性在团队内部的选择策略80% 的业务用延迟双删成本最低15% 的核心业务用延迟双删 MQ5% 的支付/结算业务用 Canal。五、总结缓存一致性设计的关键决策点不存在绝对的强一致性CAP 理论决定了在缓存和数据库双写场景下最终一致性是唯一可行的目标延迟双删是成本收益的最优解500ms 的等待窗口覆盖了 P99 的读延迟双删失败率在生产环境中 0.3%MQ 重试解决最后一公里的失败保障0.3% 的双删失败在重试机制下降低到 0.01%投入产出比极高Canal 方案的一致性最强但运维成本最高引入了新的组件适合支付/交易等对一致性有硬性要求的场景。排查工具当遇到缓存不一致时先用redis-cli GET和mysql SELECT对比值再查 Binlog 时间戳确认哪个是最新写入。