《互联网产品的服务端架构·产品、流量与基础能力》2.1 用例、用户旅程与系统边界

从「回声」新用户看到内容、发私信到下单的一条完整旅程出发,把用户故事拆成 actor、触发、结果与副作用四件事,用边界表说明每项能力归谁所有、依赖谁,并给出一个能拒绝缺失字段、重复边界和所有权重叠的 Go 校验器,最后复盘一次跨三个团队却无人对端到端结果负责的上线事故。

从「回声」的一条用户旅程说起

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

产品经理描述新用户的第一条旅程时,通常只用一句话:新用户打开发现页看到内容,给作者发一条私信,然后在站内下单。这句话在评审会上听起来完整,落到服务端却至少包含三个不同的动作,每个动作的发起者、触发条件、成功标准和留下的痕迹都不一样。

把它拆开看:

  • 浏览发现页:游客或新注册用户打开首页,系统返回一页内容。结果是页面渲染成功,副作用是曝光埋点、推荐候选集缓存,以及可能的一次去重写。
  • 发送私信:登录用户点击发送,系统把消息落库并推给在线的接收方。结果是发送方看到“已发送”,副作用是消息行、会话未读数、推送任务和一条审计记录。
  • 站内下单:登录用户提交订单,系统锁定库存、计算优惠、创建订单并等待支付。结果是订单号返回,副作用是库存预占、优惠券锁定、订单行、支付单和一条待办对账记录。

这三步里,第一步的失败可以降级成空列表,第二步的失败必须让发送方明确看到失败并允许重试,第三步的失败则可能留下“库存占了、订单没成”的半成品。如果只把整条旅程当成一个“新用户流程”,团队就会用同一套超时、重试和回滚策略去处理三种完全不同的失败。

所以本章的第一个动作不是画架构图,而是把旅程拆成用例,再把用例拆成四件事:谁发起、因为什么触发、期望什么结果、留下什么副作用。

把旅程拆成四件事

用例的四个字段看起来像文档模板,实际每一个都直接决定服务端的接口契约。

  • actor 决定鉴权和限流。游客、登录用户、运营人员、系统任务能做的事不同,能承受的配额也不同;没有 actor 的用例无法回答“这个接口该不该开放”。
  • trigger 决定幂等键和入口。点击发送、支付回调、定时任务、消息重投是四种不同的触发方式;没有 trigger 的用例无法判断重复请求应该合并还是拒绝。
  • outcome 决定验收标准。是“消息已落库”还是“对方已读”,是“订单已创建”还是“支付已确认”,对应的接口返回和监控指标完全不同。
  • side effects 决定事务边界和补偿。侧效应越多,越需要明确哪些必须同事务、哪些可以异步、哪些失败后必须人工对账。

把上面三步填进同一张表,边界问题会立刻暴露出来:

步骤actortriggeroutcome主要副作用能力 owner依赖
浏览发现页visitor / member打开首页或下拉刷新返回一页可展示内容曝光埋点、推荐缓存、去重写contentidentity、recommendation
发送私信member点击发送或重试消息落库并进入投递队列消息行、未读数、推送任务、审计messagingidentity、content
站内下单member提交订单订单创建并等待支付库存预占、优惠锁定、订单行、支付单orderidentity、content、payment

这张表是案例假设值,只用于演示拆法。真正有价值的是最后两列:每个步骤都能指出一个 owner,以及它依赖谁。只要有一列填不出来,就说明这个用例还没有准备好进入排期。

边界不是组织架构图

很多团队把边界直接画成组织架构:内容组、私信组、订单组,一个组一个服务。组织架构会变,能力归属不应该跟着变。边界描述的是“谁对一份数据或一项能力的语义负责”,一个团队可以拥有多个边界,一个边界也可以由多个团队共同开发,但 owner 必须唯一。

边界之间只有两种关系:拥有和依赖。拥有意味着可以写,依赖意味着只能通过对方公开的接口或事件读取。把“我直接查你的库”当成依赖,短期省一次接口调用,长期会把两个边界的发布节奏、数据模型和故障半径绑死。

