资讯动态

Redis 缓存学习笔记(五):Redis 与数据库的数据一致性

发布时间:2026/10/16 1:18:22 来源:尧图企业网站定制
Redis 与数据库的数据一致性众所周知引入 Redis 可以降低数据读取成本、减轻数据库压力但也增加了一份需要维护的缓存副本。由于数据库与 Redis 通常无法处于同一个本地事务中二者之间天然存在短暂不一致的可能。因此修改数据时必须仔细设计操作顺序、失败补偿与过期策略避免短暂不一致演变为长期不一致。需要先明确一个基本原则DB 是权威数据源Redis 是可丢失、可过期、可重新构建的数据副本。普通的 Cache Aside 模式追求的通常是最终一致而不是 DB 与 Redis 在任意时刻都严格一致。一、标准的 Cache Aside 读流程1. 读取 Redis 2. 命中直接返回缓存数据 3. 未命中查询 DB 4. 将 DB 查询结果写入 Redis并设置 TTL 5. 返回结果缓存必须设置合理的 TTL。TTL 不仅用于回收内存也是发生删除失败、旧值回填等异常时限制脏数据最长存活时间的最后一道防线。如果多个请求可能同时因缓存未命中而查询 DB还需要根据热点程度考虑 singleflight、互斥重建或限流避免缓存击穿。在采用 DB 读写分离时还应区分“只服务当前请求的普通读取”和“会写入共享缓存的重建读取”。对于一致性要求较高的数据缓存 miss 后真正负责重建的请求应优先读取主库避免落后从库的旧值被写入 Redis并将短暂复制延迟放大成 TTL 级不一致。二、修改 DB 和缓存的正确姿势写操作需要考虑两个问题是直接更新缓存还是删除缓存是先操作 DB还是先操作缓存主流做法是1. 更新数据库 2. 数据库事务提交成功 3. 删除缓存也就是先更新 DB事务提交后再删除缓存。这套做法不能让 DB 与 Redis 获得跨系统的瞬时强一致性但能显著缩小不一致窗口而且缓存删除天然更容易重试、幂等和收敛。2.1 为什么删除缓存而不是直接更新缓存DB 与 Redis 的连续操作通常不是原子的。并发写入时DB 的完成顺序与 Redis 的完成顺序可能不同。如果每次写请求都把业务值直接写入缓存旧请求可能在网络延迟后覆盖新值。假设 DB 初始值为 0 请求 A把 DB 更新为 1 请求 B把 DB 更新为 2 DB 中的完成顺序 A 写入 1 B 写入 2 DB 最终值 2 Redis 中的完成顺序却可能反过来 B 更新缓存为 2 A 因网络较慢随后才更新缓存为 1 最终 DB 2 Redis 1 ← 旧值覆盖新值如果改成删除缓存A 执行 DEL B 执行 DEL删除操作不携带某个历史版本的业务值。无论两个DEL以什么顺序执行多次执行后的结果都是缓存不存在下一次读取再从 DB 加载当前值。因此更高层次的原因是DB 是权威数据源而 Redis 是可重建的副本。既然 DB 与 Redis 的操作无法组成普通的本地原子事务就应尽量让缓存侧操作具有幂等、可重试和耐乱序的性质。相比直接写入业务值缓存失效DEL更容易满足这些要求。这里的“收敛”不是指执行DEL后 Redis 立刻与 DB 保存了相同数据而是指 Redis 收敛到“缓存不存在”的状态后续再以 DB 的当前值重建缓存。2.2 为什么先更新 DB再删除缓存如果先删除缓存再更新 DB可能出现下面的竞态初始DB旧值Redis旧值 写请求 A先删除 Redis 读请求 B发现缓存未命中从 DB 读到旧值 读请求 B把旧值回填 Redis 写请求 A更新 DB 为新值并提交 最终 DB 新值 Redis 旧值而且如果删除缓存后 DB 更新失败或事务回滚还会产生一次无意义的缓存失效和 DB 回源。采用“先更新 DB提交后删除缓存”后只有真正提交成功的业务修改才会触发缓存失效也显著降低了旧值长期残留的概率。不过要注意先更新 DB、再删除缓存只是主流的最终一致方案并不等于完全消除了所有竞态。后面还需要处理缓存删除失败、慢读旧值回填、DB 主从延迟等问题。三、DB 修改成功但缓存删除失败怎么办可能出现DB 更新并提交成功 → 删除 Redis 时发生网络超时、Redis 故障或进程宕机 → DB 中是新值Redis 中仍是旧值锁或调整操作顺序都无法自动解决这个问题因为它属于跨系统操作失败而不是单纯的并发顺序问题。3.1 普通业务短重试、TTL 与可观测性对一致性要求不高的普通业务可以在 DB 提交后同步尝试删除缓存失败时进行少量、带退避和抖动的短重试。如果重试后仍然失败依靠缓存 TTL 限制旧值的最长存活时间记录错误日志和失败指标必要时触发告警不要在 Redis 故障时由大量请求进行无上限的猛烈重试。该方案只适用于业务能够接受一个 TTL 级别不一致窗口的场景并且要满足以下前提缓存确实设置了 TTL读取操作不会无条件延长旧值的 TTL该数据不参与余额、库存、权限、风控等关键正确性判断。3.2 重要业务Transactional Outbox对于不能接受“删除失败后只等 TTL”的重要业务可以使用 Transactional Outbox。在同一个 DB 事务中完成1. 更新业务数据 2. 插入一条缓存失效任务 3. 一起提交事务这样可以保证业务修改成功 ⇒ 缓存失效意图一定被记录 业务事务回滚 ⇒ 缓存失效任务也不会被创建后台 Worker 再可靠执行DEL。即使应用在 DB 提交后立刻宕机失效意图也不会丢失。需要明确Outbox 保证的是缓存失效意图可恢复、可重试并最终执行而不是 DB 与 Redis 的跨系统瞬时强一致。在 Worker 删除成功之前仍可能存在短暂的不一致窗口。简化方案Worker 直接删除 Redis当任务只有一个简单消费者时可以不引入 MQ业务事务更新业务表 插入 Outbox pending 任务 Workerpending → processing → 执行 DEL → completed Recovery回收租约过期的 processing 任务 监控/死信处理积压、永久失败和人工介入 TTL最终兜底这种方案链路短、组件少通常已经能够满足缓存失效补偿的需求。Worker 领取任务和更新状态时应使用 CAS 条件更新避免多个 Worker 重复领取例如UPDATEoutbox_taskSETstatusprocessing,worker_id:worker_id,attempt_id:attempt_id,lease_until:lease_untilWHEREid:idANDstatuspending;即使任务被重复执行DEL本身也是幂等的。Recovery 回收过期租约时也必须使用状态、执行轮次或版本号作为 CAS 条件不能查询后再无条件覆盖避免把已经续租或完成的任务错误恢复为pending。需要消息解耦时Relay 投递 MQ如果缓存失效事件需要跨服务传播、被多个消费者订阅或者系统已经有成熟的 MQ 基础设施可以采用业务事务更新业务表 插入 Outbox pending 事件 Relay领取事件并置为 publishing Relay投递 MQ成功后置为 published Recovery回收租约过期的 publishing 事件 MQ Consumer幂等执行缓存失效并 ACK 监控/死信处理积压、永久失败和人工介入 TTL最终兜底MQ 不是 Transactional Outbox 的核心。Outbox 的核心是业务数据修改与失效意图在同一个 DB 事务中原子提交。如果 Worker 直接执行DEL已经足够就不必为了形式完整而强行增加 MQ。3.3 Outbox 至少要监控什么仅有重试没有监控和死信故障可能只是从“丢任务”变成“永久积压”。至少应该监控pending 任务数量 最老 pending 任务年龄 processing/publishing 租约过期数量 任务失败率与 retry_count 死信任务数量 DB 提交到缓存成功失效的延迟对于 Key 构造错误、权限错误、代码缺陷等永久错误应进入 dead/manual_review 状态并告警而不是无限静默重试。如果MQ是RocketMQ那么事务消息可以替代Outbox表四、慢读请求回填旧值即使使用“先更新 DB再删除缓存”仍有一个概率较低但真实存在的竞态。关键并不是缓存是否刚好过期而是读请求在写事务提交前读到了旧 DB 快照却在写请求提交并删除缓存后才完成回填。完整时序如下初始Redis 缓存不存在DB旧值 读请求 A缓存 miss 读请求 A查询 DB得到旧值但尚未回填 写请求 B更新 DB 为新值并提交 写请求 B删除 Redis 缓存 读请求 A把手中的旧值写入 Redis 最终 DB 新值 Redis 旧值主流普通业务通常不会为这个低概率竞态一开始就引入复杂的强并发控制而是使用Cache Aside 缓存 TTL 缓存删除失败补偿 必要的可观测性接受一个有上限的最终一致窗口。只有当业务确实不能接受或者监控证明这个竞态真实频发时再考虑延迟双删、版本校验或读写串行化。4.1 延迟双删延迟双删的流程是DB 事务提交 → 立即删除缓存 → 通过可靠的延迟任务等待一段时间 → 再删除一次缓存第二次删除试图清理第一次删除后被慢读请求重新回填的旧值。不要让 Web 请求线程直接sleep后再删除。请求线程是短生命周期的可能超时或宕机而且会白白占用线程。更稳妥的方式是使用延迟任务、调度器或消息队列由后台 Worker 执行第二次删除。延迟双删只是概率性补救并不是严格一致性证明延迟太短慢读还未回填第二次删除先执行仍然会漏掉延迟太长旧值可能存活更久慢请求耗时没有稳定上限很难精确设置延迟时间第二次删除本身也可能失败需要可靠投递和重试最后仍应保留 TTL 兜底。还要区分两个概念删除失败补偿第一次该删却没删成功因此持续重试。 延迟双删第一次已经删过但担心旧值随后复活因此晚点再删。延迟双删属于删除补偿的一种但不能代替对第一次删除失败的可靠重试。4.2 乐观并发控制版本号与条件回填可以为对象维护单调递增的缓存代数generation或者使用 DB 记录中的单调版本号。读请求 1. 记录当前 generation10 2. 查询 DB 3. 回填前再次校验 generation 4. 只有 generation 仍为 10 才允许 SET 写请求 1. 更新 DB 并提交 2. generation 增加到 11 3. 删除缓存如果读请求准备回填时发现代数已变成 11就说明其查询结果可能已经过期应放弃回填。这本质上是基于版本号的乐观并发控制也可以理解为缓存回填阶段的 CAS。Redis 中“检查 generation SET 缓存”必须通过 Lua 原子执行写侧的“generation DEL”也最好原子执行否则检查与写入之间仍有竞态。但是它仍然不能消除下面这个跨系统窗口DB 已提交新值 → Redis 中的 generation 还没更新或者更新失败 → 旧读请求仍可能通过 Redis 侧检查并回填旧值原因是 DB 提交与 Redis 脚本不属于同一个本地原子事务。所以乐观锁缺失能缩短问题窗口期但是治标不治本4.3 悲观并发控制让读重建与写请求串行更强的做法是让同一个业务 Key 的缓存重建与数据写入竞争同一把排他锁。读请求缓存未命中获取 user:1 的锁 → 再次检查缓存 → 查询 DB → 回填 Redis → 释放锁写请求获取同一把 user:1 的锁 → 更新 DB 并提交 → 删除 Redis → 释放锁这样只会出现两种顺序读先执行读旧值并回填 → 写请求随后更新 DB 并删除旧缓存 写先执行更新 DB 并删除缓存 → 读请求随后读取并回填新值需要注意只有读重建和写请求都遵守同一套锁协议才能防止二者交叉只给读侧缓存重建加锁主要解决缓存击穿不能完整解决慢读回填临界区必须覆盖“查询 DB 回填缓存”以及“更新 DB 并提交 删除缓存”分布式锁还需要处理唯一 owner token、Lua 校验释放、租约过期、续期、主从故障切换和双持有等问题锁会增加等待延迟、竞争和故障处理成本即使使用锁Redis 删除仍可能失败所以 Outbox/重试与 TTL 仍然不可替代。因此悲观锁通常只适用于确实需要限制读写交叉的少量关键 Key而不是所有普通缓存请求的默认方案。三类机制的职责可以概括为乐观锁(不治本)/悲观锁控制并发交叉避免旧操作覆盖新状态。 Outbox/重试保证失败的缓存失效动作最终能够完成。 TTL限制异常旧缓存的最长存活时间。五、DB 主从延迟导致旧值回填在采用 DB 读写分离时写请求通常访问主库普通读请求则可能访问从库。由于主从复制通常是异步的主库事务提交成功并不代表从库已经立即应用了相同变更。因此即使读请求发生在写请求完成之后也可能从尚未追平的从库读取到旧值初始主库旧值从库旧值Redis旧值 写请求更新主库为新值并提交 写请求删除 Redis 读请求发现缓存 miss 读请求查询 DB 从库 从库复制尚未追平返回旧值 读请求把旧值回填 Redis 稍后从库追平为新值 最终主库新值从库新值Redis旧值它与慢读回填的区别是慢读回填读请求在写事务提交前取得旧值提交并删缓存后才回填。 主从延迟读请求可以在写请求完成后才开始但读取了尚未追平的从库。该问题真正危险的地方是从库中的旧值可能只额外存活几毫秒或几百毫秒但一旦被回填到 Redis就可能继续存活到缓存 TTL 到期从而把短暂的数据库复制延迟放大成 TTL 级别的不一致窗口。即使从库随后已经追平Redis 也不会自动更新。对于允许短暂陈旧的普通业务可以通过合理 TTL、可观测性和告警接受一个有上限的最终一致窗口但需要满足以下前提缓存设置了合理的 TTL读取操作不会无条件延长旧值的 TTL数据不参与余额、库存、权限、支付或风控等关键判断系统能够监控主从复制延迟、缓存回源节点和异常旧值。对于一致性要求较高的数据常见且稳妥的默认方案是缓存 miss 后真正负责重建缓存的请求通过 singleflight 或同 Key 互斥合并并发回源并显式读取主库再将结果写入 Redis。缓存 miss → singleflight 按 Key 合并并发请求 → leader 二次检查缓存 → 查询主库 → 回填 Redis并设置随机 TTL → 其他等待请求共享结果这样可以避免从库短暂的复制延迟污染共享缓存。若所有缓存重建都读取主库造成了真实的主库压力再根据业务需求考虑写后按 Key 短期强制读主、GTID/LSN 位点校验或版本校验等更复杂方案。需要注意Outbox 与缓存重建读主解决的是不同问题Outbox/重试保证缓存失效意图最终执行。 缓存重建读主保证回填数据来源足够新鲜。 TTL限制异常旧缓存的最长存活时间。三者职责不同不能互相替代。六、多级缓存与派生 Key 漏删实际系统可能不只有 Redis浏览器/CDN → 网关缓存 → 应用本地缓存如 Caffeine → Redis → DB只删除 Redis并不代表其他层也失效。例如某个应用实例的本地缓存仍然保存旧值用户依然会读到脏数据。因此要明确每一层缓存的Key 规则TTL主动失效机制失效消息传播范围可接受的不一致窗口失效失败后的补偿和监控。本地缓存通常需要广播失效事件或者明确接受较短 TTL 带来的不一致窗口。另外一个业务修改可能影响多个缓存 Keyuser:1 user_profile:1 team:8:members user_search:{query} 首页聚合缓存只删除主对象缓存派生缓存仍可能是旧的。真正困难的地方往往不是DEL执行失败而是根本没有识别出所有需要失效的 Key。可以建立“业务字段变更 → 受影响缓存 Key/标签”的失效清单并通过集成测试验证。对于分页、筛选条件很多、无法枚举的派生缓存可以考虑版本化命名空间user-list:{tenant}:{namespace-version}:{query-hash}数据变化时提升命名空间版本让旧 Key 不再被访问并等待自然过期而不是使用阻塞性的KEYS命令全量扫描删除。还要避免以下 Key 设计问题Key 未包含租户 ID 或权限范围导致串数据Key 规则升级后新旧格式长期并存批量失效只成功了一部分使用KEYS pattern在线扫描阻塞 Redis缓存标签或派生关系无人维护最终漏删。七、关键业务不要让缓存承担最终正确性Cache Aside 通常只保证最终一致。以下关键判断不应只相信一个可能过期的 Redis 副本余额是否足够 库存能否扣减 用户是否有权限 订单是否已经支付 风控规则是否允许操作缓存可以加速展示、预筛选或减少普通查询但最终正确性应落在 DB 条件更新、事务、唯一约束、状态机、CAS 或其他强一致存储上。如果产品要求“修改成功后当前用户下一次读取必须看到自己的写入”可以采用更新接口直接返回 DB 提交后的新对象当前用户在短时间内绕过缓存或强制读主库客户端携带最低可接受版本缓存版本不足时回源对真正要求强一致的数据不使用普通缓存作为最终读取依据。这里的“写后短时间绕过缓存或读主库”主要用于实现当前用户的读己之写或在缓存失效尚未完成时暂时不信任旧缓存命中它不能替代共享缓存的重建策略。对于一致性要求较高的数据即使短期标记不存在只要发生缓存 miss 并准备回填共享缓存重建请求仍应优先读取主库。八、如何按业务等级选择方案普通数据Cache Aside 先更新 DB事务提交后删除缓存 删除失败时少量短重试 合理 TTL 日志与基础指标接受一个有上限的短暂最终一致窗口。重要数据Cache Aside Transactional Outbox Worker/Relay 可靠失效 Recovery 回收过期租约 重试、死信与告警 缓存 miss 时通过 singleflight 或同 Key 互斥合并重建请求 leader 二次检查缓存并读取主库 回填缓存时设置带随机抖动的 TTL这套组合既保证失效意图不会因进程宕机或网络错误永久丢失也避免落后从库把旧值重新写入共享缓存。Outbox 负责“最终删得掉”缓存重建读主负责“回填时读得新”TTL 负责限制异常旧值的最长存活时间。少数高一致性或高冲突热点在前面的基线方案上再按实际问题选择慢读旧值回填版本号条件回填延迟双删只作为概率性辅助补丁 主从延迟默认由“缓存重建请求读主库”处理 主库重建压力明显再考虑写后按 Key 短窗口读主或按业务等级分流 需要精确确认副本已追平使用 GTID/LSN 位点校验超时回退主库 防止低版本或乱序数据倒灌业务版本号 CAS作为辅助护栏 读写必须避免交叉同 Key 的读重建与写请求串行化 热点失效击穿singleflight、互斥重建、逻辑过期、限流降级短窗口读主并不是处理主从延迟的默认第一选择因为固定窗口只能根据复制延迟估算不能证明从库已经追平。优先使用“singleflight 二次检查 重建读主”这一简单基线只有主库回源压力已经被指标证实时才考虑按 Key 短窗口分流。要求更精确时应使用 GTID/LSN 位点校验而不是不断延长固定时间窗口。不要一开始就给所有业务堆上分布式锁、版本代数和延迟任务。复杂机制本身也会制造新的故障。先建立简单、可观测、可补偿的基线再根据一致性要求和真实故障指标逐步升级。九、总结Redis 与 DB 的一致性问题本质上来自“权威数据修改”和“缓存副本失效”无法在普通本地事务中原子完成。主流方案是 Cache Aside读缓存未命中 → 查询 DB → 带 TTL 回填缓存 写更新 DB 并提交 → 删除缓存删除比直接更新缓存更耐受重复和乱序但还需要处理删除失败、慢读旧值回填、DB 主从延迟、多级缓存漏删以及失效后热点 Key 击穿等问题。普通业务通常采用短重试、TTL 和可观测性接受短暂最终一致重要业务可以通过 Transactional Outbox、后台 Worker、Recovery、死信和告警保证缓存失效意图最终执行。采用读写分离时一致性要求较高的共享缓存应通过 singleflight 或同 Key 互斥合并重建请求由 leader 二次检查缓存并读取主库后回填。写后短窗口读主主要用于读己之写、绕过可能仍存在的旧缓存或在主库压力明确后进行条件分流不应遗漏或默认替代缓存重建读主。乐观锁或悲观锁可以进一步控制并发顺序但不能替代跨系统失败补偿也不能凭空消除 DB 与 Redis 之间的原子性缺口。最后可以记住这几句话DB 是权威数据源Redis 是可重建副本。 写时先提交 DB再删除缓存。 删除失败靠重试和 Outbox长期脏数据靠 TTL 兜底。 缓存重建优先读主写后短期读主按需求补充不要混为一谈。 锁与版本控制解决并发顺序不能代替失败补偿。 关键业务的最终正确性不要只依赖缓存。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