《互联网产品的服务端架构·规模化与架构演进》3.3 降级与排队

从一个大促当天误开降级开关、导致全站无法下单的事故写起,说明降级为什么必须分档、每一档牺牲什么;给出按利用率选择 normal、soft、hard、reject 四档的 Go 实现、边界测试与真实运行输出,并讨论降级与排队如何配合保护后端。

一个开关,把下单也关掉了

「拾光」的大促值班手册上只有一条降级指令:负载超过 80% 时,打开“降级开关”。这个开关是两年前写的,实现方式很直接:把商品推荐、榜单、评论和下单一起切到一个“繁忙模式”,所有非首页请求返回兜底页。

大促当天,主推商品的详情接口先被打满,整体利用率到了 82%。值班同学按手册打开开关。推荐和榜单确实停了,但下单接口也一起停了。用户能浏览商品,点“立即购买”却看到“系统繁忙”。从 20:14 到 20:31,十七分钟里没有一笔订单成功,客服电话被打爆。

更麻烦的是恢复。开关只有开和关两个状态,关掉之后所有功能同时回来,流量立刻再次打满,只能再打开。值班同学在开与关之间来回切了四次,最后靠手动扩容才稳住。事故复盘时,团队发现真正需要降级的只有推荐和榜单;下单链路虽然压力大,但仍然在容量范围内,完全不应该被牺牲。

问题的根源不是“降级”这个动作,而是降级没有分档。一个只能全开或全关的开关,在保护系统的同时,也把最该保留的核心交易一起关掉了。

四档降级各牺牲什么

降级不是“把功能关掉”这么简单。每关一个功能,都要回答三个问题:关掉之后省下多少资源,用户会看到什么,恢复时会不会引发回冲。按牺牲程度分成四档,才能在不同压力下做不同取舍。

档位触发条件(利用率)保留的功能牺牲的功能用户可见影响
normal低于 80%全部功能无无
soft80% 到 100%浏览、搜索、下单、支付推荐、榜单刷新、非核心读首页个性化变弱,榜单延迟更新
hard100% 到 120%下单、支付、登录、库存校验评论、历史订单、营销页部分页面不可用,核心交易继续
reject超过 120%登录与排队页所有业务写入新请求进入队列或收到 503

分档的关键不是数字本身,而是每一档都必须写清楚“牺牲什么”。如果值班同学只看到一个枚举值,不知道 soft 会关掉哪些接口,他就不敢用;如果 hard 档没有明确保留清单,开发同学就会把自己负责的功能悄悄塞进去,直到 hard 也扛不住。

80% 这条线是预警线,不是故障线。利用率到 80% 时系统仍然能正常服务,但已经没有足够的余量吸收突发;此时关掉推荐和榜单,是把资源提前让给交易链路。100% 是容量线,超过它意味着请求开始排队,必须牺牲非核心功能。120% 是保护线,再往上增加任何业务写入,都可能把数据库连接和线程池彻底拖垮。

这里有一个容易混淆的边界:容量足够并不等于应该停留在 normal。负载刚到 80% 时,系统看起来一切正常,但一次缓存失效或一个慢查询就可能把利用率推到 100% 以上。soft 档的意义就是在还有余量的时候主动让出非核心资源,而不是等到用户感知到变慢才动作。

失败场景:降级开关没有分级,一开就全站不可下单

案例假设值下,大促峰值期间商品详情接口的 QPS 是日常的 12 倍,推荐接口是 20 倍,下单接口只有 3 倍。整体利用率 82% 的构成里,推荐和榜单贡献了大部分增量。一个分档正确的策略应该进入 soft:停掉推荐和榜单,下单不受影响。

但两档开关把所有非首页请求一起切到兜底页,下单接口也被包括在内。后果有三层。第一层是直接收入:十七分钟没有订单,按案例假设的每分钟 600 单、客单价 180 元计算,损失约 184 万元,这还不包括恢复后的退款和补偿。第二层是用户信任:大促当天最关键的时刻无法下单,用户会转向竞品,部分不会回来。第三层是恢复风险:开关关闭时所有功能同时恢复,积压的推荐请求和榜单刷新请求一起回冲,系统再次过载,形成开、关、再开的循环。