判断一条依赖是否健康,可以问三个问题:

  • 对方能否在不通知我的情况下改表结构?
  • 对方故障时,我能否降级而不是一起挂?
  • 我能否在不读对方库的前提下,拿到完成用例所需的最小字段?

三个问题里有两个回答“不能”,这条依赖就值得重新设计。

一次跨三个团队的上线

「回声」曾经上线过一个“新用户首单礼”活动。用例描述只有一句:新用户看到活动内容,领取优惠券,下单时自动抵扣。活动涉及内容、营销、订单三个团队,每个团队都完成了自己的部分:内容团队加了活动卡片,营销团队发了券,订单团队支持了券抵扣。

上线当天,问题以三种互相矛盾的方式出现。假设活动页曝光 50 万次、领券 3 万张、下单 8000 笔(案例假设值):

  • 一部分用户看到活动卡片,点进去提示“活动不存在”,因为内容团队缓存了活动配置,营销团队切换配置时没有通知内容侧刷新。
  • 一部分用户领到了券,下单时却提示“优惠不可用”,因为订单团队只认自己库里的券状态,而营销团队的券是异步同步过去的,延迟最高 5 分钟。
  • 最严重的一批用户,订单创建成功但券没有核销,同一张券被重复用于三笔订单,最后只能人工冻结账户并逐笔对账。

事故复盘时,三个团队都能证明自己的服务“按接口文档工作”。问题在于没有人拥有“新用户首单”这个端到端用例:内容团队只对卡片曝光负责,营销团队只对发券负责,订单团队只对订单创建负责。用例的 outcome 是“新用户完成首单并正确抵扣”,但没有一个 owner 对这个 outcome 负责,也没有人定义跨边界的触发顺序和失败补偿。

更隐蔽的是副作用。领券、锁券、核销、订单创建分别是四个副作用,分属三个边界,却没有一张表写清楚哪些必须同事务、哪些可以最终一致、失败后由谁对账。于是每个团队都按自己的理解做了“合理”的异步,组合起来就变成了重复核销。

修复方案不是再加一个“活动服务”,而是先补三样东西:

  1. 给端到端用例指定一个 owner,通常是一个编排层或业务负责人,而不是某个底层服务。
  2. 把活动配置、券状态、订单优惠的所有权写清楚,禁止跨库直接读写。
  3. 把四个副作用的顺序、幂等键和补偿动作写进契约,并在发布前用校验器检查边界表。

这次事故的直接成本是三天人工对账和一批客诉;间接成本是三个团队在之后两个月里都不敢再碰跨边界活动。边界不清的代价,往往不是一次故障,而是组织失去做跨边界事情的能力。

把用例和边界写成可校验的结构

用例和边界如果只写在文档里,评审时很容易被“先上线再说”绕过。把它们写成纯数据结构和一个校验函数,至少能让缺失字段、重复边界和所有权重叠在代码评审阶段暴露,而不是在事故复盘时才发现。

下面的代码来自本书配套的 webapp-lab。ValidateUseCase 只做结构校验,不判断业务是否合理;ValidateBoundaries 检查边界名唯一、依赖存在、数据所有权不重叠。它故意不检查循环依赖:循环依赖有时是合理的双向契约,是否禁止应该由团队单独决定,而不是藏在一个通用校验器里。

// file: internal/platform/design/usecase.go
// tier: L1
// verify: go test ./internal/platform/design/

package design

import (
	"fmt"
	"strings"
)

// Actor 是用例的发起者。服务端按 actor 区分权限与限流,不猜测用户意图。
type Actor string

const (
	ActorVisitor  Actor = "visitor"
	ActorMember   Actor = "member"
	ActorOperator Actor = "operator"
	ActorSystem   Actor = "system"
)

