加一层 Caffeine,P99 从几十毫秒掉到个位数,团队很高兴。三天后运营改价,6 台机器里 5 台显示新价,1 台还是旧价——没人通知它失效。
两级缓存的本质是一笔交易:用"多久可能读到旧值"换"少一次网络往返"。交易能做,但价格必须提前写清。本地层该放什么数据是另一个问题,这里只算一致性的账。
1. 收益侧:消失的那次往返
| 路径 | 一次读的耗时构成 | 典型量级 |
|---|---|---|
| 直接查库 | 建连复用 + SQL + 结果序列化 | 毫秒到十毫秒级 |
| 走集中缓存 | 一次网络 RTT + 反序列化 | 亚毫秒到毫秒级,取决于是否同机房 |
| 走本地缓存 | 一次哈希查找 | 纳秒到微秒级 |
收益不在"快几倍",而在把读路径从"受网络与下游影响"变成"不受影响"。两种情况价值极高:读放大的页面聚合,以及下游抖动时的兜底。
代价同时固定:数据副本存在于每个进程的堆里,进程间不再有共同真相,只有一个"多久同步一次"的约定。
2. 代价侧:一致性窗口关不上
| 环节 | 引入的延迟 | 能否消除 |
|---|---|---|
| 数据变更落库 | 事务提交时间 | 不能,但可缩短 |
| 通知发出 | 消息投递延迟 | 不能完全消除,只能压低 |
| 其他实例收到 | 网络与处理排队 | 不能,实例越多尾部越长 |
| 本地缓存重建 | 下一次读触发回源 | 可预热,但不能保证同步 |
| 读请求与通知竞争 | 请求恰好在通知到达前读到旧值 | 不能,这是概率事件 |
最后一行是关键:即使通知完美,也总有窗口——A 已更新、通知在路上,B 此刻被读。真正关闭窗口只能让每次读都问权威源,那等于取消本地缓存。
所以正确表述不是"保证一致",而是"保证最多旧多久",且要写成 SLA 里的一行字。
3. 本地层的四种实现与适用面
| 实现 | 容量控制 | 过期语义 | 适用 |
|---|---|---|---|
Caffeine |
maximumSize / maximumWeight + expireAfterWrite |
写入后过期、访问后过期、刷新 | 绝大多数生产场景,默认选择 |
Guava Cache(旧) |
maximumSize + expireAfterWrite |
无异步刷新 | 遗留代码;新代码不必 |
手写 ConcurrentHashMap |
无 | 无 | 极小只读字典,清理易漏 |
ThreadLocal |
无 | 随线程 | 请求内的重复计算,不是"缓存",别跨请求 |
标准是是否需要容量上限。数据规模随业务增长(商品、用户、配置)就必须有上限与淘汰,否则本地层只是内存泄漏的优雅说法。ConcurrentHashMap 只适合启动加载后不再变的小字典。
4. 失效传播的四种手段与各自的漏失窗口
// 版本号校验:key 里带版本,读时版本不匹配就回源。窗口 = 版本发布延迟
String key = "item:" + itemId + ":v" + versionOf(itemId);
| 手段 | 机制 | 窗口性质 | 成本 |
|---|---|---|---|
| 主动通知(广播失效) | 变更后向所有实例推 key | 延迟 + 丢消息 = 不确定窗口 | 需消息通道与订阅逻辑 |
| 短 TTL | 到期自然失效 | 上限确定:≤ TTL | 命中率与 TTL 成反比 |
| 版本号校验 | 读时比对版本,不匹配则回源 | 版本传播延迟 | 每次读要多一次版本查询(常也在缓存里) |
| 消息广播(topic 扇出) | 事件广播到所有订阅者 | 同主动通知,更依赖中间件语义 | 需幂等消费与顺序 |
四者的窗口性质不同:TTL 是唯一有上界的(最多旧 TTL),通知类手段是"大概率很小、偶尔无限"(消息丢了就一直不失效)。正确做法是组合:通知做主路径,TTL 兜底,版本号给强敏感字段。
# 兜底 TTL 必须存在,哪怕有广播;广播是优化,TTL 才是承诺
cache:
local:
item-detail: { ttl: 30s, max-size: 20000 }
dict: { ttl: 10m, max-size: 5000 }
5. TTL 就是一致性下限
把 TTL 写进接口文档,比写"使用多级缓存"有用得多。
最长陈旧时间 ≈ TTL + 通知延迟 + 回源耗时
对账口径:若业务能容忍 R 秒陈旧,则 TTL ≤ R - 通知延迟 - 回源耗时
例:容忍 5 s,通知延迟 50 ms,回源 20 ms → TTL ≤ 4.9 s,实际取 2~3 s 留余量
"最终一致"在评审里常被误用:它不是属性,而是带数字的承诺。写不出数字就是把风险留给线上。
6. 实例数越多,通知越难保证
单次广播覆盖全部实例的概率 ≈ p^n // p = 单实例送达率, n = 实例数
p=0.999, n=6 → 0.994
p=0.999, n=60 → 0.942
单实例送达率再高,扇出到几十个实例后"全部同步"的概率明显下降,尾部延迟由最慢的实例决定。结论:
- 个位数实例 + 低频变更 → 广播 + TTL 兜底足够;
- 几十上百实例(尤其弹性伸缩)→ 广播成本与不确定性双升,缩短 TTL 甚至放弃本地层;
- 实例频繁创建销毁 → 新实例缓存从空开始,预热成为必需项。
7. 估算内存:先算字节,再谈上限
Caffeine.newBuilder()
.maximumWeight(256L * 1024 * 1024) // 按字节而非条数
.weigher((String k, ItemDetail v) -> estimateBytes(k, v))
.expireAfterWrite(Duration.ofSeconds(30))
.recordStats()
.build();
| 估算项 | 粗算口径 | 易错点 |
|---|---|---|
| 对象本身 | 字段宽度 + 对齐 | 漏算包装类型与对象头 |
| 引用开销 | 每个引用 4–8 字节 | 集合类的 entry 开销常被漏算 |
| key 字符串 | 字符数 × 编码宽度 + 对象头 | 长 key 在高基数下不可忽略 |
| 淘汰结构 | 与条目数同量级 | 漏算 W-TinyLFU 元数据 |
最稳:maximumWeight + 实测。先按估算设权重,再看堆增长与 GC 频率修正;按条数设上限在值大小差异大的场景必然估错。
8. 哪些键不该进本地层
| 键类型 | 判断 | 理由 |
|---|---|---|
| 变更频繁(价格、库存、状态机) | 不进 | 陈旧代价直接体现为业务错误 |
| 高基数(用户维度、订单维度) | 不进或极小上限 | 命中率低,只增加内存与 GC |
| 强一致读(余额、权限) | 不进 | 需权威源 |
| 大对象(富文本、图片元数据) | 慎入 | 易把堆推向老年代 |
| 变更低频 + 高读(字典、配置、类目) | 首选 | 收益/风险比最高 |
| 计算昂贵且幂等(聚合) | 可入,带 TTL | 重算成本高,陈旧可容忍 |
9. 热点键与本地层的"击穿"
本地层把击穿改了形态:集中缓存击穿是"大量请求同时回源库",本地层击穿是"每个实例各自回源一次"。
// 单飞:同一 key 的并发回源合并为一次,避免 N 个线程同时打库
ItemDetail v = cache.get(key, k -> loader.load(k));
get(key, mappingFunction) 对同一 key 单飞,这是它相对手写 Map 的核心差别。但跨实例不单飞:100 个实例同时到期仍会回源 100 次。对策是 TTL 加随机抖动(如 ttl × (0.9 + rand × 0.2))。
10. 序列化与对象引用
本地层的值是堆内对象,无序列化开销,也就能共享引用——这是优点也是坑:返回值被调用方改(尤其可变集合)就污染缓存,之后所有读者都拿到改过的数据。
// 反例:直接返回缓存内的可变集合
return cache.get(key, k -> queryFromDb(k)); // 调用方 add/remove 会污染缓存
// 正解:不可变视图或防御性拷贝(拷贝有成本,按 QPS 权衡)
return List.copyOf(cache.get(key, k -> queryFromDb(k)));
集中缓存天然规避了这个问题(每次反序列化出新对象),所以从单级迁到两级时,"共享引用"是最容易引入的隐性 bug。
11. 读路径与写路径
| 步骤 | 读路径 | 写路径 |
|---|---|---|
| 1 | 查本地,命中即返回 | 更新数据库(事务内) |
| 2 | 未命中查集中缓存 | 事务提交后删集中缓存 |
| 3 | 仍未命中回源数据库 | 广播失效本地层 |
| 4 | 回填集中缓存 + 本地缓存 | 延迟一段(如 500 ms)再删一次集中缓存 |
"先删还是先更"的争论,在两级下的答案是先更库、再删缓存、再广播,并留一次延迟删除兜底(应对读请求在删除后回填旧值)。广播必须在提交之后发出,否则订阅方会在提交前回源读到旧值并回填。
12. 可观测:必须分层统计命中率
cache_local_requests_total{cache="item",result="hit|miss"}
cache_redis_requests_total{cache="item",result="hit|miss"}
cache_invalidation_sent_total{cache="item"}
cache_invalidation_recv_total{cache="item"} // 与 sent 对不上就是丢消息
判据:本地命中率低说明放错了键或上限太小;命中率高但仍报陈旧,说明失效传播有问题,去看 sent 与 recv 差额。只有合并的总命中率时,这两个问题分不开。
13. 排错表
| 现象 | 最可能原因 | 核对 |
|---|---|---|
| 部分实例显示旧值 | 广播丢消息或订阅未启动 | sent vs recv 计数、订阅日志 |
| 全量实例长期旧值 | TTL 过长 + 无通知 | 检查 TTL 配置是否被环境覆盖 |
| 内存缓慢上涨 | 无上限或 key 高基数 | 堆直方图按类看实例数净增 |
| GC 频率上升 | 缓存对象过大、晋升老年代 | GC 日志里的晋升量与老年代曲线 |
| 冷启动后第一批请求慢 | 无预热 | 启动阶段命中率曲线 |
| 数据"时新时旧"且无规律 | 写路径广播早于事务提交 | 检查广播位置与事务边界 |
14. 边界
| 场景 | 判断 | 理由 |
|---|---|---|
| 实例数少 + 变更低频 | 值得做 | 通知容易保证,TTL 兜底成本低 |
| 数据本身容忍旧值(排行榜、推荐位) | 值得做 | 陈旧不产生业务错误 |
| 缓存对象极大(MB 级) | 不做本地层 | 堆与 GC 的代价超过一次往返 |
| 强一致读 | 不做本地层 | 窗口无法关闭 |
| 读 QPS 低 | 不做 | 往返成本不构成瓶颈 |
| 已有 CDN / 边缘缓存 | 优先边缘层 | 收益更大且失效语义更清晰 |
本地缓存是用一致性买延迟:只有能量化"可以旧多久"、实例数可控时,这笔买卖才成立。
15. 自测
- 两级缓存里"保证一致"为什么在原理上做不到?能保证的是什么?
- 四种失效传播手段中,哪一种的窗口有确定上界?
- 实例数从 6 增到 60,广播方案的哪两个量同时变差?
- 为什么本地层击穿比集中缓存击穿更难处理?
- 从单级缓存迁到两级缓存,"共享对象引用"会带来什么隐性 bug?
- 写路径上广播为什么要放在事务提交之后?