
黑马点评这个项目很多人做完之后都有一个共同感受功能都通了但自己写的缓存代码到底靠不靠谱心里完全没底。商户查询加了 Redis照着视频写了缓存查询和更新逻辑跑起来也很快可一旦被人问一句“缓存和数据库之间怎么保证一致”当场就卡壳。这篇文章想认真复盘一遍我在黑马点评里重写缓存模块的全过程核心主题就是基于 Cache Aside Pattern 做缓存一致性治理把“能用”改成“可靠”。文章会围绕几个关键问题展开为什么默认选 Cache Aside、代码层面怎么落地、并发场景下怎么兜底、以及面试时怎么把这件事讲明白。如果你正在做黑马点评这类仿大众点评项目或者正准备面试时聊缓存一致性这篇文章应该能帮你省下不少排查和试错的时间。1. 复盘业务场景缓存一致性问题是怎么浮出水面的1.1 商户查询典型的读多写少场景先看黑马点评的核心业务之一商铺信息查询。打开应用用户会在首页、地图、搜索结果里看到一堆商铺卡片点进详情页又是反复的商铺信息查询。业务特征非常鲜明——读量巨大写量很少而且商铺数据相对稳定不会每秒都在变。这种“读多写少、数据稳定”的业务几乎是教科书级别的缓存适用场景。直接查 MySQL 的话一张热门商铺详情页在活动期间会被刷几千次数据库连接池很容易被打穿。所以常规做法就是引入 Redis 当缓存层把商铺信息以 JSON 形式存在 Redis 里查的时候先走缓存。但问题也随之而来商家改店名、改营业时间、改地址之后MySQL 里的数据已经更新了Redis 里却还是老样子。用户刷新页面发现店铺信息没变甚至看到一个已经打烊的店铺显示“营业中”这就是最直观的缓存与数据库不一致。要解决这个问题先得想清楚一件事数据库是事实来源缓存是副本。我们的目标不是让两边每时每刻完全相等而是让不一致的时间窗口尽量小小到业务可以接受。也就是常说的最终一致性。想明白这一点后面那些方案对比才有基准。1.2 最容易踩的直觉坑先更新缓存再更新数据库很多新手第一次写更新逻辑时直觉反应是先改 Redis 再改 MySQL。因为用户修改商铺信息后肯定要让查询马上看到新值那先把缓存改掉感觉很自然。我最初也是这么写的直到被一个问题当场问住数据库更新失败了怎么办举个具体例子。线程 A 先把商铺状态从“营业中”改成“已打烊”缓存里已经显示“已打烊”结果 MySQL 更新超时回滚了。此时数据库里仍然是“营业中”缓存里却是“已打烊”。同一个店铺两个数据源给用户完全相反的信息而且这个错误状态在缓存里可能持续很久。更隐蔽的是并发交替执行。线程 A 更新缓存为“价格 100 元”线程 B 更新缓存为“价格 99 元”然后 A 更新数据库为 100 元失败回滚B 更新数据库为 99 元成功。最后库里是 99 元缓存却是 100 元。这里的问题在于缓存被当作写路径的第一落点后任何一次数据库失败都会把错的中间态永久留在缓存里。所以我的结论很明确在绝大多数业务场景下缓存不能作为写入的第一落点。写操作必须以数据库为准缓存只在“读”这一侧做加速。这就是 Cache Aside Pattern 的核心思想也是我后来整个缓存模块的地基。2. Cache Aside Pattern为什么“先更库后删缓存”是默认答案2.1 旁路缓存的工作流与角色定位Cache Aside Pattern也叫旁路缓存模式是实际工程中用得最多的缓存策略。它给读和写分别定义了两条路径读路径先查 Redis命中就直接返回缓存数据没命中就查 MySQL把结果写回 Redis再返回给调用方。下次同样的查询就能命中缓存。写路径先更新 MySQL再删除 Redis 里对应的 key。下一次读的时候发现缓存没了重新查库回填。整个流程里Redis 完全处于“被支配”的位置它只负责读加速不参与写入决策。有一点必须强调这里最容易被忽略的细节是“删除缓存”而不是“更新缓存”。很多人写着写着就顺手改成 set 新值了这个改动会带来两个隐藏问题下面单独展开。2.2 为什么是“删除缓存”而不是“更新缓存”先看性能维度。如果一个店铺被频繁修改比如每次点击量、评分微调都写数据库那“更新缓存”意味着每条数据库更新都要跟着做一次 Redis 写操作。而“删除缓存”只是标记 key 失效下一次读到了才回填。对于读多写少的场景删除一次缓存的成本明显更低而且避免了大量无意义的缓存写入。再看并发安全维度。假设线程 A 和线程 B 同时更新同一个店铺A 先把缓存更新为值 1B 把缓存更新为值 2。但数据库落库顺序可能是 A 先提交、B 后提交缓存里最终是 2数据库里也是 2这没问题。可如果提交顺序反过来了B 先提交数据库、A 后提交数据库最终是 1缓存却是 2两边就对不上了。更新缓存操作本身受并发时序影响容易出现旧覆盖新。删除缓存就没有这个问题。无论哪条线程先删后删最终结果都是缓存里没有这个 key。下一次读请求来了统一从数据库拿最新值回填。用“删除”替代“更新”本质上是用一次缓存 miss 的代价换取并发环境下更简单的一致性保证。这也是 Cache Aside 能在工程里成为默认选择的核心原因。借助一张简表对比会更直观对比项更新缓存删除缓存写路径开销每条更新都写 Redis只删 key按需回填并发乱序风险旧值可能覆盖新值无覆盖问题一致性恢复依赖正确顺序下次读自然恢复适用场景读多且写也多的少数场景绝大多数读多写少场景2.3 Cache Aside 自身的两个隐藏风险Cache Aside 并不是完美的我在实际使用中发现它有两个明显的隐藏风险。第一个风险是缓存删除后立刻有大量读请求穿过缓存打到数据库。比如一个大型活动开始时某个热门商铺的缓存刚好过期用户疯狂点击这个商铺瞬间几百个请求全部 miss直接打到 MySQL数据库连接池可能被打满。这是缓存击穿问题在后面的章节会专门讲解决方案。第二个风险更隐蔽它发生在并发读写的交界处。线程 A 更新数据库为新值线程 B 在这之前读了缓存发现缓存 miss于是去查数据库。如果 B 比 A 稍早一点查到了旧值并回填缓存就会出现缓存里存的是旧值而数据库已经更新的情况。这个时间窗口非常短但确实存在而且在高并发下会被放大。这两个风险实际上就是我在项目里后续引入互斥锁、逻辑过期和延迟双删的根本原因。理解 Cache Aside 的边界才知道什么时候需要给它补一层保险。3. 黑马点评实战落地手写 Cache Aside 的关键细节3.1 序列化选型让缓存 Key 可读可排查黑马点评标准项目里用的是 RedisTemplate很多人在这一步被序列化方式坑过。默认的 RedisTemplate 使用 JdkSerializationRedisSerializer存进去的 key 会变成\xAC\xED\x00\x05t\x00\ncache:shop:1这样的二进制串。在 Redis 可视化工具里看到的就是一堆乱码排查起来非常痛苦。我的做法是直接切换到 StringRedisTemplate配合 JSON 序列化。key 直接用字符串拼接value 用 Fastjson 或 Jackson 序列化成 JSON 存进去。这样在 Redis Desktop Manager 或者 Another Redis Desktop Manager 里看到的是清晰的cache:shop:1值也是一眼能读懂的 JSON排查问题效率提升非常多。查询代码的核心逻辑是这样public Shop queryById(Long id) { String key cache:shop: id; String shopJson stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } // 判断空值缓存避免缓存穿透 if (shopJson ! null) { return null; } Shop shop shopMapper.selectById(id); if (shop null) { // key 不存在且数据库也没有缓存空值TTL 设短一些 stringRedisTemplate.opsForValue().set(key, , 2, TimeUnit.MINUTES); return null; } // 回填缓存TTL 加随机抖动防止缓存雪崩 long ttl 30L * 60 RandomUtil.randomLong(0, 300); stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), ttl, TimeUnit.SECONDS); return shop; }这里有一个很容易被忽略的点TTL 一定要加随机抖动。如果所有商铺 key 同时过期相当于同时去 MySQL 拉数据这种集群式击穿很容易让数据库压力瞬间飙升。我在给每个 key 设置 TTL 时都会在基础时间上加上一个随机数把过期时间打散。3.2 更新操作从“事务里删缓存”到“提交后再删”写完查询再看更新。Cache Aside 的写路径是先更新 MySQL再删缓存。最简单直接的写法是把删除缓存放在事务方法里这样代码很短也能保证顺序。Transactional public void update(Shop shop) { shopMapper.updateById(shop); stringRedisTemplate.delete(cache:shop: shop.getId()); }但这里有个隐患事务还没提交删除缓存的操作就已经发出去了。假设线程 A 执行这条更新逻辑数据库更新后还没提交线程 B 在另一个连接里读数据库读到的还是旧值然后把旧值回填到缓存。A 随后提交事务数据库是新值缓存却是旧值。有人会说这个窗口很短但短不代表不存在。更稳妥的做法是把删除缓存的时机放到事务提交之后。Spring 提供了TransactionSynchronizationManager可以注册 afterCommit 回调也可以用TransactionalEventListener(phase AFTER_COMMIT)监听事务提交事件。我采用的是事务同步器的方式Transactional public void update(Shop shop) { shopMapper.updateById(shop); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { stringRedisTemplate.delete(cache:shop: shop.getId()); } }); }这样写至少能保证删缓存动作发生在数据库提交成功之后缩小了并发下读到旧值的时间窗口。这里再提一个兜底手段如果删除缓存这一步执行失败比如 Redis 服务暂时不可用那下一次读请求会继续命中旧缓存直到 TTL 过期。所以在事务里删缓存并不是绝对安全的但结合 TTL 兜底可以把最长不一致时间控制在一个可以接受的范围内。3.3 延迟双删什么时候值得为一致性多删一次延迟双删是我在并发压测时被逼出来的方案。前面提到过一个极端场景线程 A 更新数据库后删除缓存但这个删除动作完成之前线程 B 已经读到旧值并回填了缓存。这时候 A 的删除操作会把 B 刚回填的缓存也删掉那也没问题。可如果 B 的读回填发生在 A 删除之后缓存里就重新出现了旧值而且短时间内不会被清掉。延迟双删的思路很简单既然不确定回填动作什么时候结束那就让删除动作晚一点再来一次。第一次删除后等待一小段时间再执行第二次删除。public void updateWithDelay(Shop shop) { shopMapper.updateById(shop); stringRedisTemplate.delete(cache:shop: shop.getId()); // 延迟 1 秒后再删一次补偿并发回填的旧值 executorService.schedule(() - stringRedisTemplate.delete(cache:shop: shop.getId()), 1000, TimeUnit.MILLISECONDS); }延迟时间怎么定根据我的经验这个时间要大于“一次读请求从 miss 到回填缓存”的耗时。正常情况下一次查询 MySQL 加一次 JSON 序列化加一次网络 IO几十毫秒就能完成1 秒的延迟足以覆盖绝大多数并发场景。但要注意一点延迟太久会拉长不一致窗口延迟太短又可能赶不上慢查询所以这个参数需要结合业务实际压测调整。延迟双删不是银弹。第二次删除使用异步延迟执行引入了额外的调度复杂度而且如果第二次删除也失败还是得依赖 TTL 兜底。所以我的态度是默认情况下 Cache Aside 就够了只有出现明显的“并发回填旧值”迹象才考虑延迟双删。项目里我最终在缓存更新频繁的商铺详情接口上启用了这个方案其他信息变更频率低的业务仍然保持普通 Cache Aside。4. 并发场景下的三座大山穿透、击穿和雪崩怎么兜底4.1 缓存穿透查不到的 ID 也会打垮数据库做黑马点评时我最开始没意识到缓存穿透的威力。测试阶段并发量低看不出问题。后来用压测工具模拟用户遍历查询不存在的商铺 ID比如 100000 这种根本不存在的编号缓存里没有数据库里也没有每次请求都直接打穿 Redis 落到 MySQL。如果把这种不存在 ID 的请求打成高频数据库很快就会被拖垮。解决思路有两个缓存空值和布隆过滤器。布隆过滤器适合超大规模数据场景先判断 ID 是否存在不存在直接返回。但黑马点评这个项目商铺数据量不算大没必要额外引入布隆过滤器的复杂度所以我的选择是缓存空值。回看前面那段查询代码如果数据库查不到商铺就往 Redis 里写一个空字符串并设置极短的 TTL比如 2 分钟。这样同一个不存在的 ID 再次查询时直接命中空值不会打到数据库。这里有一个注意点商铺信息后续如果被创建了空值缓存还在用户会暂时看不到新数据。所以在创建商铺的逻辑里必须顺手删除这个空值缓存避免空值长时间干扰正常业务。4.2 缓存击穿热点 Key 过期的那一瞬间缓存击穿和缓存穿透听起来很像但完全是两个问题。穿透打的是“不存在的数据”击穿打的是“热点数据过期瞬间”。一个非常火的商铺详情 key 刚好过期用户还在疯狂点击几百个请求同时发现缓存 miss一起涌向 MySQL。这时候数据库压力不是线性增长而是瞬时爆炸。常规解法是互斥锁用 Redis 的 setnx 命令实现一个简单的分布式锁。拿到锁的线程负责查询数据库并回填缓存没拿到锁的线程短暂等待后重试从缓存里拿回填好的数据。public Shop queryWithMutex(Long id) { String key cache:shop: id; String json stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(json)) { return JSONUtil.toBean(json, Shop.class); } String lockKey lock:shop: id; boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (!locked) { // 没抢到锁等 50ms 再递归尝试 Thread.sleep(50); return queryWithMutex(id); } try { // 二次检查缓存防止第一个线程已经回填 json stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(json)) { return JSONUtil.toBean(json, Shop.class); } Shop shop shopMapper.selectById(id); if (shop null) { stringRedisTemplate.opsForValue().set(key, , 2, TimeUnit.MINUTES); return null; } stringRedisTemplate.opsForValue() .set(key, JSONUtil.toJsonStr(shop), 1800L, TimeUnit.SECONDS); return shop; } finally { stringRedisTemplate.delete(lockKey); } }这段代码有三个细节要特别注意。第一锁的过期时间必须大于单次查询回填的实际耗时否则线程 A 还没执行完锁就自动释放了线程 B 也会进入回填逻辑锁等于白加。第二拿到锁之后要二次检查缓存防止 A 已经回填完毕B 又白查一次数据库。第三释放锁的时候一定要用 finally确保程序异常也不会死锁。4.3 逻辑过期换一种方式避免缓存击穿互斥锁的思路是“让多个请求互相等”逻辑过期则是“让旧数据先顶着后台慢慢换新”。它的实现方式是缓存里不设置 TTL而是用一个自定义结构在 value 里记录一个逻辑过期时间。public class RedisData { private LocalDateTime expireTime; private Object data; }查询的时候先判断逻辑过期时间是否已到。如果没到直接返回缓存数据如果到了先返回旧数据给调用方同时提交一个异步任务去数据库拉最新数据、更新缓存。这样热点 key 永远不会被真正删除也就不会出现同时穿过缓存打库的瞬间。public Shop queryWithLogicalExpire(Long id) { String key cache:shop: id; String json stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(json)) { return null; } RedisData redisData JSONUtil.toBean(json, RedisData.class); Shop shop JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class); if (redisData.getExpireTime().isAfter(LocalDateTime.now())) { return shop; } // 逻辑过期先返回旧数据再异步刷新 executorService.execute(() - rebuildCache(id)); return shop; }逻辑过期的最大优势是永远不会有缓存空窗期读请求始终有数据可以返回。代价是短时间内用户看到的是旧数据一致性窗口被拉长了。所以它适合那些“短暂旧数据不会造成严重问题”的场景比如商铺评分、营业状态等。如果一致性要求极高还是互斥锁更合适。我在黑马点评项目里对访问量特别高的商铺详情接口最终采用了逻辑过期方案普通商铺接口仍然用普通 Cache Aside 加 TTL。5. 我在黑马点评里踩过的坑一致性问题的排查实录5.1 三个典型现场复盘第一个现场是 Redis 连接超时。项目跑起来之后日志里频繁出现io.lettuce.core.RedisCommandTimeoutException: Command timed out。我当时第一反应是 Redis 服务挂了后来检查发现 Redis 本身很稳定真正的原因是 Lettuce 连接池配置不够并发一高所有线程都在等待获取连接等待时间超过 Spring 配置的 timeout 默认值 2 秒直接抛超时。解决方式是调大连接池的 max-active 和 max-wait同时把 timeout 调整为 3 秒。这个坑在排查时很容易走弯路推荐先用redis-cli -h 127.0.0.1 -p 6379 ping确认服务本身没问题再回头查连接池。第二个现场是并发压测时缓存和数据库值对不上。我模拟 200 个并发请求同时更新同一个商铺并查询压测后检查数据发现缓存里反复出现旧值。日志打点显示确实发生了“线程 A 更新数据库删除缓存之后线程 B 读到了旧值并回填”的经典交叉场景。就是这个问题让我下定决心在关键接口上启用延迟双删后面又重新压测对比不一致窗口才基本消失。第三个现场是 key 乱码导致缓存删除失效。最初用默认 RedisTemplate 时代码里delete(cache:shop: id)删的 key 是普通字符串但实际 Redis 里存的 key 是 JDK 序列化后的乱码两边根本对不上所以缓存一直删不掉SQL 执行完了 Redis 里还是旧数据。排查时用 Another Redis Desktop Manager 看到一堆二进制的 key 才发现问题。最后统一切换到 StringRedisTemplate才彻底解决。5.2 排查工具与方法如何验证一致性是否真的被修复缓存一致性问题的排查我的经验是三个工具配合使用。一是 Redis 可视化工具推荐 Another Redis Desktop Manager用它观察某个 key 的当前值、TTL 剩余时间、最近更新时间。每次执行更新操作后我习惯第一时间盯一眼这个 key 有没有被删除或者 TTL 有没有变化。二是 redis-cli monitor 命令它可以实时打印 Redis 收到的所有命令。在对关键接口做压测时我会用 monitor 记录所有 delete 和 set 操作检查是否存在“删除后又立刻被 set 旧值”的异常情况。三是日志打点。在缓存查询、缓存回填、数据库更新、缓存删除这几个关键节点分别加一句日志打出时间戳和数据值。压测后把日志拉出来按商铺 ID 过滤整个并发时序一目了然。最后我还写了一个小程序定期对比热点 key 在 MySQL 和 Redis 里的值如果连续 N 分钟不一致就通过邮件告警。这个巡检脚本在项目上线后一直放着帮我发现过几次缓存刷新遗漏的问题。5.3 常见问题速查表把高频问题整理成表方便排查时直接对照症状可能原因排查手段解决建议缓存里长期是旧数据删除缓存失败且 TTL 过长检查 delete 是否执行成功缩短 TTL增加删除重试机制key 在 Redis 里是乱码使用默认 JDK 序列化可视化工具查看 key 编码改用 StringRedisTemplate并发下时新时旧读回填旧值覆盖最新缓存打点日志分析时序关键接口上延迟双删数据库压力突然飙升热点 key 过期导致击穿观察缓存命中率和 DB QPS互斥锁或逻辑过期大量不存在 ID 打库缓存穿透统计 DB 查询空结果比例缓存空值或布隆过滤器Redis 操作超时报错连接池配置过小检查连接池活跃数调大 max-active 和 max-wait6. 面试怎么讲黑马点评的缓存一致性如何经得起追问6.1 “先更库还是先删缓存”的标准回答框架这个问题的常见问法很直接“为什么是先更新数据库再删除缓存而不是先删缓存再更新数据库”我的回答框架是分情况讨论把四种顺序全部摆出来对比。第一种先删缓存再更新数据库。如果缓存删除成功但数据库更新失败下一次读请求会发现缓存 miss查数据库拿到旧值回填等于白删一次。更极端的情况是线程 A 删了缓存还没更新数据库线程 B 读数据库旧值回填A 才把数据库改成新值缓存里就此留下旧数据。第二种先更新缓存再更新数据库。这个方案我基本直接否定原因在前面第 1 章讲透了数据库一旦回滚缓存里就会留下永不落库的脏数据。第三种先更新数据库再删除缓存也就是 Cache Aside。它的失败率相对更低而且就算删缓存失败缓存里最多保留旧值TTL 过期后自然恢复。这是最稳妥的默认选择。第四种延迟双删作为对第三种方案的补偿手段解决并发回填旧值的问题。面试官如果追问“删缓存失败怎么办”可以继续往下说一种是用本地重试或消息队列异步重试保证删除动作最终成功另一种是接受 TTL 兜底但需要评估业务是否容忍这么长的不一致窗口。如果要求更高还可以考虑监听数据库 binlog 异步删除缓存那是 Canal 那套方案复杂度上一个台阶。把这些一层层讲下来基本就能证明你不是只会背结论而是真的理解每个方案的代价。6.2 复盘里的最大收获收尾黑马点评这个项目做下来我个人最大的收获不是学会了 RedisTemplate 或者记住了 Cache Aside 的流程而是真正理解了缓存一致性没有银弹。每一种方案都在解决某个特定问题同时也带来了新的问题Cache Aside 简单可靠但会留下短暂的缓存空窗期互斥锁能防击穿但会让未抢到锁的请求等待逻辑过期能保性能但会拉长数据不一致的时间。做技术选型的时候不是选“最好的方案”而是选“当前业务最承受得起的方案”。最后再分享一个小技巧。项目上线后我写过一个简单的巡检脚本每隔几分钟扫描一批热点商铺的 ID同时查 MySQL 和 Redis比较商铺名称和营业状态字段不一致时间超过五分钟就告警。每次调整缓存策略后我都会用这个脚本连续跑几个小时来验证改动是否真的有效。黑马点评这种学习项目代码跑通不算完能经受住并发和排查的考验那才算是真正掌握。