一、新用户首页为什么是空的
案例假设值:一个内容社区每天新增 8 万注册用户,新用户首次打开首页的次日留存是 35%。某次推荐服务重构后,个性化通道改成“必须同时具备用户画像和内容向量才返回结果”,而新用户的画像要到注册后第二天才有离线任务产出。
上线后的第一个上午,新用户首页推荐区大量空白。客服收到“首页什么都没有”的反馈,运营在后台看到新用户首屏曝光下降了 60%。更糟的是,这个空白不是报错,监控上没有错误率,只有一个不起眼的“平均返回条数”指标从 42 降到 31。
问题不在个性化通道本身。新用户没有行为数据,返回空列表是合理的。真正的问题是:个性化返回空之后,链路没有继续问下一路来源,而是直接把空列表当成最终结果。冷启动被实现成了“没有特征就什么都不给”。
为什么监控没有报警?因为空结果不是错误。接口返回 200,耗时也正常,日志里只有一行“personalized: 0 items”。如果没有人专门统计空结果率和兜底触发率,这类事故可以安静地持续很久。它伤害的是体验和留存,而不是可用性指标。
这类事故也给了一个提醒:推荐链路的健康指标不能只围绕“服务是否活着”,还要围绕“结果是否可用”。一个永远返回空列表的服务,从可用性看是 100%,从产品看是 0%。两套指标都要有。
二、冷启动不是随机,是分层兜底
冷启动的正确形态是一条有顺序的兜底链。每一路来源解决不同人群的问题:个性化服务老用户,热榜服务完全没有画像的新用户,新鲜内容解决时效性,编辑精选兜住质量和合规底线。链的顺序就是产品策略,必须显式写出来。
| 来源 | 适用人群 | 数据依赖 | 触发条件 | 主要风险 |
|---|---|---|---|---|
| 个性化 personalized | 有画像和行为的用户 | 用户特征、内容向量 | 特征齐全且服务健康 | 新用户无结果、模型抖动 |
| 热榜 hot | 新用户、低活跃用户 | 近实时点击与曝光 | 个性化无结果或超时 | 马太效应、内容同质化 |
| 新鲜内容 fresh | 需要时效性的场景 | 发布时间、审核状态 | 热榜不足或内容老化 | 质量参差、审核压力 |
| 编辑精选 editorial | 全部用户、兜底场景 | 人工运营配置 | 前三路都不可用 | 更新慢、覆盖面有限 |
“健康”不能简单定义成“接口返回 200”。一个来源可能返回 200 但结果为空,也可能 p99 已经超过预算,还可能返回了未审核内容。健康判定至少要覆盖三层:服务可用性、结果非空、内容合规。只有三层都通过,才应该把这一路视为可用。
兜底链的顺序不是拍脑袋排出来的,而是按人群覆盖和内容质量排出来的。个性化效果最好但覆盖最窄,热榜覆盖广但容易同质化,新鲜内容解决时效,编辑精选保证底线。顺序一旦确定,就应该作为配置管理,而不是散落在代码的多个 if 里。
每条来源还应该有独立的时间预算。个性化给 80ms,热榜给 50ms,新鲜内容给 50ms,编辑精选读本地缓存给 10ms。总预算接近首页首屏的承受范围。没有预算的兜底链,会在主路径超时后继续叠加超时,最终比不兜底还慢。
兜底链还需要定义“空结果”和“故障”的区别。个性化返回空列表,说明这一路对当前用户不适用,应该继续往下走;个性化超时或返回 5xx,说明这一路故障,也应该继续往下走,但要记录降级原因。两种情况的监控指标不同,处置方式也不同。
健康表本身会过期。如果探针每 30 秒更新一次,而下游在两次探针之间挂掉,Choose 仍可能选中一个刚失效的来源。这类窗口无法完全消除,但可以通过短探针周期、请求内快速失败和有限重试来缩小。关键是把“健康”当成一个有新鲜度的信号,而不是一个永久事实。
三、失败场景:空列表被当成最终结果
把事故拆开看,代码路径大致是这样的:
- 推荐服务先调用个性化通道,新用户返回空列表。
- 调用方判断
len(items) == 0,直接返回空,没有继续调用热榜。 - 业务服务拿到空列表,首页推荐区渲染为空,但接口状态码是 200。
- 监控只按错误率告警,没有“空结果率”和“兜底触发率”指标,事故持续了整个上午。
后果不只是少了一次曝光。新用户第一次打开产品就看到空白首页,会直接怀疑产品是否可用;按案例假设值估算,当天新用户次日留存下降了 4 个百分点。推荐团队后来补了兜底,但用户已经流失,无法追回。
这个事故的根因不是“个性化不够强”,而是“降级路径没有被当成一等公民”。团队把注意力放在模型和召回上,默认下游一定有结果;一旦某一路返回空,整条链路就失去定义。兜底链的价值,正是在主路径不适用时仍然给出可解释的结果。
复盘时还发现,个性化通道其实早就对新用户返回空,只是老用户的流量占比高,平均值把这个信号稀释了。如果当时按“新用户 / 老用户”分组看返回条数,问题在灰度阶段就能被发现。冷启动问题必须按人群看指标,不能只看全站平均。
修复方案是把来源选择抽成独立函数:输入一条有序链和一张健康表,输出第一个可用来源。调用方不再自己写 if len == 0,而是统一走 Choose;每一路的结果、耗时和降级原因都记录到同一个 request_id 下。
灰度时还应该主动演练降级:把个性化健康位手动置为 false,观察首页是否落到热榜、延迟是否在预算内、曝光是否恢复正常。只在上线后被动等待故障,兜底链永远没有经过验证。演练记录也是复盘时的重要证据。
四、用 Choose 固化顺序与降级条件
Choose 的输入是 Chain 和 healthy,输出是第一个健康来源。它先校验链本身:不能为空、不能有重复来源、不能有空来源名;然后按顺序扫描,返回第一个健康项;全部不健康时返回错误,让调用方显式处理,而不是拿到一个空字符串继续往下跑。
这里有两个设计取舍。第一,重复来源直接报错,而不是去重。兜底链是产品策略,重复往往意味着配置写错;静默去重会掩盖问题,让顺序变得不可解释。第二,全部不健康返回错误,而不是默认返回热榜。默认值会把“配置错误”和“真的没有可用来源”混在一起,事故时无法区分。
一份可读的链配置大致如下:
chain:
- personalized
- hot
- fresh
- editorial
budgets_ms:
personalized: 80
hot: 50
fresh: 50
editorial: 10
配置和代码分开之后,运营可以调整顺序,服务端只负责按顺序选择;时间预算也可以独立调整,不必每次改代码。Choose 只消费 Chain.Steps,不关心配置从哪里来。
// file: internal/reco/fallback.go
// tier: L1
// verify: go test ./internal/reco/
package reco
import (
"errors"
"fmt"
)
// Source 是一路推荐来源。顺序本身就是优先级,不能靠 map 遍历决定。
type Source string
const (
SourcePersonalized Source = "personalized"
SourceHot Source = "hot"
SourceFresh Source = "fresh"
SourceEditorial Source = "editorial"
)
// Chain 是按优先级排列的兜底链。
type Chain struct {
Steps []Source
}
// Choose 返回链上第一个健康的来源。全部不健康、空链、
// 重复来源或空来源名都返回错误,让调用方显式处理而不是拿到空字符串。
func Choose(chain Chain, healthy map[Source]bool) (Source, error) {
if len(chain.Steps) == 0 {
return "", errors.New("reco: empty chain")
}
seen := make(map[Source]struct{}, len(chain.Steps))
for _, s := range chain.Steps {
if s == "" {
return "", errors.New("reco: empty source in chain")
}
if _, ok := seen[s]; ok {
return "", fmt.Errorf("reco: duplicate source %q", s)
}
seen[s] = struct{}{}
}
for _, s := range chain.Steps {
if healthy[s] {
return s, nil
}
}
return "", errors.New("reco: no healthy source")
}
健康表由调用方维护,可以来自探针、熔断器或实时指标。Choose 本身不判断健康,只负责顺序选择,这样它保持纯函数,容易测试,也不会把探针逻辑耦合进推荐策略。健康判定的口径可以独立演进,而链的顺序保持稳定。
为什么不把健康判定放进 Choose?因为健康来源不同:有的是进程内熔断器,有的是外部探针,有的是实时指标聚合。把这些都塞进一个函数,会让它依赖网络和时钟,测试也不再稳定。把选择逻辑和健康判定分开,是这一节最重要的边界。
五、用测试固定顺序与错误分支
测试覆盖四个场景:第一路不健康时选第二路、前两路不健康时落到编辑精选、空链报错、重复来源报错。另有一个“全部不健康”的用例,验证函数返回错误而不是空字符串。
这些用例对应线上最常见的四种事故:个性化故障、热榜也不可用、配置漏写、配置重复。把它们固定在单测里,配置变更时至少能在发布前发现明显错误。
// file: internal/reco/fallback_test.go
// tier: L1
// verify: go test ./internal/reco/
package reco
import "testing"
func TestChooseReturnsFirstHealthy(t *testing.T) {
chain := Chain{Steps: []Source{SourcePersonalized, SourceHot, SourceEditorial}}
got, err := Choose(chain, map[Source]bool{
SourcePersonalized: false,
SourceHot: true,
SourceEditorial: true,
})
if err != nil {
t.Fatalf("Choose: %v", err)
}
if got != SourceHot {
t.Fatalf("got %q, want %q", got, SourceHot)
}
t.Logf("chosen=%s", got)
}
func TestChooseFallsBackToEditorial(t *testing.T) {
chain := Chain{Steps: []Source{SourcePersonalized, SourceFresh, SourceEditorial}}
got, err := Choose(chain, map[Source]bool{SourceEditorial: true})
if err != nil {
t.Fatalf("Choose: %v", err)
}
if got != SourceEditorial {
t.Fatalf("got %q, want %q", got, SourceEditorial)
}
t.Logf("chosen=%s", got)
}
func TestChooseRejectsBadChains(t *testing.T) {
if _, err := Choose(Chain{}, map[Source]bool{SourceHot: true}); err == nil {
t.Fatal("empty chain should fail")
}
if _, err := Choose(Chain{Steps: []Source{SourceHot, SourceHot}}, nil); err == nil {
t.Fatal("duplicate source should fail")
}
if _, err := Choose(Chain{Steps: []Source{SourceHot}}, map[Source]bool{}); err == nil {
t.Fatal("no healthy source should fail")
}
if _, err := Choose(Chain{Steps: []Source{""}}, map[Source]bool{}); err == nil {
t.Fatal("empty source name should fail")
}
}
测试里的健康表是显式构造的,不依赖真实探针。这样测试结果稳定,也不会因为本地网络或下游状态而波动。线上健康判定是否正确,应该由探针和集成测试覆盖,而不是让单元测试承担。
配置变更时,还应该有一条“链校验”测试:加载配置、检查来源名是否合法、是否有重复、是否至少包含一个兜底来源。这条测试不验证效果,只验证配置不会把线上带进“整条链都不可用”的状态。
六、运行命令与真实输出
在 lab 目录执行:
cd .local/checks/webapp-lab
go test -v ./internal/reco/ -run 'TestChoose' -count=1
一次本机运行的真实输出如下(耗时随机器变化):
=== RUN TestChooseReturnsFirstHealthy
fallback_test.go:22: chosen=hot
--- PASS: TestChooseReturnsFirstHealthy (0.00s)
=== RUN TestChooseFallsBackToEditorial
fallback_test.go:34: chosen=editorial
--- PASS: TestChooseFallsBackToEditorial (0.00s)
=== RUN TestChooseRejectsBadChains
--- PASS: TestChooseRejectsBadChains (0.00s)
PASS
ok webapp/internal/reco 0.237s
输出里 chosen=hot 表示个性化不健康时落到热榜;chosen=editorial 表示前两路都不可用时落到编辑精选。最后一个用例没有日志,PASS 表示空链、重复来源、全部不健康和空来源名四个错误分支都被触发。
如果本地输出与本机不完全一致,先确认 Go 版本和包路径。ok 后面的耗时每次都会变,chosen 的值和 PASS 的结果才是需要一致的部分。
七、小结
冷启动不是“没有特征就随机”,而是一条有顺序、有健康判定、有监控的兜底链。个性化负责效果上限,热榜和新鲜内容负责覆盖,编辑精选负责底线。每一路的触发条件、空结果语义和故障处置都应该写清楚。
Choose 把这条链变成可测试的纯函数:顺序显式、重复报错、全不可用返回错误。调用方不需要在业务代码里散落 if len == 0,降级行为也不会因为某次重构而悄悄消失。
判断兜底是否完整,可以问一个反事实问题:如果个性化明天完全不可用,首页还能不能给出内容?如果答案是否定的,说明兜底链只是装饰;如果答案是可以,并且能说清落到哪一路、延迟多少、内容质量如何,兜底才算真正完成。
下一节可以看 /inet-social-13-1-publish-feed-interact-notify/,从发布、Feed、互动到通知,继续拆解内容链路的服务端协作。
阅读导航:上一节:12.2 特征、候选与在线服务 · 下一节:13.1 发布→Feed→互动→通知 。
八、常见错误与边界
- 个性化返回空列表时直接结束,不继续走热榜或编辑精选。
- 把“接口返回 200”当成健康,忽略空结果、超时和内容合规。
- 兜底链顺序写在代码的多个分支里,配置变更后无法统一解释。
- 重复来源静默去重,配置写错也不会报警,线上顺序变得不可复现。
- 全部来源不可用时返回空字符串或默认热榜,掩盖真实故障。
- 只监控错误率,不监控空结果率、兜底触发率和各来源贡献占比。
- 新用户和老用户共用同一套兜底策略,没有按人群区分触发条件。
- 兜底内容不做审核和去重,热榜里的旧内容被反复推给新用户。
- 健康表长期不刷新,下游已经恢复或再次故障,选择结果仍然停留在旧状态。
- 兜底链没有总预算,主路径超时后继续叠加,首屏比不兜底还慢。
- 把空列表当成功返回,既没有兜底也没有告警,事故只能靠用户反馈发现。
- 只在上线前检查链配置,运行期没有配置校验,改错一个来源名就整链失效。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。