《互联网产品的服务端架构·产品、流量与基础能力》9.1 哪些事情不该阻塞用户

从「回声」发帖按钮转圈八秒的事故写起,说明同步与异步的分界线不是操作快慢,而是用户此刻是否在等结果、失败后能否撤回。文章把发帖拆成写库、图片处理、审核与通知四段,给出可测试的 Decide 与 Explain 实现和边界测试,避免把后台副作用塞进用户请求。

「回声」发帖按钮转了八秒

「回声」是一个带图片和关注关系的内容社区。发布一条帖子的动作看起来很简单:用户输入文字、选一张图、点发布。第一版服务端把这件事做成了一个请求:写帖子表、压缩图片、等审核服务返回结果、给粉丝写通知,全部成功之后才返回 201。

内测时数据量小,这个请求平均只要 300 毫秒。上线两周后,审核服务的 p99 从 400 毫秒涨到 3 秒,图片压缩在高分辨率照片上偶尔要 2 秒,粉丝通知又要逐条写库。发帖接口的 p95 很快突破 8 秒,移动端客户端的超时是 5 秒。

用户看到的现象是:点发布,按钮转圈,五秒后提示“网络异常,请重试”。用户再点一次,第二次请求又写了一条帖子。等第一条请求最终在服务端完成,用户主页上出现了两条一模一样的帖子,粉丝收到了两条通知。审核队列里也堆着两份相同的图片。

团队的第一反应是“审核服务太慢”,于是给它加了重试和更长的超时。结果是发帖接口从 8 秒变成 12 秒,重试还让审核服务的排队更长;高峰期 Tomcat 线程被发帖请求占满,连首页和登录都开始超时。故障从“发帖慢”扩散成“整站不可用”。

真正的问题不是审核慢,而是团队把四件失败语义完全不同的事情绑进了一个用户请求。用户点发布时,真正在等的只有“这条帖子有没有被系统接住”;图片有没有压缩完、审核有没有通过、粉丝有没有收到通知,都不需要在这一刻给出答案。

判断标准是「用户此刻是否在等这个结果」

同步与异步的分界线不是“这个操作快不快”,而是“用户此刻是否在等这个结果,以及失败后能不能撤回”。一个操作哪怕只要 20 毫秒,如果用户并不需要等它,它也不该占用请求;一个操作哪怕要 3 秒,如果结果决定用户下一步能不能操作,它也不能被悄悄推到后台。

把这两个维度交叉,可以得到三种执行位置:

动作用户是否在等失败能否撤回结论理由
写入帖子正文是可以删除,但发布动作本身要确认sync返回帖子 ID 和可见状态,用户才知道发布成功
生成缩略图是,但可先看原图可以重试async先返回原图地址,缩略图完成后再替换
审核结果不是发布这一刻可以下架async先进入“审核中”,审核完成后变更状态
粉丝通知否可以补发queued不面向发帖人,失败可重试或对账
搜索索引否可以重建queued搜索延迟几秒用户能接受,重建成本低
阅读数统计否可以重算queued允许短时误差,按批聚合
支付扣款是不能sync用户必须立刻知道成功还是失败
修改密码是不能sync结果决定用户能否继续登录
头像上传是可以重传async先返回受理,处理完再通知客户端

这张表里最容易误判的是“生成缩略图”和“审核结果”。它们的共同点是“用户看得到”,但用户看到的不是最终结果,而是一个明确的过程状态。只要服务端在响应里说清楚“原图已收到,缩略图处理中”,异步就不会伤害体验;反过来,如果响应里假装一切完成,客户端之后拿到的是空白图,异步就会变成欺骗。

ModeSync 对应“用户在这一刻等一个确定答案,且答案不能事后撤销”。支付、登录、修改密码、发布正文都属于这一类。它们的共同点不是耗时短,而是失败必须当场告诉用户;如果推到后台,用户会以为成功,后续的补偿成本远高于多等几百毫秒。

ModeAsync 对应“用户看得到结果,但结果可以撤回、重试或稍后替换”。头像处理、导出文件、批量导入属于这一类。关键设计是受理状态要可查询:响应返回一个任务 ID 或资源版本,客户端能轮询或在下次拉取时看到最终状态。

