"接口慢了就加缓存"是多数团队里最不需要讨论的动作,于是系统里同时住着三层缓存、四条失效路径和一批没人敢删的 key。真正要回答的不是"要不要加缓存",而是这一层缓存替谁挡了什么瓶颈,又把什么代价转移给了谁。加错层比不加更贵:它把一次确定的查询,换成一次不确定的命中、一次跨实例广播,和一段说不清多长的窗口。
1. 三层缓存的定位:它们解决的不是同一类问题
| 层级 | 典型载体 | 真正解决的问题 | 引入的主要代价 | 失效半径 | 读延迟量级(按拓扑核对) |
|---|---|---|---|---|---|
| 本地堆内 | Caffeine / 进程内 Map | 消除一次远程往返与序列化 | 占堆、多实例不一致、无法精确失效 | 单个实例 | 百纳秒级 |
| 集中式 | Redis / Memcached | 让 N 个实例共享同一份视图 | 网络跳数、序列化、单点与热点 | 一次广播覆盖全集群 | 亚毫秒到毫秒级 |
| 边缘 | nginx proxy_cache / CDN | 把请求挡在机房之外 | 内容须可缓存、刷新有延迟 | 一个边缘节点或区域 | 十毫秒级 |
client → edge(CDN/nginx) ─miss→ app
├─ L1(Caffeine,同进程)
├─ L2(Redis,跨实例)
└─ DB
每一段箭头都是一次可跳过的往返,也是一段可能不一致的窗口
层数增加不会让系统更一致,只会让不一致的来源变多:每一层的 miss 都原样放大给下一层。
2. 本地缓存的三个代价与适用判据
| 代价 | 具体表现 | 量化口径 | 缓解手段 |
|---|---|---|---|
| 占堆 | 条目本身加索引开销,老年代压力上升 | 条目数 × 平均对象图大小,看一次 dump 就够 | maximumSize 加 weigher |
| 多实例不一致 | A 实例已更新,B 实例仍返回旧值 | 窗口 ≈ 该实例剩余 TTL | 缩短 TTL,或广播失效 |
| 失效困难 | 不知道"哪台机器上正好有这个 key" | 实例数 N,一次失效要 N-1 条消息 | 集中广播加版本号兜底 |
Cache<String, ItemView> l1 = Caffeine.newBuilder()
.maximumSize(50_000) // 条目上限,不按字节
.weigher((k, v) -> v.estimatedBytes()) // 需要按字节控时才用 weigher
.expireAfterWrite(Duration.ofSeconds(30))
.recordStats() // 没有命中率就等于没有判据
.build();
判据要同时成立:读远多于写、能容忍秒级旧值、有可重算的兜底。缺第三条时,一次发布事故会变成"数据错到重启"。
3. 集中式缓存:成本从一次往返变成一条链路
| 成本项 | 单实例典型量级 | 规模化后的变化 | 核对点 |
|---|---|---|---|
| 每次读的往返 | 同机房亚毫秒 | 跨可用区升到毫秒级 | 部署拓扑 |
| 序列化/反序列化 | 小对象时占比不高 | value 变大后反超网络 | value 字节数 |
| 连接与线程 | 连接池数十条 | 实例数 × 池大小,易撞 maxclients |
池上限乘实例数 |
| 主从切换 | 秒级不可用 | 切换窗口内回源被放大 | 是否有降级路径 |
// 反例:N 次 get = N 次往返
for (String id : ids) { cache.get(id); }
// 正例:一次 mget,一次往返;但响应体会变大
List<String> vals = redis.opsForValue().multiGet(ids);
mget 的边界在命令的绝对执行时间:Redis 单线程执行命令,一次超大 mget 会阻塞其他请求,常见做法是按 value 大小分批。减少往返次数是对的,减少到一次通常不对。 更要紧的是集中式缓存引入了一个独立故障域:它挂掉时不是"变慢",而是全部请求打向 DB。
4. 多级缓存:失效传播才是真成本
| 本地 TTL | 不一致窗口上限 | 广播成本 | 适用数据 |
|---|---|---|---|
| 1s | 1s | 高频广播,消息量大 | 价格、库存这类准实时 |
| 10s | 10s | 低 | 商品详情、开关配置 |
| 60s+ | 60s+ | 极低 | 类目、字典、静态文案 |
| 无 TTL,只靠广播 | 取决于广播可靠性 | 最高,丢一条即永久不一致 | 不适合单独使用 |
{"type":"evict","keys":["item:1001","item:1002"],"ver":1731,"ts":1731000000}
广播失效要处理三件事:消息丢失(订阅端重连期间)、乱序(旧 evict 比新值后到)、批量风暴(大范围变更清空本地缓存,形成回源尖峰)。前两个用单调递增的 ver 解决,第三个只能靠限制单次广播的 key 数或主动预热。
判据:本地 TTL 决定窗口下界,广播只能压短,不能消零。
5. 哪些数据该进缓存,哪些不该
| 判据 | 该进 | 不该进 | 理由 |
|---|---|---|---|
| 读写比 | 读:写 明显大于 10:1 | 写多读少 | 命中率低,纯增失效成本 |
| 可重算 | 能随时从源重建 | 唯一副本、无源数据 | 丢了不可怕的才叫缓存 |
| 失效依据 | 有主键或 updated_at |
多表拼接且无变更通知 | 无法判断何时失效 |
| 大小 | 单 value 小 | 大对象、大集合 | 传输与堆成本反超收益 |
| 一致性 | 最终一致可接受 | 强一致(余额、扣减中间态) | 缓存天生是副本 |
不该放的四类:强一致数据(每次命中都在赌没人改过它)、频繁变更数据(只多了"每次写都要失效")、大对象(序列化与拷贝反成主开销)、可推导数据(一次 join 的结果集,缓存它等于复制一份 schema)。
def cacheable(d) -> bool:
return (d.read_write_ratio > 10 # 读远多于写
and d.rebuildable # 能从源重建
and d.invalidation_key # 有明确失效依据
and d.value_bytes < 64 * 1024
and d.consistency == "eventual")
判据的价值不在打分,而在于任何一条不满足时团队必须给出理由。
6. 失效策略:四种做法,四种代价
| 策略 | 一致性强度 | 实现成本 | 失效风暴风险 | 典型误用 |
|---|---|---|---|---|
| 纯 TTL | 最终一致,窗口 = TTL | 最低 | 集中过期时高 | 用 TTL 兜住所有一致性问题 |
| 写时删 | 读可能拿到旧值(删前读、删后回填) | 低 | 中 | 不处理并发读写竞态 |
| 写时更新 | 较好 | 中,双写须都成功 | 中 | 缓存写失败只回滚库 |
| 版本号 / 逻辑过期 | 可控,可读旧值兜底 | 高 | 低 | 缺后台刷新,旧值永不更新 |
写路径:1) 更新 DB 2) 删除缓存(不是更新缓存)
只删不更:并发写时"更"会写回旧值,"删"最多一次 miss
读路径:1) 查缓存 2) miss 查 DB 3) 回填,并写入当时读到的 ver
写时删仍有窄窗口:读线程在删之前读到旧值、在删之后回填,旧值就复活了,窗口等于"读 DB + 回填"的耗时。没有哪种策略把窗口消成零,区别只在窗口多长。
7. 穿透、击穿、雪崩:手段与各自的账单
| 问题 | 触发条件 | 常见手段 | 代价 | 副作用 |
|---|---|---|---|---|
| 穿透 | 反复查不存在的 key | 空值缓存、布隆过滤器 | 内存或可接受误判率 | 空值 TTL 过长会挡住新增数据 |
| 击穿 | 热点 key 过期瞬间 | 单飞、逻辑过期 | 实现复杂度 | 单飞失败要能重试,否则全线阻塞 |
| 雪崩 | 大量 key 同时过期、或缓存层整体不可用 | TTL 打散、多级、熔断降级 | 需预热与容量冗余 | 降级后 DB 未必扛得住 |
// Caffeine 的 get(key, mapping) 自带按 key 的单飞语义:
// 同 key 并发只放一个线程回源,其余等待结果
ItemView v = l1.get(key, k -> loadFromDb(k));
雪崩的难点不是"缓存挂了",而是"挂了以后 DB 容量没人算过":降级开关没在压测里验证过,就只是把缓存故障换成数据库故障。
8. 热点 Key:分裂与本地兜底
| 手段 | 适用 | 代价 |
|---|---|---|
key 分裂(key#0..N) |
单 key QPS 超过单实例承载 | 读要聚合,写要同步 N 份 |
| 本地兜底(短 TTL 的 L1) | 极热且能容忍秒级旧值 | 内存占用与不一致 |
| 读副本分流 | 读多写少 | 副本延迟导致读旧值 |
shard = hash(key) % N
读:只读 item:{id}#{shard}(推荐,写入时同步 N 份)
写:更新 DB 后,对 N 个分片各发一次删除
取舍是写入放大 N 倍换读取分摊 N 倍;N 越大,每个分片多占一份连接与内存,一次失效变 N 次操作。
9. 对账与可观测:命中率不是越高越好
| 指标 | 口径 | 用途 | 异常信号 |
|---|---|---|---|
| 命中率 | hits / (hits + misses) | 判断这层值不值 | 骤降 = 失效风暴或 key 结构变了 |
| 回源率 | 回源 QPS / 总 QPS | 容量规划 | 看绝对值,不看比例 |
| 失效消息量 | evict 条/秒 | 评估广播成本 | 突增 = 批量变更 |
-- 抽样对账:取最近变更过的一批主键,逐条比对缓存版本
SELECT id, version, updated_at FROM item
WHERE updated_at > NOW() - INTERVAL 5 MINUTE
ORDER BY updated_at DESC LIMIT 200;
-- 失配率 = 不一致条数 / 抽样条数,按周期记趋势
命中率的边际收益按回源绝对量算:99%→99.5% 与 90%→95% 都是回源减半,后者却要靠更长 TTL 换。
10. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 加了缓存 QPS 没涨 | 命中率低,写多读少 | 先看 recordStats 的命中率再决定是否保留 |
| 各实例返回不同数据 | 本地 TTL 长、广播丢失 | 补版本号校验,缩短 TTL |
| 发布后集中出现慢查询 | 新实例冷启动,本地缓存为空 | 发布前预热,或灰度放量 |
| 缓存里出现永不过期的旧值 | 写时删失败且无人重试 | 失效失败进重试队列并告警 |
11. 边界:这些场景不该加缓存层
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 数据量小、读频率低 | 启动时加载或干脆不加 | 一次加载比每次查缓存便宜 |
| 强一致业务 | 只缓存不可变数据 | 副本与源之间的窗口无法消除 |
| 单机单实例部署 | 本地缓存足够 | 集中式缓存解决的是多实例共享问题 |
| 写多读少 | 优化写路径 | 每次写都要失效,缓存是负债 |
12. 自测
- 三层缓存同时失效时,DB 承压的是哪一部分流量,你算过吗?
- 本地缓存 TTL 设 30 秒,最坏情况下用户能看到多久以前的数据?
- 为什么写路径推荐"删缓存"而不是"更新缓存"?
- 命中率从 99% 提到 99.5%,回源量减少了多少,值得吗?
- 广播失效消息丢失后,系统靠什么机制最终恢复一致?
- 什么情况下"数据量小"本身就说明不该加缓存层?