加了本地缓存之后,一个错价撑了 47 秒才自愈:多级缓存的 5 个一致性缺口
title: 加了本地缓存之后一个错价撑了 47 秒才自愈多级缓存的 5 个一致性缺口tags: [多级缓存, Caffeine, Redis, 缓存一致性, Canal]category: 后端运营在后台把一款商品的价格从 199 改成 99点了保存刷新页面看到的还是 199。他连点了五次保存然后给我发消息说系统坏了。实际情况是Redis 里的价格在他第一次保存后 40 毫秒就更新了但商品详情服务的 12 个实例里有 9 个的本地缓存还留着旧值。这 9 个实例的本地缓存 TTL 是 60 秒最慢的一个撑到第 47 秒才过期重载。这 47 秒里用户看到的价格取决于负载均衡把请求打到了哪台机器上——同一个商品刷新一次 199再刷新一次 99。这就是多级缓存最典型的坑你以为加了一层缓存只是多了一层性能收益实际上是多了一个必须单独维护的一致性边界。这篇把我们这套三级缓存Caffeine Redis CDN踩过的五个缺口写清楚。为什么要在 Redis 前面再压一层本地缓存先说清楚动机不然容易被理解成过度设计。商品详情接口在大促期间的 QPS 峰值是 8.6 万。Redis 集群 6 主 6 从单节点承载大约 1.4 万 QPS看起来撑得住。真正的问题不是 Redis 扛不住是网络往返的时间成本一次 Redis GET 在同机房内 P99 是 1.8ms而商品详情页要组装 SKU、库存、价格、活动、评价 5 个维度串行调用就是 9ms 起步。上了 Caffeine 之后同样的 5 次读取变成本地内存访问P99 从 9.2ms 降到 0.4ms。这个收益是实打实的代价就是本文要讲的一致性问题。三层各自的定位层级介质命中率单次耗时容量失效难度L1 本地Caffeine 堆内82%0.05ms单机 2 万条高要广播L2 分布式Redis 集群16%1.8ms全量 800 万条低直接 delL3 CDN边缘节点页面级20ms静态化页面中要刷新回源MySQL2%12ms——缺口一本地缓存没有跨实例失效通道这是开头那个 47 秒事故的直接原因。Redis 删了 key但没人通知 12 个实例把自己内存里的副本扔掉。我们最初的写法就是最朴素的双层查询Service public class ProductCacheService { // maximumSize 控制条目数expireAfterWrite 是写入后的绝对过期 // 注意不要用 expireAfterAccess热点商品会被无限续命脏数据永不淘汰 private final CacheLong, ProductVO localCache Caffeine.newBuilder() .maximumSize(20_000) .expireAfterWrite(60, TimeUnit.SECONDS) .recordStats() .build(); Resource private StringRedisTemplate redisTemplate; public ProductVO get(Long skuId) { // 第一层本地内存命中直接返回不产生任何网络 IO ProductVO local localCache.getIfPresent(skuId); if (local ! null) { return local; } // 第二层Redis命中后回填本地 String json redisTemplate.opsForValue().get(prod: skuId); if (json ! null) { ProductVO vo JSON.parseObject(json, ProductVO.class); localCache.put(skuId, vo); return vo; } // 第三层回源数据库 return loadFromDbAndFill(skuId); } }这段代码本身没写错问题在于它只有进没有出。expireAfterWrite(60, SECONDS)意味着任何一次数据变更最坏情况要等 60 秒才能在所有实例上生效。顺带说一个容易选错的点expireAfterAccess在缓存场景下几乎总是错的选择。它的语义是最后一次访问后 N 秒过期对热点商品来说永远不会过期而热点商品恰恰是最需要及时更新价格的那批。我们第一版用的就是expireAfterAccess那款 199 变 99 的商品因为是爆款被访问得太频繁本地缓存理论上可以永久保留脏数据。修复方案是加一条基于 Redis Pub/Sub 的广播失效通道Component public class CacheEvictListener implements MessageListener { Resource private ProductCacheService productCacheService; Override public void onMessage(Message message, byte[] pattern) { // 频道里传的就是 skuId 字符串格式简单可以少一次反序列化开销 String skuId new String(message.getBody(), StandardCharsets.UTF_8); try { productCacheService.evictLocal(Long.parseLong(skuId)); } catch (NumberFormatException e) { // 防御性处理曾经有人往这个频道里发过调试字符串导致监听线程抛异常后 // Redis 客户端把这个订阅关系断掉了之后所有失效消息都收不到 log.warn(illegal evict message: {}, skuId); } } }那个catch NumberFormatException不是凑数的。有一次同事在 redis-cli 里手动PUBLISH prod:evict test试通道是否活着监听线程抛出未捕获异常Lettuce 的订阅连接被判定异常后重连但重连过程中丢了大约 12 秒的消息。那 12 秒里发生的三次价格变更全部没有同步到本地缓存。缺口二Pub/Sub 不保证送达重启的实例会漏消息修完缺口一之后我们以为一致性问题解决了。三周后又出现了单实例价格不一致这次的原因是那台实例正好在配置变更的时间点重启Redis Pub/Sub 是发后即忘的订阅者不在线就收不到也没有任何补偿。Pub/Sub 和消息队列在这一点上的差别是很多人做缓存广播时踩的坑特性Redis Pub/SubRocketMQ 广播模式Canal MQ离线消息丢弃保留有 offset保留投递保证至多一次至少一次至少一次与业务代码耦合需手动发消息需手动发消息无侵入监听 binlog延迟亚毫秒5-20ms50-200ms漏发风险有人忘了发就漏同左只要改了库就一定有我们最终的组合是Pub/Sub 做主通道快本地缓存保留 60 秒兜底 TTL防漏实例启动后主动清空本地缓存防重启漏消息。三条一起才把这个洞堵住。第三条特别容易被忽略。实例重启后本地缓存本来就是空的看起来没问题但我们用了 Caffeine 的持久化预热把热点 key 在启动时批量加载预热数据来自一个每 5 分钟刷新一次的快照文件。这个快照最长可能是 5 分钟前的直接加载就等于引入了 5 分钟的脏数据。改成预热时从 Redis 读最新值之后才干净。缺口三更新顺序错了缓存里会留下永久脏数据这是最隐蔽的一个。先看两种更新顺序// 写法 A先更新数据库再删缓存Cache Aside推荐 Transactional(rollbackFor Exception.class) public void updatePrice(Long skuId, BigDecimal price) { productMapper.updatePrice(skuId, price); // 注意删缓存放在事务提交之后执行否则事务未提交时 // 其他线程回源读到的还是旧值又会把旧值写回缓存 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { Override public void afterCommit() { redisTemplate.delete(prod: skuId); redisTemplate.convertAndSend(prod:evict, String.valueOf(skuId)); } }); }afterCommit这个回调是关键。我们最早把redisTemplate.delete()直接写在updatePrice后面在Transactional方法内部。问题在于此时数据库事务还没提交删缓存这个动作先执行了。如果在事务提交前的这几毫秒内有请求进来它会发现缓存为空、回源查数据库——读到的是未提交前的旧值然后把旧值写回缓存。事务提交后缓存里躺着的是旧价格而且因为没有后续的删除动作它会一直待到 TTL 过期。我们线上抓到过这个场景窗口期只有 3-8 毫秒但在 8 万 QPS 下3 毫秒足够进来 240 个请求。至于先删缓存再更新数据库这种写法理论上可以配合延迟双删但我不推荐延迟多久是拍脑袋定的定短了没用定长了这段时间内缓存命中率是 0反而把压力全打到数据库。如果你确实需要极强的一致性与其在删除时机上做文章不如接受读写都走数据库或者上 Canal 监听 binlog 做兜底刷新。缺口四CDN 层的静态化页面刷新粒度太粗L3 这层我们用的是页面静态化 CDN。商品详情页在发布时渲染成 HTML 推到 CDN价格是嵌在 HTML 里的。价格变更时需要刷新 CDN而 CDN 厂商的刷新接口有配额限制我们的套餐是每天 2000 个 URL。大促期间一天的价格变更超过 8000 次配额根本不够。最后的做法是把易变字段从静态页面里挖出来HTML 里只保留标题、图片、详情描述这些几乎不变的内容价格和库存改成页面加载后由 JS 单独请求接口获取。这样 CDN 缓存的 HTML 可以放心设置 24 小时 TTL价格接口走 L1 L2 两层缓存。改造后的数字CDN 刷新请求从每天 8000 降到 60 次以内首屏时间因为多了一次异步请求增加了 80ms但价格错误投诉从每周 3-5 起降到 0。这是一笔我认为很划算的交易——用户能接受价格晚 80ms 出现不能接受价格是错的。缺口五缓存击穿时本地缓存反而放大了回源压力最后一个是我们最晚才意识到的。某个爆款商品的缓存过期瞬间12 个实例的本地缓存几乎同时失效因为它们是在同一次预热中写入的expireAfterWrite起点一致12 个实例同时穿透到 RedisRedis 也恰好过期于是 12 个请求同时打到 MySQL。单看 12 个请求不多但每个实例内部有 200 个 Tomcat 线程第一个请求还在查库时后面 199 个也发现缓存为空跟着一起查。瞬间 2400 个查询打在同一行数据上MySQL 连接池HikariCP 上限 50直接打满其他业务的查询全部排队。解法有两层。本地这层用 Caffeine 的get(key, mappingFunction)替代getIfPresentputpublic ProductVO get(Long skuId) { // Caffeine 的 get(key, function) 内部对同一个 key 加了锁 // 同一实例内并发请求同一 key 时只有一个线程执行 mappingFunction // 其余线程阻塞等待结果天然解决单机维度的击穿 return localCache.get(skuId, id - { String json redisTemplate.opsForValue().get(prod: id); if (json ! null) { return JSON.parseObject(json, ProductVO.class); } return loadFromDbWithLock(id); }); }localCache.get(key, function)和getIfPresent的区别常被忽略前者在ConcurrentHashMap.compute的语义下执行对同一个 key 是串行的。改完这一行单实例内 200 个线程的并发穿透变成 1 个。跨实例那层loadFromDbWithLock里加了 Redis 分布式锁抢不到锁的实例等 50ms 后重读缓存。再加上给 TTL 增加随机抖动60 秒基础值 0-15 秒随机避免所有实例同时过期。复盘改造前后的真实数字指标改造前改造后详情接口 P999.2ms0.6msRedis QPS8.6 万1.4 万本地缓存命中率—82%价格不一致最长持续47 秒400ms 内CDN 刷新次数/天800060缓存击穿时 DB 峰值连接50打满3400ms 是 Pub/Sub 广播 各实例处理的实际延迟主要消耗在消息序列化和 Caffeine 的 invalidate 上。这个数字对我们的业务是可以接受的。我的取舍判断不是所有数据都值得放本地缓存。我们的规则是三条同时满足才加 L1读写比大于 100:1、单条数据小于 4KB、能容忍最长 1 秒的不一致。价格、库存这类高频变更的数据其实是不满足第三条的我们最后是靠广播把不一致压到 400ms 才敢放进去。像用户余额、优惠券状态这类我们至今没放本地缓存。本地缓存的容量要按内存算不是按条数算。maximumSize(20000)这个配置看起来很安全但如果单条对象是 50KB两万条就是 1GB。我们踩过一次商品详情 VO 里带了完整的富文本描述本地缓存把老年代吃掉 2.3GBFull GC 从每天 2 次涨到每小时 4 次。后来改用maximumWeightweigher按实际字节数限制。如果你的服务实例少于 4 个我建议先别上本地缓存。实例少意味着 Redis 的 QPS 压力本来就不大本地缓存的收益有限而一致性成本是固定的——不管你有 3 个实例还是 30 个广播通道、兜底 TTL、启动清理这套东西一样都不能少。最后留个问题假设你的本地缓存广播用的是 RocketMQ 广播模式某个实例连续 30 分钟消费失败比如反序列化异常期间它的本地缓存一直是脏的。你会怎么设计一个自动检测机制让这个实例主动把自己从负载均衡里摘掉而不是继续对外提供错误数据健康检查接口该检查什么指标才能发现这种进程活着但数据是错的状态评论区聊聊你们的做法。