3086 字
约 10 分钟
15
S12-A04 · 缓存架构决策:本地、集中与多级

"接口慢了就加缓存"是多数团队里最不需要讨论的动作,于是系统里同时住着三层缓存、四条失效路径和一批没人敢删的 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. 自测

  1. 三层缓存同时失效时,DB 承压的是哪一部分流量,你算过吗?
  2. 本地缓存 TTL 设 30 秒,最坏情况下用户能看到多久以前的数据?
  3. 为什么写路径推荐"删缓存"而不是"更新缓存"?
  4. 命中率从 99% 提到 99.5%,回源量减少了多少,值得吗?
  5. 广播失效消息丢失后,系统靠什么机制最终恢复一致?
  6. 什么情况下"数据量小"本身就说明不该加缓存层?
S12-A04 · 缓存架构决策:本地、集中与多级
https://www.clxhxhhr.top/posts/6084/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。