资讯动态

分布式缓存一致性难题:从并发覆盖到延迟双删的工程实践

发布时间:2026/10/20 3:30:22 来源:尧图企业网站定制
先说个真事。我之前负责过一个电商项目的商品详情页QPS 上来之后缓存几乎成了整个系统的生命线。有一回上线了一个促销活动运营改完商品价格前端页面愣是两个小时没变过来。那会儿大家第一反应都是“缓存没清吧”手动 flush 了一遍 Redis页面还是旧数据。最后查了半天才发现根本不是没清缓存而是清完缓存之后有个读请求在那一瞬间把数据库里的旧值又给塞回去了。那种感觉怎么说呢缓存一致性这个事儿看着是个老话题谁都能聊两句“缓存三兄弟”“缓存雪崩”但真到了线上问题往往出在那些你以为绝对没问题的细节上。所以我今天想把这些年踩过、填过的坑梳理一遍围绕“分布式缓存一致性”这一个核心从问题的本质、读写模式、方案选型到线上排查完整讲一遍。这篇文章既适合刚接触分布式系统、对各种缓存模式还停留在名词层面的新人也适合已经在线上被缓存问题折磨过、想系统性梳理一遍思路的同行。1. 分布式缓存一致性问题到底难在哪1.1 缓存与数据库的“双写”天生就是不同步的很多人一上来就背“缓存一致性方案”但压根没想过一个问题为什么数据库和缓存会不一致本质上这是两个独立的存储系统。数据库是唯一的数据源数据是持久的、完整的缓存只是一层加速层存的是数据的副本。读写请求只要经过缓存就必须同时维护两边的数据状态。但问题是应用层对两个系统的写入并不是原子的。举个最简单的例子。假设商品价格是 100 元运营改成了 200 元。这时候你既要更新数据库里的价格又想刷新 Redis 里的价格。顺序怎么走先更新数据库再更新缓存如果缓存更新失败DB 是 200、缓存还是 100不一致。先更新缓存再更新数据库如果数据库更新失败缓存是 200、DB 是 100更麻烦因为下一次缓存过期回填的还是数据库里的 100。而且这还只是单线程视角下的分析。线上系统是并发请求情况要复杂得多。1.2 并发覆盖才是最大的隐性杀手并发覆盖这个问题我在很长一段时间里都低估了它。假设查同一个商品 key 的两个请求同时在执行。请求 A 读到的数据库值是 v1请求 B 读到的数据库值是 v2v2 比 v1 新。但网络是乱的A 的查询慢B 的查询快。结果 B 先把 v2 写进了缓存然后 A 慢悠悠地把 v1 也写进了缓存。最终缓存里留下的是 v1旧值。数据库里的最新值是 v2缓存里是 v1不一致。这种情况就算你用的是“先更新数据库再删除缓存”的标准姿势也会出问题。因为删除缓存之后并发读请求可以在任意时刻把旧数据回填进去。这就是经典的“缓存击穿瞬间的并发回填覆盖”也是很多系统性故障的根源。1.3 网络延迟与时钟偏差让“谁新谁旧”都说不清还有一种不一致发生在多副本缓存的场景里。一个 Redis 集群可能有主从节点或者业务上自己做了多级缓存、多机房缓存。同一份数据有多个副本更新的时候需要同步到所有副本。但网络是有延迟的副本 A 更新完了、副本 B 还没更新这时候读请求如果路由到了副本 B拿到的就是旧值。有人会说那我给数据加个时间戳比较一下哪个新就用哪个不就行了想法很好但现实是不同服务器的时钟可能有偏差。你让 A 服务器的 10:00:00.500 和 B 服务器的 10:00:00.300 比大小如果 A 的时钟快了 300 毫秒那比较结果就是错的。这个偏差在单机房可能不明显跨机房、跨地域部署的时候问题就非常突出。1.4 CAP 理论下的无奈取舍绕不开 CAP。分布式系统里一致性、可用性、分区容错性三者最多只能同时满足两个。缓存作为分布式系统的一部分同样受这个约束。分区容错性P是必须保证的因为网络分区在分布式环境里是常态不是“会不会发生”的问题而是“什么时候发生”的问题。于是你只能在一致性C和可用性A之间做选择。这里的一致性指的又是强一致性也就是任何时刻读到的数据都是最新的。要实现这个目标网络分区时就得拒绝请求牺牲可用性。绝大多数互联网业务都接受不了这个代价所以最终选择了最终一致性——允许有一段时间数据不一致但经过若干时间后数据会达到一致状态。理解了这个约束你才能真正理解后面所有方案的设计逻辑分布式缓存一致性方案本质上都是在“把不一致的窗口时间缩到最短”这件事上做文章。2. 主流缓存读写模式与一致性取舍2.1 Cache Aside最常用但细节决定成败Cache Aside 是应用最广泛的缓存读写模式也常被翻译成“旁路缓存”。读流程是先读缓存。缓存命中就直接返回缓存 Miss 就查数据库把结果回填到缓存再返回给调用方。写流程是先更新数据库然后删除缓存而不是更新缓存。这里很多人有疑问为什么更新完数据库之后是“删除缓存”而不是“更新缓存”答案有几个。第一更新缓存需要知道完整的新值有时候更新数据库的 SQL 只改了某一个字段你并不想为了缓存把所有字段都查一遍第二更新缓存是非原子的会出现“数据库更新到一半、缓存先被写入了新值”这类中间态第三缓存更新频繁的话很多写入其实是无效的因为数据很快又被覆盖了而删除缓存是惰性的等到下次读请求来了再回填能省不少写操作。但 Cache Aside 有一个经典缺陷删除缓存这一步可能会失败。一旦失败旧值就留在缓存里后续所有读请求拿到的都是旧数据直到缓存过期或手工介入。另外Cache Aside 里“先更新数据库再删缓存”和“先删缓存再更新数据库”是有本质区别的。先删缓存再更新数据库会在删完缓存到更新完数据库之间出现一个空洞期。这个空洞期里来了一个读请求缓存 Miss它就会去数据库里读旧值然后回填缓存把旧值“永久”固化在缓存里。正确顺序是先更新数据库再删缓存这样即使删除缓存后出现并发回填回填到的也是新数据库值。至于回填瞬间读到的是新还是旧取决于更新是否已经完成。2.2 Read Through / Write Through缓存层自己做主Read Through 和 Write Through 模式里应用不再直接操作数据库和缓存两个系统而是把缓存组件当作一个代理所有读写都通过它完成。读请求由缓存组件负责缓存 Miss 时由它去数据库加载数据并回填写请求也由缓存组件负责它先把数据写入缓存再同步写入数据库。因为写操作是缓存和数据库同步完成的所以一致性比 Cache Aside 要强。但代价是实现复杂度高而且多数通用缓存中间件并不直接提供 Write Through 能力往往要自己封装。我见过一些团队为了追求一致性把业务代码改造成全量走自定义缓存代理结果后续每次上线都变得很痛苦因为那层代理逻辑越来越重出问题反而更难排查。所以 Write Through 更适合读多写少但要求严格一致的小范围场景不太适合大而全的业务改造。2.3 Write Behind异步写回性能换一致性Write Behind 模式也叫 Write Back是让应用只写缓存然后由后台异步任务把数据批量写回数据库。这个模式的写性能是最高的因为没有同步写 DB 的耗时。但代价非常大缓存是易失的如果缓存节点宕机还没写回数据库的数据就丢了就算缓存不丢数据也有一段较长的时间处于“仅存在于缓存、数据库没有”的状态强一致性完全谈不上。所以 Write Behind 一般用在“数据丢了也能接受或者能通过其他数据源重建”的场景比如浏览量、点击量计数器而不是订单、库存这类核心数据。2.4 四种模式快速对比模式读性能写性能数据一致性强度实现复杂度典型场景Cache Aside高中最终一致依赖删除缓存低绝大多数业务系统Read Through高中比 Cache Aside 强中需要缓存组件统一管理的场景Write Through中较低强一致高写少读多、一致性要求高的场景Write Behind高最高弱一致可能丢数据高计数器、可容忍丢失的场景3. 实际项目中的一致性方案选型与落地3.1 先分清业务能容忍什么方案选型的第一原则不是“哪个最先进”而是“业务能不能容忍短暂不一致”。我一般把业务需求分成三类第一类可以接受秒级到分钟级的不一致。比如用户昵称、文章标题、商品非关键描述信息。这类数据即使旧个几分钟用户基本感知不到用简单的过期时间 Cache Aside 就够了。第二类可以接受毫秒级到百毫秒级的不一致。比如商品价格、库存余量、订单状态用户刷新一下页面可能就会察觉必须把不一致窗口缩到很小。这类需要用延迟双删、版本号等方案。第三类任何时刻都不允许不一致。比如支付金额、对账数据。这类数据就别折腾缓存了直接读写数据库或者用分布式事务不要为了炫技把核心数据放在不可控的缓存链路里。明确了业务等级后面所有技术选型都顺了。3.2 方案一延迟双删Cassandra 级常用的简单套路延迟双删是处理 Cache Aside 并发回填覆盖的经典方案。核心思想是不删一次缓存而是删两次中间故意隔一段时间。具体步骤更新数据库之前先删除一次缓存。更新数据库。等一小段时间 T再删除一次缓存。第一次删除是为了让旧值快速从缓存中消失第二次删除是为了清掉在“删除缓存 → 更新数据库”这个窗口期里可能被并发读请求回填的旧值。这里的关键参数是 T也就是第二次删除与第一次删除之间的间隔。这个 T 怎么定它至少要大于“一个典型的读请求从发现缓存 Miss 到回填缓存完成”的耗时。你可以在项目里做一个简单统计缓存的回填耗时 P99 是多少。假设统计出来回填耗时的 P99 是 300 毫秒那 T 至少要定成 500 毫秒到 1 秒。太小了可能还没等并发读请求回填完第二次删除就执行了等于白删太大了不一致窗口拉长而且请求路径上多了一次无意义的等待。我用过一个稍微改进的思路在更新完数据库后主动 sleep T 毫秒再执行删除。sleep 放在业务线程里会占住线程资源所以高并发下可以考虑用异步任务去执行第二次删除比如丢到线程池或者延迟消息队列里。延迟消息队列里执行即便线程池满或应用重启删除任务也不会丢可靠性更高。但延迟双删有一个硬伤如果第二次删除因为宕机、进程被 kill 等原因没执行那么旧值会一直留在缓存里。所以这个方案通常要配合一个兜底机制。3.3 方案二删除重试与消息队列兜底为了应对“删除缓存失败”最直接的做法就是重试。我在方案里一般是这样设计的执行删除缓存的操作如果返回失败或者抛异常不直接忽略而是把“待删除的 key”封装成一个消息投递到消息队列里。再由一个独立的消费者去查这个 key 是否还在并尝试再次删除。如果删除还是失败就继续重试直到成功或超过最大重试次数。伪代码大概是这样的# 更新数据库 update_db(product_id, new_price) # 删除缓存失败则丢到 MQ try: delete_cache(fproduct:{product_id}) except Exception as e: mq.send(cache_delete_retry, {key: fproduct:{product_id}})消费者侧def handle_delete_msg(msg): key msg[key] # 先确认 key 里存的不是最新值避免误删并发更新的新缓存 val cache.get(key) if val and not is_latest(val): cache.delete(key) else: cache.delete(key)这个方案的优点是能处理“删除操作本身失败”的场景但解决不了“并发读请求回填覆盖”的问题。所以消息队列兜底通常和延迟双删组合使用双删负责处理并发回填MQ 负责处理删除失败。3.4 方案三订阅数据库变更日志从源头解耦还有一种思路是从变更源头下手。数据库每一条数据变更都会记录在 binlog 里。我们可以通过 CDCChange Data Capture工具订阅 binlog解析出变更内容然后推送或者写入到缓存。这个方案的优点非常突出第一业务代码完全不用关心缓存逻辑更新数据库就是唯一入口天然统一。第二不存在“删缓存失败”的问题因为推送是异步的失败可以重复消费。第三对于数据量很大、更新很频繁、并且更新的字段可能是计算结果的场景binlog 订阅能把复杂的失效逻辑剥离出去。用起来也有几个需要额外注意的地方。一个是 binlog 里记录的是数据库行的最终状态不是“变化过程”。比如一行数据一秒内被更新了 100 次binlog 里可能只有最后几次变更推送也只能按最终值处理。这反而成了优势因为缓存里只需要最新值。另一个是要保证投递到缓存时是有序的。同一个 key 的变更如果乱序到达会出现旧值覆盖新值的问题。我用的时候一般会按 key 做分区保证同一个 key 的变更消息始终投递到同一个消费者并且消费者侧按顺序处理。# 伪代码binlog 解析后投递 def handle_binlog_event(row): key f{row.table}:{row.id} value row.columns # 乐观锁携带变更版本号写入时校验 cache.set_with_version(key, value, versionrow.version)3.5 方案四版本号与 CAS 控制写入杜绝旧值覆盖新值前面几个方案都是在“删缓存”这个思路上打转但并发回填的本质问题是一个读取操作无法判断自己读到的数据库值是不是最新的于是就可能把旧值写进缓存。要根治它一种思路是给数据加上版本信息。具体做法是在业务表里加一个 version 字段每次更新数据时 version 自增。缓存写入的时候也把 version 一起存进去。写入前先比较缓存的版本号和当前数据的版本号如果当前版本号不大于缓存版本号就拒绝写入。在 Redis 里可以用 Lua 脚本实现比较和写入的原子性-- KEYS[1] 是缓存 key -- ARGV[1] 是当前数据的版本号 -- ARGV[2] 是当前数据的值 local cur redis.call(GET, KEYS[1]) if cur then local cur_ver tonumber(redis.call(HGET, KEYS[1], version)) local new_ver tonumber(ARGV[1]) if new_ver cur_ver then return -1 -- 版本过期拒绝写入 end end redis.call(HSET, KEYS[1], value, ARGV[2]) redis.call(HSET, KEYS[1], version, ARGV[1]) return 1这个方案能比较好地规避并发回填覆盖但引入了一个新问题业务表需要加版本字段并且所有更新逻辑都要带上版本自增对现有系统的侵入性不小。版本号之间的比较在不同副本之间也需要统一规则如果两个副本的版本号没有全局递增还是会出现冲突。所以版本号方案更适合新项目从第一天就设计好或者用于特定核心数据的定制方案。3.6 多级缓存的一致性同步稍微大型一点的系统不会只有 Redis 一层缓存。常见的是本地缓存比如进程内缓存 Redis 数据库的三级结构。本地缓存的特点是访问速度最快但每个服务节点各存一份。数据更新时你不仅要更新 Redis还得把每个服务节点本地缓存里的旧值干掉或者更新。我常用的方案是消息广播。更新完数据库后往消息队列或者 Redis Pub/Sub 发一条“key 已变更”的通知所有服务节点订阅这个消息收到后主动失效本地缓存里的对应 key。还有更轻量的做法如果是网关层统一入口也可以由网关在返回响应前统一清理本地缓存。这里有个细节本地缓存的失效通知和下一次读请求之间存在时间差所以本地缓存的过期时间不能设得太长。我一般建议本地缓存 TTL 控制在 30 秒到 1 分钟级别既保证性能又让不一致窗口可控。4. 线上问题排查与监控实录4.1 案例一缓存里永远是旧值删了又变回旧值这个案例就是文章开头那个场景。现象是运营改了数据库价格页面迟迟不更新手动 flush 缓存后短暂恢复过一会又变回旧值。排查思路是这样的第一步确认更新链路有没有走缓存删除。查应用日志看更新数据库的请求后面是否跟着删除缓存的操作。我们当时发现日志里删除动作确实执行了。第二步确认删除后是否有并发读请求回填。这就需要看缓存 Miss 的日志。我们把缓存 Miss 和回填的日志配上时间戳发现每次删除缓存之后都会有那么几个读请求在 50 毫秒内触发了回填。问题就出在第二次删除没做或者说第一次删除到数据库更新完成之间的窗口期里并发回填把旧值写回去了。解决办法就是前面提到的延迟双删。加上之后又观察了几天没有再出现这个问题。实操心得排查这类问题日志必须带上请求 ID、时间戳、key 的完整信息。没有这些你根本不知道哪个请求在什么时候回填了缓存全靠猜。4.2 案例二缓存过期引发的雪崩 vs 击穿另一个高频问题不是“不一致”但和一致性紧密相关就是缓存过期。缓存雪崩是大量 key 同时过期导致大量请求同时穿透到数据库数据库压力瞬间飙高。解决办法是给过期时间加随机值让过期时间分散开比如基础 TTL 是 1 小时再加 0 到 300 秒的随机量。缓存击穿是某一个热点 key 过期瞬间大量并发请求同时 Miss然后同时打到数据库。解决办法有这么几种第一热点数据设置为永不过期由后台任务定期刷新第二加互斥锁只有一个请求能去数据库回填其他请求等待或直接返回旧值第三逻辑过期时间存的时候存两个值数据和过期时间戳读的时候如果发现逻辑过期异步去刷新。这三种方案我全都用过。体验下来热点数据永不过期 异步刷新最省心但要注意刷新任务的可靠性避免刷新任务挂掉以后数据就永远固化成旧值。逻辑过期时间需要改缓存的数据结构侵入性略大但能兼顾一致性和可用性。4.3 案例三多副本缓存之间数据打架还有一类问题是多副本缓存的场景。某个服务的缓存同时部署在两个机房两个机房的读请求各自读本机房的缓存副本。更新时只更新了 A 机房的缓存B 机房还是旧值。排查时先确认流量是不是按机房路由的再确认更新时是否做了跨机房广播。多机房环境里我一般在更新完主缓存后把失效消息同时发布到多个机房的 MQ 主题里每个机房独立消费、独立失效本机房缓存。但跨机房的网络延迟、消息乱序还是可能导致某些副本先更新了新值、再被旧值的消息覆盖。所以副本层级最好也引入版本号或时间戳比较写之前先判断消息是否比当前缓存新。这里的“新”需要可靠的时间来源单机房内可以用本地时钟跨机房建议用统一的单调递增序号或者数据库自增序号。4.4 监控指标与告警阈值清单没有监控的方案就是盲人摸象。缓存一致性相关的监控下面这几个指标必须要有监控项指标说明建议告警阈值缓存命中率命中次数 / 总读次数低于 85% 告警回填耗时缓存 Miss 之后查询 DB 并写入缓存的耗时P99 超过 500ms 告警删除缓存失败数删除缓存操作抛异常或返回失败的次数大于 0 即告警MQ 中删除重试消息积压量重试队列积压的消息数量大于 100 告警数据库慢查询缓存回填导致的 DB 慢查询次数持续超过基线 20% 告警缓存与数据库数据差异抽样定时抽样对比缓存值和 DB 值统计不一致比例超过 0.01% 告警最后一项“数据差异抽样”很有用。可以写一个定时任务每小时随机抽 1000 个 key对比缓存值和数据库值。对比的时候要注意缓存值可能稍有延迟所以对不上不一定就是不一致但比例一旦明显升高就必须人工介入查看了。4.5 线上突发不一致的应急操作真到了线上发现大量不一致的时候不要慌按下面几步处理第一步确认影响范围。是某一个 key 还是某一片 key如果是某个业务字段先看是否可以通过过期时间自动恢复如果不能走第二步。第二步主动清缓存。把所有涉及不一致的 key 批量删除强制读请求回填数据库最新值。但要注意如果系统没有延迟双删或版本控制清完缓存后并发读请求可能又把脏值回填回去所以清缓存要配合限流或者短暂停掉相关读流量。第三步如果清缓存没用那就需要直接改数据库或者重启相关服务再观察结果。第四步复盘。确认根因是哪个环节是删除失败还是并发覆盖然后针对性地补方案。最怕的是清完缓存数据短暂恢复就以为问题解决了结果第二天又犯。我见过太多系统在同一个坑里反复栽跟头。最后分享一点体会做了几年后端我的最大感受是不要追求绝对的一致性而是要把“不一致的窗口时间”缩到业务可接受的范围内并且给这个窗口加上监控和兜底。方案永远不是越复杂越好延迟双删 消息队列兜底能解决的问题就别上什么分布式事务本地缓存 Redis 能搞定的场景就别为了统一缓存代理改一整套架构。另外一切以可观测性为基础。没有日志、没有监控、没有对比任务线上出了问题你连定位都困难更别提什么一致性方案了。我在每个项目上线前都会专门花时间把缓存相关的日志打印齐全包括缓存 Miss、缓存回填、缓存删除、删除重试这些关键节点的耗时和结果。事实证明这些日志在后续排查故障的时候是最值钱的东西。

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

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

免费获取报价 →
↑