分布式缓存一致性设计:缓存与数据库双写场景下的最终一致性方案复盘
2026/7/24 15:07:31 网站建设 项目流程

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

一、缓存更新的经典陷阱:先删缓存再写库,为什么还有数据不一致

在引入 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%)<100msCanal金融级一致性

在团队内部的选择策略:80% 的业务用"延迟双删"(成本最低),15% 的核心业务用"延迟双删 + MQ",5% 的支付/结算业务用 Canal。

五、总结

缓存一致性设计的关键决策点:

  1. 不存在绝对的强一致性:CAP 理论决定了在缓存和数据库双写场景下,最终一致性是唯一可行的目标;
  2. 延迟双删是成本收益的最优解:500ms 的等待窗口覆盖了 P99 的读延迟,双删失败率在生产环境中 < 0.3%;
  3. MQ 重试解决"最后一公里"的失败保障:0.3% 的双删失败在重试机制下降低到 < 0.01%,投入产出比极高;
  4. Canal 方案的一致性最强但运维成本最高:引入了新的组件,适合支付/交易等对一致性有硬性要求的场景。

排查工具:当遇到缓存不一致时,先用redis-cli GETmysql SELECT对比值,再查 Binlog 时间戳确认哪个是"最新写入"。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询