《互联网产品的服务端架构·规模化与架构演进》11.2 限流、降级与预案

大促预案最容易写成一张只有阈值的表:订单每秒五千、详情每秒两万,却没人回答超限之后用户看到什么。本节把核心接口、限流阈值与降级动作放在一起校验,给出覆盖率计算与 Go 实现,并复盘一次详情接口直接返回 503、导致 App 首页整屏空白的故障。

预案评审会上,每个人都在报阈值

「回声」大促前两周开了一次预案评审会。订单团队说下单接口每秒五千,商品团队说详情接口每秒两万,支付团队说回调每秒三千。每个数字后面都有一张压测曲线,会议看起来很有料。直到主持人问了一句:超限之后呢?

会议室停顿了几秒。有人回答“返回 503”,有人回答“让网关排队”,有人回答“还没定”。这三个回答对应三种完全不同的用户体验:503 是直接失败,排队是延迟上升但可能成功,没定则意味着事故发生时由值班同学临场决定。预案评审如果只评审阈值,就等于只评审了“什么时候开始疼”,没有评审“疼的时候怎么处理”。

限流和降级是两件事。限流决定放多少请求进来,降级决定进来的请求退到什么程度。前者保护系统,后者保护用户体验。两者必须一起设计,否则系统可能活下来,用户却什么都看不到。

核心接口与降级动作要一起定义

预案的第一张表不应该只有接口名和阈值,而应该把“超限后做什么”写在同一个格子里。核心接口至少包括下单、支付回调、商品详情、库存查询、优惠券核销五类;每一类都要有一个明确的降级动作,并且这个动作要能被前端感知和解释。

核心接口限流阈值(案例假设值)降级动作用户感知恢复条件
下单5000 QPS返回“排队中”,异步落单延迟增加,不直接失败队列消化到阈值以下
支付回调3000 QPS落盘后异步处理,立即返回成功无感知,到账可能延迟回调积压低于一分钟
商品详情20000 QPS返回缓存快照,关闭实时库存库存可能不准,页面仍可浏览数据库连接池恢复
库存查询15000 QPS返回最近一次快照并标注“稍后确认”可能看到旧库存热点行锁等待回落
优惠券核销8000 QPS先记账后核销,失败自动补偿券状态短暂延迟核销队列清空

表里的阈值和动作都是案例假设值,不能直接套用。真正要带走的是“用户感知”和“恢复条件”两列:降级动作必须回答用户会看到什么,恢复条件必须回答什么时候可以退出降级。没有恢复条件的降级,往往会变成永久降级。

降级不是关闭功能

最常见的错误降级是“直接关闭”。商品详情超限就返回 503,推荐位超限就整块不渲染,搜索超限就显示空白页。从系统角度看,这确实降低了负载;从用户角度看,这等于产品突然消失。好的降级应该尽量保留主路径,只关闭最贵的部分。例如商品详情可以返回缓存快照,但仍然保留购买按钮;推荐位可以下沉到页面底部,而不是整块消失。

降级动作要能自动触发

预案写在文档里,事故时靠人执行,通常会慢十分钟。降级动作应该尽量绑定自动触发条件,例如错误率连续一分钟超过 5%、p99 超过 1 秒、数据库连接池使用率超过 90%。自动触发不等于完全无人值守,仍然需要值班同学确认和必要时人工升级,但至少能把“发现”和“决策”之间的时间压缩。

降级动作按用户影响排序

同样是通过降级保护系统,不同动作对用户的影响差别很大。预案应该按影响从轻到重排序,先执行影响小的动作,只有在还不够时才继续升级。案例假设「回声」的降级阶梯是:关闭商品详情的实时库存、关闭推荐位的个性化排序、把下单改为排队、最后才对非核心接口返回 503。

这个顺序不是技术复杂度排序,而是用户价值排序。实时库存关闭后,用户仍能浏览和下单,只是可能看到“库存确认中”;个性化推荐关闭后,首页仍有商品,只是不再千人千面;下单排队会让用户等,但不会直接丢掉订单;503 是最后手段,因为它等于告诉用户“这里现在不能用”。

影响面要写进预案

预案里应该有一列“影响面”,写清楚这个动作会影响多少用户、哪些入口、是否可回滚。没有影响面的降级动作,评审时无法判断轻重,执行时也无法向客服解释。影响面不需要精确到个位数,但至少要区分“全量用户”“新用户”“某个渠道”“某个商品类目”。

