ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis缓存与数据库一致性:高并发下的双写一致性解决方案

Redis缓存与数据库一致性:高并发下的双写一致性解决方案

先直接从从业者的视角开始聊吧。做后端这几年,Redis 大概是每个项目都绕不开的组件,高并发场景下用 Redis 扛读流量已经是行业标配。但流量扛住了,新问题马上就来:缓存里的数据和数据库里的数据对不上了。这个矛盾在线上环境几乎是每天都在发生,小到某个商品价格显示异常,大到订单状态错乱引发资损,表现形式五花八门,根子却几乎都指向同一个问题——高并发和数据一致性天然存在冲突,而你选择的缓存方案是否真的适配了业务场景。

这篇文章我把这些年在这块踩过的坑、试过的方案、以及在真实项目里沉淀下来的取舍逻辑,尽可能完整地做一个梳理。适合谁看?如果你的系统里已经引入了 Redis,但遇到过高并发下缓存数据偶尔错乱的问题,或者你正在设计缓存架构、纠结于“先删缓存还是先更新数据库”这类细节,这篇文章应该能给你一些明确答案。

1. 高并发与数据一致性:一对天生冲突的目标

1.1 为什么高并发下 Redis 成了标配

先捋一个基本事实:单机 MySQL 在普通配置下能稳定支撑的读 QPS 大概在 3000 到 8000 之间,写 QPS 会更低一些。到了秒杀、热点商品详情页、社交 feed 流这种动辄每秒几万甚至几十万读请求的场景,数据库无论如何都是扛不住的。

Redis 的厉害之处在于它是基于内存的键值存储,单实例纯读 QPS 可以轻松超过 10 万,配合集群甚至可以到百万级别。而且它支持丰富的数据结构,不只是 get/set 这么简单,可以干的事很多:做缓存、做分布式锁、做计数器、做排行榜,甚至做轻量级的消息队列。

正因为 Redis 如此顺手,大家几乎下意识地把它放在数据库前面,让绝大多数读请求直接打在缓存上。这个架构在性能上没有问题,但引入了一个关键代价:系统的数据源从一个变成了两个。一份数据同时存在于 Redis 和数据库里,什么时候更新谁、以谁为准、更新失败怎么办,这些问题一旦在并发场景下处理不妥,就会出现数据不一致。

我之前经历过一个电商项目,商品详情页的 QPS 高峰期能到 5 万以上,数据库根本扛不住,所以把商品信息整个塞进了 Redis。最开始上线时一切正常,后来运营在后台修改商品价格,改完之后用户端却仍然是旧价格。按说后台改完数据库,再主动更新一下 Redis 缓存就行,但问题在于这个更新操作是异步的,而数据库更新成功之后紧接着有大量用户请求把旧数据读进了缓存,新旧数据互相覆盖,最后表现出来就是“刷新一下价格变一下”的诡异现象。

这个例子暴露了一个本质问题:在并发环境下,多份数据副本的更新顺序、更新时机和更新失败处理,任何一个环节出了问题,都会导致副本之间的不一致。

1.2 一致性问题的本质:快照与持久化的时差

如果把 Redis 缓存看作数据库某个时刻的一份快照,那么一致性问题本质上就是“快照与源数据之间的时间差”问题。

数据库是持久化存储,是数据的权威来源,事务、崩溃恢复、主从复制这些机制保证了它的可靠性。但 Redis 作为缓存,默认是“可丢”的,哪怕支持 RDB 和 AOF 持久化,它的定位依然是加速层。这就意味着,系统设计时默认允许缓存和数据库之间存在一个短暂的不一致窗口。

问题在于,这个窗口在低并发场景下几乎感知不到,因为操作串行化了,先更新数据库再更新缓存,两步之间不过几毫秒。但一旦并发请求打进来,不同请求之间会互相穿插,时序就不可控了:

  • 请求 A 更新数据库,将商品价格改为 100。
  • 请求 A 还没来得及更新缓存,请求 B 已经发起了读请求。
  • 请求 B 发现缓存中的旧价格是 80,直接将旧数据返回给用户,同时可能因为缓存过期而触发回源。
  • 请求 A 此时才更新缓存为 100,但请求 B 读到的旧数据已经返回给用户了。

