
做黑马点评这个项目复盘的时候我其实是被一场线上事故逼着去重新审视 Redis 缓存一致性的。项目里商品详情页的 QPS 很高给 Redis 加缓存之后命中率一度到了 95%但有一次运营改了商品评分之后用户端看到的还是旧评分数据库里明明是新值缓存里却死活不更新。我翻遍了业务代码发现问题出在缓存更新的顺序和过程中这里面最核心的就是 Cache Aside Pattern 的落地细节。这篇文章结合我在黑马点评里的实际排障和重构经验把 Redis 缓存一致性实战中的坑、原理和方案完整复盘一遍给也准备做点评类、零售类项目的同学做个参考。1. 黑马点评的业务痛点缓存加进去之后问题才刚刚开始1.1 为什么这个项目非上 Redis 不可黑马点评模拟的是类似大众点评的业务场景商家、点评、优惠券、签到、关注。最典型的高并发读就是首页的商家列表和商家详情页。没有缓存的时候一次详情请求要查询 shop 主表还要关联评论数、评分、营业状态等聚合数据代码里甚至要发多条 SQL 再在内存里装配。热点商家在搞优惠活动的时段详情页 QPS 能冲到几百上千数据库连接池瞬间被打满接口响应从 20ms 涨到 2000ms 甚至超时。这时候上 Redis 几乎是必然选择。Redis 是纯内存操作单线程模型下也能扛住极高的 QPS把商家详情的 JSON 字符串缓存起来后读请求命中就直接返回响应压到 1ms 以内数据库压力大幅下降。如果你把黑马点评的缓存拿掉再压一遍测接口就能直观感受到差距。所以缓存不是可选项是保证点评类业务读多写多但依然能抗住的关键手段。1.2 缓存加了读路径通了写路径开始爆炸但缓存不是加完就一劳永逸。黑马点评这类项目有个很典型的特征虽然读多但写也不少。商家改店名、改评分、改营业时段优惠券秒杀后库存要减用户签到后签到位图要变。这些写操作都会让数据库里的真实数据和缓存里的旧值产生偏差。我刚开始也图省事把缓存一致性简单理解成数据库更新完顺手把缓存里的 key 也更新一遍。但实际一跑就出问题。比如两个线程同时更新同一个商家线程 A 把评分改成 4.5线程 B 把营业时间改成 22:00数据库最终结果可能是 A 的 4.5 和 B 的 22:00 都生效了但如果 A、B 各自执行set覆盖缓存最终缓存里可能是 A 写的完整旧数据加 B 写的完整新数据混合体或者干脆是某一个线程读出来的旧快照。这种双写不一致比不加缓存还难查。1.3 先想清楚这里的一致性到底指的是什么谈到缓存一致性要先定一个调。业界公认的强一致性方案通常要引入分布式锁、事务消息、甚至两阶段提交代价极高普通业务根本扛不住。黑马点评这种教学项目和多数中小型服务追求的其实是最终一致性加尽可能短的延迟。也就是说允许缓存和数据库在极端情况下短暂不一致比如几百毫秒但在几秒内必须恢复对齐。这个定位非常重要因为后面所有方案都是在不一致窗口大小和实现复杂度之间做取舍。Cache Aside Pattern 之所以经典就是因为它用最简单的手段在大多数场景下能把这个窗口压得很小同时实现成本很低。2. Cache Aside Pattern 的正确打开方式先更新数据库再删缓存这个顺序不能变2.1 教科书定义与常见的大脑短路Cache Aside也叫旁路缓存操作原则很简单读请求先读缓存命中直接返回未命中则查数据库把结果写进缓存并设置过期时间然后返回。写请求先更新数据库然后删除缓存而不是更新缓存。读路径大家都能理解。真正容易出问题的是写路径。很多人第一反应是既然缓存数据旧了那我把缓存更新成新值不就行了为什么要删除而且为什么要先更新数据库我的理解是删除比更新更安全。缓存里存的往往是聚合视图比如一家商家的详情是把 shop 表、review 统计、优惠券状态等多处信息拼出来的。你在写操作时把这些信息全部重算一次再写进缓存成本高、容易漏。而删除则简单得多下一次读请求发现缓存 miss自然会回去查最新数据库并重新组装。从这个角度看Cache Aside 本质上是一个延迟重算策略。2.2 为什么先删缓存再更新数据库在实际并发里会翻车不少初学者包括早年的我会想反正写操作频繁先把缓存删掉让读请求回源数据库再更新数据库这样缓存永远不会是旧值。这是错的。原因在于删除缓存和更新数据库是两个独立步骤中间一定有间隙。我画一下真实并发时序线程 A 发起写操作先删除缓存的 key。线程 B 发起读操作缓存 miss于是去查询数据库。此时线程 A 还没更新数据库所以线程 B 读到的是旧值。线程 B 把旧值写回缓存。线程 A 更新数据库数据库变成新值但缓存已经被 B 写成了旧值。结果就是数据库是新值缓存是旧值而且这个旧值可能一直存活直到缓存过期。这个时序在真实项目里一点都不罕见因为读请求和写请求是并发的频率稍高就会碰撞。2.3 反过来先更新数据库为什么窗口就小很多Cache Aside 的标准顺序先更新数据库再删除缓存能把不一致窗口压缩到极短。同样的并发场景反过来线程 A 更新数据库数据库为新值。线程 B 在读缓存时 miss去查数据库它可能在线程 A 提交前或提交后查询。如果查询在 A 提交后读到的是新值写回缓存也是新值。如果查询在 A 提交前读到了旧值但写回缓存的动作是有延迟的。线程 A 更新完成后删除缓存。注意这个删除动作发生在 B 写回缓存之前还是之后决定了旧值能否被清掉。最坏的一种情况是B 在线程 A 更新前读到了旧值但因为网络原因迟迟没有写回缓存等线程 A 更新完并删除缓存后B 才把旧值写回于是缓存又变成旧值。这种窗口确实存在但比先删再写的场景小得多因为它在时间上多依赖了一个读完再写的长链路。这也是教科书推荐这个顺序的核心原因宁可面对一个极小概率的残留窗口也不要去踩一个大概率发生的坑。2.4 一定要有兜底每个缓存 key 都要有过期时间哪怕实施标准 Cache Aside也一定要保证所有缓存 key 都有合适的过期时间。过期时间是最后一层保险就算上面最坏情况真的发生缓存过期后下一次读请求依然会从数据库拉最新数据最终一致性会被自动修复。黑马点评里商家详情的 key 我习惯设置 20 到 30 分钟过期还要加上随机偏移量。这么做一方面防止缓存雪崩另一方面等于给每个 key 加了一个自我修复的倒计时。你甚至可以把这个过期时间看作你最坏情况下的不一致容忍上限所以不要设置成一天甚至永久。3. 一次真实的黑马点评缓存事故现象、根因和完整排查链路3.1 现场评分改了前端纹丝不动有一次运营反馈说某个店铺的评分从 4.8 改成了 4.5但用户端页面看到的还是 4.8。我先查数据库确认 UPDATE 语句已经提交数据库里是 4.5再查 RedisGET cache:shop:detail:1001返回的 JSON 里评分还是 4.8TTL 还剩 1800 秒。这就排除了缓存过期后重新加载的可能问题出在缓存更新机制本身。3.2 第一轮排查读路径代码没问题写路径一看就头大先把查询代码过一遍模式是标准的 Cache Aside 读路径public Shop getShopById(Long id) { String key cache:shop:detail: id; String json stringRedisTemplate.opsForValue().get(key); if (json ! null) { return JSONUtil.toBean(json, Shop.class); } Shop shop shopMapper.selectById(id); stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 20, TimeUnit.MINUTES); return shop; }看起来没问题。再看更新代码问题立刻浮现。我最初为了让改完立刻生效写的是public void updateShop(Shop shop) { stringRedisTemplate.delete(cache:shop:detail: shop.getId()); shopMapper.updateById(shop); }先删缓存后更新数据库。当时我的想法是删掉缓存后读请求就会回源等数据库更新完后面读到的一定是新值。但这正是最容易导致旧值回填的写法。3.3 第二层根因并发时间线的完整还原运营改评分的时候刚好有一个用户打开了这个店铺的详情页。用户的读请求先到运营线程先执行了delete把缓存 key 删掉然后用户线程去查数据库。此时运营线程还没来得及执行updateById也就是说数据库的评分还是 4.8。用户线程把 4.8 这个旧值写回了缓存。运营线程随后执行 UPDATE数据库变成 4.5但缓存里躺着的已经是用户线程写回去的 4.8。这个碰撞窗口很小但发生的概率和并发程度成正比。那天恰好运营操作和用户访问撞在了一起就抓到了活体的不一致现场。我把这个时序写进复盘文档给我的团队演示了一遍大家都沉默了——因为每个人第一版代码几乎都是先删缓存再更新库。3.4 修复与验证先改顺序再用并发压测说话修复很简单public void updateShop(Shop shop) { shopMapper.updateById(shop); stringRedisTemplate.delete(cache:shop:detail: shop.getId()); }改完之后我没有急着上线而是写了一个小的并发测试。用两个线程一个线程循环执行更新评分后删除缓存另一个线程循环执行读缓存若 miss 则查库回填。跑了几分钟后对比数据库最终值和缓存值不一致次数从修复前的几十次降到了零。这个测试虽然简陋但足以说明先更新数据库再删缓存的顺序在正确性上是明显占优的。3.5 问题没有结束Redis 一次抖动删除缓存失败原以为改完顺序就万事大吉结果没过几天又出事了。那次 Redis 实例发生网络抖动stringRedisTemplate.delete()抛出超时异常而更新数据库已经提交成功了。异常导致删除操作没执行缓存里还是旧值用户端看到的评分继续出错只能等缓存过期这个窗口长达几十分钟。这就暴露了 Cache Aside 一个非常现实的问题你无法保证每一次 Redis 操作都成功。删除缓存失败必须要有补偿机制。我知道很多教学项目不写这块但真实上线后你一定会遇到。于是在后面的版本里我给删除缓存加上了重试队列。4. 延迟双删的工程化落地间隔计算、重试与兜底4.1 为什么标准的 Cache Aside 还差一口气在讲延迟双删之前先承认一个事实哪怕我们改成了先更新数据库再删除缓存极端并发下仍然可能遇到一个小概率窗口就是旧值回填发生在删除动作之后。具体时序可以这样写线程 A 更新数据库数据库变成新值 v2。线程 B 在 A 更新前发起了读请求查到了数据库旧值 v1但 B 的网络或 CPU 调度导致它写回缓存的动作被推迟。线程 A 执行 DEL key此刻缓存里可能是空或者还是旧值 v1反正 DEL 把键删掉了。线程 B 终于执行写回缓存把 v1 写进去。缓存最终得到的是 v1而数据库是 v2。这个时间线里哪怕删除成功了缓存还是旧值。要解决它只能等这个迟到的写回发生之后再删一次。这就是延迟双删的由来。它不是独立于 Cache Aside 的模式而是在 Cache Aside 基础上加了一次延迟的补删。4.2 延迟时间怎么算公式和黑马点评里的实测值延迟双删的关键参数是第二次删除要等多久。这个时间不能拍脑袋。核心原则是等待时间要大于一个读请求从查询数据库到把结果写回缓存的最坏耗时。在黑马点评里我给商家详情接口加了统计日志观测缓存 miss 之后一条读请求从selectById到opsForValue().set的完整耗时。压测峰值下这个耗时最大到了 60ms平时平均在 20ms 左右。我当时把双删延迟定成 500ms等于最大值的 8 倍多保守到几乎不会漏网又不会让用户明显感觉到延迟。如果你自己写类似项目建议先打点测一测再定别直接抄别人的数。如果读链路里有慢查询、GC、网络跨机房等因素这个值可能需要放大到 1 秒以上。数值不是越大约好因为延迟时间就是业务可容忍的不一致窗口的一部分。4.3 同步 sleep 还是异步延迟我选了异步最朴素的写法是在更新方法里Thread.sleep(500)后再删一次。但这么干有一个明显问题写接口的响应会被拖慢 500ms这在 QPS 稍高的时候会浪费大量线程资源还容易引发上层超时。更规范的做法是异步化。在黑马点评的单体架构里我引入了一个极简的延迟任务组件用一个DelayQueue配一个定时消费线程。写请求在数据库提交后把要删除的 key 封装成延迟任务丢进队列消费者线程等到 500ms 后执行删除。这样写接口不用 sleep用户感知不到额外等待。代码结构大致如下public void updateShop(Shop shop) { shopMapper.updateById(shop); String key cache:shop:detail: shop.getId(); stringRedisTemplate.delete(key); delayDeleteCache(key, 500); }这里的delayDeleteCache就是把 key 放进延迟队列由后台线程在指定时间后再次删除。实现起来的核心点只有一个延迟任务要带重试能力删除失败后按 1 秒、2 秒、4 秒的指数间隔重试最多试三次再失败就写入失败日志表。4.4 延迟双删治标不治本但它很实用必须说明延迟双删并不是银弹。它只能尽量覆盖迟到的旧值回填这个窗口但无法消除所有不一致的可能性。比如两次更新之间如果删除缓存动作本身就失败且重试也失败那最终一致性还是要靠过期时间来兜底。但在大多数业务里延迟双删已经足够了。它把不一致窗口从可能持续几秒甚至到过期压缩到毫秒级别且大概率不会发生并且代价比引入分布式锁、强一致中间件低得多。对黑马点评这种项目来说Cache Aside 异步延迟双删 过期兜底是个性价比非常高的组合。5. 比双删更省心的方案Binlog 订阅 异步删除缓存5.1 应用层删缓存的边界多个服务改同一份数据时怎么办在很多工程场景里并不只有一个服务在写数据库。比如黑马点评如果拆成商家服务、订单服务、营销服务任何一个服务都可能修改 shop 表甚至关联表。如果每个服务都在自己的业务代码里写删除缓存的逻辑很快会出现两种情况要么漏删要么删错 key。Cache Aside 的应用层方案这时候就显得力不从心。更可靠的思路是把删除缓存这件事从业务代码里剥离出来让数据库本身的变更事件来驱动缓存失效。5.2 Canal 的玩法伪装成 MySQL 从库读 binlogCanal 是一个开源中间件原理很聪明它把自己伪装成 MySQL 的从节点向主库请求 binlog拿到行级别的变更记录后解析成结构化数据。我们在消费端收到事件根据表名、主键拼出缓存 key执行删除。对黑马点评的改造可以这样设计shop表发生 UPDATECanal 捕获到shop_id把消息投递到 MQ消费者拿到shop_id后执行DEL cache:shop:detail:shop_id。业务代码里不再需要手动删缓存只需要保证数据库 binlog 开着。几个关键点要注意幂等Redis DEL 天然幂等同一个 key 删多少次结果都一样所以消息重复投递不是问题。顺序多个更新事件顺序错乱会导致多删或漏删吗不会因为最终只要执行了一次 DEL缓存就会被清掉下一次读会回源。所以顺序性在删缓存场景下没有那么重要。延迟从 binlog 解析到 MQ 再到消费者执行通常有几十毫秒级延迟这已经比大多数业务可接受的最终一致性要求好。5.3 黑马点评里用这套方案是不是杀鸡用牛刀从架构炫技的角度看给一个教学项目上 Canal 确实有点重。因为你需要额外搭建 Canal server、配置 MySQL binlog、引入 MQ、编写消费端程序还要处理部署监控。这些成本对于一个单体 Spring Boot 项目来说很容易变成负担。但如果你在做真实项目并且确实存在多服务写同一张表、多个团队维护多个服务的情况Binlog 订阅几乎是唯一能保证业务代码里不漏删缓存的方案。我自己的判断是先老老实实把 Cache Aside 和延迟双删用熟等你发现业务代码里到处是删除缓存的 call删来删去还总有遗漏的时候再考虑 Canal 也不迟。5.4 还有一条中间路线本地消息表 事务如果觉得 Canal 太重又不想在业务代码里写脆弱的删除逻辑可以用经典的本地消息表方案。在更新数据库时顺手在同一个本地事务里插入一条待删除缓存消息记录事务提交后由后台任务扫描这张表把消息发到 MQ 或直接执行 Redis 删除成功后删除这条消息记录。这样能保证数据库更新成功和缓存删除消息被持久化是原子的不会出现更新库成功但删除动作彻底丢失的问题。这个方案不需要额外中间件只多建一张表非常适合黑马点评或者刚起步的小团队。本地消息表看似朴素但它是很多大规模系统里事务消息的雏形理解了它你再看 RocketMQ 的事务消息就非常容易看懂。6. 复盘里最容易被忽视的细节序列化、过期策略和监控6.1 Redis 序列化方式不一致删缓存删了个寂寞在黑马点评里我见过不少同学踩这个坑写入缓存时用的是StringRedisTemplatekey 序列化器是StringRedisSerializer缓存 key 是明文shop:1但到了删除缓存时如果用RedisTemplate而不是StringRedisTemplate默认 key 序列化器是JdkSerializationRedisSerializershop:1会被序列化成类似\xAC\xED\x00\x05t\x00\x07shop:1的字节。结果就是你执行delete删的是一个不存在的乱码 key真正的明文 key 根本删不到。这种问题不看 Redis 客户端里的 keys 列表根本发现不了。解决办法是老生常谈的统一 key 和 value 的序列化器。推荐的做法是 key 用StringRedisSerializervalue 用GenericJackson2JsonRedisSerializer。后者在 JSON 里写入class信息牺牲一点存储换来反序列化的健壮性。但要注意这个 JSON 字符串不是纯业务数据如果你在另一个系统直接读缓存需要兼容那个额外字段。6.2 过期时间的双重作用防雪崩也做一致性兜底上一章提到过期时间是最底层的兜底这里再补充一个实践细节。缓存 key 的过期时间一定不要设成固定值否则大量 key 在同一秒过期瞬间回源数据库会造成缓存雪崩。黑马点评里我习惯设置基础时间 30 分钟再叠加一个 0 到 5 分钟的随机量。这样同一个商家详情 key 的实际过期时间在 30 到 35 分钟之间浮动既避免雪崩又保证了缓存自我修复的下限。还要警惕一个问题如果某些 key 被高频访问读请求在缓存 miss 后回填时会顺带重置过期时间那这个 key 可能永远不过期。这时候过期时间作为一致性兜底的作用就失效了。所以在写回缓存时要明确区分第一次加载和缓存续期的逻辑至少不要让非热点的旧 key 被无限续命。6.3 如何确认缓存与数据库最终一致你可以在项目里加一个简单的比对脚本用来做回归验证。具体思路给 shop 表加一个update_time字段缓存的 JSON 里也保存这个字段定时任务随机抽若干商家查数据库里的update_time和缓存里的update_time对比不一致则告警。这个验证比人工查 TTL 可靠得多也是我在黑马点评里最终采用的检查方式。如果不想加字段也可以比对数据库里被缓存对象的业务字段快照。但加一个更新时间是最便宜、最直观的做法。注意比对的时间粒度不要太细因为缓存允许有一个短暂的延迟窗口比如 500ms所以定时比对任务建议按秒级甚至分钟级执行避免把正常延迟误报警。6.4 几条写进我自己的复盘笔记的经验到这里整个缓存一致性方案的脉络已经梳理完了。我最后想分享几条自己的经验都是踩过坑以后才想明白的读多写少是使用 Cache Aside 的前提如果是写频繁且并发高的数据比如秒杀库存不如直接用 Redis 作为主存储而不是用 MySQL 加缓存绕来绕去。写操作里不要赌删除缓存一定能成功重试和兜底是必须品。最简单可靠的兜底就是过期时间。每次改动缓存逻辑时把并发时序画出来哪怕是文字版能帮你提前发现很多隐藏问题。写完方案后问自己一句如果删除缓存失败最坏会发生什么一致性方案要跟团队水平匹配。架不住 Canal 就别上 Canal先把延迟双删和本地消息表跑透效果一样能打。复盘到最后我反而觉得黑马点评最有价值的不是用了多少中间件而在于它是一个能把缓存一致性这些纸上谈兵的细节变成真实踩坑和修复过程的样本项目。如果你也在做类似的点评、电商或内容系统的实战项目建议不要跳过缓存一致性这一课照着 Cache Aside 先实现一遍再逐步加上重试、延迟双删和更高级的补偿方案。你会发现每一次加复杂度之前你都能说出它到底在防哪种并发场景这才是做项目复盘最值钱的部分。