降级要有退出条件

每一个降级动作都要有退出条件,并且退出条件应该是可观测的。例如“数据库连接池使用率连续五分钟低于 70%”“下单队列积压低于 30 秒”“错误率连续五分钟低于 1%”。没有退出条件的预案,事故结束后没人敢恢复,系统会长期跑在降级状态,用户体验和收入都在慢慢流失。

一次“限流生效、页面白屏”的故障

事故发生在一次大促开场。商品详情接口的限流阈值是每秒两万,开场后真实流量冲到每秒六万。限流组件按预案生效,超过阈值的请求被直接拒绝,返回 503。从系统指标看,这是成功的一次限流:数据库没有被压垮,应用 CPU 保持在 60%,下游库存服务也没有超时。

但用户看到的是另一回事。「回声」的 App 首页由商品详情卡片组成,首屏加载会并发请求二十个商品详情。详情接口一旦返回 503,首页的卡片区域就整块空白,只剩下顶部的搜索框。更糟的是,商品详情页的“立即购买”按钮依赖详情接口返回的库存状态,详情失败后按钮也不渲染,用户即使从其他入口进入商品页,也无法下单。

故障持续了十五分钟。值班同学最初以为是前端问题,后来才定位到限流返回码。修复方式是把详情接口的降级动作从“直接拒绝”改成“返回缓存快照”,并把库存状态改为异步确认。缓存快照的命中率是 92%,剩下的 8% 返回“商品信息加载中”,页面至少不会整屏空白。

根因不是限流阈值设错了,而是降级动作选错了。预案把“保护数据库”当成了唯一目标,选择了实现最简单的 503,却没有回答“用户看到什么”。评审时也没有人检查降级动作的用户影响,因为表格里只有接口名和阈值两列。

这次故障留下的教训是:限流阈值决定系统什么时候开始拒绝,降级动作决定用户看到什么。两者必须一起评审,而且降级动作要按用户影响排序,不能按实现难度排序。

把预案校验写成函数

配套代码 internal/promo/plan.go 只做两件事:校验每个核心接口都有降级动作,计算必需接口的覆盖率。它不执行限流,也不调用配置中心,因为这一层的职责是“预案是否完整”,不是“预案是否生效”。

// file: internal/promo/plan.go
// tier: L1
// verify: go test ./internal/promo/

package promo

import "fmt"

// Measure 是预案中的一个限流/降级动作。
// Name 是接口名,Limit 是每秒允许的请求数,Degrade 是超限后的降级动作。
type Measure struct {
	Name    string
	Limit   int
	Degrade string
}

// Validate 检查预案是否可执行。
// 每个列出的接口都必须有降级动作;接口名不能为空或重复;
// Limit 必须为正数,否则等于没有限流。
func Validate(ms []Measure) error {
	seen := make(map[string]struct{}, len(ms))
	for i, m := range ms {
		if m.Name == "" {
			return fmt.Errorf("promo: measure at index %d has empty name", i)
		}
		if m.Limit <= 0 {
			return fmt.Errorf("promo: measure %q has non-positive limit %d", m.Name, m.Limit)
		}
		if m.Degrade == "" {
			return fmt.Errorf("promo: measure %q has no degrade action", m.Name)
		}
		if _, ok := seen[m.Name]; ok {
			return fmt.Errorf("promo: duplicate measure %q", m.Name)
		}
		seen[m.Name] = struct{}{}
	}
	return nil
}

// Coverage 返回必需接口中有降级动作的比例。
// required 是必须覆盖的接口清单;同一接口重复出现只算一次。
// 空清单返回 0:没有声明必需接口时,不应该报告“100% 覆盖”。
func Coverage(ms []Measure, required []string) float64 {
	if len(required) == 0 {
		return 0
	}
	have := make(map[string]struct{}, len(ms))
	for _, m := range ms {
		if m.Degrade != "" {
			have[m.Name] = struct{}{}
		}
	}
	seen := make(map[string]struct{}, len(required))
	covered := 0
	for _, name := range required {
		if _, ok := seen[name]; ok {
			continue
		}
		seen[name] = struct{}{}
		if _, ok := have[name]; ok {
			covered++
		}
	}
	return float64(covered) / float64(len(seen))
}