// UseCase 是一次可观察的用户旅程片段:
// 谁、因为什么触发、期望什么结果、会留下什么副作用。
type UseCase struct {
	Name        string
	Actor       Actor
	Trigger     string
	Outcome     string
	SideEffects []string
}

// ValidateUseCase 拒绝无法落成接口契约的用例。
// 缺 actor 就无法鉴权,缺 trigger 就无法定幂等键,缺 outcome 就无法验收。
func ValidateUseCase(u UseCase) error {
	if strings.TrimSpace(u.Name) == "" {
		return fmt.Errorf("design: use case name is empty")
	}
	if strings.TrimSpace(string(u.Actor)) == "" {
		return fmt.Errorf("design: use case %q has no actor", u.Name)
	}
	if strings.TrimSpace(u.Trigger) == "" {
		return fmt.Errorf("design: use case %q has no trigger", u.Name)
	}
	if strings.TrimSpace(u.Outcome) == "" {
		return fmt.Errorf("design: use case %q has no outcome", u.Name)
	}
	for i, effect := range u.SideEffects {
		if strings.TrimSpace(effect) == "" {
			return fmt.Errorf("design: use case %q has empty side effect at %d", u.Name, i)
		}
	}
	return nil
}

// Boundary 是一个能力的所有权边界:它拥有哪些数据或能力,依赖哪些其他边界。
type Boundary struct {
	Name      string
	Owns      []string
	DependsOn []string
}

// ValidateBoundaries 检查边界集合能否作为一份可评审的依赖图。
// 三条规则:边界名唯一、依赖必须存在、数据所有权不能重叠。
func ValidateBoundaries(bs []Boundary) error {
	names := make(map[string]struct{}, len(bs))
	for _, b := range bs {
		name := strings.TrimSpace(b.Name)
		if name == "" {
			return fmt.Errorf("design: boundary name is empty")
		}
		if _, ok := names[name]; ok {
			return fmt.Errorf("design: duplicate boundary %q", name)
		}
		names[name] = struct{}{}
	}

	owners := make(map[string]string)
	for _, b := range bs {
		for _, dep := range b.DependsOn {
			dep = strings.TrimSpace(dep)
			if dep == b.Name {
				return fmt.Errorf("design: boundary %q depends on itself", b.Name)
			}
			if _, ok := names[dep]; !ok {
				return fmt.Errorf("design: boundary %q depends on unknown %q", b.Name, dep)
			}
		}
		for _, owned := range b.Owns {
			owned = strings.TrimSpace(owned)
			if owned == "" {
				return fmt.Errorf("design: boundary %q owns an empty data set", b.Name)
			}
			if prev, ok := owners[owned]; ok {
				return fmt.Errorf("design: data %q owned by both %q and %q", owned, prev, b.Name)
			}
			owners[owned] = b.Name
		}
	}
	return nil
}

几个值得说明的取舍:

  • Actor 用字符串别名而不是枚举整数,是为了让日志、审计和接口文档直接可读;代价是运行期仍可能传入未登记的 actor,这需要网关或权限系统兜底。
  • SideEffects 允许为空,因为查询类用例本来就不产生副作用;但一旦填写,空字符串会被拒绝,避免“有个副作用但没人知道是什么”的模糊记录。
  • 边界校验只检查静态关系,不检查运行时调用。一个服务完全可以在校验通过后绕过网关直接连别人的库,这需要数据库权限和网络策略来管,不能指望一个结构体校验器。

测试:把边界规则固定下来

这类校验器最怕两件事:规则被后人悄悄放宽,以及新字段加进来却没有用例覆盖。下面的表驱动测试把两类风险都固定住:合法用例必须通过,缺 name、actor、trigger、outcome 或副作用为空时都必须失败;边界图则覆盖重复名、未知依赖、自依赖、所有权重叠和空数据名。

// file: internal/platform/design/usecase_test.go
// tier: L1
// verify: go test ./internal/platform/design/

package design

import "testing"