修复分三步。第一,把降级从布尔开关改成四档枚举,每一档有明确的保留清单和牺牲清单。第二,触发条件从单一利用率扩展为利用率加关键接口错误率,避免推荐接口把整体利用率抬高后误判交易链路。第三,reject 档不能只返回 503,要配合有界队列和排队页,让用户可以等待,而不是直接流失。

排队是降级的一部分,不是降级的替代品。队列必须有上限、有超时、有公平性策略。无界队列会把内存打满,超时太长的队列会让用户等到已经不需要结果。合理的做法是:soft 和 hard 档尽量不排队,靠牺牲非核心功能保住核心链路;reject 档才启用短队列,队列满后立即拒绝,并把预计等待时间告诉用户。

队列长度应该按处理能力换算成等待时间,而不是按请求个数设置。每秒能处理 500 个请求、队列上限 3000,意味着最长等待约 6 秒;如果上游超时是 3 秒,这个队列就有一半的等待没有意义。上限、超时和上游超时必须一起定,否则队列只是在把失败推迟。

排队页也不是一张静态页面。用户需要知道自己在第几位、大概还要多久,页面要能轮询或接收推送,并在轮到用户时给出一个有时效的令牌。没有令牌的排队页会在恢复瞬间被所有等待用户同时刷新,把刚恢复的系统再次打满。

公平性同样要在队列层解决。所有请求挤在一个先进先出队列里,一个热点商品的抢购流量会占满整个队列,普通商品的用户只能陪跑。按商品、按用户或按租户拆成多个有界子队列,再按权重出队,才能保证热点不至于把其他业务一起拖死。

最后是恢复。降级容易,恢复难。队列排空、缓存回填、连接池重建都需要时间,不能一关开关就全量放行。恢复应该按比例放量:先放 10%,观察错误率和延迟,再逐步提高;同时保留一个可以随时退回上一档的开关。没有演练过的恢复流程,第一次执行大概率会变成第二次故障。

用 Decide 与 Action 把档位和动作分开

下面的实现来自 webapp-lab 的 internal/burst 包。Decide 只根据负载和容量选择档位,Action 把档位翻译成值班同学可以执行的动作。两个函数分开,是为了让判定逻辑可以单独测试,也让动作文案可以随产品调整而不影响阈值。

// file: internal/burst/degrade.go
// tier: L1
// verify: go test ./internal/burst/

package burst

import "math"

// Level 是降级的四个档位,从正常服务到直接拒绝。
type Level int

const (
	LevelNormal Level = iota // 全量服务
	LevelSoft                // 关闭非核心功能
	LevelHard                // 只保留核心链路
	LevelReject              // 拒绝新请求,保护后端
)

// Decide 按负载与容量的比值选择降级档位。
// 边界约定:低于 80% 为 normal,[80%, 100%] 为 soft,(100%, 120%] 为 hard,
// 超过 120% 为 reject。容量满并不等于正常:利用率一到 80% 就要开始让出非核心资源。
// 非法输入(容量非正、负载为负、NaN 或 Inf)返回 normal,调用方应另行记录告警。
func Decide(load, capacity float64) Level {
	if capacity <= 0 || load < 0 ||
		math.IsNaN(load) || math.IsNaN(capacity) ||
		math.IsInf(load, 0) || math.IsInf(capacity, 0) {
		return LevelNormal
	}

	ratio := load / capacity
	switch {
	case ratio < 0.8:
		return LevelNormal
	case ratio <= 1.0:
		return LevelSoft
	case ratio <= 1.2:
		return LevelHard
	default:
		return LevelReject
	}
}

// Action 返回该档位下系统实际执行的动作。
// 每个档位牺牲什么必须写清楚,否则值班同学只看到一个枚举值,不知道该关哪些功能。
func Action(l Level) string {
	switch l {
	case LevelNormal:
		return "serve: 全量功能正常服务(非法输入也回落到本档)"
	case LevelSoft:
		return "shed: 关闭推荐、榜单刷新与非核心读"
	case LevelHard:
		return "protect: 只保留下单、支付、登录与库存校验"
	case LevelReject:
		return "reject: 返回排队页或 503,保护后端不再接收新请求"
	default:
		return "serve: 未知档位按正常处理"
	}
}