Validate 把“每个核心接口必须有降级动作”作为硬约束。空降级动作直接报错,而不是给一个默认的“返回 503”。默认值看起来友好,实际上会把“没想清楚”伪装成“已经配置”,评审时更难发现。

重复接口名也被当成错误。同一个接口在预案里出现两次,通常意味着两个人各自维护了一份阈值,或者复制粘贴时忘了改名字。运行时到底用哪一条,取决于配置加载顺序,这种不确定性不应该留到大促当天。

Coverage 的返回值是 0 到 1 的比例,而不是百分比字符串。调用方可以自己决定展示方式,也方便做阈值判断。必需接口重复出现只算一次,避免通过重复声明把覆盖率刷上去。

空必需清单返回 0,而不是 1。数学上“没有必需接口”可以认为覆盖率是 100%,但在预案场景里,空清单更可能是漏填,而不是真的没有核心接口。返回 0 会迫使调用方显式声明必需接口,减少“看起来全覆盖、实际没检查”的假象。

Coverage 只统计有降级动作的接口。一个接口即使出现在预案里,只要降级动作为空,也不算覆盖。这和 Validate 的硬约束一致:有阈值不等于有预案。

测试:缺降级、重名与覆盖率

预案校验的测试要覆盖三类问题:缺降级动作、重复接口名、以及覆盖率的分母口径。下面的测试还固定了空降级动作不计数、必需接口去重和空清单返回 0 这几个边界。

// file: internal/promo/plan_test.go
// tier: L1
// verify: go test ./internal/promo/

package promo

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

func TestValidateAcceptsCompletePlan(t *testing.T) {
	ms := []Measure{
		{Name: "order.create", Limit: 5000, Degrade: "返回排队中,异步落单"},
		{Name: "product.detail", Limit: 20000, Degrade: "读缓存并关闭实时库存"},
	}
	if err := Validate(ms); err != nil {
		t.Fatalf("Validate returned error: %v", err)
	}
}

func TestValidateRejectsMissingDegrade(t *testing.T) {
	ms := []Measure{{Name: "order.create", Limit: 5000}}
	err := Validate(ms)
	if err == nil {
		t.Fatal("want error when a core interface has no degrade action")
	}
	if !strings.Contains(err.Error(), "degrade") {
		t.Fatalf("error %q should mention degrade", err)
	}
}

func TestValidateRejectsDuplicateName(t *testing.T) {
	ms := []Measure{
		{Name: "order.create", Limit: 5000, Degrade: "排队"},
		{Name: "order.create", Limit: 3000, Degrade: "直接拒绝"},
	}
	if err := Validate(ms); err == nil {
		t.Fatal("want error for duplicate measure name")
	}
}

func TestValidateRejectsBadNameAndLimit(t *testing.T) {
	cases := []Measure{
		{Name: "", Limit: 100, Degrade: "拒绝"},
		{Name: "pay.callback", Limit: 0, Degrade: "拒绝"},
		{Name: "pay.callback", Limit: -1, Degrade: "拒绝"},
	}
	for _, m := range cases {
		if err := Validate([]Measure{m}); err == nil {
			t.Fatalf("Validate(%+v) = nil, want error", m)
		}
	}
}

func TestCoverageCountsRequiredInterfaces(t *testing.T) {
	ms := []Measure{
		{Name: "order.create", Limit: 5000, Degrade: "排队"},
		{Name: "product.detail", Limit: 20000, Degrade: "读缓存"},
	}
	required := []string{"order.create", "product.detail", "pay.callback"}
	got := Coverage(ms, required)
	if math.Abs(got-2.0/3.0) > 1e-9 {
		t.Fatalf("Coverage = %v, want 2/3", got)
	}
}

func TestCoverageIgnoresDuplicates(t *testing.T) {
	ms := []Measure{{Name: "order.create", Limit: 5000, Degrade: "排队"}}
	required := []string{"order.create", "order.create"}
	if got := Coverage(ms, required); math.Abs(got-1) > 1e-9 {
		t.Fatalf("Coverage = %v, want 1", got)
	}
}

func TestCoverageDoesNotCountEmptyDegrade(t *testing.T) {
	ms := []Measure{{Name: "order.create", Limit: 5000}}
	if got := Coverage(ms, []string{"order.create"}); got != 0 {
		t.Fatalf("Coverage = %v, want 0 for empty degrade", got)
	}
}

