一个开关,把下单也关掉了
「拾光」的大促值班手册上只有一条降级指令:负载超过 80% 时,打开“降级开关”。这个开关是两年前写的,实现方式很直接:把商品推荐、榜单、评论和下单一起切到一个“繁忙模式”,所有非首页请求返回兜底页。
大促当天,主推商品的详情接口先被打满,整体利用率到了 82%。值班同学按手册打开开关。推荐和榜单确实停了,但下单接口也一起停了。用户能浏览商品,点“立即购买”却看到“系统繁忙”。从 20:14 到 20:31,十七分钟里没有一笔订单成功,客服电话被打爆。
更麻烦的是恢复。开关只有开和关两个状态,关掉之后所有功能同时回来,流量立刻再次打满,只能再打开。值班同学在开与关之间来回切了四次,最后靠手动扩容才稳住。事故复盘时,团队发现真正需要降级的只有推荐和榜单;下单链路虽然压力大,但仍然在容量范围内,完全不应该被牺牲。
问题的根源不是“降级”这个动作,而是降级没有分档。一个只能全开或全关的开关,在保护系统的同时,也把最该保留的核心交易一起关掉了。
四档降级各牺牲什么
降级不是“把功能关掉”这么简单。每关一个功能,都要回答三个问题:关掉之后省下多少资源,用户会看到什么,恢复时会不会引发回冲。按牺牲程度分成四档,才能在不同压力下做不同取舍。
| 档位 | 触发条件(利用率) | 保留的功能 | 牺牲的功能 | 用户可见影响 |
|---|---|---|---|---|
| normal | 低于 80% | 全部功能 | 无 | 无 |
| soft | 80% 到 100% | 浏览、搜索、下单、支付 | 推荐、榜单刷新、非核心读 | 首页个性化变弱,榜单延迟更新 |
| hard | 100% 到 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,不提供排队。用户直接流失,核心交易也没有恢复的机会。
- 队列无界或超时过长。内存会被打满,用户等到结果时已经不再需要。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。