63.3 特征与在线服务

一句话

在线推荐的一半延迟花在“取特征”上,而特征工程最大的风险不是慢,是离线训练与在线服务算出来的不是同一个数——同一个特征在离线用批处理算、在线用实时算,分布一漂移,模型离线涨点、线上不涨。

特征的三种来源

来源 延迟 新鲜度 典型 风险
本地/进程内 纳秒 随请求 请求上下文(时间、设备) 无
在线 KV(Redis/特征库) 0.5–2 ms 秒级–分钟级 用户画像、物品统计 批量回填延迟
下游服务调用 5–50 ms 实时 库存、价格、实时行为 超时与扇出

🔑 特征分三类:用户侧(相对静态)、物品侧(更新频繁)、上下文/交叉(必须实时算)。前两类预计算、第三类现场算,是延迟与新鲜度的常规切法。

代码:50 个特征怎么在 20ms 内取完

package main

import (
	"fmt"
	"math"
)

type src struct {
	name  string
	count int
	ms    float64 // 单次耗时
	batch bool    // 是否支持批量(一次拿多个 key)
}

var sources = []src{
	{"本地/上下文", 10, 0.05, true},
	{"在线 KV", 30, 1.2, true},  // MGET 批量
	{"下游服务", 10, 8.0, false}, // 每个一次调用
}

func main() {
	// ① 串行:逐个取
	serial := 0.0
	for _, s := range sources {
		serial += float64(s.count) * s.ms
	}
	// ② 按源并行 + 源内批量
	parallel := 0.0
	for _, s := range sources {
		if s.batch {
			parallel = math.Max(parallel, s.ms) // 批量后只算一次往返
		} else {
			parallel = math.Max(parallel, float64(s.count)*s.ms) // 服务调用无法批量
		}
	}
	fmt.Printf("50 个特征:串行 %.1fms,按源并行+批量 %.1fms\n", serial, parallel)
	fmt.Printf("瓶颈:下游服务 %.0f 次 × %.0fms = %.0fms(必须合并接口或预取)\n",
		10.0, 8.0, 80.0)

	fmt.Println("\n=== 特征新鲜度预算 ===")
	type feat struct {
		name   string
		update string
		lagS   float64
	}
	feats := []feat{
		{"用户长期兴趣", "天级", 86400},
		{"用户实时行为(近 1 小时)", "分钟级", 300},
		{"物品统计(点击率)", "小时级", 3600},
		{"库存/价格", "实时", 1},
		{"会话内上下文", "请求级", 0},
	}
	for _, f := range feats {
		fmt.Printf("  %-26s 更新 %-8s 最大陈旧 %6.0fs\n", f.name, f.update, f.lagS)
	}

	fmt.Println("\n=== 线上线下不一致的四个来源 ===")
	fmt.Println("1) 离线用批处理(T+1 聚合),在线用实时累加 → 分布不同")
	fmt.Println("2) 离线用未来数据(滑窗跨越了预测点)→ 特征泄漏")
	fmt.Println("3) 在线缺失时填 0,离线缺失时填均值 → 同一缺失两种语义")
	fmt.Println("4) 时间口径不同(UTC vs 本地、事件时间 vs 处理时间)")
}

本机实测(macOS,Apple M1,Go 1.27):

50 个特征:串行 116.5ms,按源并行+批量 80.0ms
瓶颈:下游服务 10 次 × 8ms = 80ms(必须合并接口或预取)

=== 特征新鲜度预算 ===
  用户长期兴趣                     更新 天级       最大陈旧  86400s
  用户实时行为(近 1 小时)             更新 分钟级      最大陈旧    300s
  物品统计(点击率)                  更新 小时级      最大陈旧   3600s
  库存/价格                      更新 实时       最大陈旧      1s
  会话内上下文                     更新 请求级      最大陈旧      0s

=== 线上线下不一致的四个来源 ===
1) 离线用批处理(T+1 聚合),在线用实时累加 → 分布不同
2) 离线用未来数据(滑窗跨越了预测点)→ 特征泄漏
3) 在线缺失时填 0,离线缺失时填均值 → 同一缺失两种语义
4) 时间口径不同(UTC vs 本地、事件时间 vs 处理时间)

读数:

方式 延迟 说明
串行逐个取 116 ms 直接超出 20ms 预算 5 倍
按源并行 + 批量 80 ms 瓶颈变成“10 次下游服务调用”
合并下游接口 8 ms 10 次调用合成 1 次批量

🔑 特征取数的三条铁律:能批量就批量(MGET/批量 RPC)、能并行就并行(按源分组)、下游能合并就合并(一次拿 10 个物品的库存)。三件事做完,50 个特征的延迟可以从 116ms 压到 10ms 量级。

线上线下一致性:最贵的坑

坑 现象 解法
特征口径不同 离线 AUC 0.85,线上 CTR 不涨 特征平台统一计算逻辑(离线/在线同一份代码或同一份数据)
特征泄漏 离线 AUC 异常高(0.99) 严格按“预测时间点”截断,滑窗不跨未来
缺失值处理不同 线上稀疏特征偏差 同一套缺失填充规则
时间口径不同 大促当天特征错位 统一事件时间 + 时区

⚠️ 最隐蔽的是特征泄漏:训练时用了“未来才知道”的数据(比如用最终成交金额算的特征去预测是否成交),离线指标会漂亮到不真实(AUC 0.99)。上线后特征拿不到,效果立刻回落。

反模式

反模式 后果
精排同步调 20 个下游 P99 抖动,整条链路超时
特征不做批量,逐个 GET 30 个特征 30 次往返
离线/在线两套代码算同一特征 分布漂移,线上线下不一致
缺失值随意填(0/均值混用) 模型学到错误信号
没有特征监控 特征断更几天才发现
特征值不带时间戳 无法判断新旧,回滚困难

自检

Q1 为什么“特征离线算、在线再算一遍”是最危险的做法?

▸ 答案:因为两套实现几乎不可能完全一致:聚合窗口、缺失值处理、时间口径、浮点精度、更新频率都会不同。训练时模型学到的是离线分布,线上喂进去的是另一套分布,结果就是“离线涨点、线上不涨”甚至下降。正确做法是同一份逻辑(特征平台统一计算,或离线直接读在线特征库的快照)。

Q2 50 个特征串行 116ms,为什么并行后还有 80ms?

▸ 答案:因为“下游服务”这一类不支持批量,10 次调用 × 8ms 串行就是 80ms,并行也只能把它们并行(10 路并发 → 8ms,但要 10 个连接且下游扛得住)。真正有效的是合并接口:一次 RPC 拿 10 个物品的库存/价格,把 10 次变成 1 次。批量与合并是特征延迟优化的主要手段,不是“加机器”。

Q3 特征泄漏怎么在离线评估阶段发现?

▸ 答案:三个信号:① 离线 AUC/NDCG 异常高(如 0.95+),远超业务常识;② 去掉某个特征后指标大幅下降,且该特征在预测时刻本不可得;③ 用“严格时间切分”重训后指标显著回落。工程上用“特征时间戳 ≤ 样本时间戳”做硬校验,并在特征平台里标注每个特征的“可用时点”。

关联