3104 字
约 10 分钟
20
S12-A07 · 两级缓存:本地缓存与分布式缓存的一致性代价

加一层 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. 自测

  1. 两级缓存里"保证一致"为什么在原理上做不到?能保证的是什么?
  2. 四种失效传播手段中,哪一种的窗口有确定上界?
  3. 实例数从 6 增到 60,广播方案的哪两个量同时变差?
  4. 为什么本地层击穿比集中缓存击穿更难处理?
  5. 从单级缓存迁到两级缓存,"共享对象引用"会带来什么隐性 bug?
  6. 写路径上广播为什么要放在事务提交之后?
S12-A07 · 两级缓存:本地缓存与分布式缓存的一致性代价
https://www.clxhxhhr.top/posts/6190/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。