更麻烦的一种情况是两个写请求同时操作同一份数据。请求 A 将价格从 80 改为 100,请求 B 将价格从 100 改为 120,但两者的执行顺序在数据库和缓存中不一致,比如数据库最终是 120,而缓存被 A 写成了 100。这种错乱几乎无法通过简单的“更新缓存”来解决,因为缓存已经被覆盖了。

也就是说,高并发场景下发生数据不一致,本质上不是 Redis 或者数据库某个单一组件出了问题,而是并发时序导致的多副本写冲突。理解了这一点,才能看懂后面各种解决方案为什么都带着各自的取舍。

2. 高并发场景下的三大经典故障与防御策略

聊一致性之前,必须先处理高并发本身带来的三个经典故障:缓存穿透、缓存击穿、缓存雪崩。这三个问题如果不解决,Redis 集群可能直接被流量打挂,后面的数据一致性更是无从谈起。

2.1 缓存穿透:查不到的数据才是最可怕的

缓存穿透是指请求查询的数据在数据库里根本不存在,所以缓存里也没有,每一次请求都会直接打到数据库。比如一个恶意用户反复请求一个不存在的商品 ID,Redis 里查不到,MySQL 里也查不到,所有请求穿透缓存直达数据库,瞬间就能把数据库拖垮。

我见过最夸张的一次是有人写了个脚本,批量遍历请求一个电商系统的商品详情接口,其中大量无效 ID 根本不存在,几万 QPS 直接绕过 Redis 打到了 MySQL 上,数据库连接数瞬间飙升到上限,整个服务不可用。

应对方案基本有两条路:

第一种是缓存空值。把不存在的 key 也写进 Redis,设置一个较短的过期时间,比如 60 秒。这样相同的无效请求在过期时间内不会穿透到数据库。这个方案实现简单,效果直接,但有个问题:如果恶意请求全是随机 ID,缓存空值的 key 会无限膨胀,浪费大量内存。所以还需要配套做 key 的规范化校验和过期时间控制。

第二种是布隆过滤器。把所有合法 ID 提前写入一个布隆过滤器,请求进来先判断 ID 是否存在,不存在直接返回。布隆过滤器的特点是判断“不存在”是准确的,判断“存在”可能有误判。它不需要存储完整数据,只存哈希位图,内存占用极低。缺点是实现复杂度略高,而且当数据新增时需要同步更新过滤器,如果某些 ID 被删除了,布隆过滤器本身不支持删除,处理起来比较麻烦。

我通常的建议是:基础校验 + 缓存空值,先快速止血;如果穿透流量持续很大,再上布隆过滤器,两者结合是最稳妥的。

2.2 缓存击穿:单个热点 Key 的瞬时崩塌

缓存击穿和穿透是两码事。击穿指的是某个热点 key 在缓存过期的瞬间,大量请求同时涌入,全部回源到数据库。比如一个爆款商品的详情页,平时每秒就有几千请求,缓存刚好在高峰期过期了,这几千请求同一瞬间全部打到 MySQL 上,数据库的压力一瞬间飙升。

针对这个问题的经典做法是互斥锁。当某个 key 在缓存中不存在时,不是让所有请求都去查数据库,而是只让一个请求去回源,其他请求先等待,等缓存重建完成后再读取。这个“等待”可以用 Redis 的 SETNX 实现分布式锁,也可以用本地锁做轻量版。

伪代码大概是这样的:

