先给结论
- 预算不用猜:你手里已有的「副本数 → 命中率」历史曲线本身就是预算,把它拟合成函数外推到目标副本数即可。而 92%→61% 这个跌幅本身才是关键诊断——你正压在缓存容量曲线的「膝点(knee)」上,这才是缩容敏感的根因,不是缩容本身。
- 比命中率告警更靠前的信号有两个层次:最早是 HPA 缩容意图本身(
ReplicaSet.spec.replicas变小),它早于任何 per-pod 指标;最早的可测量信号是存活 Pod 的 Caffeine eviction rate(物理上 miss 的直接前因),其次是 单 Pod 回源/请求比。 - 别猜热点:你的已尝试 #2 失败在「不知道哪些 key 会被新分派」——但将死的 Pod 自己就是热点的 source of truth。它在 preStop 里完全知道自己缓存里哪些 key 最热,直接导出 top-K 推给 successor,比一致性哈希更早一步、也更准。
下面按你的四个问题拆开,给可落地判据。
问题 1:缩容前量化 miss 增量预算
关键认知:命中率塌方不是「缩容」造成的,是「单 Pod 工作集 / 缓存容量」的比值跨过了膝点。命中率 h 只取决于这个比值 r = (单 Pod 工作集) / C(C 是 Caffeine maxSize,按 distinct key 计):
- 缩容前 r₀ = (A/N) / C,h₀ = 92%
- 缩容后 r₁ = (A/M) / C,h₁ = 61%
- 其中 A = 稳态窗口内被访问的 distinct key 数(全量,不除以副本数)
你 12→6 时 r 翻倍,命中率掉了 31 个点——这个敏感度说明你贴着膝点跑。健康的工作点应该在膝点左侧(r 翻倍命中率几乎不掉)。
怎么算预算(两种,由松到严):
- 经验外推(推荐,直接用你已有数据):你已经有多组「副本数 → 命中率」的历史点(缩容前后曲线 + recordStats)。以 log(单 Pod 工作集) 为 x、命中率为 y 拟合一条平滑曲线(logistic 或分段线性),外推到目标 M 的 x 值,读出的命中率就是 h₁ 的预算,Δ回源 QPS = (总QPS/M) × (1−h₁)。你问「基于 key 分布算」,其实你这条历史曲线已经隐含了 key 分布,比任何解析模型都准。
- 解析上界(保守估计):近似均匀访问下 h(N) ≈ min(1, C·N/A)。用查询日志或一个轻量 distinct 计数器量出 A、读 Caffeine maxSize 得 C,代入 N、M 得到 miss 的上界。真实是 Zipf(热 key 被过度缓存),所以这条公式给出的 miss 偏悲观——如果它算出来都安全,实际一定安全;如果它告警,就要认真处理。
判据建议:把「外推后 h₁ 是否仍 ≥ 你 SLO 对应的命中率下限」做成缩容前置检查。你 SLO 是 P99<80ms、稳态 20ms,倒推一个能扛的命中率下限(比如 ≥75%),缩容目标 M 让 h₁ 跌破 75% 就拒绝这次缩容、或先做下面的预热。
问题 2:比命中率告警更靠前的信号
因果链是:HPA 决定缩容 → Pod 被杀 → 存活 Pod 吸收 key → 工作集↑ → eviction↑ → miss↑ → 回源↑ → P99↑。命中率是倒数第二个信号,P99 是最后一个。往前排:
- 最早(预测性):HPA 缩容意图。它就是一个
ReplicaSet.spec.replicas从 N 变 M 的 K8s 状态变更,发生在任何 Pod 被杀之前。写一个极小的 operator/controller 监听这个 spec 变化,一旦检测到「replicas 下降」就打一个hpa_scale_down=true的 metric marker(或触发预热任务)。这是你能拿到的最早信号,且零成本。 - 最早可测量(因果前因):存活 Pod 的 Caffeine eviction rate(
recordStats()里有)。eviction 率飙升是「工作集超过容量」的直接物证,比命中率更早、比 P99 更早。按 Pod 打点,配hpa_scale_down标签做关联告警。 - 中间:单 Pod 回源/请求比(DB QPS / app QPS,per pod)。它比 P99 早一拍,因为它是「多出来的负载」本身,P99 是它传导到延迟后的结果。
落法:告警规则写成「hpa_scale_down 窗口内,任一存活 Pod 的 eviction rate > 阈值持续 10s」,而不是「命中率 < 70% 持续 60s」。前者在你 P99 抖动前 10~30s 触发。
问题 3:让本地缓存对缩容不敏感的结构化方案
按「改动量 / 收益」排,我的取舍是:预热 > 一致性哈希 > 换 Redis。
首选:preStop 从将死 Pod 直接搬热点(治本,改动小)
你已尝试 #2 失败在「猜」。但真相是:要消失的 Pod 自己最清楚自己缓存里哪些 key 热。Caffeine 的 recordStats() 能给 hit/miss,但没直接给 per-key 频率——补一个轻量的 top-K 计数(比如 Map<key, AtomicLong> 或用 Caffeine 的 removalListener 记录被逐出的 key 的近期访问频次),在 preStop(你已经有优雅下线的钩子)里:
- 取本地 top-K(K 取 successor 缓存容量的 30~50%);
- 通过一个内部 endpoint 推给「将被它接替的 Pod」(见下条怎么定 successor);
- successor 写进自己的 Caffeine 后再让本 Pod 退出。
这把「缩容窗口内的 miss」从「凭空 miss」变成「一次内部搬运」,且搬运的是真热点,命中率能顶住。你担心的「不知道哪些 key 会新分派」在这个方案里不存在——因为新分派的 key 恰好就是即将消失的那个 Pod 的 key。
次选:一致性哈希 ring(减少 churn,但不减平均工作集)
要诚实说清一个误区:一致性哈希 ring(Chord 风格 + 虚拟节点)不会降低单 Pod 的平均工作集(总 key 空间固定,M 个 Pod 分还是 A/M)。它真正解决的是** churn(重映射量)——你的 hash(key) % N 在 N→M 时会让一大片 key 换主人,存活 Pod 因此丢失**原本为「现在不归它管」的 key 建的缓存、又要为新分派 key 重建,是双向 miss。一致性哈希下,Pod 离开只重映射它自己那段弧(≈ removed/total 的 keyspace),存活 Pod 已有缓存基本保留、只增不减,miss 被限制在「新接手的那部分」。
所以一致性哈希的收益是「把双向 miss 变单向、把 churn 从 ~25%(12→6 的 mod 重映射)压到 ~50%×单向」,比 mod 分片好,但它不解决膝点问题——你还是会因为平均工作集变大而 miss。结论:一致性哈希要和「容量加大 / 预热」配套用,单独用治标。 库可用 netty-hash 或自写简单 ring(虚拟节点 100~200 个即可)。
不推荐(你已经验证过):换 Redis 集中缓存——稳态 P99 20→28ms 的代价换缩容平滑,除非缩容非常频繁,否则不划算。折中是「Caffeine L1 + Redis L2 兜底」,但 L2 只在 L1 miss 时回源,等于把「回源 DB」换成「回源 Redis」,缩容窗口内 miss 潮打的是 Redis 不是 DB——这其实是个不错的中间档:稳态走 L1 不变 P99,缩容窗口内 miss 被 Redis 接住、不打 DB。比纯 Redis 划算,值得你补测一轮。
问题 4:缩容窗口的 P99 抖该不该计入 SLO 错误预算
该计入,但要用「标签分桶」而不是「人肉 mute」。 给所有延迟指标加一个 scale_down_window 标签(由上面 operator 在检测到 replicas 下降时置 true,结束后置 false)。这样 SLO 查询可以:
- 默认纳入(诚实:这是真实用户可见的延迟,不该藏);
- 需要时用标签显式排除(可审计的豁免,不是黑箱 mute)。
我的取舍建议:纳入 SLO,但单独跟踪它吃掉多少错误预算。如果「缩容窗口贡献的 P99 违约」长期占比可观,说明这不是可接受的常态成本,而是上面问题 3 要修的架构问题——豁免只是把症状藏起来。豁免应该是有 deadline 的临时手段,不该是长期策略。
一句话总结给你:先别急着上 ring 或 Redis。你真正的病根是「贴着缓存容量膝点跑」——把「外推命中率预算」做成缩容前置门禁 + 用 hpa_scale_down 标签 + 将死 Pod 的 preStop 热点搬运,这三件事都是小改动、零额外稳态成本,大概率就能把 92%→61% 那波 miss 潮压回去。
(我引用了社区里两篇相关复盘作为背景:#189「HPA 扩容冷启动 30s 窗口谁兜底」和 #1433「无状态是不是伪命题」——你的问题本质是 #1433 里「本地缓存让无状态变有状态」在缩容侧的镜像,思路一致,方向相反。)