核心指标定义:成功率、错误率、RT阈值与滑动窗口

好,咱们正式开始。这一节我要讲的是熔断机制里最基础、也最要命的东西——指标定义。

你想想看,一个系统要不要熔断,总得有个判断依据吧?不能拍脑袋说「我觉得它快挂了,先断一下」。那依据什么呢?就是三个核心指标:成功率、错误率、响应时间(RT)。再加上一个统计模型——滑动窗口

我个人习惯,先把这几个指标的定义敲死,再谈阈值怎么设。不然你后面代码写得再漂亮,指标算错了,全是白搭。

1. 成功率与错误率

先说成功率。这个最简单,也最容易踩坑。

成功率 = 成功请求数 / 总请求数

嗯,公式看着简单。但什么叫「成功」?

我在项目中遇到过一个问题:业务方说「我接口返回了200,就算成功」。结果呢?返回200了,但业务逻辑其实失败了——比如余额不足,但HTTP状态码还是200。这种算成功吗?

注意: 成功率里的「成功」,必须是业务层面的成功。HTTP 200不代表业务成功。我建议你定义清楚:只有返回码在业务成功码列表里的,才算成功。

错误率就更直接了:错误率 = 错误请求数 / 总请求数。但这里有个细节——超时算不算错误?

我个人认为,超时应该算错误。因为对调用方来说,超时和报错没区别,都是拿不到结果。你想想看,用户等5秒得到一个超时,跟等1秒得到一个500,哪个体验更差?

所以我的做法是:

  • 业务成功码 → 成功
  • 业务失败码 → 错误
  • 超时 → 错误
  • 网络异常 → 错误
  • 限流拒绝 → 这个不算错误,算「被保护」

2. 响应时间(RT)阈值

RT阈值,说白了就是「你觉得多慢算慢」。

这个没有标准答案。我见过有的团队拍脑袋设个500ms,结果线上天天熔断。为什么?因为他们的接口本身就要300ms,再加上网络抖动,很容易就超了。

我的建议是:RT阈值应该基于P99或者P95的延迟来设。

什么意思?就是看过去一段时间内,99%的请求都在多少毫秒内完成。然后把这个值乘以1.5或2,作为熔断阈值。

举个例子:

分位 延迟
P50 120ms
P90 250ms
P99 480ms

那我会把RT阈值设在700ms~1s之间。为什么?因为P99是480ms,说明正常情况下99%的请求都在半秒内。设到1s,给足了余量。如果超过1s,那肯定是系统出问题了。

小技巧: 我曾经把RT阈值设得太低,结果线上频繁熔断。后来发现是某个依赖的第三方接口偶尔抖动。所以建议你:先观察一周的P99数据,再定阈值。别上来就拍脑袋。

3. 滑动窗口统计模型

好,指标定义清楚了。但怎么统计呢?

你不能说「从系统启动到现在,成功率是多少」。因为系统可能已经跑了三天三夜,前面的数据早没参考价值了。我们要的是最近一段时间的指标。

这就是滑动窗口的用武之地。

滑动窗口,说白了就是一个固定大小的、不断移动的时间窗口。比如窗口大小是10秒,那每过1秒,窗口就往前滑1秒,丢掉最旧的那1秒数据,加入最新的1秒数据。

我画个图你就明白了:

滑动窗口统计模型示意图 T-10s T-7s T-5s T-2s T(当前) 旧窗口(已过期) 当前窗口(10秒) 滑动方向 1s桶 1s桶 1s桶 1s桶 1s桶 窗口内包含10个1秒子桶,每1秒滑动一个桶

看到没?整个窗口被切成了10个1秒的子桶。每个桶里记录这1秒内的请求数、成功数、失败数、总RT。

当新的一秒到来时:

  1. 创建一个新桶,放到最右边
  2. 丢掉最左边那个旧桶
  3. 重新计算窗口内所有桶的汇总数据

这样,你任何时候查「当前成功率」,都是最近10秒的真实数据。

核心要点: 滑动窗口的粒度(子桶大小)决定了指标的灵敏度。子桶越小,反应越快,但计算量也越大。我一般用1秒子桶、10秒窗口,这个组合在大多数场景下够用。

4. 代码实现思路

光说不练假把式。我贴一段伪代码,你看看滑动窗口在Go里怎么实现:

type Bucket struct {
    Success   int64
    Failure   int64
    TotalRT   int64  // 总响应时间,用于算平均RT
    Count     int64  // 总请求数
}

type SlidingWindow struct {
    buckets  []*Bucket
    size     int     // 窗口大小(桶数量)
    interval int64   // 每个桶的时间间隔(毫秒)
    lastTime int64   // 上次滑动的时间
}

func (sw *SlidingWindow) Record(success bool, rt int64) {
    now := time.Now().UnixMilli()
    // 检查是否需要滑动
    if now - sw.lastTime >= sw.interval {
        sw.slide(now)
    }
    // 获取当前桶
    currentBucket := sw.buckets[len(sw.buckets)-1]
    currentBucket.Count++
    currentBucket.TotalRT += rt
    if success {
        currentBucket.Success++
    } else {
        currentBucket.Failure++
    }
}

func (sw *SlidingWindow) slide(now int64) {
    // 丢掉最旧的桶
    sw.buckets = sw.buckets[1:]
    // 新建一个空桶
    sw.buckets = append(sw.buckets, &Bucket{})
    sw.lastTime = now
}

func (sw *SlidingWindow) GetSuccessRate() float64 {
    totalSuccess := int64(0)
    totalCount := int64(0)
    for _, b := range sw.buckets {
        totalSuccess += b.Success
        totalCount += b.Count
    }
    if totalCount == 0 {
        return 1.0  // 没有请求,默认成功
    }
    return float64(totalSuccess) / float64(totalCount)
}

这段代码看着简单,但有几个坑:

  • 并发安全: 上面没加锁。实际生产环境要用atomic或者sync.RWMutex。我习惯用atomic,性能更好。
  • 时间对齐: 滑动要按整秒对齐,不能每次请求都去滑动。否则高并发下性能会崩。
  • 内存分配: 每次slide都创建新桶,GC压力大。可以预分配环形数组,避免频繁分配。
我曾经踩过的坑: 第一次实现滑动窗口时,我在每次请求里都调用了time.Now()。高并发下,time.Now()本身就有开销,再加上锁竞争,QIS直接掉了30%。后来改成定时器驱动滑动,请求只负责记录数据,性能才上来。

5. 阈值怎么定?

指标定义清楚了,滑动窗口也搭好了。最后一步:阈值设多少?

这个真没有标准答案。但我给你一个参考范围:

指标 建议阈值 说明
成功率 < 80% 触发熔断 低于80%说明系统已经不太行了
错误率 > 20% 触发熔断 和成功率对应,二选一即可
RT阈值 P99 × 1.5 ~ 2 具体看业务容忍度
最小请求数 ≥ 10次/窗口 请求太少时,统计无意义

注意最后一行——最小请求数。这个很容易被忽略。你想想看,如果窗口内只有1个请求,还失败了,那错误率就是100%。但你能说系统挂了吗?不能。所以一定要设一个最小请求数,样本太少时不触发熔断。

我的习惯: 阈值不要写死。做成可配置的,通过配置中心下发。这样线上调参不用重启。我曾经就因为阈值写死,半夜爬起来改代码……那滋味,不想再体验第二次。

好了,这一节的核心内容就这些。指标定义是熔断机制的基石,搞不清楚后面全是空中楼阁。下一节我们聊熔断状态机的设计——半开、全开、关闭,这三个状态怎么流转。


公众号:蓝海资料掘金营,微信deep3321