ModeQueued 对应“结果不面向当前用户”。通知、索引、统计、审计属于这一类。它们可以排队,但排队不等于可以不管:队列要有积压监控、重试上限和死信处理,否则只是把同步故障改成了延迟故障。

耗时在这里只用于容量规划,不参与模式判断。一个 5 秒的隐藏统计任务仍然应该排队;一个 2 秒的支付确认仍然必须同步。把 p95 当成判断依据,团队就会不断争论“多慢算慢”,而真正该问的是“用户等不等”。

失败场景:把审核结果放进发布响应

回到「回声」。发帖接口把审核调用放在响应路径上,带来三个连锁后果。

第一层是重复写入。客户端 5 秒超时,服务端 8 秒才完成。用户重试后,两条请求都写入了帖子。服务端没有幂等键,数据库也没有唯一约束,重复帖子只能靠人工删除。删除之后,粉丝收到的两条通知无法撤回,用户对“发布成功”的信任开始下降。

第二层是资源耗尽。发帖请求持有数据库连接、等待审核 HTTP 响应、占用应用线程。审核变慢时,线程池被发帖请求占满,首页、登录、评论这些本来无关的接口也拿不到线程。一个下游的延迟,通过同步调用链变成了整站不可用。

第三层是状态不清。审核失败时,接口返回 500,但帖子其实已经写进数据库。用户重试后,数据库里多了一条“未审核”的帖子;运营后台看到它,却不知道它是否应该展示。同步调用把“写库成功”和“审核成功”两个状态绑成一个布尔值,失败后没有人知道系统停在哪一步。

修复不是简单地把审核改成异步,而是先把发帖拆成一条明确的状态链:

  • 同步阶段只做两件事:校验参数、写入帖子(状态为 pending_review),并在同一个事务里写一条 outbox 记录。
  • 响应立即返回 202 或 201,带上帖子 ID、当前状态和“审核通常在 30 秒内完成”的提示。
  • 图片处理、审核、粉丝通知各自消费 outbox 事件,失败重试,超过上限进入死信。
  • 审核完成后更新帖子状态;客户端通过详情接口或推送看到状态变化。
  • 所有消费者以帖子 ID 作为幂等键,重复投递不会产生第二条帖子或第二条通知。

这套改动之后,发帖接口的 p95 回到 120 毫秒。审核仍然可能慢,但慢只影响“审核中”持续多久,不再影响用户能不能发布,也不再占用首页的线程。用户看到的是“已发布,审核中”,而不是一个转圈八秒后报错的按钮。

这里有一个产品侧的取舍:把审核结果异步化,意味着违规内容可能短暂可见。如果业务不能接受,正确的做法是让 pending_review 的帖子只对作者可见,而不是把审核塞回发布请求。前者把等待变成状态,后者把状态变成阻塞。

用 Decide 把判断写成代码

上面的判断可以落成一个很小的纯函数。输入是任务的名字、是否用户可见、是否可逆,以及一个案例假设的 p95 耗时;输出是 sync、async 或 queued。函数不接受“团队觉得快”这种模糊输入,也不把耗时当作分支条件。

// file: internal/async/mode.go
// tier: L1
// verify: go test ./internal/async/

package async

import (
	"fmt"
	"strings"
)

// Mode 是任务相对于用户请求的执行位置。
type Mode string

const (
	// ModeSync 在返回响应前完成,用户等待结果。
	ModeSync Mode = "sync"
	// ModeAsync 先返回受理结果,再在后台执行。
	ModeAsync Mode = "async"
	// ModeQueued 不面向当前用户,排队后由后台消费。
	ModeQueued Mode = "queued"
)

// Task 是一个待判断的副作用。P95Ms 是案例假设的执行耗时,
// 用来提醒评审者:耗时不是决定同步或异步的依据。
type Task struct {
	Name        string
	UserVisible bool
	Reversible  bool
	P95Ms       int
}

// Decide 只回答一个问题:用户此刻是否在等这个结果。
// 耗时、实现难度和团队习惯都不参与判断。
func Decide(t Task) (Mode, error) {
	if strings.TrimSpace(t.Name) == "" {
		return "", fmt.Errorf("async: task name is required")
	}
	if t.P95Ms < 0 {
		return "", fmt.Errorf("async: task %q has negative p95", t.Name)
	}
	if !t.UserVisible {
		return ModeQueued, nil
	}
	if !t.Reversible {
		return ModeSync, nil
	}
	return ModeAsync, nil
}

