「回声」发帖按钮转了八秒
「回声」是一个带图片和关注关系的内容社区。发布一条帖子的动作看起来很简单:用户输入文字、选一张图、点发布。第一版服务端把这件事做成了一个请求:写帖子表、压缩图片、等审核服务返回结果、给粉丝写通知,全部成功之后才返回 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 秒的支付确认仍必须同步。
- 把“用户可见”直接当成“同步”。缩略图、导出文件可见,但可以先返回受理状态。
- 把审核塞进发布响应。审核慢会拖垮发帖,还会让“写库成功、审核失败”变成无法解释的中间态。
- 异步后不返回任何标识。用户不知道请求是否被接住,只能重复点击,重复写入反而更多。
- 把队列当垃圾桶。没有积压监控、重试上限和死信的队列,只是把同步故障推迟到不可见的地方。
- 同步阶段写库、异步阶段又写一次。拆分时要明确唯一写入口,否则两个阶段会互相覆盖状态。
- 忽略“不可逆”的含义。删除、支付、改密码失败后无法靠重试回到原状,必须当场给出确定结果。
- 只在客户端做超时。服务端没有幂等键时,客户端每一次重试都会变成一条新的业务记录。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。