先直接从从业者的视角开始聊吧。做后端这几年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 缓存穿透查不到的数据才是最可怕的缓存穿透是指请求查询的数据在数据库里根本不存在所以缓存里也没有每一次请求都会直接打到数据库。比如一个恶意用户反复请求一个不存在的商品 IDRedis 里查不到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 双写场景的时序竞争问题一致性问题的核心场景是“双写”同一个数据既存在数据库里也存在缓存里。系统运行时读请求和写请求同时在操作这份数据而读写路径上的时序会产生竞争。把问题简化一下。假设一个商品的价格字段数据库是权威源缓存是加速副本。写请求负责更新数据库并同步更新缓存读请求负责从缓存读取。单线程执行时整个链路是确定的——读要么读到旧值要么读到新值不会混乱。但并发执行时两个请求互相穿插不确定的结果就出现了。经典的竞态条件如下写请求 A 更新数据库将价格从 80 改成 100数据库已经提交。读请求 B 此时发起读取发现缓存中价格是 80于是返回旧值。写请求 A 这时再更新缓存为 100。读请求 B 的这一次访问已经结束用户看到的是旧价格。从数据库角度看最终状态是对的从用户角度看他在写操作完成之后仍然读到了旧数据。如果业务上允许“短暂读到旧值”那没问题但如果业务要求写入后立即读取到新值这就是一个失败的操作。更隐蔽的是两个写请求并发的情况写请求 A 把价格改为 100先更新数据库。写请求 B 把价格改为 120也更新数据库数据库最终是 120。请求 B 先更新缓存为 120。请求 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 延迟双删一个非常简单但有效的补偿手段先更新数据库再删除缓存这个模式在大部分场景够用了。但它有一个竞态漏洞请求 A 进行写操作完成数据库更新准备删除缓存。请求 B 在这个瞬间发起了读请求发现缓存中还是旧值于是回源数据库拿到了旧值因为 A 的更新事务在 B 查询之前还没提交或者 B 的查询快照在 A 提交之前。请求 B 将旧值写回缓存。请求 A 删除缓存删除的其实是旧值操作成功缓存此刻没有数据。下一次读请求回源数据库拿到的是 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 事件流解析出来当作一条可靠的数据变更消息投递到消息队列再由消费者执行缓存删除。流程大致如下业务代码只负责更新数据库不需要关心缓存。一个独立的同步服务监听数据库 binlog解析出变更事件。将变更事件封装成消息投递到消息队列。缓存消费者收到消息后执行删除缓存操作。如果删除失败消息队列的重投机制自动补偿直至删除成功。这个方案的优势非常明显。第一它彻底消除了业务代码里对缓存操作的显示依赖数据库事务和缓存操作不再耦合任何一方失败都不会拖累另一方。第二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 是否存在并查看其 TTLredis-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_keyproduct: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 id123 | 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 订阅这些重型方案也不迟。
阅读完成 · 觉得有帮助?