// Explain 返回 Decide 的结论和一句可读理由,便于在评审记录里留下依据。
// 输入不合法时返回空 Mode 和错误文本。
func Explain(t Task) (Mode, string) {
	m, err := Decide(t)
	if err != nil {
		return "", err.Error()
	}
	switch m {
	case ModeSync:
		return m, fmt.Sprintf("%q 的结果决定用户能否继续,且失败后不可撤回,必须在响应内完成", t.Name)
	case ModeAsync:
		return m, fmt.Sprintf("%q 用户看得到结果,但可以撤回或重试,先返回受理状态", t.Name)
	default:
		return m, fmt.Sprintf("%q 不影响用户当前操作,可以排队并由后台消费", t.Name)
	}
}

Decide 的判断顺序是有意安排的。先校验输入,保证空名字和负耗时不进入后续逻辑;再判断 UserVisible,因为不可见的任务无论能否撤销都不该占用用户请求;最后在可见任务里用 Reversible 分开同步和异步。这个顺序让每个分支只有一个理由,评审时不需要同时考虑三个维度。

P95Ms 只做非负校验,不参与模式选择。这不是遗漏,而是本节最重要的约束:如果把 P95Ms > 500 写成异步条件,团队就会用性能数字替代产品语义,最终把“支付确认慢”也优化成后台任务。耗时应该出现在容量规划和超时预算里,而不是出现在“用户等不等”的判断里。

Explain 返回同一结论和一句理由,目的是让判断可审计。发帖、支付这类关键路径在评审时,应该能直接贴出一行“用户可见且不可逆,必须在响应内完成”,而不是靠口头解释。错误输入返回空模式和错误文本,调用方可以把它当成配置错误,而不是默认走某个模式。

测试:慢但不可逆仍然同步

这段逻辑最容易写错的地方,是把“快”当成“可以异步”,或者把“用户可见”直接等同于“同步”。下面的测试用五个案例把这两个错误钉死,其中 slow-but-visible 和 fast-but-hidden 是专门用来反驳耗时直觉的。

// file: internal/async/mode_test.go
// tier: L1
// verify: go test ./internal/async/

package async

import (
	"strings"
	"testing"
)

func TestDecideFollowsUserWait(t *testing.T) {
	cases := []struct {
		name string
		task Task
		want Mode
	}{
		{"visible-irreversible", Task{Name: "publish", UserVisible: true, Reversible: false, P95Ms: 30}, ModeSync},
		{"visible-reversible", Task{Name: "resize-avatar", UserVisible: true, Reversible: true, P95Ms: 800}, ModeAsync},
		{"hidden", Task{Name: "rebuild-index", UserVisible: false, Reversible: false, P95Ms: 5000}, ModeQueued},
		{"slow-but-visible", Task{Name: "payment", UserVisible: true, Reversible: false, P95Ms: 2500}, ModeSync},
		{"fast-but-hidden", Task{Name: "sync-stats", UserVisible: false, Reversible: true, P95Ms: 5}, ModeQueued},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got, err := Decide(c.task)
			if err != nil {
				t.Fatal(err)
			}
			if got != c.want {
				t.Fatalf("mode = %s, want %s", got, c.want)
			}
		})
	}
}

func TestDecideRejectsInvalidTask(t *testing.T) {
	if _, err := Decide(Task{Name: "  "}); err == nil {
		t.Fatal("want error for blank name")
	}
	if _, err := Decide(Task{Name: "resize", P95Ms: -1}); err == nil {
		t.Fatal("want error for negative p95")
	}
}

func TestExplainKeepsModeAndReason(t *testing.T) {
	m, reason := Explain(Task{Name: "publish", UserVisible: true})
	if m != ModeSync {
		t.Fatalf("mode = %s, want %s", m, ModeSync)
	}
	if !strings.Contains(reason, "publish") || !strings.Contains(reason, "不可撤回") {
		t.Fatalf("reason = %q", reason)
	}

	m, reason = Explain(Task{})
	if m != "" || reason == "" {
		t.Fatalf("invalid task: mode=%q reason=%q", m, reason)
	}
}

