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 万条 | 低,直接 del |
| L3 CDN | 边缘节点 | 页面级 | 20ms | 静态化页面 | 中,要刷新 |
| 回源 | MySQL | 2% | 12ms | — | — |
缺口一:本地缓存没有跨实例失效通道
这是开头那个 47 秒事故的直接原因。Redis 删了 key,但没人通知 12 个实例把自己内存里的副本扔掉。
我们最初的写法就是最朴素的双层查询:
@Service public class ProductCacheService { // maximumSize 控制条目数,expireAfterWrite 是写入后的绝对过期 // 注意不要用 expireAfterAccess,热点商品会被无限续命,脏数据永不淘汰 private final Cache<Long, 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/Sub | RocketMQ 广播模式 | Canal + MQ |
|---|---|---|---|
| 离线消息 | 丢弃 | 保留(有 offset) | 保留 |
| 投递保证 | 至多一次 | 至少一次 | 至少一次 |
| 与业务代码耦合 | 需手动发消息 | 需手动发消息 | 无侵入,监听 binlog |
| 延迟 | 亚毫秒 | 5-20ms | 50-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 个实例同时穿透到 Redis,Redis 也恰好过期,于是 12 个请求同时打到 MySQL。
单看 12 个请求不多,但每个实例内部有 200 个 Tomcat 线程,第一个请求还在查库时,后面 199 个也发现缓存为空,跟着一起查。瞬间 2400 个查询打在同一行数据上,MySQL 连接池(HikariCP 上限 50)直接打满,其他业务的查询全部排队。
解法有两层。本地这层用 Caffeine 的get(key, mappingFunction)替代getIfPresent+put:
public 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 秒随机),避免所有实例同时过期。
复盘:改造前后的真实数字
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 详情接口 P99 | 9.2ms | 0.6ms |
| Redis QPS | 8.6 万 | 1.4 万 |
| 本地缓存命中率 | — | 82% |
| 价格不一致最长持续 | 47 秒 | 400ms 内 |
| CDN 刷新次数/天 | 8000+ | 60 |
| 缓存击穿时 DB 峰值连接 | 50(打满) | 3 |
400ms 是 Pub/Sub 广播 + 各实例处理的实际延迟,主要消耗在消息序列化和 Caffeine 的 invalidate 上。这个数字对我们的业务是可以接受的。
我的取舍判断
不是所有数据都值得放本地缓存。我们的规则是三条同时满足才加 L1:读写比大于 100:1、单条数据小于 4KB、能容忍最长 1 秒的不一致。价格、库存这类高频变更的数据其实是不满足第三条的,我们最后是靠广播把不一致压到 400ms 才敢放进去。像用户余额、优惠券状态这类,我们至今没放本地缓存。
本地缓存的容量要按内存算,不是按条数算。maximumSize(20000)这个配置看起来很安全,但如果单条对象是 50KB,两万条就是 1GB。我们踩过一次,商品详情 VO 里带了完整的富文本描述,本地缓存把老年代吃掉 2.3GB,Full GC 从每天 2 次涨到每小时 4 次。后来改用maximumWeight+weigher按实际字节数限制。
如果你的服务实例少于 4 个,我建议先别上本地缓存。实例少意味着 Redis 的 QPS 压力本来就不大,本地缓存的收益有限,而一致性成本是固定的——不管你有 3 个实例还是 30 个,广播通道、兜底 TTL、启动清理这套东西一样都不能少。
最后留个问题
假设你的本地缓存广播用的是 RocketMQ 广播模式,某个实例连续 30 分钟消费失败(比如反序列化异常),期间它的本地缓存一直是脏的。你会怎么设计一个自动检测机制,让这个实例主动把自己从负载均衡里摘掉,而不是继续对外提供错误数据?健康检查接口该检查什么指标才能发现这种"进程活着但数据是错的"状态?
评论区聊聊你们的做法。