func validUseCase() UseCase {
	return UseCase{
		Name:        "member-sends-message",
		Actor:       ActorMember,
		Trigger:     "member taps send",
		Outcome:     "message is persisted and delivered",
		SideEffects: []string{"message row", "unread counter"},
	}
}

func TestValidateUseCaseAcceptsCompleteCase(t *testing.T) {
	if err := ValidateUseCase(validUseCase()); err != nil {
		t.Fatalf("want valid, got %v", err)
	}
}

func TestValidateUseCaseRejectsMissingFields(t *testing.T) {
	cases := []struct {
		name string
		drop func(*UseCase)
	}{
		{"name", func(u *UseCase) { u.Name = " " }},
		{"actor", func(u *UseCase) { u.Actor = "" }},
		{"trigger", func(u *UseCase) { u.Trigger = "" }},
		{"outcome", func(u *UseCase) { u.Outcome = "  " }},
		{"side-effect", func(u *UseCase) { u.SideEffects = []string{""} }},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			u := validUseCase()
			c.drop(&u)
			if err := ValidateUseCase(u); err == nil {
				t.Fatalf("want error when %s is missing", c.name)
			}
		})
	}
}

func TestValidateBoundariesAcceptsDependencyGraph(t *testing.T) {
	bs := []Boundary{
		{Name: "content", Owns: []string{"content", "feed"}, DependsOn: []string{"identity"}},
		{Name: "identity", Owns: []string{"user", "session"}},
		{Name: "order", Owns: []string{"order"}, DependsOn: []string{"content", "identity"}},
	}
	if err := ValidateBoundaries(bs); err != nil {
		t.Fatalf("want valid graph, got %v", err)
	}
}

func TestValidateBoundariesRejectsBrokenGraph(t *testing.T) {
	cases := []struct {
		name string
		bs   []Boundary
	}{
		{
			name: "duplicate-name",
			bs: []Boundary{
				{Name: "content", Owns: []string{"content"}},
				{Name: "content", Owns: []string{"other"}},
			},
		},
		{
			name: "unknown-dependency",
			bs:   []Boundary{{Name: "order", DependsOn: []string{"payment"}}},
		},
		{
			name: "self-dependency",
			bs:   []Boundary{{Name: "content", DependsOn: []string{"content"}}},
		},
		{
			name: "ownership-overlap",
			bs: []Boundary{
				{Name: "content", Owns: []string{"content"}},
				{Name: "search", Owns: []string{"content"}},
			},
		},
		{
			name: "empty-owned-name",
			bs:   []Boundary{{Name: "content", Owns: []string{" "}}},
		},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if err := ValidateBoundaries(c.bs); err == nil {
				t.Fatal("want error for broken boundary graph")
			}
		})
	}
}

测试里最值得看的是 ownership-overlap 和 unknown-dependency 两个用例。前者防止两个边界同时声称拥有同一份数据,后者防止依赖表里出现一个还没排期的名字。它们都不复杂,但能在代码评审时把“先写着,以后再拆”的模糊地带变成一条明确的失败。

测试没有覆盖的部分也要说清楚:它不验证 actor 是否真的有权执行该用例,不验证副作用是否在事务里,也不验证依赖方向是否形成灾难性的循环。这些需要接口测试、权限矩阵和架构评审来回答。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/platform/design/ -run 'TestValidateUseCase|TestValidateBoundaries' -count=1 -v

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

=== RUN   TestValidateUseCaseAcceptsCompleteCase
--- PASS: TestValidateUseCaseAcceptsCompleteCase (0.00s)
=== RUN   TestValidateUseCaseRejectsMissingFields
=== RUN   TestValidateUseCaseRejectsMissingFields/name
=== RUN   TestValidateUseCaseRejectsMissingFields/actor
=== RUN   TestValidateUseCaseRejectsMissingFields/trigger
=== RUN   TestValidateUseCaseRejectsMissingFields/outcome
=== RUN   TestValidateUseCaseRejectsMissingFields/side-effect
--- PASS: TestValidateUseCaseRejectsMissingFields (0.00s)
    --- PASS: TestValidateUseCaseRejectsMissingFields/name (0.00s)
    --- PASS: TestValidateUseCaseRejectsMissingFields/actor (0.00s)
    --- PASS: TestValidateUseCaseRejectsMissingFields/trigger (0.00s)
    --- PASS: TestValidateUseCaseRejectsMissingFields/outcome (0.00s)
    --- PASS: TestValidateUseCaseRejectsMissingFields/side-effect (0.00s)
