本地缓存 + Redis + CDN 三层叠加:一次缓存一致性把价格写脏,我们用了 3 套兜底才止血
很多教程把缓存讲成加个 Redis 就完事但真到了线上单层缓存几乎扛不住读多写少的场景。我们商品详情页的峰值读是 3 万 QPS如果全打到 Redis光是网卡带宽就先爆了。这篇文章聊的是我们落地的三级缓存——本地Caffeine 分布式Redis 边缘CDN以及最要命的一致性问题。我会用一次把价格写脏的真实事故把三层缓存各自的边界、彼此怎么配合、以及三道经典防线讲清楚。先说一个判断缓存架构的核心难点从来不是怎么读得快而是怎么在多层之间不让旧数据露给用户。第一层本地缓存 Caffeine挡住 90% 的重复读进程内缓存是延迟最低的一层命中就直接返回连网络都不出。我们用 Caffeine 做本地缓存CacheString, Product cache Caffeine.newBuilder() .maximumSize(10_000) // ① 上限 1 万条超出按 W-TinyLFU 淘汰 .expireAfterWrite(60, TimeUnit.SECONDS) // ② 写后 60 秒过期兜底防脏数据常驻 .recordStats() // ③ 开启命中率统计方便观察 .build(); public Product getProduct(String id) { return cache.get(id, k - productMapper.selectById(k)); // ④ 未命中才回源数据库 }几个关键点①maximumSize控制内存占用我们 JVM 堆 4G留 1 万条本地缓存约 200MB不能再大否则 GC 压力上升②expireAfterWrite是写后过期即使失效广播丢消息最多脏 60 秒也能自愈这是最后一道兜底③ 一定要开recordStats我们靠命中率曲线发现过一次缓存命中率从 95% 掉到 40%的事故——后面会讲④cache.get的回源函数要保证幂等Caffeine 在并发未命中时只会回源一次不会击穿到数据库。这一层能挡掉九成的重复读把 Redis 的压力降到原来的十分之一。第二层Redis 做分布式缓存解决多实例共享本地缓存的致命问题是每个实例各存一份更新时只更新了自己这一个实例其他实例还是旧值。所以第二层必须是一个所有实例共享的 Redispublic Product getFromRedis(String id) { String key product: id; String json redis.get(key); if (json ! null) return JSON.parseObject(json, Product.class); // ① 命中直接返回 Product p productMapper.selectById(id); // ② 未命中回源 if (p null) { redis.setex(key, 60, NULL); return null; } // ③ 防穿透空值也短缓存 redis.setex(key, 300, JSON.toJSONString(p)); // ④ 正常值缓存 5 分钟 return p; }这里有两道经典防护③ 防缓存穿透查不到的 id 也缓存一个短 TTL 的NULL否则攻击者用不存在的 id 能直接打穿到数据库另外我们的热点 sku 会加一层随机 TTL300±60 秒避免几百万 key 同时过期造成缓存雪崩。但 Redis 这层解决不了本地缓存旧值的问题——这正是事故的根源它只是让多个实例有了一个相对统一的旧值源。第三层CDN 边缘缓存扛住静态和半静态内容商品详情页的图片、描述、规格参数这些变化极慢的内容我们直接推到 CDN 边缘节点。CDN 这层不归 Java 管但设计上必须和前面两层配合动态价格走本地Redis静态描述走 CDN页面用静态壳 动态片段的方式拼装。这里有个常见误区要提醒CDN 缓存的是整段响应如果页面把价格和描述混在同一个 URL 里那价格也会被 CDN 缓存住变成更难发现的脏数据。所以我们强制把会变的内容和不变的内容拆成不同请求CDN 只缓存后者。边缘层能把 3 万 QPS 里的绝大部分静态请求消化在离用户几十公里的节点上回源到我们机房的只剩动态部分。三层之外的三道经典防线分层解决的是读得快但缓存本身有三类会让系统崩的问题我们在每一层都补了对应防护。第一道是缓存击穿某个热点 key 过期的瞬间海量请求同时打到数据库。做法是用互斥锁只放一个线程回源public Product getWithMutex(String id) { Product p cache.getIfPresent(id); if (p ! null) return p; synchronized (id.intern()) { // ① 按 key 加锁相同 key 互斥、不同 key 并行 p cache.getIfPresent(id); // ② 双重检查避免锁释放瞬间重复回源 if (p ! null) return p; p productMapper.selectById(id); cache.put(id, p); return p; } }① 用String.intern()让相同 id 共用同一把锁避免把所有 key 锁成串行② 双重检查是关键否则多线程排队拿到锁后还是会各回源一次。第二道是雪崩大量 key 同一时刻过期我们用随机 TTL 打散失效时间。第三道是穿透查不到的 key 缓存 NULL 短值第二层代码已体现。这三道防线和三级缓存是正交的两套思路——一个管读得快不快一个管缓存别把自己玩崩。一致性事故价格改了用户还看到旧价 2 小时真正出问题的是一次运营改价。流程是运营后台改价 → 写数据库 → 删 Redis key → 广播消息让各实例清本地缓存。代码大致是Transactional public void updatePrice(Long id, BigDecimal price) { productMapper.updatePrice(id, price); // ① 先写库 redis.del(product: id); // ② 删 Redis注意是删除不是更新 redis.publish(cache:invalidate, product: id); // ③ 广播让其他实例清本地缓存 }事故发生在③那次 Redis 主节点在发布消息的瞬间发生了主从切换Pub/Sub 消息丢了。结果 Redis 里的 key 被删了下次读会回源拿到新价但 6 个应用实例的本地 Caffeine 缓存里还是旧价格并且因为本地缓存 TTL 是 60 秒理论上 60 秒后会自愈。可问题在于删除 Redis 后请求回源拿到新价写入本地缓存但部分实例在切换窗口里回源拿到了旧主上的旧值主从延迟又把旧值写进了本地缓存。叠加下来少数用户连续 2 小时看到错误价格客诉进来后才发现。我们那天的命中率曲线掉到 40% 就是信号但没人盯——这也是为什么我前面强调一定要开recordStats。用 Canal 解耦失效链路前面那次事故的病根是失效消息由业务代码自己发写链路和失效链路强耦合主从切换时消息必丢。我们改成用 Canal 监听 MySQL binlog由数据库变更主动驱动缓存失效业务代码彻底不用管广播// Canal 客户端监听到 binlog 的 UPDATE 后主动失效本地与 Redis 缓存 EventListener public void onBinlog(BinlogEvent e) { if (!t_product.equals(e.getTable())) return; // ① 只关心商品表 String id e.getAfter().get(id); redis.del(product: id); // ② 删 Redis下次读回源拿新值 cacheInvalidateBus.publish(product: id); // ③ 再广播清各实例本地缓存 }这样失效不再依赖业务成功发消息只要库里数据变了缓存就一定被清从根上消除了写成功但失效丢失的窗口。代价是多维护一个 Canal 组件和一套消息总线但对边价格这类强一致数据这代价我们愿意付。这里我也想泼盆冷水很多人一上来就接 Canal其实单机小业务用写后删缓存足够Canal 是规模上来后的进阶方案别为了酷而引入。三层该怎么分工一张表说清层级延迟共享范围适合的数据不适合的数据本地 Caffeine纳秒级单实例展示类、变化慢强一致、价格库存Redis毫秒级全实例共享热点读、会话超大数据、频繁写CDN就近全局边缘静态资源、描述动态、个性化这张表是我们踩完坑后定的数据分级依据越靠边缘、变化越慢的内容越往外推越靠近用户、TTL 越长的层越不能缓存强一致数据。我的取舍如果你的读 QPS 不到 5000老实说单加一个 Redis 就够上三级缓存是过度设计。三级缓存真正的价值在万级以上的读峰值但它把一致性这个难题放大了三倍。我们现在的策略是展示类数据描述、评价摘要放满三级交易类数据价格、库存只放 Redis 且强制回源校验本地缓存对它们直接关闭。别指望一套缓存策略打天下按数据的一致性要求分级才是最省心的做法。另外缓存系统一定要把命中率监控和脏数据告警做成标配否则像我们那次曲线掉了 2 小时才有人发现。另外补一句版本细节我们本地缓存从 Caffeine 2.9 升到 3.1 时构建依赖从 Guava 换成了独立维护的模块升级本身无害但 Caffeine 3.x 要求 JDK 11当时有台老机器还是 JDK 8编译直接挂。所以缓存组件的版本要和 JDK 版本一起规划别只看功能点。我们在 3 万 QPS 下用 Caffeine 3.1 Redis 6.2 这套组合本地命中率稳定在 92% 以上Redis 命中率 99%真正回源到数据库的只有不到 1%——这正是三级缓存存在的意义把最贵的那一层数据库保护起来。也别迷信数字命中率监控必须配告警否则像我们那次曲线掉了 2 小时才有人看脏数据已经露给用户了。思考题你的系统里哪些数据其实根本不该进本地缓存如果让你给价格和商品描述设计不同的缓存策略你会怎么分层欢迎评论区交流。