很多团队第一次把接口 P99 从 90ms 压到 6ms,靠的不是换数据库,也不是加机器,而是在 Redis 前面挂了一层 Caffeine。这个改动通常只要几十行代码,收益立竿见影,于是它几乎成了“高并发改造”的标准动作。
但第二组数字往往在几周后才出现:运营改了商品价格,部分用户十几分钟还看到旧价;库存扣减在某个实例上判成“有货”,落到数据库时被约束打回;灰度发布时新实例刚起来,流量一压上来 Redis 的 QPS 反而翻了一倍。这两组数字来自同一次改动,只是前者出现在压测报告里,后者出现在故障复盘里。
这篇文章想回答一个具体问题:在 Redis 前面再加一层本地缓存,你优化的到底是什么,又拿什么做了交换? 先说明信息边界:本次运营提供的站内参考资料为空,没有可引用的具体讨论帖,因此下文不涉及任何站内案例或外部新闻,只讨论这类结构在通用工程实践中的机制与取舍。
先给结论
本地缓存不是“更快的 Redis”,它是一个会独立演化、并且可能返回旧值的额外数据源。 只要引入它,读路径上就有了 N+1 份副本——N 个实例各一份,加 Redis 一份——而副本之间的一致性只能靠 TTL、广播或版本校验维持,这三种手段都有明确的失效边界。
所以判断要不要加,不该看 QPS 曲线,而该看三件事:读写比例是否足够悬殊、读到旧值的最坏后果是什么、失效通知链路有多可靠。
核心判断:如果一次读请求拿到 5 秒前的数据会导致业务错误(而不只是体验变差),那么本地缓存就不应该出现在这条读路径上。
这个结论有边界。对于商品详情、配置项、字典表、首页楼层这类“写少读极多、旧值只影响观感”的数据,本地缓存几乎是性价比最高的优化;对于余额、库存、优惠券核销状态、订单状态机这类“读到旧值就会做出错误决策”的数据,本地缓存带来的收益远小于它引入的风险。真正的难点在于大量字段处在两者之间——比如“商品是否下架”,旧值会让用户点进一个已经买不了的商品页,这既不是纯体验问题,也未必造成资损。
问题是怎么形成的
单机本地缓存天然是多副本结构。假设你有 20 个实例,每个实例的 Caffeine 里存着同一份商品价格,这份数据在系统里就有 21 个副本。任何一次写操作,都必须让这 21 个副本在某个时刻达成一致,否则就会出现“同一个用户刷新两次看到两个价格”。
而本地缓存能用的同步手段其实只有三种,每种都有硬伤:
- TTL 到期:最简单,但一致性窗口等于 TTL,且各实例的过期时刻是错开的,最坏情况下不同用户在同一分钟内看到不同结果。
- 主动广播失效:通过 MQ 或 Redis Pub/Sub 通知所有实例删 key。问题是广播本身不可靠——消息可能丢、消费者可能积压、实例可能正在重启。
- 版本号校验:每次读都去 Redis 取一个版本号,版本变了才回源。这保住了正确性,但把“省掉一次网络往返”的收益又还了回去,只剩省掉大 value 传输的好处。
一个典型的读路径长这样:
流程图
渲染中...
这条链路上,C 返回的值可能来自任意时刻的回填。假设商品价格在 Redis 的 TTL 是 5 分钟,本地缓存 TTL 是 30 秒,那么一次改价之后,最坏情况要经过“本地 30 秒 + Redis 剩余 5 分钟”才能全量可见。如果广播失效这条链路再出一次问题——比如某个实例因为 GC 停顿错过了消息——它会一直返回旧值,直到本地 TTL 到期。这就是“两层缓存把一致性窗口叠加放大”的机制。
更隐蔽的是冷启动问题。K8s 弹性伸缩把实例从 10 个扩到 30 个,新实例本地缓存是空的,所有请求都会穿透到 Redis。如果本地缓存原本承担了 90% 的读流量,这一瞬间 Redis 的 QPS 会涨到原来的十倍。很多“扩容把自己扩挂”的事故,根因就在这里。
三种典型做法的取舍
面对“要不要加本地缓存”,实践中大致有三种选择,它们的差别不在性能数字,而在你愿意为一致性付出多少复杂度。
| 方案 | 优点 | 代价 | 适用场景 |
|---|---|---|---|
| 只用 Redis | 单一数据源,一致性模型简单,运维成本低 | 每次读有一次网络往返,热点 key 会打满单分片 | 大多数业务的默认选择 |
| 本地 + Redis + 广播失效 | 读性能最好,Redis 压力大幅下降 | 需要可靠广播通道,存在消息丢失窗口,排障变难 | 极热 key、可容忍秒级不一致 |
| 本地 + Redis + 版本号校验 | 正确性有保障,不依赖广播可靠性 | 每次读仍需访问 Redis,收益打折 | 一致性要求较高但 value 较大的场景 |
选第二种方案时,还有一个常被忽略的细节:更新顺序。“先更新数据库再删缓存”和“先删缓存再更新数据库”在并发读下都会出问题,流传甚广的“延迟双删”本质上是用一个不可靠的时间窗口去赌读请求已经完成。在多级缓存下这个问题被放大——你不仅要删 Redis,还要通知所有实例删本地,而通知的到达时间不可控。比较务实的做法是给缓存值带上数据版本或更新时间戳,读的时候做一次廉价校验,而不是指望删除操作一定成功。
穿透、击穿、雪崩在多级结构下的变形
这三个词经常被并列讨论,但在多级缓存下它们的表现形式并不一样,应对手段也不能混用。
缓存穿透指查询一个根本不存在的 key,请求全部落到数据库。本地缓存对它的帮助有限,因为你不可能把无限多的“不存在”都缓存下来。可行做法是缓存空值并给一个较短 TTL(比如 60 秒),同时在入口做参数合法性校验;如果攻击者用随机 key 打,那已经不是缓存问题,而是限流和风控问题,需要在网关层用 Sentinel 或 Nginx 的 limit_req 兜底。
缓存击穿指某个热点 key 过期瞬间,大量请求同时回源。在多级结构下这个问题更严重:本地缓存和 Redis 可能同时失效,形成一次“双穿”。标准解法是让重建过程串行化——Java 侧可以用 Caffeine 的 refreshAfterWrite 配合异步加载,让旧值在后台刷新期间继续服务;Go 侧可以用 singleflight 把并发回源合并成一次。
Java
LoadingCache<String, Item> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.refreshAfterWrite(Duration.ofSeconds(30))
.expireAfterWrite(Duration.ofMinutes(10))
.build(key -> loadFromRedisOrDb(key));
注意 refreshAfterWrite 和 expireAfterWrite 的分工:前者决定何时触发异步刷新,后者决定何时真正淘汰。只设 expireAfterWrite 会导致过期瞬间同步回源,反而制造尖峰。
缓存雪崩指大批 key 同时过期。多级缓存下最典型的雪崩不是 TTL 撞车,而是实例重启——30 个新 Pod 同时冷启动、同时回源。对策是给 TTL 加随机抖动,并在发布流程里做预热:新实例就绪前先加载一批热点 key,再接入流量。
实践建议:先分类,再决定加不加
比“要不要用 Caffeine”更前置的问题是“这份数据属于哪一类”。可以按下面的顺序做判断:
- 统计这条读路径的读写比,低于 10:1 的,本地缓存的收益通常不足以抵消复杂度。
- 明确读到旧值的最坏后果,能写进故障预案的才允许加本地缓存。
- 确认失效通知链路的可靠性,广播丢失时有没有兜底(版本号、短 TTL)。
- 估算本地缓存的内存占用,JVM 里超过堆的 20% 就要警惕 GC 压力,
maximumSize必须显式设置。 - 检查扩缩容策略,弹性伸缩场景下必须配合预热,否则扩容即雪崩。
如果以上都通过了,再按数据分类落地:
| 数据类型 | 建议策略 |
|---|---|
| 字典、配置、类目树 | 本地缓存为主,TTL 分钟级,配合配置中心推送失效 |
| 商品详情、内容页 | 本地 + Redis,广播失效 + 短 TTL 兜底 |
| 库存、余额、券状态 | 只用 Redis 或直接读库,本地缓存不参与判定 |
| 用户会话、权限 | Redis 为主,本地缓存只存不可变部分 |
最后一条容易被低估:权限数据的本地缓存是安全事故的高发区。用户被移出某个角色后,如果实例上的权限缓存仍然有效,他在这段时间内仍能访问受限资源。这类数据要么把 TTL 压到秒级,要么在鉴权时做一次轻量校验,不要图省事。
总结与讨论
回到开头的问题。本地缓存带来的不是“免费的性能”,而是一次明确的所有权转移:你把一部分一致性责任从 Redis 这个单点,分散到了 N 个可能失联的实例上。这笔交易在“读极热、旧值无害”的场景里非常划算,在“写后必须立即可见”的场景里则是负债。
三条可以带走的结论:第一,判断标准是旧值的最坏后果,不是 QPS;第二,多级缓存的一致性窗口是叠加的,本地 TTL 和 Redis TTL 要一起算;第三,雪崩在多级结构下的主要形态是冷启动,扩容和发布流程必须把预热算进去。
留一个开放问题:你们线上有没有哪个字段,其实一直处在“旧值只是体验差一点”和“旧值会导致业务错误”之间的灰色地带?你们最后是怎么划这条线的? 这个问题没有标准答案,但它值得在下次加缓存之前,先在团队里讨论清楚。