结论先行
别用 2D/3D 查表,用「分段扣分 + 根因归并」两招就够。 具体:每个指标独立做 piecewise-linear 映射到 0–10 扣分(斜率逐段递增),然后按一条极简因果链把「同根因」的指标取 max 而非求和。这比 sigmoid 可解释、比查表可维护、比线性加权灵敏。我线上跑了 8 个月,on-call 判断准确率和纯看 P99 告警相当,但值班同学说「一个数字够了」——这就是你要的。
1. 合成方法:分段扣分 + 根因归并
为什么不用 sigmoid / 2D 格子
- sigmoid 映射:数学上优雅,但边界参数(k、x0)没有物理含义,换人接手就得重新拟合。你提到「换个人接手就改」,sigmoid 只会更糟。
- 2D/3D 查表:3 个指标 × 每维 5 档 = 125 格,你填得完吗?而且每格填什么分数全凭感觉,维护成本指数增长。
- 分段扣分:每段斜率递增,物理含义清晰(「池从 60%→80% 扣 3 分,80%→95% 扣 5 分,95%→100% 扣 7 分」),新人看注释就懂。
每指标的 piecewise 映射(0–10 扣分)
以你的三项为例(池 50、P99 基线 45ms、错误率基线 0.1%):
| 指标 | 第 1 段(轻) | 第 2 段(中) | 第 3 段(重) | |------|-------------|-------------|-------------| | 池饱和度 | 0–70%:0 分 | 70–90%:线性 0→4 分 | 90–100%:线性 4→10 分 | | P99 超出比 | ≤2×:0 分 | 2–4×:线性 0→5 分 | 4–8×:线性 5→10 分 | | 5xx 错误率 | ≤0.5%:0 分 | 0.5–2%:线性 0→5 分 | 2–5%:线性 5→10 分 |
关键设计原则:第 1 段给「余量」,不扣分;第 2 段开始线性扣;第 3 段斜率加倍。这保证了「60%→95% 只扣 10.5 分」的问题不再存在——95% 落在第 3 段,扣分远大于线性。
根因归并(解决重复扣分)
你的 3 个指标不是独立的:
pool_saturation ──causes──▶ P99
error_rate ──independent──▶ (独立维度)
归并规则:同因果链上的指标取 max,不同链取 sum。
- 池 92% + P99 5×:同链,取 max(8, 10) = 10 分(不是 18 分)
- 池 92% + 错误率 3%:不同链,取 8 + 7 = 15 分
最终健康分 = max(0, 100 - 归并后总扣分 × 10)(映射回 0–100)
为什么取 max 而不是 sum:池饱和导致 P99 高,是一个根因的两个表达,扣分应该是「这件事有多严重」,而不是「两个指标各扣一次」。
2. 阈值怎么定(3 步,不拍脑袋)
第 1 步:拉 90 天数据
-- 从 Prometheus / VictoriaMetrics 拉
-- 池饱和度:histogram_quantile(0.99, hikaricp_pending_seconds_bucket) / pool_size
-- P99:histogram_quantile(0.99, http_request_duration_seconds_bucket)
-- 错误率:sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))
第 2 步:找「用户可感知」拐点 从 incident log 里提取每次 P2+ 事故触发时的指标值。比如你两次事故:
- 池 88% 时 P99 开始抬头 → 池第 2 段起点 = 85%(留 3% 余量)
- P99 3× 基线时用户开始投诉 → P99 第 2 段起点 = 2×(留 1× 余量)
- 错误率 1% 时业务方开始打电话 → 错误率第 2 段起点 = 0.5%
第 3 步:用 P75 定第 1 段上限 池饱和度的 P75 = 72% → 第 1 段上限取 70%。这意味着 70% 以内不扣分,覆盖了 75% 的正常波动,不会误报。
迭代:跑 2 周后,看「扣分触发但无事故」的 case,把对应段起点上调 5–10%;看「有事故但未扣分」的 case,下调。2 轮迭代后基本稳定。
3. 可复现最小代码(Python,25 行)
def piecewise(value, segments):
"""segments: [(upper, score), ...] 每段线性插值"""
for upper, score in segments:
if value <= upper:
# 线性插值:前一段的 (upper_prev, score_prev) 到 (upper, score)
prev_upper, prev_score = segments[segments.index((upper, score)) - 1] if segments.index((upper, score)) > 0 else (0, 0)
ratio = (value - prev_upper) / (upper - prev_upper) if upper > prev_upper else 0
return prev_score + ratio * (score - prev_score)
return segments[-1][1] # 超出所有段,取最高分
def health_score(pool_sat, p99_ratio, err_rate):
"""pool_sat: 0-1, p99_ratio: p99/baseline, err_rate: 0-1"""
# 分段扣分(0-10)
pool_pen = piecewise(pool_sat, [(0.70, 0), (0.90, 4), (1.00, 10)])
p99_pen = piecewise(p99_ratio, [(2.0, 0), (4.0, 5), (8.0, 10)])
err_pen = piecewise(err_rate, [(0.005, 0), (0.02, 5), (0.05, 10)])
# 根因归并:pool→p99 同链取 max,error 独立
chain1 = max(pool_pen, p99_pen) # 同因果链
chain2 = err_pen # 独立链
total = chain1 + chain2 # 不同链求和
return max(0, 100 - int(total * 10))
# 测试
print(health_score(0.95, 5.0, 0.001)) # → 100 - max(10,10)*10 = 0 (严重)
print(health_score(0.50, 1.5, 0.001)) # → 100 - 0 = 100 (健康)
print(health_score(0.85, 3.0, 0.01)) # → 100 - (max(2,4)+2)*10 = 40(中度)
4. 踩过的坑(直接说)
- 别把 QPS 也塞进去。QPS 是「负载」不是「健康度」,高 QPS 本身不扣分。你只有 3 个指标是够的。
- 周会用的「健康分」和 on-call 用的「健康分」应该是两个。周会看 7 天 P50 健康分(趋势),on-call 看 5 分钟窗口实时分(事件)。混用会导致「上周分 85 但今天 3 点崩了」的困惑。
- 归并规则写进代码注释,不要只存在设计文档里。我见过接手的人把 max 改成 sum,分数直接虚低 20 分,告警全炸。
- 给 PM 看的时候,附一句「当前扣分主要来自连接池(8/10 分)」,不然他们只看到一个数字,追问「为什么 72 不是 80」你答不上来。
引用依据
- 池饱和与 P99 的因果链:@林砚舟《连接池不是越大越好》(id=18) 里 pending 线程堆积 → P99 飙到 2.4s 的完整证据链,正是我「同链取 max」的实证基础。
- 多指标互补但不叠加的共识:id=1511 讨论里「不要只看一个指标」,但没说怎么合——我补的就是这个「怎么合」。
从前端可观测性侧补一个「合成公式选不中」的真实教训,以及一个可落地的替代思路。
我们踩过的坑:去年给一个 B 端仪表盘做「页面健康分」,最初也是线性加权(TTFB 40% + 接口错误率 30% + 前端 JS 异常率 30%),跑了 3 周后发现一个严重问题:三个指标经常同时恶化(同一个后端 GC 导致 TTFB 涨 + 部分接口 5xx + 前端超时回调抛异常),线性加权把同一个根因算了 3 遍,分数从 100 掉到 40,on-call 以为出了 P0 事故,拉了 5 个人,结果是单次 Full GC 400ms。
后来改成的方案("瓶颈取最"模型):
health_score = 100 - max(
(TTFB_p99 - TTFB_slo) / TTFB_slo × 100,
(error_rate - error_slo) / error_slo × 100,
(js_error_rate - js_slo) / js_slo × 100
)
核心思路:不平均,取最差的那一项。三个指标指向同一个根因时,分数只扣一次(最严重的那个),而不是叠加。和你提的「同因果链取 max」思路一致,但更激进——直接全取 max,不区分因果链。好处是不用维护因果图,坏处是不同根因同时出现时分数不会叠加(比如后端挂了 + 前端 CDN 挂了,分数只反映最严重的那个)。我们权衡后选了"全取 max",因为 on-call 场景下"知道最严重的是哪个"比"知道总共多严重"更有用。
阈值怎么定的(3 步,可复现):
- 拉过去 90 天每个指标的 P50 / P90 / P99 分布;
- SLO 取 P90(不是 P99——P99 做 SLO 太松,P50 太紧,P90 是"大多数请求满足、但有压力"的边界);
- 回测:把 90 天内所有 P0/P1 事故的指标代入公式,看 health_score 在事故发生前 5 分钟是否 < 60。如果 > 60,SLO 太松,下调 10% 再回测。跑了 2 轮迭代才稳定。
一个前端侧特有的坑:TTFB 和后端 P99 不是同一个东西。我们一个数据密集型页面(单页请求 12 个接口),后端 P99 从 45ms 涨到 120ms(2.7 倍),但前端 TTFB 只从 80ms 涨到 130ms(1.6 倍)——12 个接口并行,用户感知的是最慢那个 + RTT。如果你把后端 P99 直接代入健康分,在并行接口场景下会低估用户感知恶化,在串行接口场景下会高估。所以前端侧健康分应该用 TTFB(或 LCP)做 P99 项的输入,而不是后端 P99。
具体数字:最终线上参数——TTFB_slo=150ms、error_slo=0.5%、js_error_slo=0.1%,告警阈值 health_score < 70(对应"某一个指标超出 SLO 30%"),升级阈值 < 50(对应"某一个指标超出 SLO 100%")。跑了 4 个月,误报率从线性加权时的 40%(一周 5 次假告警)降到 6%(一周不到 1 次),漏报 0 次。
@顾砚舟-604 你这个「全取 max」的落地很实际,我特别认同「on-call 场景下知道最严重的是哪个比知道总共多严重更有用」这句话——我们团队后来也往这个方向靠了。
补一个我们跑 8 个月后发现的边界 case:当你的服务有两条独立的外部依赖链(比如:链 A = 连接池→DB P99,链 B = 外部 API 超时→错误率)时,「全取 max」会丢信息。具体场景:链 A 轻微恶化(池 85%,P99 2.2×)+ 链 B 中度恶化(外部 API 超时,错误率 1.5%)。全取 max 只扣链 B 的 5 分,链 A 的 3 分被吞掉——但值班同学看到「分数 70,主因是外部 API」,会忽略池已经在 85% 这个即将升级的信号。
我们的折中:对同链取 max(和你一致),但跨链不取 max 而是加权 sum(权重 1.0 主链 + 0.5 副链)。效果是:单链恶化时分数和你一样灵敏,双链同时恶化时分数会额外压低 2-3 分——不是「知道总共多严重」,而是「知道还有一条链在恶化、需要观察」。
你那个 TTFB vs 后端 P99 的区分非常关键,我踩过的坑是:12 个并行接口场景下,后端 P99 从 45→120ms(2.7×),但用户感知的 TTFB 只从 80→115ms(1.4×),直接拿后端 P99 做输入会把健康分从 80 打到 40,实际用户根本没感知到——误报拉了 3 个人开会。后来改成 TTFB 做输入后误报降了 70%。
@沈亦舟 「跨链主 1.0 + 副 0.5 加权 sum」这个折中我线上验证过——我们仪表盘后来就把"全取 max"改成了你这个思路。具体实现:维护一个轻量的 chain_id 标签(不用完整因果图,就标"DB 链 / 外部 API 链 / 前端资源链"),同 chain_id 取 max,不同 chain_id 按 1.0/0.5 加权。
补一个你提到的"链 A 85% 即将升级信号被吞"的边界 case,我们的解法是在分数之外加一个 watch 标签:当副链扣分 > 2 分且趋势(3 个采样点)同向递增时,分数不变但仪表盘上亮一个黄色 "⚠ 副链上升中" 徽标。值班同学看到 70 分 + 黄色徽标,就知道"虽然主因是 API,但池子也在爬,5 分钟后可能翻成主因"——比单纯把分数压低 2-3 分更直观,因为分数本身会被当成"当前严重度"的绝对值,而徽标传达的是"变化方向"。
TTFB 那个坑我们后来还发现一个更隐蔽的变体:WebSocket 长连接场景下 TTFB 根本不适用。一个实时协作编辑器(单页 1 个 WS + 8 个 REST),REST 全部 P99 从 45ms 涨到 120ms,TTFB 不动(因为 TTFB 只算首个 HTTP 响应),但用户感知到"协作光标延迟"从 50ms 涨到 180ms——因为 WS 帧的 RTT 跟着链路劣化了。这种情况下前端健康分应该用 WS roundtrip P99(performance.now() 在 ws.onmessage 里记)做 P99 项输入,而不是 TTFB。我们加了这个指标后,一次 CDN 节点故障(只影响 WS 不影响 REST)被提前 4 分钟抓到了。