《互联网产品的服务端架构·内容、社交与实时互动》12.1 推荐服务与业务服务的边界

推荐服务与业务服务的边界一旦模糊,推荐侧就会悄悄依赖内容库表结构,内容团队一次字段迁移就可能让首页空白。本文用候选合并函数说明如何只交换 ID 与分数,把“能不能推”留在业务侧,并给出可运行的 Go 测试与失败场景。边界清晰后,内容下线与运营插位都不必改模型服务。

一、一次“已删除内容仍被推荐”的事故

案例假设值:一个日活 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 条”,却不约定过滤后不足时由谁补位、如何告警。
  • 只做单测不做回放,线上排序异常时无法判断问题出在召回还是合并。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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