一个服务的水位一直在 70%,团队觉得"还有余量",于是没扩。某天上游做了一次促销,流量涨了 40%,服务在十分钟内开始超时——因为 70% 是"平均水位",而峰值早就贴着上限。
水位是一个典型的被平均值掩盖的指标。扩容决策依赖的不是"平均用了多少",而是"峰值离上限还有多远",以及"从当前状态涨到上限需要多久"。前者决定风险,后者决定反应时间。
1. 四个水位口径
| 口径 | 含义 | 用途 |
|---|---|---|
| 平均水位 | 日均或小时均值 | 成本与利用率 |
| 峰值水位 | 一定窗口内的最高值 | 风险判断 |
| 分位水位 | P95/P99 的用量 | 排除瞬时尖刺 |
| 水位斜率 | 上涨速度 | 反应时间 |
第二项与第三项要一起看:P99 高但峰值不高,说明有偶发尖刺(可能是批任务);峰值远高于 P99,说明有明显的流量尖峰,需要按峰值配。
四个口径还有一个共同前提:水位是"用了多少"还是"离上限多远",两者不能混称。资源打满时水位 100%,而延迟超标时资源可能只用 50%——这是两套完全不同的风险,看板上应当分开呈现。
第四项最容易被忽略:水位从 70% 涨到 95% 用了多久。如果只用五分钟,那么"还有 30% 余量"这句话没有任何意义,因为扩容需要的时间可能超过它。
2. 扩容触发的三条判据
| 判据 | 阈值示例 | 优先级 |
|---|---|---|
| 峰值水位 | > 80% 连续 3 天 | 中 |
| 峰值水位 + 斜率 | 一个扩容周期内会到 95% | 高 |
| 延迟或错误率 | P99 超 SLA | 最高 |
触发条件还要考虑"持续时间":单点尖峰不应触发扩容,否则每天都会扩缩一次。所以阈值应当带上持续时长或分位条件,把噪声过滤掉。
第三条不是"容量"指标,但它是最真实的信号:容量不足的第一表现往往是延迟,而不是资源打满。等到资源打满再扩,已经晚了一步。
// 扩容规则:三类判据并联,任一条满足即触发
scale_out:
- metric: cpu_peak_7d
condition: "> 0.80"
action: "+1 实例"
- metric: projected_peak
condition: "> 0.95 within 7d" # 按斜率外推
action: "+2 实例,提前"
- metric: p99_latency
condition: "> sla * 1.2 for 3d"
action: "+1 实例,并排查下游"
3. 扩多少:三个算法
| 算法 | 做法 | 适用 |
|---|---|---|
| 按比例 | 目标水位 60%,实例数 = 当前 × 峰值 / 0.6 | 无状态、线性扩展 |
| 按余量 | 保证"一个实例故障仍能承载" | 高可用要求 |
| 按增长 | 按未来一个季度的预期峰值配 | 增长明确的业务 |
这个算法还有一层含义:"能承载 N-1"决定了单实例的承载能力要求,而这是压测出来的固定值,不能靠加实例改变。所以余量不足时,正确的动作可能是"提高单实例效率"而不是"加机器"。
第二项的价值在于故障场景:三个实例里挂一个,剩下两个能否承载?如果不能,说明余量实际为零。所以"能承载 N-1"是一个比"水位 60%"更硬的要求。
// 两种算法的差异(当前峰值 1000 QPS,单实例承载 400)
按比例(目标 60%):需要 1000/0.6/400 ≈ 5 个实例
按 N-1 余量 :需要满足 2 个承载 1000 → 单实例 500,需重新压测
// 结论:N-1 要求实际上抬高了"单实例承载能力"的门槛,不能只靠加实例解决
4. 水位与共享资源
| 资源 | 水位是否可随实例线性扩展 |
|---|---|
| 无状态计算 | 是 |
| 数据库连接 | 否(连接总数固定) |
| 缓存带宽 | 部分(带宽有限) |
| 下游调用配额 | 否(配额按账号算) |
共享资源的清单值得单独维护一张,扩容前逐项过一遍:
// 扩容前必须核对的共享资源清单
- 数据库:连接数上限、CPU、慢查询
- 缓存:连接数、带宽、内存水位
- 消息队列:分区数(决定消费并行度上限)
- 下游 API:配额、对方限流阈值
- 网络:出口带宽、NAT 端口
// 任一项水位 > 70% → 扩容前先解决它,否则扩了无益
第二项是扩容最常见的失效点:应用扩了一倍,数据库连接数没变,于是每个实例分到的连接减半,吞吐反而下降。
// 扩容前先确认共享资源的水位
mysql -e "SHOW GLOBAL STATUS LIKE 'Threads_connected'"
mysql -e "SHOW VARIABLES LIKE 'max_connections'"
redis-cli info clients | grep connected_clients
// 若连接水位已 > 70%,扩应用实例前必须先解决连接池或加只读副本
5. 缩容与扩容的不对称
| 方向 | 风险 |
|---|---|
| 扩容 | 需要预热、冷启动、缓存重建 |
| 缩容 | 需要优雅摘流、连接迁移 |
| 频繁扩缩 | 缓存反复冷启,命中率长期偏低 |
还有一类隐性成本:扩缩容会触发配置下发与健康检查风暴,实例越多,每次变更的传播成本越大。所以"实例数"本身也有一个合适的上限,不是越细越好。
第三项常被忽略:自动扩缩容如果过于灵敏,会让缓存一直处于半冷状态。这类系统的延迟看起来"随机抖动",实际是扩缩容在反复清空本地缓存。
// 扩缩容的三个稳定参数
scale_up_cooldown: 3m # 扩完至少观察 3 分钟再考虑再扩
scale_down_cooldown: 15m # 缩容更保守,避免抖动
min_instances: 3 # 下限,避免缩到无法承载故障
水位还应当与"业务指标"对齐看:同样的 CPU 水位,在请求分布不同时代表的余量完全不同。低代价请求与高代价请求混在一起时,CPU 水位 60% 可能已经离峰值很近——因为峰值时段的大请求正在挤压计算能力。
6. 排错:五种"水位不高却过载"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 平均 70% 但经常超时 | 峰值远高于平均 | 按峰值与斜率判断 |
| 扩了实例吞吐没涨 | 共享资源瓶颈 | 查连接与下游配额 |
| 夜间频繁缩容白天再扩 | 冷却时间太短 | 拉长缩容冷却 |
| 单实例负载不均 | 长连接未重新分配 | 摘流重连或分批重启 |
| 延迟高但 CPU 低 | 等待下游或锁 | 看链路耗时而非资源 |
第四项在长连接服务里很常见:扩容后新实例分不到流量,因为老连接一直粘在旧实例上。表现为"实例数增加了但单实例负载没降"。
7. 边界:什么情况不必精细管水位
| 场景 | 为什么 | 做法 |
|---|---|---|
| 流量平稳且远低于容量 | 无风险 | 只做月度检查 |
| 批处理(可延后) | 慢一点无影响 | 只看总时长 |
| 资源冗余充足 | 无压力 | 保留告警即可 |
| 短生命周期服务 | 来不及扩 | 按峰值一次配足 |
最后一条与决策节奏有关:扩容应当有预案的分档,而不是临场算。比如"峰值 80% 加 1、90% 加 2 并提前通知"——写成档位后,夜间值班的人不需要重新做一遍计算。
判断口径一句话:从当前水位涨到上限,需要多久。时间短于扩容周期,就必须提前扩;远长于扩容周期,才能等告警。
还有一条与成本有关的边界:水位不应追求"刚好"。把水位常年压到 85% 看起来省钱,但一次流量波动就会击穿,且没有任何时间做扩容——"留多少余量"本质是一次风险定价,而不是纯粹的成本优化。
8. 自测
- 四个水位口径中,哪两项必须一起看?为什么"P99 高但峰值不高"指向批任务?
- 为什么"水位斜率"比"当前水位"更能决定扩容时机?
- "能承载 N-1"为什么比"目标水位 60%"更硬?它对单实例能力提出了什么额外要求?
- 为什么应用扩容后吞吐可能反而下降?扩容前该先查哪两个数?
- 缩容为什么比扩容更需要冷却时间?过于灵敏的自动扩缩会造成什么现象?
- 判断"是否需要精细管水位"的那个问题是什么?