func TestCoverageEmptyRequiredIsZero(t *testing.T) {
	if got := Coverage(nil, nil); got != 0 {
		t.Fatalf("Coverage with no required interfaces = %v, want 0", got)
	}
}

TestValidateRejectsMissingDegrade 是这条规则的核心回归测试。它不关心错误文案的完整内容,只检查错误里出现 degrade,避免测试因为文案调整而频繁失败,同时保证错误确实指向缺失的降级动作。

TestCoverageCountsRequiredInterfaces 用 2/3 这个不好约分的比例,能抓住“分子分母写反”“把已覆盖数当覆盖率”这类错误。如果用 2/2 或 1/2,某些错误实现也可能碰巧通过。

TestCoverageIgnoresDuplicates 锁住必需接口的去重。如果实现直接拿 len(required) 当分母,重复声明会把覆盖率压低;如果实现用 len(ms) 当分母,又会被预案条数干扰。正确的分母是去重后的必需接口数。

TestCoverageEmptyRequiredIsZero 固定空清单行为。这个选择带有产品判断:宁可让调用方看到 0 并去补清单,也不要让一个空清单显示 100% 覆盖。

测试没有覆盖降级动作是否“合理”。一个接口可以填“返回 503”并通过校验,但用户体验可能很差。合理性需要评审,代码只能检查完整性。把完整性自动化,把合理性留给人,是这类校验函数的合理边界。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/promo/ -run 'TestValidate|TestCoverage' -count=1 -v

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

=== RUN   TestValidateAcceptsCompletePlan
--- PASS: TestValidateAcceptsCompletePlan (0.00s)
=== RUN   TestValidateRejectsMissingDegrade
--- PASS: TestValidateRejectsMissingDegrade (0.00s)
=== RUN   TestValidateRejectsDuplicateName
--- PASS: TestValidateRejectsDuplicateName (0.00s)
=== RUN   TestValidateRejectsBadNameAndLimit
--- PASS: TestValidateRejectsBadNameAndLimit (0.00s)
=== RUN   TestCoverageCountsRequiredInterfaces
--- PASS: TestCoverageCountsRequiredInterfaces (0.00s)
=== RUN   TestCoverageIgnoresDuplicates
--- PASS: TestCoverageIgnoresDuplicates (0.00s)
=== RUN   TestCoverageDoesNotCountEmptyDegrade
--- PASS: TestCoverageDoesNotCountEmptyDegrade (0.00s)
=== RUN   TestCoverageEmptyRequiredIsZero
--- PASS: TestCoverageEmptyRequiredIsZero (0.00s)
PASS
ok  	webapp/internal/promo	0.341s

八个用例全部通过,退出码为 0。ok 后面的耗时是一次本机运行的值,换机器或换一次运行都会变化。

小结

限流和降级是一套动作的两半。限流回答“放多少进来”,降级回答“进来的请求退到哪里”。只评审阈值的预案是不完整的,因为它没有回答用户会看到什么。

预案的三个硬约束是:每个核心接口都有降级动作、接口名不重复、必需接口的覆盖率可计算。降级动作还要按用户影响排序,并写明恢复条件。没有恢复条件的降级,最后往往变成永久降级。

下一节讨论大促复盘:事故过去之后,如何把时间线、影响和待办沉淀下来,而不是只留下一句“流量太大”。

阅读导航:上一节:11.1 容量规划与压测 · 下一节:11.3 大促复盘 。

常见错误与边界

  • 预案只有阈值没有降级动作。超限时由值班同学临场决定,等于没有预案。
  • 把“返回 503”当成默认降级。它实现最简单,但对用户的影响往往最大。
  • 降级动作按实现难度排序,而不是按用户影响排序。先关最贵的功能,不等于先关最不重要的功能。
  • 降级没有恢复条件。事故结束后没人敢恢复,系统长期跑在降级状态。
  • 同一个接口在预案里出现两次。运行时用哪条取决于加载顺序,结果不可预测。
  • 用重复声明刷覆盖率。必需接口必须去重,否则覆盖率会失真。
  • 只校验预案完整性,不评审降级合理性。代码能保证“有动作”,不能保证“动作对”。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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