TestDecideFollowsUserWait 用表驱动覆盖了三种模式和两个反直觉案例。slow-but-visible 是 2500 毫秒的支付确认,仍然要求 sync;fast-but-hidden 是 5 毫秒的统计同步,仍然要求 queued。这两个案例放在同一张表里,评审时很难再用“这个操作很快”作为异步的理由。

TestDecideRejectsInvalidTask 覆盖空名字和负耗时。空名字看起来只是展示问题,实际上它会让 Explain 生成一句没有主语的日志,排障时无法定位是哪个任务;负耗时则通常来自配置解析错误,如果不在这里拦住,后续的容量报表会出现负数。

TestExplainKeepsModeAndReason 检查理由里包含任务名和关键判断词。理由不是给机器看的枚举,而是给评审和值班人员看的句子;如果它只返回 sync,这个函数就退化成 Decide 的重复实现,失去解释价值。

这些测试不验证任务是否真的应该同步,那是产品和架构评审的职责。单元测试只保证:一旦评审做出决定,代码不会因为输入顺序、耗时数字或空值而悄悄改变结论。

运行命令与真实输出

在仓库根目录执行:

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

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

=== RUN   TestDecideFollowsUserWait
=== RUN   TestDecideFollowsUserWait/visible-irreversible
=== RUN   TestDecideFollowsUserWait/visible-reversible
=== RUN   TestDecideFollowsUserWait/hidden
=== RUN   TestDecideFollowsUserWait/slow-but-visible
=== RUN   TestDecideFollowsUserWait/fast-but-hidden
--- PASS: TestDecideFollowsUserWait (0.00s)
    --- PASS: TestDecideFollowsUserWait/visible-irreversible (0.00s)
    --- PASS: TestDecideFollowsUserWait/visible-reversible (0.00s)
    --- PASS: TestDecideFollowsUserWait/hidden (0.00s)
    --- PASS: TestDecideFollowsUserWait/slow-but-visible (0.00s)
    --- PASS: TestDecideFollowsUserWait/fast-but-hidden (0.00s)
=== RUN   TestDecideRejectsInvalidTask
--- PASS: TestDecideRejectsInvalidTask (0.00s)
=== RUN   TestExplainKeepsModeAndReason
--- PASS: TestExplainKeepsModeAndReason (0.00s)
PASS
ok  	webapp/internal/async	0.237s

-run 的正则只匹配 Decide 和 Explain 两组测试,避免把同一包里的重复消费和重试测试混进本节的输出。输出里的 0.237s 是一次本机运行的耗时,会随机器和负载变化,不能当成性能结论。

小结

同步与异步的分界不是操作快慢,而是用户此刻是否在等结果、失败后能否撤回。用户可见且不可逆的动作必须同步;用户可见但可逆的动作可以异步,但必须给出可查询的受理状态;用户不可见的动作走队列,同时要有积压、重试和死信。

发帖的例子说明,真正该拆的不是“快操作”和“慢操作”,而是“用户等待的核心结果”和“可以稍后完成的副作用”。把审核、图片、通知从发布响应里拿出去,不是降低一致性要求,而是把一致性从“一个请求内的全有或全无”改成“一条可观察的状态链”。

下一节会沿着这条状态链往下走:事件如何表达事实、通知如何表达动作,以及“至少一次投递”为什么要求每个消费者都自带幂等。

阅读导航:上一节:目录与学习路径 · 下一节:9.2 事件、通知与最终一致 。

常见错误与边界

  • 用耗时决定同步异步。5 秒的隐藏统计仍应排队,2 秒的支付确认仍必须同步。
  • 把“用户可见”直接当成“同步”。缩略图、导出文件可见,但可以先返回受理状态。
  • 把审核塞进发布响应。审核慢会拖垮发帖,还会让“写库成功、审核失败”变成无法解释的中间态。
  • 异步后不返回任何标识。用户不知道请求是否被接住,只能重复点击,重复写入反而更多。
  • 把队列当垃圾桶。没有积压监控、重试上限和死信的队列,只是把同步故障推迟到不可见的地方。
  • 同步阶段写库、异步阶段又写一次。拆分时要明确唯一写入口,否则两个阶段会互相覆盖状态。
  • 忽略“不可逆”的含义。删除、支付、改密码失败后无法靠重试回到原状,必须当场给出确定结果。
  • 只在客户端做超时。服务端没有幂等键时,客户端每一次重试都会变成一条新的业务记录。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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