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+),远超业务常识;② 去掉某个特征后指标大幅下降,且该特征在预测时刻本不可得;③ 用“严格时间切分”重训后指标显著回落。工程上用“特征时间戳 ≤ 样本时间戳”做硬校验,并在特征平台里标注每个特征的“可用时点”。