《互联网产品的服务端架构·内容、社交与实时互动》2.1 发布链路的同步与异步

从「回声」创作者点击发布后转圈八秒的故障出发,拆解发布链路里哪些步骤必须在请求内完成、哪些应该交给后台,给出同步预算的定法与一张失败可见性对照表,并用 Go 校验器拦住缺少异步步骤、预算超限和重复步骤等编排错误,解释为什么机审不能塞进同步链路。

发布按钮背后的八秒转圈

「回声」是一个为本书构造的假想产品。后文出现的用户量、比例、耗时与成本,除运行输出外都是案例假设值,用来演示判断过程,不是任何真实系统的实测。

创作者写完一篇长文,点击发布,页面开始转圈。第一版把整条链路都放在请求里:创建内容、转码封面、机审文本和图片、写搜索索引、给粉丝发通知。产品经理认为“发布成功”应该意味着所有事情都完成,工程师照做了。

上线第一周,发布接口的 p99 从 180ms 涨到 8.2s(案例假设值)。用户以为没点上,继续点,同一篇文章出现三份;转码服务抖动的十分钟里,发布成功率掉到六成;客服收到“按钮坏了”的反馈,但数据库里其实已经写入了内容。

问题不在于哪个步骤慢,而在于把“能否创建内容”和“内容何时可被消费”混成了一件事。同步部分只需要回答一个问题:这次提交能否被接受。转码、审核、索引、通知都可以在请求返回之后继续做,用户不需要盯着转圈等它们完成。

同步只做“能否创建内容”

判断一个步骤该不该同步,可以先问三个问题:

  • 它失败时,用户能否立刻采取不同动作?能,才值得同步返回。
  • 它不做完,内容是否仍然是合法、可回滚的?是,就可以异步。
  • 它的耗时是否由外部系统决定?是,就不要让用户为别人的抖动买单。

按这三个问题拆「回声」的发布链路,结果如下。表中耗时是案例假设值,只用于演示预算怎么分配:

步骤是否同步预算失败时用户看到失败后谁重试
参数与长度校验同步20ms具体字段错误用户修改
权限、配额与频率限制同步30ms无权限或稍后再试用户
幂等写入内容行同步80ms创建失败,可重试用户或客户端
封面转码异步不占首屏先显示占位图后台任务
机器审核异步不占首屏显示“审核中”审核队列
搜索索引异步不占首屏暂不可搜索索引消费者
粉丝通知异步不占首屏无提示通知消费者

同步预算是 130ms,同步截止时间可以定 300ms 到 400ms,给数据库抖动和网络留出余量。截止时间不是 SLA,也不是所有步骤预算的简单相加就该贴着它;它是一条熔断线:超过这条线,请求应该返回“已受理,稍后可见”,而不是继续挂着。

这里有一个容易忽略的边界:同步部分只保证“内容行已创建且状态正确”,不保证“内容已经能被所有人看到”。前端必须接受这个语义,否则产品会在发布成功后立刻跳转到详情页,然后拿到 404,因为索引还没写完。

失败场景:机审被塞进同步链路

「回声」的第二版把机器审核放进了同步链路。理由是“违规内容不能被发布”,听起来很合理。实际情况是:审核服务依赖三个模型,其中一个模型每两周更新一次,更新窗口的 p99 会从 400ms 涨到 1.8s。

故障当天,审核服务有 30% 的请求超时。发布接口跟着超时,前端提示“发布失败”,但内容行其实已经写入。用户重新提交,幂等键没有覆盖“审核超时后重试”这条路径,于是同一篇文章生成了多条草稿。运营后台看到重复内容,以为是刷屏,批量封了几个正常账号。

更糟的是重试放大了故障。客户端每 2 秒重试一次,网关把超时请求继续转发给已经过载的审核服务,形成正反馈。恢复之后,审核队列里堆着三份同样的内容,人审同学需要逐条比对。

修复分三步:

  1. 审核移出同步链路,发布接口只写内容行并返回“审核中”。
  2. 同步部分用幂等键覆盖“创建内容”这一个动作,重复提交返回同一条内容,而不是新建。
  3. 审核队列按内容 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 对外承诺。它是内部熔断线,对外承诺需要单独定义可用性和延迟目标。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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