发布按钮背后的八秒转圈
「回声」是一个为本书构造的假想产品。后文出现的用户量、比例、耗时与成本,除运行输出外都是案例假设值,用来演示判断过程,不是任何真实系统的实测。
创作者写完一篇长文,点击发布,页面开始转圈。第一版把整条链路都放在请求里:创建内容、转码封面、机审文本和图片、写搜索索引、给粉丝发通知。产品经理认为“发布成功”应该意味着所有事情都完成,工程师照做了。
上线第一周,发布接口的 p99 从 180ms 涨到 8.2s(案例假设值)。用户以为没点上,继续点,同一篇文章出现三份;转码服务抖动的十分钟里,发布成功率掉到六成;客服收到“按钮坏了”的反馈,但数据库里其实已经写入了内容。
问题不在于哪个步骤慢,而在于把“能否创建内容”和“内容何时可被消费”混成了一件事。同步部分只需要回答一个问题:这次提交能否被接受。转码、审核、索引、通知都可以在请求返回之后继续做,用户不需要盯着转圈等它们完成。
同步只做“能否创建内容”
判断一个步骤该不该同步,可以先问三个问题:
- 它失败时,用户能否立刻采取不同动作?能,才值得同步返回。
- 它不做完,内容是否仍然是合法、可回滚的?是,就可以异步。
- 它的耗时是否由外部系统决定?是,就不要让用户为别人的抖动买单。
按这三个问题拆「回声」的发布链路,结果如下。表中耗时是案例假设值,只用于演示预算怎么分配:
| 步骤 | 是否同步 | 预算 | 失败时用户看到 | 失败后谁重试 |
|---|---|---|---|---|
| 参数与长度校验 | 同步 | 20ms | 具体字段错误 | 用户修改 |
| 权限、配额与频率限制 | 同步 | 30ms | 无权限或稍后再试 | 用户 |
| 幂等写入内容行 | 同步 | 80ms | 创建失败,可重试 | 用户或客户端 |
| 封面转码 | 异步 | 不占首屏 | 先显示占位图 | 后台任务 |
| 机器审核 | 异步 | 不占首屏 | 显示“审核中” | 审核队列 |
| 搜索索引 | 异步 | 不占首屏 | 暂不可搜索 | 索引消费者 |
| 粉丝通知 | 异步 | 不占首屏 | 无提示 | 通知消费者 |
同步预算是 130ms,同步截止时间可以定 300ms 到 400ms,给数据库抖动和网络留出余量。截止时间不是 SLA,也不是所有步骤预算的简单相加就该贴着它;它是一条熔断线:超过这条线,请求应该返回“已受理,稍后可见”,而不是继续挂着。
这里有一个容易忽略的边界:同步部分只保证“内容行已创建且状态正确”,不保证“内容已经能被所有人看到”。前端必须接受这个语义,否则产品会在发布成功后立刻跳转到详情页,然后拿到 404,因为索引还没写完。
失败场景:机审被塞进同步链路
「回声」的第二版把机器审核放进了同步链路。理由是“违规内容不能被发布”,听起来很合理。实际情况是:审核服务依赖三个模型,其中一个模型每两周更新一次,更新窗口的 p99 会从 400ms 涨到 1.8s。
故障当天,审核服务有 30% 的请求超时。发布接口跟着超时,前端提示“发布失败”,但内容行其实已经写入。用户重新提交,幂等键没有覆盖“审核超时后重试”这条路径,于是同一篇文章生成了多条草稿。运营后台看到重复内容,以为是刷屏,批量封了几个正常账号。
更糟的是重试放大了故障。客户端每 2 秒重试一次,网关把超时请求继续转发给已经过载的审核服务,形成正反馈。恢复之后,审核队列里堆着三份同样的内容,人审同学需要逐条比对。
修复分三步:
- 审核移出同步链路,发布接口只写内容行并返回“审核中”。
- 同步部分用幂等键覆盖“创建内容”这一个动作,重复提交返回同一条内容,而不是新建。
- 审核队列按内容 ID 去重,同一内容只保留最新一次送审。
这个故障的教训不是“审核不重要”,而是“审核的失败不应该表现为发布失败”。用户可以接受“已发布,审核中”,不能接受“不知道发没发出去”。
用 Plan 把编排写成可校验的结构
编排如果只存在于代码的调用顺序里,评审时很难发现“所有步骤都是同步的”。把它写成数据结构,至少能让缺少异步步骤、预算超限和重复步骤在测试阶段暴露。
下面的代码来自本书配套的 webapp-lab。Validate 只检查计划的结构,不判断某个步骤该不该异步;SyncBudget 先复用同一套校验,再返回同步预算之和。异步步骤的 BudgetMS 保留下来做观测,但不计入首屏等待。
// file: internal/moderate/pipeline.go
// tier: L1
// verify: go test ./internal/moderate/
package moderate
import (
"fmt"
"strings"
)
// Stage 表示一个发布步骤是在请求内完成,还是交给后台继续做。
type Stage string
const (
// StageSync 步骤必须在本轮请求返回前完成,用户会等待它。
StageSync Stage = "sync"
// StageAsync 步骤进入队列后即可返回,由后台消费者完成。
StageAsync Stage = "async"
)
// Step 是发布链路中的一个步骤。
// BudgetMS 只对同步步骤参与预算计算,异步步骤的耗时不影响首屏等待。
type Step struct {
Name string
Stage Stage
BudgetMS int
}
// Plan 是一次发布动作的编排。
// SyncDeadlineMS 是同步部分允许占用的总预算,通常是用户能接受的首屏等待上限。
type Plan struct {
Steps []Step
SyncDeadlineMS int
}
// Validate 检查发布计划的结构是否自洽。
// 它不判断某个步骤该不该异步,只拦住明显会让发布链路上线即故障的写法:
// 空名称、重复步骤、负预算、未知阶段、同步预算超限,以及一个异步步骤都没有。
func Validate(p Plan) error {
if p.SyncDeadlineMS < 0 {
return fmt.Errorf("moderate: sync deadline %dms is negative", p.SyncDeadlineMS)
}
seen := make(map[string]bool, len(p.Steps))
hasAsync := false
syncTotal := 0
for i, s := range p.Steps {
name := strings.TrimSpace(s.Name)
if name == "" {
return fmt.Errorf("moderate: step %d has empty name", i)
}
if seen[name] {
return fmt.Errorf("moderate: duplicate step %q", name)
}
seen[name] = true
if s.BudgetMS < 0 {
return fmt.Errorf("moderate: step %q has negative budget %dms", name, s.BudgetMS)
}
switch s.Stage {
case StageSync:
syncTotal += s.BudgetMS
case StageAsync:
hasAsync = true
default:
return fmt.Errorf("moderate: step %q has unknown stage %q", name, s.Stage)
}
}
if !hasAsync {
return fmt.Errorf("moderate: plan needs at least one async step")
}
if syncTotal > p.SyncDeadlineMS {
return fmt.Errorf("moderate: sync budget %dms exceeds deadline %dms", syncTotal, p.SyncDeadlineMS)
}
return nil
}
// SyncBudget 返回同步步骤的预算之和。
// 它先调用 Validate,因此预算超限、缺少异步步骤等问题会直接报错,而不是返回一个看似可用的数字。
func SyncBudget(p Plan) (int, error) {
if err := Validate(p); err != nil {
return 0, err
}
total := 0
for _, s := range p.Steps {
if s.Stage == StageSync {
total += s.BudgetMS
}
}
return total, nil
}
这里刻意要求计划里至少有一个异步步骤。一个全部同步的发布计划在纸面上可能“都能跑通”,但它把转码、审核、索引、通知的可用性全部绑进了发布接口。要求至少一个异步步骤,是在结构层面提醒编排者:发布请求的职责边界在哪里。
测试:把预算超限和缺少异步步骤变成红灯
测试覆盖两类情况:一类是合法计划能通过并正确求和,另一类是每种结构错误都能被指名。错误信息里带步骤名,是为了在 CI 日志里不用再翻代码定位。
// file: internal/moderate/pipeline_test.go
// tier: L1
// verify: go test ./internal/moderate/
package moderate
import (
"strings"
"testing"
)
func TestValidateAcceptsPublishPlan(t *testing.T) {
p := Plan{
Steps: []Step{
{Name: "create", Stage: StageSync, BudgetMS: 80},
{Name: "transcode", Stage: StageAsync, BudgetMS: 5000},
{Name: "scan", Stage: StageAsync, BudgetMS: 3000},
},
SyncDeadlineMS: 200,
}
if err := Validate(p); err != nil {
t.Fatalf("valid plan rejected: %v", err)
}
}
func TestSyncBudgetSumsOnlySyncSteps(t *testing.T) {
p := Plan{
Steps: []Step{
{Name: "create", Stage: StageSync, BudgetMS: 80},
{Name: "transcode", Stage: StageAsync, BudgetMS: 5000},
{Name: "index", Stage: StageSync, BudgetMS: 40},
{Name: "notify", Stage: StageAsync, BudgetMS: 20},
},
SyncDeadlineMS: 200,
}
got, err := SyncBudget(p)
if err != nil {
t.Fatal(err)
}
if got != 120 {
t.Fatalf("sync budget = %d, want 120", got)
}
}
func TestSyncBudgetReportsPlanError(t *testing.T) {
p := Plan{
Steps: []Step{
{Name: "create", Stage: StageSync, BudgetMS: 250},
{Name: "scan", Stage: StageAsync, BudgetMS: 5000},
},
SyncDeadlineMS: 200,
}
got, err := SyncBudget(p)
if err == nil {
t.Fatal("want error for over-budget plan")
}
if got != 0 {
t.Fatalf("budget = %d on error, want 0", got)
}
if !strings.Contains(err.Error(), "exceeds deadline") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateRejectsBrokenPlans(t *testing.T) {
cases := []struct {
name string
plan Plan
want string
}{
{
name: "empty-name",
plan: Plan{
Steps: []Step{{Name: " ", Stage: StageSync}},
SyncDeadlineMS: 100,
},
want: "empty name",
},
{
name: "duplicate-name",
plan: Plan{
Steps: []Step{
{Name: "create", Stage: StageSync, BudgetMS: 10},
{Name: "create ", Stage: StageSync, BudgetMS: 10},
{Name: "scan", Stage: StageAsync},
},
SyncDeadlineMS: 100,
},
want: "duplicate step",
},
{
name: "negative-budget",
plan: Plan{
Steps: []Step{
{Name: "create", Stage: StageSync, BudgetMS: -1},
{Name: "scan", Stage: StageAsync},
},
SyncDeadlineMS: 100,
},
want: "negative budget",
},
{
name: "unknown-stage",
plan: Plan{
Steps: []Step{
{Name: "create", Stage: Stage("later"), BudgetMS: 10},
{Name: "scan", Stage: StageAsync},
},
SyncDeadlineMS: 100,
},
want: "unknown stage",
},
{
name: "negative-deadline",
plan: Plan{
Steps: []Step{{Name: "scan", Stage: StageAsync}},
SyncDeadlineMS: -1,
},
want: "negative",
},
{
name: "no-async-step",
plan: Plan{
Steps: []Step{
{Name: "create", Stage: StageSync, BudgetMS: 10},
{Name: "index", Stage: StageSync, BudgetMS: 10},
},
SyncDeadlineMS: 100,
},
want: "at least one async step",
},
{
name: "sync-budget-over-deadline",
plan: Plan{
Steps: []Step{
{Name: "create", Stage: StageSync, BudgetMS: 80},
{Name: "index", Stage: StageSync, BudgetMS: 80},
{Name: "scan", Stage: StageAsync},
},
SyncDeadlineMS: 120,
},
want: "exceeds deadline",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := Validate(c.plan)
if err == nil {
t.Fatal("want error for broken plan")
}
if !strings.Contains(err.Error(), c.want) {
t.Fatalf("error %q does not contain %q", err, c.want)
}
})
}
}
表驱动用例里的 duplicate-name 故意在第二个名字后面加了空格。校验器按去掉首尾空白后的名称判重,否则“create”和“create ”会被当成两个步骤,预算也会被重复计算。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/moderate/ -run 'TestValidate|TestSyncBudget' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestValidateAcceptsPublishPlan
--- PASS: TestValidateAcceptsPublishPlan (0.00s)
=== RUN TestSyncBudgetSumsOnlySyncSteps
--- PASS: TestSyncBudgetSumsOnlySyncSteps (0.00s)
=== RUN TestSyncBudgetReportsPlanError
--- PASS: TestSyncBudgetReportsPlanError (0.00s)
=== RUN TestValidateRejectsBrokenPlans
=== RUN TestValidateRejectsBrokenPlans/empty-name
=== RUN TestValidateRejectsBrokenPlans/duplicate-name
=== RUN TestValidateRejectsBrokenPlans/negative-budget
=== RUN TestValidateRejectsBrokenPlans/unknown-stage
=== RUN TestValidateRejectsBrokenPlans/negative-deadline
=== RUN TestValidateRejectsBrokenPlans/no-async-step
=== RUN TestValidateRejectsBrokenPlans/sync-budget-over-deadline
--- PASS: TestValidateRejectsBrokenPlans (0.00s)
--- PASS: TestValidateRejectsBrokenPlans/empty-name (0.00s)
--- PASS: TestValidateRejectsBrokenPlans/duplicate-name (0.00s)
--- PASS: TestValidateRejectsBrokenPlans/negative-budget (0.00s)
--- PASS: TestValidateRejectsBrokenPlans/unknown-stage (0.00s)
--- PASS: TestValidateRejectsBrokenPlans/negative-deadline (0.00s)
--- PASS: TestValidateRejectsBrokenPlans/no-async-step (0.00s)
--- PASS: TestValidateRejectsBrokenPlans/sync-budget-over-deadline (0.00s)
PASS
ok webapp/internal/moderate 0.320s
PASS 和退出码 0 是判定标准。表驱动用例里的每一个子测试都单独列名,是为了让失败时能直接看出是哪条规则没有生效。
小结
发布链路的同步部分只做一件事:确认这次提交可以被接受。转码、审核、索引、通知都应该在请求返回之后继续,用户看到的是“已发布,审核中”,而不是一个无法解释的转圈。
同步预算不是越宽松越好,也不是越紧越好。它应该由用户愿意等待的时间倒推,再留出数据库和网络的抖动余量。把编排写成 Plan 并在测试里检查预算与异步步骤,能让“所有事情都塞进同步”这种错误在评审阶段就变成一条明确的失败。
阅读导航:上一节:目录与学习路径 · 下一节:2.2 机审、人审与复审 。
常见错误与边界
- 把“发布成功”定义成所有后续步骤都完成。用户只关心提交是否被接受,后续步骤应该通过状态字段表达。
- 同步预算按平均值分配。平均值好看,p99 才是用户遇到的;预算要覆盖抖动,而不是覆盖理想路径。
- 把所有步骤都设成异步。创建内容本身必须有同步结果,否则用户无法知道提交是否成功,也无法拿到内容 ID。
- 异步步骤失败后静默重试。转码可以重试,审核和通知需要幂等键与去重,否则会放大下游压力。
- 用同一个重试策略处理所有失败。参数错误重试没有意义,超时和限流才值得退避重试。
- 发布接口返回成功但详情页立刻 404。前端要接受“已受理”状态,或者详情页读取主库而不是只读索引。
- 认为加了异步就万事大吉。队列积压、消费者停滞和死信同样会让内容长期不可见,需要单独的积压告警。
- 把同步截止时间当成 SLA 对外承诺。它是内部熔断线,对外承诺需要单独定义可用性和延迟目标。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。