一、一次“已删除内容仍被推荐”的事故
案例假设值:一个日活 300 万的社区产品,首页推荐位贡献 45% 的点击。某个周三上午,内容平台把 content.status 拆成 lifecycle_state 与 visibility 两列,迁移脚本先加新列、回填、再删旧列,整个窗口约 40 分钟。
推荐服务的召回 SQL 里仍然写着 WHERE c.status <> 'deleted'。旧列被删除后,推荐接口开始返回 500,首页推荐区空白了 23 分钟,直到值班同学把推荐服务回滚到上一版本。期间用户仍能刷新,但看到的只有骨架屏。
复盘时大家先争论“删除的内容为什么还能进候选”。继续追下去才发现,真正的问题不是删除判断写错了,而是推荐服务根本没有资格知道内容表长什么样。它读业务库、JOIN 内容表、自己复制一份可见性规则,等于把内容团队的表结构变成了自己的运行时依赖。
这类耦合通常不是一次设计评审引入的,而是从一句“顺手过滤一下”开始的:推荐服务为了少返回无效 ID,直接连上内容库;为了排序更准,又读了作者粉丝数;再后来,连运营配置表也接了进来。等业务表一改,推荐服务就成了第一个倒下的下游。
组织上的诱因也很现实:推荐团队想快速提升点击率,最省事的办法就是多拿一点业务数据;业务团队则认为“反正只读,不会出事”。两边都没有恶意,但每一次“只读一下”都在增加发布时的耦合面。等到事故出现,已经很难说清哪一次读取是必要的。
二、划边界:一个回答“推什么”,一个回答“能不能推”
把职责写成一句话:推荐服务只回答“给谁推什么”,业务服务回答“能不能推、以什么形态推”。推荐服务的输入是用户 ID、请求上下文、候选 ID 与特征向量,输出是有序的 ID、分数和来源。内容的上下架、删除、地域、可见性、付费状态,全部由业务服务在组装响应时判定。
可以用三条判据检查边界:推荐服务能否在不读业务库的情况下启动?业务规则变化时推荐服务是否需要发版?两边能否独立回滚?三条里只要有一条是否定,就说明还存在隐式耦合。
| 职责 | 推荐服务 | 业务服务 | 常见越界 |
|---|---|---|---|
| 用户画像 | 消费特征 ID 与数值 | 维护资料、关系与权限 | 推荐服务直连用户库查关注关系 |
| 内容状态 | 只消费候选 ID 与分数 | 判断删除、上架、地域、可见性 | 召回 SQL 里 JOIN 内容表过滤状态 |
| 排序信号 | 多路召回、粗排、精排 | 提供业务规则与强制插入位 | 把运营规则硬编码进模型 |
| 返回结构 | 有序 ID 列表与来源 | 组装卡片、鉴权、下发 | 推荐服务直接返回完整内容对象 |
| 变更节奏 | 跟随模型与特征迭代 | 跟随产品与合规迭代 | 两边共用一张宽表和同一套枚举 |
边界清晰后,有一个看似反直觉但很重要的结论:推荐服务可以返回业务侧最终会过滤掉的候选。业务服务按 ID 批量取内容,过滤掉不可见项后如果数量不足,再向推荐服务要下一批,或者由推荐侧多召回 20% 到 30% 的余量。推荐侧不需要知道“为什么不可见”,只需要保证顺序和分数可用。
接口契约也要写清楚数量语义:推荐侧返回的是“最多 N 条候选”,业务侧过滤后可能不足 N 条;不足时由业务侧决定补量还是展示空位。把这个语义写进接口文档,可以避免推荐侧为了“凑满”而越权读取内容状态。
接口只交换必要字段
一个最小响应可以长这样:
request_id: 7f3c9a21
model_version: reco-2026.10
items:
- candidate_id: "post-1042"
score: 0.93
source: personalized
- candidate_id: "post-887"
score: 0.91
source: hot
标题、作者、封面都不在响应里。业务服务拿着 candidate_id 批量取内容,再按可见性过滤。这样推荐服务不需要理解内容模型,内容服务也不需要理解排序分数,两边各自演进。
这也解释了为什么候选合并函数应该是一个纯函数:输入若干路召回结果,输出稳定的候选列表。它不查库、不读配置、不感知内容状态,因此可以在单测里完整覆盖,也不会因为内容表迁移而崩溃。
推荐服务对外暴露的接口应当只有两三个:召回、排序、相关推荐。每个请求都带 request_id 与候选数量上限,每个响应都带 candidate_id、score、source、model_version。不要暴露标题、作者、封面等业务字段;这些字段一旦进入推荐响应,下游就会开始依赖推荐服务的字段裁剪逻辑,边界很快又糊了。
另一个常见反问是:既然业务侧最懂可见性,为什么不干脆让推荐服务每返回一个候选就同步问一次业务?答案在延迟和故障域。一次首页请求可能召回 500 个候选,逐个同步校验会把 1 次 RPC 变成 500 次,推荐服务的 p99 直接受业务服务尾延迟支配。正确方向是批量:业务侧按 ID 列表批量取内容并过滤,推荐侧只负责顺序。
三、失败场景:字段迁移把推荐服务打穿
把上面的案例拆成时间线,问题会更清楚。
- T+0:内容团队开始迁移,新增
lifecycle_state与visibility,旧status列暂时保留。 - T+15:回填完成,迁移脚本删除
status列。推荐服务的 SQL 在下一分钟开始报Unknown column。 - T+17:推荐接口错误率从 0.2% 升到 98%,首页推荐区降级为空。
- T+23:值班同学回滚推荐服务镜像,但旧镜像仍引用
status,只能临时加回列。 - T+40:内容团队补了一版兼容视图,事故结束。
直接后果是首页推荐位 23 分钟无内容,按案例假设值估算,大约损失 40 万次推荐曝光。更隐蔽的后果是团队开始害怕改表:内容团队每次迁移都要先问推荐服务能不能兼容,推荐服务则要求内容团队提前两周发通知。两个团队都被一张本不该共享的表绑住了。
这个事故里,监控其实早就给出了信号:推荐接口的错误日志集中在同一条 SQL,发布记录里也能看到内容库的 DDL 变更。但告警只按“接口错误率”聚合,没有人把两条信息连起来。事后补的规则不是“禁止改表”,而是把推荐服务的数据库账号从内容库移除,让越界读取在发布前就失败。
正确的做法不是让推荐服务“更小心地读内容表”,而是切断这条读取路径。内容侧发布变更事件或候选 ID 流,推荐侧只保存 ID 与特征;业务侧在最终组装时做可见性判断。这样内容表怎么拆、枚举怎么改,都不会影响推荐服务的可用性。
有人会退一步:不 JOIN 在线表,改读内容库的只读从库。这仍然没有解决问题,只是把故障从“接口 500”变成“数据延迟”。内容删除后,从库延迟 30 秒,推荐位就会继续展示已删除内容 30 秒;合规要求更高的场景,这个窗口不可接受。只要推荐服务还在解释内容表的语义,它就和内容团队共享了发布节奏。
四、用 Merge 收口多路候选
Merge 是边界上的一个小契约:它接收多路召回的分组,合并同一个 ID,保留最高分及其来源,再按分数降序、同分按 ID 升序排序,最后截断到 limit。它不关心内容是否可见,也不关心分数怎么来的,只保证输出稳定、可解释。
实现上有三个容易忽略的点。第一,同一个 ID 出现在多路召回时,不能简单追加,否则下游会重复展示;第二,同分时必须用 ID 做次级排序,否则 map 遍历顺序会让结果在每次请求间抖动;第三,limit 和空输入要显式报错,避免调用方把“参数没传”误当成“没有候选”。
limit 的语义是“最多返回多少条”,不是“必须返回多少条”。候选不足时返回实际数量,让业务侧决定是否补量。把这两件事分开,推荐服务就不需要为了凑数去其他系统取数据,边界也更稳定。
// file: internal/reco/boundary.go
// tier: L1
// verify: go test ./internal/reco/
package reco
import (
"errors"
"fmt"
"sort"
)
// Candidate 是一路召回给出的候选。Source 记录它来自哪条召回通道,
// 便于排查“这个内容为什么出现”,而不是只保留一个分数。
type Candidate struct {
ID string
Source string
Score float64
}
// Merge 合并多路召回结果:同一 ID 只保留最高分及对应来源,
// 结果按 Score 降序、同分按 ID 升序排列,并截断到 limit 条。
// groups 为 nil、limit <= 0 或任一候选缺少 ID 时返回错误。
func Merge(groups [][]Candidate, limit int) ([]Candidate, error) {
if limit <= 0 {
return nil, errors.New("reco: limit must be positive")
}
if groups == nil {
return nil, errors.New("reco: groups must not be nil")
}
best := make(map[string]Candidate)
for gi, group := range groups {
for ci, c := range group {
if c.ID == "" {
return nil, fmt.Errorf("reco: empty candidate id at group %d index %d", gi, ci)
}
prev, ok := best[c.ID]
if !ok || c.Score > prev.Score {
best[c.ID] = c
continue
}
// 同分时按来源名稳定选择,避免合并顺序影响最终来源标注。
if c.Score == prev.Score && c.Source < prev.Source {
best[c.ID] = c
}
}
}
out := make([]Candidate, 0, len(best))
for _, c := range best {
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Score != out[j].Score {
return out[i].Score > out[j].Score
}
return out[i].ID < out[j].ID
})
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
函数返回的 Candidate 保留了 Source,这样线上排查“这条内容为什么出现”时,能直接看到它来自热榜还是个性化通道。来源信息也让 A/B 实验更容易归因:不是只看整体点击率,而是看每一路召回的贡献。
把合并逻辑做成纯函数,还有一个工程上的好处:它可以被离线回放。线上出现“某条内容排得不对”时,把当时的几路召回结果导出,重新跑一遍 Merge,就能判断是召回、合并还是排序的问题。如果函数里夹着数据库查询和配置读取,这个回放几乎不可能复现。
另一个细节是错误处理。groups == nil 和 limit <= 0 都返回错误,而不是返回空列表。空列表在调用方看来是“确实没有候选”,会被静默降级;错误则会触发告警和重试。两者在线上对应完全不同的处置路径,不能混为一谈。
五、用测试固定排序与来源语义
测试不需要连接数据库,也不需要构造完整请求。它只验证契约:同 ID 保留最高分、同分按 ID 升序、来源随胜出候选一起保留、limit 生效、非法输入报错。
其中“同分按 ID 升序”看起来只是排序细节,实际是线上稳定性的关键。推荐结果经常被缓存和 diff,如果同分顺序每次不同,缓存命中率会下降,实验组和对照组的差异也会混入噪声。
// file: internal/reco/boundary_test.go
// tier: L1
// verify: go test ./internal/reco/
package reco
import (
"reflect"
"testing"
)
func TestMergeKeepsHighestScoreAndSource(t *testing.T) {
groups := [][]Candidate{
{{ID: "a", Source: "hot", Score: 0.8}, {ID: "b", Source: "hot", Score: 0.6}},
{{ID: "a", Source: "personalized", Score: 0.9}, {ID: "c", Source: "fresh", Score: 0.6}},
}
got, err := Merge(groups, 10)
if err != nil {
t.Fatalf("Merge: %v", err)
}
want := []Candidate{
{ID: "a", Source: "personalized", Score: 0.9},
{ID: "b", Source: "hot", Score: 0.6},
{ID: "c", Source: "fresh", Score: 0.6},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
t.Logf("merged=%v", got)
}
func TestMergeLimitAndErrors(t *testing.T) {
groups := [][]Candidate{{{ID: "a", Score: 1}, {ID: "b", Score: 0.5}}}
got, err := Merge(groups, 1)
if err != nil {
t.Fatalf("Merge: %v", err)
}
if len(got) != 1 || got[0].ID != "a" {
t.Fatalf("limit not applied: %#v", got)
}
t.Logf("limited=%v", got)
if _, err := Merge(nil, 10); err == nil {
t.Fatal("nil groups should fail")
}
if _, err := Merge(groups, 0); err == nil {
t.Fatal("zero limit should fail")
}
if _, err := Merge([][]Candidate{{{Source: "hot", Score: 1}}}, 10); err == nil {
t.Fatal("empty candidate id should fail")
}
}
测试里的 t.Logf 不是为了调试,而是把合并结果打印出来,方便在 go test -v 的输出里直接核对来源字段。比起只断言长度,这种方式更容易发现“分数对了但来源标错”的问题。
测试也刻意不引入第三方断言库,保持 go test 零依赖。这不是排斥工具,而是让示例可以在任意 Go 1.26 环境直接运行。书里的代码块与 lab 文件逐字节一致,读者复制出来就能跑,不需要先补依赖。
边界测试还应该覆盖“空组”和“空候选列表”两种情况。空组表示某一路召回没有结果,应该被跳过;空候选列表则可能是上游故障,调用方应结合监控判断。Merge 对两者都保持宽容,只对非法参数报错,这样不会把上游的短暂空结果误判成参数错误。
六、运行命令与真实输出
在 lab 目录执行:
cd .local/checks/webapp-lab
go test -v ./internal/reco/ -run 'TestMerge' -count=1
一次本机运行的真实输出如下(耗时随机器变化):
=== RUN TestMergeKeepsHighestScoreAndSource
boundary_test.go:29: merged=[{a personalized 0.9} {b hot 0.6} {c fresh 0.6}]
--- PASS: TestMergeKeepsHighestScoreAndSource (0.00s)
=== RUN TestMergeLimitAndErrors
boundary_test.go:41: limited=[{a 1}]
--- PASS: TestMergeLimitAndErrors (0.00s)
PASS
ok webapp/internal/reco 0.212s
输出里 {a personalized 0.9} 表示 ID 为 a 的候选在热榜与个性化两路都出现,最终保留个性化来源的 0.9 分;b 与 c 同为 0.6 分,按 ID 升序排列。limited=[{a 1}] 里的两个空格来自 Source 为空,这是测试里故意只传分数、不传来源的用例。
-run 'TestMerge' 只跑合并相关的用例,避免输出被其他测试淹没。CI 里则应该去掉 -run,执行 go test ./internal/reco/ -count=1,并配合 go vet ./internal/reco/ 与 gofmt -l internal/reco,保证格式、静态检查和测试三道门都过。
如果本地输出与书里不完全一致,先核对 Go 版本与工作目录。ok 后面的耗时每次都会变,日志行和断言结果才是需要一致的部分。把耗时当成断言,是初次运行测试时最常见的误判。
七、小结
推荐服务与业务服务的边界,核心不是“谁调用谁”,而是“谁拥有判断权”。内容能不能推,是业务规则;给谁推什么,是推荐模型。两者通过 ID、分数和来源交换信息,而不是通过共享表结构耦合。
Merge 这类纯函数把边界固定下来:输入输出明确、排序稳定、错误显式。它看起来只是一个合并工具,实际是在防止推荐服务一步步长成第二个内容服务。
把边界落到代码上,意味着推荐服务可以独立部署、独立扩容、独立回滚,内容团队也可以按自己的节奏迁移表结构。两边只在 ID、分数、来源和特征上交换信息,故障域被切开,发布节奏也被解开。
判断边界是否真的划清了,可以用一个简单问题检验:内容团队明天要加一个“仅会员可见”的字段,推荐服务需要改代码吗?如果答案是需要,说明推荐服务仍在解释业务语义;如果答案是不需要,边界才是可持续的。
下一步可以看 /inet-social-12-2-feature-candidate-online/,讨论特征与候选进入在线服务后,哪些计算必须留在离线侧。
阅读导航:上一节:目录与学习路径 · 下一节:12.2 特征、候选与在线服务 。
八、常见错误与边界
- 让推荐服务直接 JOIN 内容表过滤删除状态,把表结构变成运行时契约。
- 多路召回结果直接拼接,导致同一 ID 重复曝光,点击率统计被稀释。
- 同分时依赖 map 遍历顺序,结果每次请求都可能不同,缓存与实验都被污染。
- 把
limit <= 0当成“不限制”,结果一次返回全部候选,打爆下游序列化。 - 推荐服务返回完整内容对象,业务侧再裁剪,导致字段权限和合规规则出现两份。
- 认为“推荐侧多召回一些就行”,却不定义业务过滤后的补位策略,最终仍然空窗。
- 用共享宽表替代接口契约,短期省事,长期让两个团队无法独立发布。
- 把推荐服务当作内容服务的代理,连字段裁剪和权限判断一起接管。
- 为了“凑满”返回条数,让推荐侧读取内容状态,重新引入业务库依赖。
- 接口只约定“返回 N 条”,却不约定过滤后不足时由谁补位、如何告警。
- 只做单测不做回放,线上排序异常时无法判断问题出在召回还是合并。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。