《互联网产品的服务端架构·内容、社交与实时互动》12.2 特征、候选与在线服务

在线推荐最常见的性能事故,是在请求链路里现算长周期特征,p99 会从几十毫秒涨到近一秒。本文拆开离线特征与在线轻量计算,用 Missing 和 L2 归一化守住输入契约,并给出可复现的 Go 测试与 p99 退化案例。在线服务只做查找、拼接与截断。

一、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 归一化却没有稳定的最大最小值,导致每次请求的尺度都不同。
  • 把候选合并、去重和截断分散在多个服务里,排序结果无法复现。
  • 召回通道越多越好,却不限制每路数量,在线打分被候选规模拖垮。
  • 没有特征读取超时和降级路径,一个下游抖动就拖垮整个信息流。
  • 把“快特征”理解成“可以在请求里现算”,忽略远程调用的网络抖动。
  • 只监控平均延迟,不按用户行为量分桶,热点用户的尾延迟被平均值掩盖。
  • 在线服务自己解释特征业务含义,上游字段一改就要跟着发版。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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