一、p99 从 80ms 涨到 900ms 的那次发布
案例假设值:一个内容社区的信息流推荐,日常 p99 约 80ms,候选规模 500 条,在线服务部署 24 个实例。某个版本为了提升新内容的冷启动效果,在打分前加了一段“用户近 90 天行为统计”:每次请求都去行为明细表聚合曝光、点击、收藏和停留时长。
上线当天下午,晚高峰 p99 升到 900ms,超时率 7%,信息流首屏加载从 1.2 秒变成 4 秒以上。回滚后指标恢复。事后统计,这段聚合 SQL 单次平均 120ms,但在热点用户上会扫描几十万行,尾延迟被放大到 800ms 以上。
问题不在于“90 天行为特征没用”,而在于它被放在了错误的计算位置。行为明细的聚合结果一天只变几次,完全可以离线算好;在线服务真正需要的,是按键取一个已经算好的向量,再做轻量拼接和归一化。
这类问题往往在发布时看起来很合理:需求是“让模型更懂用户最近的行为”,实现上最直接的路径就是在打分前查一次明细表。代码评审关注了 SQL 是否正确,却很少有人问“这段计算为什么必须在请求里做”。等尾延迟上来,才发现真正需要改的是计算位置,而不是 SQL 写法。
二、把特征分成两类:离线算好,在线只查
推荐特征可以粗分成两类:慢特征和快特征。慢特征变化慢、计算重,例如用户 30 天点击率、内容 embedding、作者历史表现;快特征变化快、计算轻,例如当前请求的时间、网络、页面位置、实时会话里的最后一次点击。
| 特征类型 | 典型例子 | 计算位置 | 更新频率 | 在线职责 |
|---|---|---|---|---|
| 用户长期画像 | 30 天品类偏好、点击率 | 离线批处理 | 每天或每小时 | 按键读取 |
| 内容向量 | embedding、质量分 | 离线训练后写入 | 跟随模型版本 | 按键读取 |
| 关系特征 | 关注、拉黑、共同好友数 | 近线图计算 | 分钟级 | 批量查询 |
| 实时上下文 | 时段、网络、页面位置 | 在线请求内 | 每请求 | 直接计算 |
| 会话行为 | 本次会话最后点击 | 在线缓存 | 秒级 | 读取并拼接 |
快特征也不等于“可以在请求里随便算”。判断标准是计算量是否恒定、是否依赖外部系统。读一次本地缓存是快特征,查一次远程用户画像也是“快”,但它引入了网络抖动,仍然应该批量化和超时控制。在线服务的时间预算通常按毫秒分配,任何一个不受控的下游都可能把预算吃光。
判断一个特征该放哪里,可以问三个问题:它需要扫多少行数据?它的结果多久变一次?它失败时能否降级?扫描行数大、变化慢、失败可降级的,优先离线;只在当前请求内才有意义、计算量恒定的,才留在在线。
以“用户 30 天点击率”为例,按案例假设值,单个用户平均 2 万行行为,离线任务每小时全量重算一遍,在线只读一个 float64。如果放到在线,单次聚合至少几十毫秒,热点用户可能上百毫秒,而且每个候选都要算一次。同一个特征,放在不同位置,成本相差三个数量级。
在线服务的工作因此被压缩成四件事:按 user_id 和 content_id 批量读取特征、拼接成向量、做必要的归一化与截断、调用模型打分。它不应该在请求链路里做聚合、JOIN 大表或远程调用多个下游。每多一次远程调用,p99 就多一个受制于人的尾巴。
批量读取的 key 设计也很关键。用户特征按 user_id 一次取回,内容特征按 content_id 列表一次批量取回,关系特征按 (user_id, content_id) 批量取回。不要在循环里逐个调用,否则即使每个调用只有 2ms,500 个候选也会累积成一秒。批量接口还应该支持部分命中,缺失的 key 返回空值,而不是让整批失败。
候选也是同样的道理。候选来自多路召回:个性化、热榜、关注、相似内容、运营池。每一路只负责“尽量多、尽量相关”地给出 ID,在线服务负责合并、去重、截断和打分。召回可以慢一点、可以离线预计算,但在线打分必须稳定。
三、失败场景:请求链路里的 90 天聚合
把事故拆开看,问题出在三个叠加因素。
- 聚合 SQL 没有合适的覆盖索引,热点用户要扫描几十万行行为明细。
- 在线服务对每个候选都调用一次聚合,500 个候选变成 500 次查询,虽然加了缓存,但缓存键是“用户 + 候选”,命中率很低。
- 超时后没有降级,请求继续等待数据库,连接池被占满,错误从推荐服务扩散到整个信息流。
更麻烦的是,这段逻辑在压测里没有暴露。压测用户的行为数据分布均匀,单次聚合很快;真实热点用户的行为数据高度集中,尾延迟完全不同。用平均延迟做容量评估,就会得出“还能再扛一扛”的错误结论。
连接池是另一个放大器。请求变慢后,每个请求占用连接的时间变长,连接池很快耗尽,后续请求开始排队。排队又让延迟更高,形成正反馈。事故报告里看到的“数据库连接数打满”,通常是结果而不是原因;根因仍然是请求链路里那段不该存在的重计算。
修复分三步。第一步,把 90 天行为聚合改成离线任务,产出 user_behavior_vector,在线只按 user_id 读取。第二步,把逐候选查询改成一次批量读取,500 次变成 1 次。第三步,给特征读取加超时和默认值,读不到时走降级特征,而不是让整个请求失败。
修复后,同一批流量的 p99 回到 90ms 左右,超时率降到 0.1% 以下。这个结果不是“SQL 优化了”,而是“重计算被搬到了它该在的位置”。
排查这类问题时,平均延迟几乎没有参考价值。应该看 p99、超时率和连接池等待时间,并按用户行为量分桶。热点用户和普通用户的曲线往往差一个数量级,混在一起看,就会得到“整体还行”的错觉。
四、用 Missing 与 Normalize 守住输入契约
在线服务最怕的不是模型不准,而是输入悄悄变了:上游少发一个特征,代码里零值参与计算,分数看起来正常,效果却慢慢变差。Missing 和 Normalize 就是为此准备的两个小函数。
Missing 接收当前向量和必需特征名,返回缺失项并按字典序升序排列。调用方可以据此决定:缺的是可降级特征就走默认值,缺的是关键特征就拒绝请求并告警。返回排序后的列表,是为了让日志和告警稳定,不会因为 map 遍历顺序每次不同。
Normalize 做 L2 归一化:每个分量除以向量的欧氏长度。选 L2 而不是 min-max,是因为在线特征经常没有稳定的上下界,min-max 需要额外的最大最小值,反而增加一次依赖;L2 只依赖当前向量,计算量固定。空向量、零向量和重复特征名都会报错,避免脏数据进入模型。
一次请求拼接后的向量可以长这样:
features:
- name: user_ctr_30d
value: 0.12
- name: user_stay_avg
value: 38.5
- name: item_quality
value: 0.87
- name: session_last_click
value: 1
这些值里,前三个来自离线或近线存储,最后一个来自会话缓存。在线服务只负责按名字取值、检查缺失、归一化,然后把向量交给模型。它不解释每个特征的业务含义,也不回查产生这些特征的原始明细。
// file: internal/reco/feature.go
// tier: L1
// verify: go test ./internal/reco/
package reco
import (
"errors"
"fmt"
"math"
"sort"
)
// Feature 是一个命名特征。在线服务只接收已经算好的数值,
// 不在这里回查用户历史行为。
type Feature struct {
Name string
Value float64
}
// Vector 是一次请求的特征集合。
type Vector []Feature
// Missing 返回 required 中尚未出现在 v 里的特征名,按字典序升序,
// 重复的 required 只返回一次。调用方据此决定走降级还是拒绝请求。
func Missing(v Vector, required []string) []string {
have := make(map[string]struct{}, len(v))
for _, f := range v {
have[f.Name] = struct{}{}
}
seen := make(map[string]struct{}, len(required))
missing := make([]string, 0, len(required))
for _, name := range required {
if _, ok := have[name]; ok {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
missing = append(missing, name)
}
sort.Strings(missing)
return missing
}
// Normalize 对向量做 L2 归一化:每个分量除以向量的欧氏长度。
// 空向量、零向量和重复特征名都返回错误,避免把脏数据送进模型。
func Normalize(v Vector) (Vector, error) {
if len(v) == 0 {
return nil, errors.New("reco: empty vector")
}
seen := make(map[string]struct{}, len(v))
var sumSquares float64
for _, f := range v {
if _, ok := seen[f.Name]; ok {
return nil, fmt.Errorf("reco: duplicate feature %q", f.Name)
}
seen[f.Name] = struct{}{}
sumSquares += f.Value * f.Value
}
if sumSquares == 0 {
return nil, errors.New("reco: zero vector")
}
scale := math.Sqrt(sumSquares)
out := make(Vector, len(v))
for i, f := range v {
out[i] = Feature{Name: f.Name, Value: f.Value / scale}
}
return out, nil
}
这里有一个刻意的取舍:Normalize 不排序、不去重、不补默认值。它只做一件事,就是归一化。补默认值是业务策略,放在调用方更合适;如果函数内部偷偷补零,调用方就永远不知道上游缺了特征。函数越小,责任越清楚。
为什么不把归一化直接放进模型?因为模型版本和归一化策略需要独立演进。同一个模型可能服务多个入口,不同入口的特征尺度不同;把归一化留在服务侧,可以按入口配置策略,而不必为每个入口训练一个新模型。代价是服务侧必须保证策略一致,这正是测试要覆盖的部分。
五、用测试固定缺失顺序与归一化
测试覆盖三类行为:缺失特征是否按升序返回、重复的必需特征是否只出现一次、L2 归一化结果是否正确。另有一组反向用例,验证空向量、重复特征和零向量都会报错。
这些测试不依赖数据库、不依赖模型文件,运行时间在毫秒级。它们的作用不是证明模型有效,而是证明输入契约没有被改坏。推荐系统的很多“效果下降”最终都能追到这类契约变化上。
// file: internal/reco/feature_test.go
// tier: L1
// verify: go test ./internal/reco/
package reco
import (
"math"
"reflect"
"testing"
)
func TestMissingReturnsSortedUniqueNames(t *testing.T) {
v := Vector{{Name: "age", Value: 1}, {Name: "city", Value: 2}}
got := Missing(v, []string{"tags", "age", "ctr", "tags"})
want := []string{"ctr", "tags"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
t.Logf("missing=%v", got)
}
func TestNormalizeL2(t *testing.T) {
v := Vector{{Name: "a", Value: 3}, {Name: "b", Value: 4}}
got, err := Normalize(v)
if err != nil {
t.Fatalf("Normalize: %v", err)
}
if math.Abs(got[0].Value-0.6) > 1e-9 || math.Abs(got[1].Value-0.8) > 1e-9 {
t.Fatalf("unexpected normalized vector: %#v", got)
}
t.Logf("normalized=%v", got)
}
func TestNormalizeRejectsBadVectors(t *testing.T) {
if _, err := Normalize(nil); err == nil {
t.Fatal("empty vector should fail")
}
if _, err := Normalize(Vector{{Name: "a", Value: 1}, {Name: "a", Value: 2}}); err == nil {
t.Fatal("duplicate feature should fail")
}
if _, err := Normalize(Vector{{Name: "a", Value: 0}}); err == nil {
t.Fatal("zero vector should fail")
}
}
测试里的浮点比较使用 1e-9 容差,而不是直接 ==。归一化涉及除法和平方根,不同平台的低位结果可能不同;用容差比较,既验证了数值正确,又不会让测试变成平台相关的脆弱断言。
还有一个容易被忽略的边界:必需特征列表本身可能重复。Missing 内部对 required 做了去重,保证同一个缺失特征只出现一次。否则告警里会出现重复项,调用方按数量判断严重程度时就会误判。
六、运行命令与真实输出
在 lab 目录执行:
cd .local/checks/webapp-lab
go test -v ./internal/reco/ -run 'TestMissing|TestNormalize' -count=1
一次本机运行的真实输出如下(耗时随机器变化):
=== RUN TestMissingReturnsSortedUniqueNames
feature_test.go:20: missing=[ctr tags]
--- PASS: TestMissingReturnsSortedUniqueNames (0.00s)
=== RUN TestNormalizeL2
feature_test.go:32: normalized=[{a 0.6} {b 0.8}]
--- PASS: TestNormalizeL2 (0.00s)
=== RUN TestNormalizeRejectsBadVectors
--- PASS: TestNormalizeRejectsBadVectors (0.00s)
PASS
ok webapp/internal/reco 0.263s
输出里 missing=[ctr tags] 说明 ctr 和 tags 两个必需特征都没出现,顺序是字典序;normalized=[{a 0.6} {b 0.8}] 是 {3, 4} 做 L2 归一化的结果。反向用例没有日志,PASS 就表示三个错误分支都被触发。
-count=1 的作用是禁用测试缓存。Go 默认会缓存成功过的测试结果,改了文档或环境后仍可能显示缓存命中;做示例验证时加上它,能确保输出来自本次真实执行。
七、小结
特征与候选的在线服务,核心不是“算得多”,而是“算得少且稳定”。慢特征离线算好,快特征在线轻算;多路召回负责广度,在线服务负责合并、截断和打分。把重计算留在请求链路里,迟早会在热点数据上付出尾延迟的代价。
Missing 和 Normalize 这类小函数,价值在于把输入契约显式化。缺什么、怎么归一化、哪些情况必须报错,都写进代码和测试,线上才不会靠“看起来正常”来判断。
下一节可以看 /inet-social-12-3-cold-start-fallback/,讨论新用户没有特征时,如何用兜底链保证首页不空白。
阅读导航:上一节:12.1 推荐服务与业务服务的边界 · 下一节:12.3 冷启动与效果兜底 。
八、常见错误与边界
- 在在线请求里聚合行为明细,把平均延迟当成 p99,压测通过就上线。
- 对每个候选单独查一次特征,500 个候选变成 500 次远程调用。
- 特征缺失时静默补零,分数看似正常,模型效果长期漂移。
- 用 min-max 归一化却没有稳定的最大最小值,导致每次请求的尺度都不同。
- 把候选合并、去重和截断分散在多个服务里,排序结果无法复现。
- 召回通道越多越好,却不限制每路数量,在线打分被候选规模拖垮。
- 没有特征读取超时和降级路径,一个下游抖动就拖垮整个信息流。
- 把“快特征”理解成“可以在请求里现算”,忽略远程调用的网络抖动。
- 只监控平均延迟,不按用户行为量分桶,热点用户的尾延迟被平均值掩盖。
- 在线服务自己解释特征业务含义,上游字段一改就要跟着发版。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。