Decide 的边界约定写在注释里:低于 80% 为 normal,80% 到 100% 为 soft,100% 到 120% 为 hard,超过 120% 为 reject。端点归属必须明确,否则 100% 到底算 soft 还是 hard 会在不同实现里给出不同答案。这里把 100% 放在 soft 的上端点,意味着容量刚好打满时仍然保留核心交易,只牺牲非核心功能;一旦超过 100%,才进入 hard。

非法输入返回 normal,是一个有意的兜底。Decide 的签名没有 error 返回值,因为降级判定必须在任何情况下都能给出一个档位,不能因为参数异常让调用方卡住。容量为零、负载为负、NaN 或 Inf 都回落到 normal,同时由调用方记录告警。这个选择有风险:如果监控数据本身坏了,系统会停留在 normal,而不是自动进入保护状态。所以生产接入时,参数校验和监控数据质量检查要放在调用 Decide 之前。

Action 把每个档位翻译成一句可执行的话。normal 的文案里专门写了“非法输入也回落到本档”,是为了让值班同学在日志里看到 normal 时,能意识到可能是数据异常而不是系统真的健康。reject 的文案明确写出“返回排队页或 503”,避免实现同学只返回一个空响应。

测试:四个档位与端点归属

降级逻辑的测试重点在端点。80%、100%、120% 这三个数字附近,差 0.1% 就可能换一个档位,必须逐条固定。非法输入的兜底行为也需要测试,否则一次 NaN 就可能让系统停在错误的状态。

// file: internal/burst/degrade_test.go
// tier: L1
// verify: go test ./internal/burst/

package burst

import (
	"math"
	"strings"
	"testing"
)

func TestDecideBands(t *testing.T) {
	cases := []struct {
		load, capacity float64
		want           Level
	}{
		{0, 100, LevelNormal},
		{79.9, 100, LevelNormal},
		{80, 100, LevelSoft},
		{100, 100, LevelSoft},
		{100.1, 100, LevelHard},
		{120, 100, LevelHard},
		{120.1, 100, LevelReject},
		{300, 100, LevelReject},
	}
	for _, c := range cases {
		if got := Decide(c.load, c.capacity); got != c.want {
			t.Fatalf("Decide(%v, %v) = %v, want %v", c.load, c.capacity, got, c.want)
		}
	}
}

func TestDecideInvalidInputFallsBackToNormal(t *testing.T) {
	cases := []struct{ load, capacity float64 }{
		{10, 0},
		{10, -1},
		{-1, 100},
		{math.NaN(), 100},
		{10, math.NaN()},
		{math.Inf(1), 100},
		{10, math.Inf(1)},
	}
	for _, c := range cases {
		if got := Decide(c.load, c.capacity); got != LevelNormal {
			t.Fatalf("Decide(%v, %v) = %v, want LevelNormal", c.load, c.capacity, got)
		}
	}
}

func TestActionDescribesEveryLevel(t *testing.T) {
	levels := []Level{LevelNormal, LevelSoft, LevelHard, LevelReject}
	seen := map[string]bool{}
	for _, l := range levels {
		action := Action(l)
		if action == "" {
			t.Fatalf("Action(%v) is empty", l)
		}
		if seen[action] {
			t.Fatalf("Action(%v) duplicates another level: %q", l, action)
		}
		seen[action] = true
	}
	if !strings.HasPrefix(Action(LevelReject), "reject:") {
		t.Fatalf("Action(LevelReject) = %q, want a reject-prefixed instruction", Action(LevelReject))
	}
}

func TestActionUnknownLevelIsSafe(t *testing.T) {
	if got := Action(Level(99)); !strings.HasPrefix(got, "serve:") {
		t.Fatalf("Action(99) = %q, want a serve fallback", got)
	}
}

