
Go 推荐引擎特征平台在线特征和离线特征要分开管一、一张表存所有特征在线和离线混管的三个坑刚开始搭推荐引擎时很多团队会把所有特征扔进一张 Hive 表或一个 Redis 集群里。用户年龄、性别这类静态特征和最近 5 分钟点击次数这类实时特征混在一起统一管理看似省事跑起来全是坑。第一个坑是数据新鲜度错位。离线特征通过 Spark 批处理每天凌晨产出一次在线特征需要秒级更新。如果都放在同一个 Redis 里批处理任务写 Key 时会锁住相同的 Key在线写入被阻塞。流量高峰期延迟从 1ms 飙到 50ms 以上推荐效果直接打折。某电商平台实测特征 Redis 的 p99 延迟在凌晨 2-3 点批处理期间从 2ms 跳升到 87ms恰好是海外用户活跃时段。第二个坑是存储成本失控。离线特征数据量巨大——全量用户画像、全量物品 Embedding 向量动辄 TB 级别。这些数据放到 Redis 里内存成本按 1GB/月 30 元算TB 级别就是每月 3 万元往上走。但离线特征根本不需要常驻内存90% 的数据在两次批处理之间不会被访问。第三个坑是特征语义混淆。用户 30 天点击品类偏好和用户最近 5 分钟点击品类偏好在代码里都叫category_preference上线后才发现前者是离线 T1 的数据后者是实时统计的数据。混在一个系统里特征消费者根本不知道拿到的数据是新鲜的还是过期的。二、在线 100ms、离线 1TB两个通路的设计差异在线特征管道的核心诉求是低延迟和高吞吐。数据源是 Kafka 实时流计算引擎用 Flink 做窗口聚合滚动窗口 5 分钟、滑动窗口 30 分钟结果写入 Redis 集群。Redis 数据结构选型有讲究计数类特征点击次数、曝光次数用 HashKey 是user:{uid}:counterField 是特征名Value 是计数值支持 HINCRBY 原子递增列表类特征最近 N 次点击商品 ID用 ListLPUSH LTRIM 保持固定长度集合类特征去重兴趣标签用 Set 或 HyperLogLog 做近似去重。存储选型上在线特征用 Redis 是必然选择——所有操作都在微秒级完成。但全量用户特征不能都放 Redis内存会爆炸。实战方案是热点数据驻内存只把过去 7 天活跃用户的特征放在 Redis 里不活跃用户回源 HBase 查询。活跃用户占比通常 20%-30%Redis 的存储量直接减少 70% 以上。离线特征管道管的是天级/小时级产出的特征。数据源是 HDFS 上的埋点日志用 Spark SQL 做聚合计算结果写入 HBase 或 Cassandra。为什么不用 Redis因为离线特征数据量是 TB 级的——全量用户的 30 天行为统计、物品的长期画像这些不需要毫秒级访问10ms 级别的 HBase 读取完全够用。两张表的边界画清楚以后特征服务的逻辑就简单了收到请求 → 先从 Redis 拿在线特征 → 再从 HBase 拿离线特征 → 按维度拼接 → 返回。同一个特征维度如用户品类偏好在线版本和离线版本用不同字段区分category_pref_online最近 5 分钟和category_pref_offline最近 30 天特征语义一目了然。三、Go 特征服务并发查询与超时熔断的实现特征服务是典型的 IO 密集型微服务Go 的 goroutine 并发模型天然适合这类场景。一次请求可能需要从 Redis在线特征、HBase离线特征、Embedding 服务向量特征三个来源获取数据用errgroup并发发起type FeatureResult struct { Online map[string]float64 Offline map[string]float64 Embed []float32 } func (s *FeatureService) GetUserFeatures(ctx context.Context, uid string) (*FeatureResult, error) { g, ctx : errgroup.WithContext(ctx) result : FeatureResult{} // 并发查询在线特征Redis超时 30ms g.Go(func() error { onlineCtx, cancel : context.WithTimeout(ctx, 30*time.Millisecond) defer cancel() features, err : s.redisClient.HGetAll(onlineCtx, user:uid:online) if err ! nil { if errors.Is(err, context.DeadlineExceeded) { // Redis 超时不致命降级为空特征 s.metrics.IncRedisTimeout(online) return nil } return fmt.Errorf(redis online features: %w, err) } result.Online features return nil }) // 并发查询离线特征HBase超时 100ms g.Go(func() error { offlineCtx, cancel : context.WithTimeout(ctx, 100*time.Millisecond) defer cancel() features, err : s.hbaseClient.Get(offlineCtx, user_features, uid) if err ! nil { if errors.Is(err, context.DeadlineExceeded) { s.metrics.IncHBaseTimeout(offline) return nil // 离线特征也可以降级 } return fmt.Errorf(hbase offline features: %w, err) } result.Offline features return nil }) // 并发查询 Embedding 向量 g.Go(func() error { embedCtx, cancel : context.WithTimeout(ctx, 50*time.Millisecond) defer cancel() vec, err : s.embedClient.GetUserEmbedding(embedCtx, uid) if err ! nil { s.metrics.IncEmbedError() return nil // Embedding 缺失不是致命错误 } result.Embed vec return nil }) if err : g.Wait(); err ! nil { return nil, err } return result, nil }关键设计点每个数据源的超时时间独立设置。Redis 在线特征 30ms 超时打不中就降级HBase 离线特征 100ms 超时允许稍慢Embedding 服务 50ms 超时。任何一个源挂了其他源继续工作返回不完整但有价值的特征——推荐效果可能下降几个点但推荐服务不会直接不可用。四、新鲜度 vs 一致性的取舍在线特征不是银弹把在线和离线特征分开管解决了新鲜度和成本的问题但也引入了一个新矛盾在线特征和离线特征可能打架。举个例子用户 A 过去 30 天主要看母婴类内容离线特征标记他的品类偏好是母婴。但最近 5 分钟他连续点击了数码类商品在线特征显示当前偏好是数码。离线推荐给母婴实时推荐给数码两个信号矛盾时听谁的这是一个工程问题而非算法问题。实践中采用分层权重离线特征权重 0.3在线特征权重 0.7。为什么在线权重更高因为在推荐场景下近期的行为信号比历史信号更能预测下一步的点击概率。AB 测试数据表明这个权重比例在点击率上提升了 12%但也带来了一个新问题偶尔会推荐出与用户长期兴趣完全不符的内容导致用户困惑。另一个边界条件是在线特征的准确性问题。基于 Flink 窗口计算的实时特征在窗口边界处可能出现数据重复或丢失。5 分钟滚动窗口里如果事件时间戳有漂移同一事件可能跨两个窗口各计一次。解决方案是加一层去重——对事件 ID 做 Bloom Filter 判重虽然牺牲了一点精度误判率 1%但避免了明显的重复计数错误。五、总结推荐引擎的特征管理必须把在线和离线两条管道分开核心原因是新鲜度要求不同、存储成本约束不同、数据量级不同。在线特征走 Kafka → Flink → Redis追求秒级更新和毫秒级读取离线特征走 HDFS → Spark → HBase接受 T1 延迟但换取了 TB 级存储和低成本。Go 特征服务实现时使用errgroup并发查询多个特征源每个源独立设置超时和降级策略确保部分特征缺失比整个请求挂掉更好。超时控制、熔断降级和监控埋点是特征服务稳定性的三道防线——基础设施不需要漂亮话但少一个防线线上就会多出一个告警。架构层面在线特征和离线特征的权重分配、一致性冲突处理、实时计算精度取舍这些不是纯技术问题而是需要结合业务指标持续调优的工程决策。选择分离双管道只是第一步真正的挑战在于让两条管道在特征层面实现语义上的对齐。