=== RUN   TestValidateBoundariesAcceptsDependencyGraph
--- PASS: TestValidateBoundariesAcceptsDependencyGraph (0.00s)
=== RUN   TestValidateBoundariesRejectsBrokenGraph
=== RUN   TestValidateBoundariesRejectsBrokenGraph/duplicate-name
=== RUN   TestValidateBoundariesRejectsBrokenGraph/unknown-dependency
=== RUN   TestValidateBoundariesRejectsBrokenGraph/self-dependency
=== RUN   TestValidateBoundariesRejectsBrokenGraph/ownership-overlap
=== RUN   TestValidateBoundariesRejectsBrokenGraph/empty-owned-name
--- PASS: TestValidateBoundariesRejectsBrokenGraph (0.00s)
    --- PASS: TestValidateBoundariesRejectsBrokenGraph/duplicate-name (0.00s)
    --- PASS: TestValidateBoundariesRejectsBrokenGraph/unknown-dependency (0.00s)
    --- PASS: TestValidateBoundariesRejectsBrokenGraph/self-dependency (0.00s)
    --- PASS: TestValidateBoundariesRejectsBrokenGraph/ownership-overlap (0.00s)
    --- PASS: TestValidateBoundariesRejectsBrokenGraph/empty-owned-name (0.00s)
PASS
ok  	webapp/internal/platform/design	0.236s

PASS 和退出码 0 是判定标准;耗时只说明这次运行有多快,不能当成性能指标。需要单独确认某个子用例时,可以把 -run 换成更精确的正则,或者加 -v 观察每个子测试的名字。

小结

用户旅程是产品语言,用例是服务端能接住的契约。把旅程拆成 actor、trigger、outcome 和副作用,才能回答“谁能调、重复调用会怎样、失败了怎么验收、留下了什么要补偿”。边界表则把“归谁所有、依赖谁”从口头约定变成可评审的输入。

一个用例跨几个团队并不可怕,可怕的是没有一个 owner 对端到端结果负责。校验器能拦住缺失字段、重复边界和所有权重叠,但拦不住组织上的模糊;后者需要负责人、契约和演练来补。

下一节会把这张边界表落到数据上,讨论用户、内容、关系和订单四类数据的所有权,以及为什么“单写者”是注销、审计和模型演进的共同前提。

阅读导航:上一节:目录与学习路径 · 下一节:2.2 领域模型与数据所有权 。

常见错误与边界

  • 把用户旅程直接当成一个接口。旅程包含多个 actor 和多种失败模式,硬塞进一个接口会让超时、重试和回滚互相打架。
  • 用例只写 happy path。没有触发条件和副作用的用例,无法设计幂等键,也无法判断失败后该重试还是该对账。
  • 边界按团队划分。组织架构一变,数据 ownership 就出现真空;边界应该按能力和数据语义划分。
  • 允许“临时直接读库”。一次临时读取会变成永久依赖,最终让两个边界的发布节奏绑死。
  • 把校验器当权限系统。结构校验只能证明文档完整,不能证明调用方有权限,也不能阻止绕过网关的直连。
  • 边界表只画一次。产品迭代、团队调整和并购都会改变 ownership,边界表需要随版本更新。
  • 忽略副作用的顺序。多个副作用跨边界时,顺序和补偿动作不写清楚,最终一致就会变成重复扣减。
  • 用“以后再拆”掩盖没有 owner。没有 owner 的跨边界用例,迟早会在事故复盘时变成三个团队互相证明自己没错。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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