TestDecideBands 覆盖 0%、79.9%、80%、100%、100.1%、120%、120.1% 和 300% 八个点,明确每个端点属于哪一档。TestDecideInvalidInputFallsBackToNormal 覆盖容量为零、容量为负、负载为负、NaN 和 Inf,确认全部回落到 normal。TestActionDescribesEveryLevel 检查四个档位的动作文案非空且互不重复,防止复制粘贴时漏改。

TestActionUnknownLevelIsSafe 处理枚举越界。Go 的枚举没有强制范围,数据库里读到一个旧版本写入的档位值是有可能的;此时返回 normal 的兜底文案,比 panic 更适合生产环境。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/burst/ -count=1 -run 'TestDecide|TestAction' -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestDecideBands
--- PASS: TestDecideBands (0.00s)
=== RUN   TestDecideInvalidInputFallsBackToNormal
--- PASS: TestDecideInvalidInputFallsBackToNormal (0.00s)
=== RUN   TestActionDescribesEveryLevel
--- PASS: TestActionDescribesEveryLevel (0.00s)
=== RUN   TestActionUnknownLevelIsSafe
--- PASS: TestActionUnknownLevelIsSafe (0.00s)
PASS
ok  	webapp/internal/burst	0.225s

四个用例全部通过,包级结果是 ok webapp/internal/burst。这些测试只验证纯函数,不涉及真实流量切换;上线前还需要在预发环境验证每一档实际关闭了哪些接口,以及恢复时会不会出现回冲。

演练应该覆盖“进入档位”和“退出档位”两个方向。只演练打开 soft 不够,还要验证从 soft 回到 normal 时推荐和榜单会不会把积压请求一次性放出来,以及 hard 回到 soft 时评论和历史订单的恢复顺序。每一次演练都要记录触发指标、实际关停接口和用户可见变化,形成可以复用的值班卡片。

监控要能回答“现在处于哪一档、已经持续多久、上一档是什么时候退出的”。只报当前利用率不够,因为值班同学需要判断系统是在恶化还是在恢复。档位变化本身也应该是一条独立事件,写入变更记录,方便事后对齐用户投诉和订单损失。

误降级的成本同样要算。soft 档停掉推荐会让首页转化率下降,hard 档停掉评论会影响用户决策,这些损失不一定比过载小。分档策略应该和业务方一起评审,明确每一档可以接受的影响范围,而不是由技术团队单方面决定。

小结

降级分档的本质,是把“关什么”变成一份可以提前评审的清单。normal、soft、hard、reject 四档分别对应不同的利用率区间,每一档都要写清楚保留什么、牺牲什么、用户会看到什么。一个布尔开关做不到这件事,它只能在全开和全关之间跳变,最容易牺牲的往往正是最该保留的核心交易。

Decide 负责判定,Action 负责解释,两者分开让阈值和文案可以独立演进。非法输入回落到 normal 是可用性优先的选择,但必须配合调用方的数据质量告警,否则监控坏了系统也不会进入保护状态。排队则是 reject 档的配套手段:有界、有超时、有预计等待时间,才能让用户在系统过载时仍然有路可走。

阅读导航:上一节:3.2 弹性扩容与预热 · 下一节:4.1 分库分表与路由 。

常见错误与边界

  • 用布尔开关代替分档。一开就全站不可下单,保护系统变成了制造故障。
  • 分档只写档位名,不写保留清单。值班同学不知道 soft 会关哪些接口,实际不敢用。
  • 只看整体利用率,不看关键接口错误率。推荐接口的流量可能把整体利用率抬高,误导判定。
  • 把 80% 当成故障线。80% 是预警线,此时系统仍能服务,应该主动让出非核心资源。
  • 端点归属不明确。100% 和 120% 到底属于哪一档,必须在代码和文档里写成同一套约定。
  • 非法输入自动进入保护档。监控数据坏了就全站降级,可能比不降级损失更大;应回落 normal 并告警。
  • reject 档只返回 503,不提供排队。用户直接流失,核心交易也没有恢复的机会。
  • 队列无界或超时过长。内存会被打满,用户等到结果时已经不再需要。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查