先说个真事。我之前负责过一个电商项目的商品详情页,QPS 上来之后,缓存几乎成了整个系统的生命线。有一回上线了一个促销活动,运营改完商品价格,前端页面愣是两个小时没变过来。那会儿大家第一反应都是“缓存没清吧”,手动 flush 了一遍 Redis,页面还是旧数据。最后查了半天才发现,根本不是没清缓存,而是清完缓存之后,有个读请求在那一瞬间把数据库里的旧值又给塞回去了。那种感觉怎么说呢,缓存一致性这个事儿,看着是个老话题,谁都能聊两句“缓存三兄弟”“缓存雪崩”,但真到了线上,问题往往出在那些你以为绝对没问题的细节上。
所以我今天想把这些年踩过、填过的坑梳理一遍,围绕“分布式缓存一致性”这一个核心,从问题的本质、读写模式、方案选型到线上排查,完整讲一遍。这篇文章既适合刚接触分布式系统、对各种缓存模式还停留在名词层面的新人,也适合已经在线上被缓存问题折磨过、想系统性梳理一遍思路的同行。
1. 分布式缓存一致性问题到底难在哪
1.1 缓存与数据库的“双写”天生就是不同步的
很多人一上来就背“缓存一致性方案”,但压根没想过一个问题:为什么数据库和缓存会不一致?
本质上,这是两个独立的存储系统。数据库是唯一的数据源,数据是持久的、完整的;缓存只是一层加速层,存的是数据的副本。读写请求只要经过缓存,就必须同时维护两边的数据状态。但问题是,应用层对两个系统的写入并不是原子的。
举个最简单的例子。假设商品价格是 100 元,运营改成了 200 元。这时候你既要更新数据库里的价格,又想刷新 Redis 里的价格。顺序怎么走?
先更新数据库,再更新缓存?如果缓存更新失败,DB 是 200、缓存还是 100,不一致。
先更新缓存,再更新数据库?如果数据库更新失败,缓存是 200、DB 是 100,更麻烦,因为下一次缓存过期,回填的还是数据库里的 100。
而且这还只是单线程视角下的分析。线上系统是并发请求,情况要复杂得多。
1.2 并发覆盖才是最大的隐性杀手
并发覆盖这个问题,我在很长一段时间里都低估了它。
假设查同一个商品 key 的两个请求同时在执行。请求 A 读到的数据库值是 v1,请求 B 读到的数据库值是 v2,v2 比 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(f"product:{product_id}") except Exception as e: mq.send("cache_delete_retry", {"key": f"product:{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 里。我们可以通过 CDC(Change 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, version=row.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、缓存回填、缓存删除、删除重试这些关键节点的耗时和结果。事实证明,这些日志在后续排查故障的时候,是最值钱的东西。