推荐系统推理服务化:特征抽取和模型推理拆成独立微服务
推荐系统推理服务化特征抽取和模型推理拆成独立微服务一、单体推理服务的性能天花板特征与推理耦合引发的连锁故障推荐系统上线初期绝大多数团队的做法是把特征抽取和模型推理塞进同一个服务进程。请求进来 → 查用户画像 → 查物品特征 → 拼特征向量 → 调用模型 → 返回分数一条链路走完开发简单、部署也简单。但当 QPS 从几千涨到几十万问题就接踵而至。第一个问题是资源错配。特征抽取是 IO 密集型操作大量时间消耗在 Redis 查询和特征拼接上模型推理是计算密集型操作吃 GPU 或 CPU SIMD 指令。两种负载混在一个进程里IO 线程池和计算线程池互相抢占二者都跑不满。实测数据表明在混合部署模式下GPU 利用率常年在 30% 左右徘徊而 P99 延迟却比分离部署高出 40%。第二个问题是故障爆炸半径。特征服务挂了推理也跟着挂模型更新导致 OOM特征查询也一并超时。把两个生命周期完全不同的逻辑耦合在一起任何一个环节出问题都会拖垮整个链路。基础设施不需要漂亮话——这种耦合架构在压测时数据很诚实单点故障会让整个推荐链路的可用性从 99.9% 跌到 99.5%。第三个问题是迭代效率。特征工程团队改一个特征处理逻辑需要和模型团队协调发版窗口模型团队要上线新模型又担心影响到特征层的稳定性。两人的代码在一个仓库里纠缠发布节奏互相阻塞。二、拆分后的架构特征服务专注 IO、推理服务专注 GPU拆分的核心思想是按资源类型解耦。特征服务作为纯 IO 型微服务部署在 CPU 节点上内存配置充足以满足本地缓存需求通过 gRPC 或 HTTP 对外暴露特征查询接口。推理服务部署在 GPU 节点上只接收已经拼接好的特征向量负责模型前向计算并返回分数。特征服务内部需要做三件事特征查询、特征处理和特征缓存。特征查询通过多级缓存加速——本地 LRU 缓存命中率约 80%→ Redis 集群命中率约 15%→ 离线特征存储兜底。特征处理包括缺失值填充、归一化、离散特征 Embedding 查表等。所有操作都在 CPU 上完成不涉及 GPU 调用。推理服务的职责更纯粹加载模型 → 接收特征向量 → 前向传播 → 返回分数。模型通过 TensorFlow Serving 或 Triton Inference Server 管理支持多模型版本共存和动态加载。推理服务和特征服务之间通过 Protocol Buffers 定义接口契约两个团队的迭代互不干扰。这种架构的核心收益是资源利用率最大化。特征服务的 CPU 使用率可以稳定在 60-70%推理服务的 GPU 使用率可以稳定在 80% 以上。二者不再互相拖累。三、生产级 gRPC 接口设计与并发控制特征服务和推理服务之间的通信协议选型gRPC 是比 HTTP/JSON 更好的选择。推荐场景的特征向量通常是稠密浮点数组Protocol Buffers 的二进制编码比 JSON 节省 60% 以上的传输带宽。以下是特征服务的 gRPC 接口定义syntax proto3; service FeatureService { // 批量获取用户特征支持多用户并发查询 rpc GetUserFeatures(UserFeatureRequest) returns (UserFeatureResponse); // 批量获取物品特征 rpc GetItemFeatures(ItemFeatureRequest) returns (ItemFeatureResponse); } message UserFeatureRequest { repeated string user_ids 1; repeated string feature_names 2; // 按需查询减少无效传输 int32 timeout_ms 3; // 超时由调用方控制 } message UserFeatureResponse { mapstring, FeatureVector features 1; } message FeatureVector { repeated float values 1; int32 dimension 2; }推理服务侧使用 Go 的 goroutine 实现并发调用单个推荐请求可能涉及上百个物品的特征查询。采用errgroup控制并发度避免瞬间打爆特征服务import golang.org/x/sync/errgroup func (s *InferenceService) BatchInfer(ctx context.Context, items []string, userVec []float32) ([]float32, error) { g, ctx : errgroup.WithContext(ctx) g.SetLimit(20) // 最大并发20防止打满特征服务连接池 itemFeatures : make([][]float32, len(items)) for i, itemID : range items { i, itemID : i, itemID // 避免闭包变量捕获问题 g.Go(func() error { // 调用特征服务获取物品特征 resp, err : s.featureClient.GetItemFeatures(ctx, pb.ItemFeatureRequest{ ItemIds: []string{itemID}, FeatureNames: s.config.ItemFeatureNames, TimeoutMs: 50, // 特征服务超时50ms }) if err ! nil { // 特征服务超时不是致命错误使用默认特征兜底 itemFeatures[i] s.defaultItemVec return nil } itemFeatures[i] resp.Features[itemID].Values return nil }) } if err : g.Wait(); err ! nil { return nil, fmt.Errorf(batch infer: %w, err) } return s.modelInfer(ctx, userVec, itemFeatures) }关键设计点特征服务调用失败不中断整个请求而是使用默认特征向量兜底——这确保了推荐服务在特征链路抖动时仍能返回结果只是质量可能有所下降。p50 延迟下推荐结果的差异通常在 5% 以内但可用性从 99.5% 提升到了 99.95%。四、拆分不全等于银弹网络开销和一致性的代价拆分带来了网络延迟的额外开销。单体模式下特征查询是函数调用纳秒级延迟拆分后变成 RPC 调用毫秒级延迟。单次调用增加 1-2ms 看起来不大但一次推荐请求可能涉及 200 个物品的特征查询如果串行调用就是 200-400ms 的增量。解决方案是批量查询 并行调用。特征服务接口设计为批量模式一次传多个 ID推理服务侧用 goroutine 并发调用将总延迟控制在 10ms 以内。但这也带来了新的复杂度批量大小需要仔细权衡——太大了特征服务的响应会变慢太小了网络往返次数太多。另一个代价是特征一致性。特征服务和推理服务分离后特征更新和模型更新是两条独立的流水线。可能出现特征已经更新了新版本但模型还在用旧版本的特征分布做推理的情况。这会导致推荐效果波动。解决方式是特征版本管理特征数据带上版本号推理服务在请求特征时声明期望的版本。特征服务如果发现本地版本不匹配会强制回源拉取对应版本数据。还有一个容易被忽视的代价是运维复杂度翻了倍。多一套服务意味着多一套监控、多一套告警、多一套发布流水线。如果团队规模小5 人以下拆分带来的收益可能抵不过运维成本的增加。原则上QPS 低于 5000 时单体架构完全够用。五、总结推荐系统推理链路从单体拆分为特征服务和推理服务两个独立微服务核心动机是资源错配和故障隔离。特征服务是 IO 密集型应该部署在 CPU 节点上做缓存和特征处理推理服务是计算密集型应该独占 GPU 资源做模型前向计算。工程落地时gRPC Protocol Buffers 是推荐的通信方案批量查询接口配合并发调用可以将网络延迟控制在可接受范围内。特征查询失败使用默认向量兜底牺牲少量推荐质量换取可用性提升。拆分不是万能的。QPS 低于 5000 的团队没必要引入这份复杂度。网络延迟、特征一致性和运维成本是拆分必须支付的三笔账在做架构决策前需要结合自身业务规模认真评估。架构选择的核心原则是选择与你当前规模匹配的复杂度不要让架构跑在业务前面。