public String getData(String key) { // 先尝试从缓存读取 String value = redis.get(key); if (value != null) { return value; } // 缓存 miss,尝试获取互斥锁 String lockKey = "lock:" + key; boolean locked = redis.setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS); if (locked) { try { // 双重检查,防止拿到锁后缓存已被其他线程重建 value = redis.get(key); if (value != null) { return value; } value = queryDB(key); redis.set(key, value, 3600, TimeUnit.SECONDS); return value; } finally { redis.delete(lockKey); } } else { // 拿不到锁,说明有其他线程正在重建缓存,可短暂休眠后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getData(key); } }

这里有个细节容易踩坑:拿到锁之后一定要做一次双重检查,因为在你等待锁的过程中,别的线程可能已经完成了缓存重建。如果没有双重检查,很容易出现重复查库和重复覆盖缓存的情况,反而可能把并发时序搞乱。

2.3 缓存雪崩:批量 Key 同时过期的连锁反应

雪崩和击穿的区别在于范围。击穿是单个 key 过期导致瞬间压力,雪崩是大量 key 在同一时间段集中过期,导致数据库瞬间被海量回源请求击垮。

最常见的原因是设置了相同的过期时间,比如所有商品数据统一缓存 1 小时,整点一到,全部失效,下一波流量全部打到数据库。

规避手段也很成熟:

  • 过期时间加随机抖动,比如 3600 秒加上一个 0 到 300 秒的随机值,把过期时间打散。
  • 热点 key 不设置过期时间,改为由后台任务主动更新缓存。这里需要注意,如果更新任务失败,缓存里永远是旧数据,所以需要配合主从节点做双读,防止单点故障。
  • 多级缓存兜底,比如本机使用本地缓存(如本地内存或进程内缓存)作为二级缓存,Redis 挂掉或者过期时先读本地缓存。

这三种经典故障本身不难理解,难的是把它们和后续的数据一致性问题联动起来看。比如用互斥锁解决了击穿,但如果回源查库后写缓存的方式不对,同样会引入不一致问题。再比如后台任务主动更新缓存,如果更新频率和数据库变更频率无法对齐,也会出现缓存落后于数据库的情况。所以高并发场景下的缓存架构,从来不是“把流量挡在数据库前面”这么简单,每个防御策略都会带来新的数据一致性挑战。

3. 缓存与数据库一致性的根源拆解

3.1 双写场景的时序竞争问题

一致性问题的核心场景是“双写”:同一个数据既存在数据库里,也存在缓存里。系统运行时,读请求和写请求同时在操作这份数据,而读写路径上的时序会产生竞争。

把问题简化一下。假设一个商品的价格字段,数据库是权威源,缓存是加速副本。写请求负责更新数据库并同步更新缓存,读请求负责从缓存读取。单线程执行时,整个链路是确定的——读要么读到旧值,要么读到新值,不会混乱。但并发执行时,两个请求互相穿插,不确定的结果就出现了。

经典的竞态条件如下:

  1. 写请求 A 更新数据库,将价格从 80 改成 100,数据库已经提交。
  2. 读请求 B 此时发起读取,发现缓存中价格是 80,于是返回旧值。
  3. 写请求 A 这时再更新缓存为 100。
  4. 读请求 B 的这一次访问已经结束,用户看到的是旧价格。

从数据库角度看,最终状态是对的;从用户角度看,他在写操作完成之后仍然读到了旧数据。如果业务上允许“短暂读到旧值”,那没问题;但如果业务要求写入后立即读取到新值,这就是一个失败的操作。

更隐蔽的是两个写请求并发的情况:

  1. 写请求 A 把价格改为 100,先更新数据库。
  2. 写请求 B 把价格改为 120,也更新数据库,数据库最终是 120。
  3. 请求 B 先更新缓存为 120。
  4. 请求 A 后更新缓存为 100。

数据库是最新值 120,缓存却是 100。这个错误不是“短暂”的,它会一直持续到缓存过期或下一次更新,这种问题危害极大,因为它让系统长期处于不一致状态。

3.2 更新顺序的选择:先DB后缓存,还是先缓存后DB?

围绕“先更新谁”这个问题,业界有大量的讨论。总得来说,主流的做法是“先更新数据库,再更新缓存”或者“先更新数据库,再删除缓存”。至于“先更新缓存再更新数据库”的方案,只在极特殊场景下才会用,常规业务系统不建议尝试。

先更新数据库再更新缓存,存在的问题是当两个写请求并发时,数据库更新顺序和缓存更新顺序可能不一致,导致缓存长期保留旧值。解决办法只有一个:不要更新缓存,改为删除缓存。因为删除缓存之后,下一次读取会自动回源数据库,拉取此时的真实值。相较而言,“删除缓存”比“更新缓存”要安全得多。

于是方案变成:先更新数据库,再删除缓存。这个动作惯性很大,因为更新缓存需要业务数据完整,会经历网络传输、组装、执行序列化,耗时较长,期间并发请求很容易穿插;而删除缓存只是一个 Redis DEL 操作,耗时极短,穿插的概率显著降低。

这是 Cache Aside Pattern 的核心思想:读请求 miss 时回源数据库并写缓存,写请求只更新数据库并删除缓存,由下一次读请求负责重建缓存。

但这里又有一个新问题:删除缓存失败了怎么办?更新数据库成功,但删除缓存时 Redis 出错了,缓存里仍然是旧数据。后续所有请求读到的都是旧值,直到缓存过期。这也是一致性问题的高频来源。

3.3 缓存删除失败的补偿机制

删除缓存失败不是小概率事件,尤其是在缓存服务处于高负载、网络抖动、GC 停顿或者 Redis 连接池耗尽的时候。我统计过线上数据,在一个峰值 QPS 5 万的系统里,缓存删除接口每天可能出现几十次超时,如果不做补偿,每一次超时都对应着一个潜在的不一致窗口。

补偿手段至少有三种:

第一种是删除失败立即重试。在 API 层捕获删除失败的异常,马上重试一次。因为失败大概率是网络抖动或偶发超时,立即重试通常能恢复。要注意重试要有次数和退避策略,避免缓存服务持续故障时造成无意义的重复调用。

第二种是异步重试。设置一个本地消息表,当缓存删除失败时,把删除任务写入一个持久化的待处理队列,由一个后台任务定时扫描并重试。这个方案能保证“最终一定删除成功”,但实现复杂度高一些,需要注意幂等——同一个 key 被重复删除并不会造成危害,所以幂等性天然满足。

第三种是引入订阅数据库变更的机制。通过解析数据库 binlog,将每一次数据变更转发到一个可靠的消费队列(比如消息队列),由消费者负责删除对应缓存。这个方案把“删除缓存”这个动作从业务代码中彻底剥离,删除失败的补偿机制完全由消息队列的重投来保证。这个方案后面会单独展开,因为它也是解决一致性问题的重量级手段之一。

4. 一致性方案落地:从 Cache Aside 到 Binlog 订阅

4.1 Cache Aside Pattern 的正确写法

Cache Aside 是最经典、也是大多数团队应该首先采用的缓存模式。它的规则很简单:

  • 读请求:先读缓存,命中则返回;未命中则回源数据库,将结果写入缓存,再返回。
  • 写请求:先更新数据库,再删除缓存。

正确写法和错误写法之间的差异,在并发场景下会彻底暴露。先说几个必须注意的细节:

回源写缓存时,一定要对写缓存操作做超时控制。我曾经见过某团队在回源时将数据库查出的数据序列化为 JSON 后直接写入 Redis,这个序列化过程如果因为数据量巨大或者网络问题耗时过长,会导致大量请求阻塞在缓存写入环节,进而拖垮整个接口。建议设置一个最长耗时,超过则先返回数据本身,缓存留待下一次读取时再重建。

删除缓存时,不建议直接把删除动作放在数据库事务内。如果把删除缓存和数据库更新放在同一个事务里,一旦数据库事务提交后,删除缓存的代码块抛异常,事务无法回滚,缓存删除却已经失败,此时系统会进入不一致状态。正确做法是保证数据库事务只负责数据库操作,事务提交后,再独立执行缓存删除。

如果业务要求读操作必须读取到最新值,则还需要在读请求上加逻辑保护。比如先查缓存,发现缓存不存在时直接回源数据库,而不要依赖其他缓存重建机制。这个逻辑看起来多余,实际上至关重要,能有效规避异步删除缓存导致的一致性窗口。

4.2 延迟双删:一个非常简单但有效的补偿手段

先更新数据库再删除缓存,这个模式在大部分场景够用了。但它有一个竞态漏洞:

  1. 请求 A 进行写操作,完成数据库更新,准备删除缓存。
  2. 请求 B 在这个瞬间发起了读请求,发现缓存中还是旧值,于是回源数据库,拿到了旧值(因为 A 的更新事务在 B 查询之前还没提交,或者 B 的查询快照在 A 提交之前)。
  3. 请求 B 将旧值写回缓存。
  4. 请求 A 删除缓存,删除的其实是旧值,操作成功,缓存此刻没有数据。
  5. 下一次读请求回源数据库,拿到的是 A 更新的新值,问题消失。

看不出来问题对吗?关键在于第 2 步和第 3 步之间的时序。如果步骤 4 执行时,请求 B 还没完成步骤 3,也就是 A 在 B 写入旧值之前删除了缓存,那最终缓存里就是 B 写回的旧值。于是缓存长期停留在旧状态。

延迟双删就是用来解决这个问题的。具体做法是:在写完数据库之后,先删一次缓存,然后休眠一小段时间,再删第二次缓存。

public void updateData(String key, Object newValue) { // 1. 更新数据库 updateDB(newValue); // 2. 第一次删除缓存 redis.delete(key); // 3. 休眠一段时间,给并发读请求足够的时间回源并写缓存 try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 4. 第二次删除缓存 redis.delete(key); }

这段代码的核心在于 sleep 时间的选择。太短了,可能还没有覆盖到并发读请求完成回源和写缓存的耗时,第二次删除执行得太早,旧值比删除动作晚到;太长了,整个写接口延时变高,用户体验受影响。

实际生产中的经验值是 500 到 1000 毫秒。这个时间大致能覆盖读请求在数据库回源、网络传输、序列化、写入缓存的耗时。更精确的估算方式可以参考业务的 P99 读耗时再额外加一点余量,比如读耗时 P99 是 200 毫秒,那 sleep 可以设置为 400 到 500 毫秒。

延迟双删是治标手段,但它确实能大幅降低不一致窗口,实现成本低,容易理解,适合大多数业务场景。

4.3 基于 Binlog 的订阅方案:把一致性交给数据库

延迟双删还有一个绕不过去的局限:如果删除缓存时 Redis 正好不可用,删除失败且未进入重试循环,那么一切照旧,缓存里仍然是旧数据。想要彻底解决这个问题,需要把缓存删除这个动作从业务链路中解耦出来,交给一个可靠的机制去执行。

可行路线是订阅数据库的 binlog。以 MySQL 为例,每次数据变更都会记录到 binlog 中,我们可以使用客户端模拟主从复制的协议,把 binlog 事件流解析出来,当作一条可靠的数据变更消息投递到消息队列,再由消费者执行缓存删除。

流程大致如下:

  1. 业务代码只负责更新数据库,不需要关心缓存。
  2. 一个独立的同步服务监听数据库 binlog,解析出变更事件。
  3. 将变更事件封装成消息,投递到消息队列。
  4. 缓存消费者收到消息后,执行删除缓存操作。
  5. 如果删除失败,消息队列的重投机制自动补偿,直至删除成功。

这个方案的优势非常明显。第一,它彻底消除了业务代码里对缓存操作的显示依赖,数据库事务和缓存操作不再耦合,任何一方失败都不会拖累另一方。第二,binlog 事件是按数据变更顺序产生的,天然有序,不会出现先删除后更新导致的覆盖问题。第三,消息队列的重试机制保证了最终一致性,删除失败可以被可靠补偿。

劣势也有。首先是架构复杂度显著提升,需要额外维护一个 binlog 同步服务和消费服务。其次是引入中间件带来的延迟,从数据库提交到缓存删除之间通常会有几百毫秒甚至更长的链路延迟,实时性不如同步删除。最后是技术门槛,需要选型或自研 binlog 解析组件,对团队的中间件能力有要求。

我在一个订单系统里采用过这个方案。订单状态变更频繁,用户下单后立刻查询订单详情,必须看到最新状态。最初用同步删除,总会出现“订单支付成功了,但查询接口仍显示待支付”的投诉。后来改成 binlog 订阅方案,支付系统只负责更新订单数据库,缓存删除由消费者异步执行,数据不一致的投诉几乎归零。付出的代价是增加了一个中间件依赖和几个小时的排障时间,换来的是更牢固的一致性保证。

4.4 版本号方案:让数据自带序号

前面几个方案都是针对“缓存只是数据库的只读副本”这种场景。如果业务中存在多端同时写同一个 key 的场景,比如两个服务同时更新一个用户的余额,那需要的是更强的写并发控制,这时候可以给数据加一个版本号,在写入缓存时同时写入版本号。

更新数据时,数据库会通过乐观锁控制——更新语句中带上版本号条件,只有版本号匹配才能更新成功。

UPDATE account SET balance = 100, version = version + 1 WHERE id = 123 AND version = 5;

如果更新成功,说明当前是当前版本,此时将新数据和新的版本号写入缓存。如果更新失败,说明另有一个并发请求先行修改了数据,当前请求需要重新读取、重试。

缓存读取时,不必依赖缓存中的值,可以先读缓存里的版本号,再查数据库确认版本号是否匹配。如果版本不一致,则以数据库为准,并刷新缓存。

这个方案一致性最强,但代价是引入了额外的版本字段和更复杂的读写逻辑,性能也打了折扣。它适用于数据一致性要求极高、写冲突可能性较大的场景,比如账务系统、点券系统等。

日常业务中,我更推荐把版本号用在数据库更新上作为乐观锁机制,避免并发写冲突,而缓存侧仍然用 Cache Aside 模式,通过删除缓存来避免写入冲突。只有当业务明确要求“缓存必须承载最新的权威数据”,才考虑把版本号引入缓存读写链路。

5. 生产环境中的取舍与一致性监控

5.1 什么时候必须强一致,什么时候可以最终一致

在真实项目中,数据一致性从来不是一个绝对值,而是一个范围。有的场景要求“事务提交后,任何客户端立刻读到最新值”,有的场景允许“几秒甚至几十秒内的延迟”。如果所有业务都要求强一致,系统设计会变得极度复杂,性能和可用性也会大打折扣。

用一个简单的列表来区分:

  • 订单状态、支付结果、库存扣减:这些是用户侧能直接感知且影响交易的核心数据,必须做到近乎强一致,至少要求事务提交后用户在可感知的时间内读到最新值。
  • 商品详情页、推荐列表、配置信息:这些是读多写少的展示型数据,允许秒级的最终一致,出现短暂旧值可以容忍。
  • 用户画像、埋点统计、排行榜:这些数据大部分时候不对用户实时可见,最终一致性已经足够。

这个分类直接影响方案选择。核心交易数据,推荐走 binlog 订阅方案,或者干脆把这类数据从缓存里拿掉,直接让查询走数据库,避免承担一致性风险;展示型数据,用 Cache Aside 加延迟双删就能覆盖大多数场景;统计型数据直接用 Redis 做纯缓存即可,不需要过多考虑一致性问题。

5.2 一致性延迟的监控指标设计

方案选完了,不能就此不管。数据一致性问题的风险在于“动态发生的概率低,但一旦发生,影响面大”。如果没有监控,线上出现不一致时你根本不知道是哪一个环节出了毛病。

我一般会在一致性链路上监控四个指标:

第一个是缓存删除失败率。所有删除缓存的操作,无论同步还是异步,都统计成功和失败的次数。失败率突然升高,说明 Redis 连接或服务可能有问题,需要马上介入。

第二个是回源写缓存耗时分布。回源是缓存从旧值到新值重建的关键步骤,耗时长意味着一致性窗口变大。一般统计 P95 和 P99,一旦 P99 超过 500 毫秒就要排查原因。

第三个是数据库变更到缓存生效的时差。这个需要通过定期抽样对比实现:选一组核心 key,记录数据库最近更新时间,然后读取缓存中的更新时间戳,计算差值。差值异常偏大时,说明缓存更新链路可能出现了阻塞。

第四个是热点 key 的过期分布。周期性地从 Redis 扫描各 key 的过期时间分布,看是否存在瞬间大量过期的情况。这个指标可以预警缓存雪崩,同时帮助判断延迟双删的时间设置是否需要调整。

监控本身不解决一致性问题,但一致性问题的排查如果没有监控数据支撑,几乎等于大海捞针。

5.3 一个实际项目中的选型复盘

拿我之前负责的一个跨平台订单系统举例。这个系统有用户端、运营后台、订单服务三个入口,都涉及订单数据的读写。初期方案用的是“先更新数据库,再删除缓存”,并在删除缓存后加了一个 500 毫秒的延迟双删。运行一段时间后,偶尔还是会出现运营后台改完订单状态,用户端半小时内仍然显示旧状态的问题。

查来查去,根子在于删除缓存逻辑分散在各业务代码里,某些服务的删除操作没走统一的工具类,而是自己在代码里拼 Redis key,拼错了 key,等于删了个寂寞。另外,运营后台改订单时使用了批量更新,更新数据库成功但删除缓存只删除单个 key,批量涉及的 key 全部漏掉。

后来我们做了两件事。第一,把所有关于缓存删除的代码收敛到一个公共的删除组件,统一管理 key 规则、失败重试和监控上报。第二,引入 binlog 订阅方案,数据库变更后自动触发缓存删除,删除失败由消息队列重投,不依赖调用方的代码质量。

改造之后,一致性问题基本消失。这个复盘里最重要的教训是:方案本身没有绝对的对错,但执行环节的标准化非常关键。再好的方案,如果落在参差不齐的代码实现里,都会变成另一个定时炸弹。

6. 高频问题排查实录

6.1 缓存与数据库数据不一致现象速查表

长期和 Redis 打交道,我整理了一份高频问题速查表,排查时对照排查,效率很高。

现象可能原因排查方向
缓存里是旧值,数据库是新值删除缓存失败或删除动作未执行检查删除操作日志、Redis 连接状态、重试机制
缓存和数据库都是旧值,但比最新值旧延迟双删 sleep 时间不足,旧值在第二次删除后写回调大 sleep 时间,延长第二次删除等待窗口
缓存和数据库都是新值,但并发场景下偶发不同两个写请求并发时更新顺序不一致,缓存被覆盖改用删除缓存而非更新缓存,或引入版本号
缓存里存在某个 key,数据库中已删除删除数据库记录时没有同步删除缓存检查删除数据对应的缓存清理逻辑
缓存 key 大量同时过期,数据库瞬时压力大过期时间没有加随机值调整过期时间策略,增加随机抖动
缓存命中率持续降低,数据库压力升高缓存 key 生命周期过短,或回源写缓存失败检查过期时间、回源日志和缓存写入成功率
热点 key 变更后长时间不一致热点 key 做了本地缓存或常驻缓存,更新不及时检查多级缓存各层级的失效策略

这张表的共同点是,大多数不一致的根因都可以归为两类:要么是缓存操作没有执行成功,要么是缓存操作的时序在当前并发条件下不够合理。针对这两类根因去排查,方向基本正确。

6.2 排查实例:一张订单表为何反复出现脏数据

分享一个在测试环境反复出现的脏数据案例。某订单表里的金额字段经常和数据库不一致,修复后过一会儿又不对了,每次不一致的金额都是旧值。

第一轮排查先看删除缓存日志。日志显示删除缓存动作全部返回成功,但删除的 key 和业务查询时用的 key 不一致——一个用的是订单号,一个用的是订单号加渠道维度拼接的复合 key。找到问题后统一 key 规则,脏数据暂时消失。

隔了一天又出现,而且出现在新订单上。这次查日志发现,删除缓存之前数据库更新事务并没有提交完成,读取请求在事务未提交时回源数据库,拿到了事务修改前的旧值,写入缓存。也就是说,先删缓存在事务提交之前的场景也会产生问题。

最终修复方式是把数据库事务提交成功后再执行缓存删除,同时把延迟双删的 sleep 时间从原来的 300 毫秒提升到 600 毫秒。这个案例说明,数据一致性问题往往不是单点失效,而是时序和实现细节叠加的产物。

6.3 线上排障工具箱:必备脚本与命令

有没有一些顺手的小脚本可以快速定位问题?我分享几个常用的。

检查 Redis key 是否存在并查看其 TTL:

redis-cli -h your-host -p your-port exists product:123 redis-cli -h your-host -p your-port ttl product:123

查看缓存 value 和数据库当前值的差异,可以用一个极简脚本:

#!/bin/bash redis_key="product:123" cache_value=$(redis-cli -h your-host -p your-port GET $redis_key) db_value=$(mysql -h your-db-host -u user -p pass -e "SELECT price FROM product WHERE id=123" | tail -n1) echo "Cache: $cache_value" echo "DB: $db_value"

如果需要确认 Redis 删除操作是否失败,最直接的方式是开启 Redis 的 slowlog 以及记录客户端断开连接时的错误日志:

redis-cli -h your-host -p your-port slowlog get 10

如果 Redis 实例的内存和连接数出现异常,优先检查 bigkey,因为 bigkey 的删除和更新会引发阻塞和超时:

redis-cli -h your-host -p your-port --bigkeys

这套工具谈不上高明,但在定位实际问题时,比单纯看监控面板要快得多。

结尾想分享一个个人体会。Redis 高并发场景下的数据一致性,永远是一个系统性的问题,解决方案再多,也绕不开对业务场景的权衡。你不能既要高性能、又要强一致、还要低成本,这本身是不现实的。对我来说,做这个环节的架构决策时最实用的一招,是把业务按“是否能容忍秒级延迟”做分类,再对应去筛选方案,而不是拿着某一个方案去套所有业务。踩过几次坑之后,我反而更相信简单的方案加扎实的执行,数据库权威源加正确的缓存删除策略,大多数业务真的不需要那么复杂的架构。先把这些基础功夫做扎实,再考虑上不上 binlog 订阅这些重型方案也不迟。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进