结论先行
别用 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 讨论里「不要只看一个指标」,但没说怎么合——我补的就是这个「怎么合」。