“可靠性达到百分之九十九点九”还不是一项完整的工程约定。它针对哪些用户、在哪个位置测量、观察多长时间,又把怎样的结果视为成功?只有回答这些问题,错误预算才能把允许的失败变成可计算的数量。因此,首先要定义统计总体和成功规则,而不是先挑选仪表盘上的绿色阈值。两个页面展示相同的百分比,如果分母和判定不同,也不能直接比较。

统计真正承诺的用户体验

服务水平指标描述实际测得的量,服务水平目标则为它规定阈值与时间窗口。对于基于事件的指标,成功比例等于符合条件的成功事件数除以全部符合条件的事件数。成功可以要求一次业务操作正确完成,并且没有超过延迟上限。仅有正常的响应状态码,不能证明订单已经正确写入;服务器内部计时也可能没有包含用户实际经历的网络等待。

分母必须明确,例如某个接口收到的有效客户请求尝试,而文档约定的合成探针可以另行统计。还要事先决定重试、客户端取消、认证失败、超时以及异步完成如何表示。排除条件应来自服务承诺的边界,不能在事故后临时创造,用来美化结果。应保留足够的记录,分辨一条事件是按规则排除,还是因为采集失败而没有进入统计;这两者的含义完全不同。

一条失败可以同时触发多个条件

假设成功请求既要返回预期响应,也要在三百毫秒内完成。一个缓慢的服务器错误同时违反两个条件,但仍然只是一条不良请求。应计算失败条件的并集,或者直接对成功判定取反。如果把“超时延迟计数”和“错误状态计数”相加,而两者不是互斥类别,就会重复计入交叉部分,导致预算消耗被夸大。

对于目标值与符合条件的请求总数,允许失败数就是请求数乘以一减目标值,剩余预算再减去实际失败数。请求型预算统计的是事件,时间型预算统计的是服务处于指定状态的时长。三十天内百分之九十九点九的时间型目标,对应四十三点二分钟的不良时间;不能把这个换算直接套到任意流量下的请求统计。部分用户失败、高低峰切换和不同请求权重,都会破坏两种口径之间的简单对应关系。

滚动窗口还会随着旧事件离开而变化,即使没有新增失败。应在同一个窗口内重新计算额度与失败数,不能把终身累计扣款直接当成滚动余额。发布政策还必须说明负余额会触发哪些实际行动;计算本身既不授权继续制造失败,也不会自动阻止部署,这些决策需要事先约定。

坏掉的样子

只统计应用服务器日志,可能看不到到达应用之前就被拒绝的请求。只记录已经完成的请求,也可能遗漏仍然卡在队列中的请求。这样得到的成功率可以很好看,用户却已经无法使用服务。需要选择合适的观测位置,纳入超时结果,并展示覆盖程度或独立检查;不能把遥测缺失解释成服务成功,更不能靠丢掉失败样本让分母看起来干净。

如果窗口里没有符合条件的事件,成功比例没有可解释的分母,应标记为没有观察值,并另行处理监控缺失。请求量很小时,高目标也会对单次失败特别敏感,因为预算是一个数学额度,不一定恰好等于整数次事件。不要为了得到一个“至少允许失败一次”的结果而向上取整。跨实例比较时也必须保留同样的范围和请求权重,直接平均各实例的百分比会改变指标含义。

快速验证

把代码保存为 slo-population.py,用 Python 3 执行。这个模拟接口的规则是:只有状态二百且耗时不超过零点三秒才算成功。样本包含已完成的客户事件,以及明确标记为探针的独立事件,用来验证分类计算。它没有证明真实采集器的完整性,也没有把正常状态码等同于真实业务结果已经正确完成。

from fractions import Fraction

# Synthetic events: (kind, status, duration_seconds).
events = ([('user', 200, 0.10)] * 9970
          + [('user', 200, 0.50)] * 10
          + [('user', 503, 0.02)] * 10
          + [('user', 503, 0.50)] * 10
          + [('probe', 503, 0.50)] * 100)
eligible = [(status, duration) for kind, status, duration in events if kind == 'user']
bad_status = sum(status != 200 for status, _ in eligible)
too_slow = sum(duration > 0.30 for _, duration in eligible)
bad = sum(status != 200 or duration > 0.30 for status, duration in eligible)
good = len(eligible) - bad
target = Fraction(999, 1000)
allowed = len(eligible) * (1 - target)
remaining = allowed - bad

assert len(eligible) == 10000 and bad_status == too_slow == 20
assert bad == 30 and bad_status + too_slow == 40
assert allowed == 10 and remaining == -20
assert Fraction(good, len(eligible)) == Fraction(997, 1000)
# The inclusive threshold is part of the synthetic contract.
assert not (200 != 200 or 0.30 > 0.30)
assert 200 != 200 or 0.300001 > 0.30
# Aggregation must weight each eligible event, not each instance equally.
assert Fraction(999 + 0, 999 + 1) == target
assert (Fraction(999, 999) + Fraction(0, 1)) / 2 != target
assert 1 * (1 - target) == Fraction(1, 1000)
print(f"eligible={len(eligible)}; good={good}; bad_union={bad}")
print(f"status_failures={bad_status}; slow={too_slow}; wrong_sum={bad_status + too_slow}")
print(f"SLI={good / len(eligible):.3%}; allowed={float(allowed):.0f}; remaining={float(remaining):.0f}")

样本里有一万次符合条件的客户事件,另有一百次探针被排除。客户事件中,状态不符合预期的有二十次,延迟超限的也有二十次,其中十次同时属于两类。因此失败并集实际只有三十次,成功事件为九千九百七十次;直接相加会错误地得到四十次失败。

输出的成功指标为百分之九十九点七,低于目标。按请求数计算的允许失败数为十次,剩余预算为负二十次,这些单位都是事件次数,而不是停机秒数。分别查看状态错误与延迟超限仍然有助于诊断,但判断目标是否达成时必须使用同一个已声明的成功规则。把计算接入告警和发布策略之前,应逐项核对生产计数器的范围、阈值是否包含边界、时间窗口及缺失数据处理方式。

延伸阅读

来源