2. 实时与批量特征:两种生产模式对比、延迟与吞吐量权衡
做特征平台这些年,我见过不少团队在「实时还是批量」这个问题上反复纠结。说实话,没有银弹。两种模式各有各的脾气,选错了,后面运维起来真是欲哭无泪。
这一章,咱们就把这两种生产模式掰开揉碎,看看它们到底差在哪,以及怎么在延迟和吞吐量之间找到那个平衡点。
2.1 批量特征生产模式
批量特征,说白了就是「定时算好,存起来等用」。我最早接触特征工程时,用的就是这套路子。
核心流程
- 数据源:通常是 Hive 表、离线数仓、或者日志文件。
- 调度触发:按天/小时跑 Spark 或 Hive 任务。
- 特征计算:做聚合、Join、窗口函数。
- 结果写入:写到 KV 存储(如 Redis、HBase)或离线表。
- 线上读取:模型服务通过 Key 直接查。
典型场景:用户画像标签、历史统计特征(近7天点击率)、CTR 模型的基础特征。
优点
- 吞吐量极高:Spark 集群一跑,几十亿条数据轻松搞定。
- 逻辑简单:回溯、重算、调试都很方便。
- 成本可控:离线计算资源便宜,按需调度。
缺点
- 延迟高:至少 T+1,甚至小时级。用户刚发生的动作,要等下次调度才能反映到特征里。
- 数据漂移:调度时间和事件时间对不上,容易算错。
我的经验:批量特征适合「变化慢、依赖历史长」的场景。比如用户的性别、年龄、会员等级,这些没必要实时算。
2.2 实时特征生产模式
实时特征,就是「事件一来,立刻算,立刻用」。我记得第一次做实时特征项目时,被 Flink 的状态管理和 exactly-once 语义折腾得不轻。
核心流程
- 数据源:Kafka 消息队列,事件流。
- 流式计算:Flink/Spark Streaming 做滑动窗口、计数、去重。
- 状态存储:RocksDB 或内存状态。
- 结果推送:写入 Redis/内存网格,或直接推给模型服务。
- 线上读取:毫秒级响应。
典型场景:实时点击率、用户实时行为序列、风控中的设备指纹特征。
优点
- 延迟极低:秒级甚至毫秒级。
- 时效性强:用户刚点了一下,特征立刻更新。
- 支持滑动窗口:比如「过去5分钟点击次数」,批量模式很难做到。
缺点
- 吞吐量受限:单节点处理能力有限,需要做分区和扩容。
- 状态管理复杂:窗口过期、状态快照、故障恢复,都是坑。
- 成本高:实时计算资源贵,运维复杂度高。
避坑指南:我曾经在一个项目中,把「用户实时行为序列」全量存到 Redis,结果内存直接爆了。后来改成只保留最近100条,才稳住。
2.3 延迟与吞吐量的权衡
你想想看,延迟和吞吐量本质上是一对矛盾。想要延迟低,就得牺牲吞吐量;想要吞吐量高,就得接受延迟。
为什么会这样?因为实时计算需要保持状态,状态越多,处理越慢。批量计算可以分片并行,吞吐自然高。
对比表格
| 维度 | 批量特征 | 实时特征 |
|---|---|---|
| 延迟 | 分钟~小时级 | 毫秒~秒级 |
| 吞吐量 | 极高(可扩展) | 中等(受状态限制) |
| 计算引擎 | Spark、Hive、MapReduce | Flink、Spark Streaming、Kafka Streams |
| 存储 | HDFS、Hive 表 | Redis、内存网格、RocksDB |
| 数据一致性 | 最终一致(容易回溯) | 精确一次(实现复杂) |
| 运维成本 | 低 | 高 |
| 适用特征 | 历史统计、画像标签 | 实时行为、滑动窗口 |
如何选择?
我个人习惯按「特征新鲜度要求」来分:
- 新鲜度要求 < 1小时:用实时模式。
- 新鲜度要求 1小时~1天:用微批(Mini-batch),比如每5分钟跑一次 Spark。
- 新鲜度要求 > 1天:用批量模式,省心省力。
小技巧:很多团队会做「Lambda 架构」——批量算全量,实时算增量,最后合并。但要注意,合并逻辑容易出 bug,我建议尽量用纯实时或纯批量,别搞混搭。
2.4 核心知识结构图
下面这张图,帮你把两种模式的核心差异和权衡关系理清楚。
2.5 混合策略:Lambda 架构
嗯,这里要注意。虽然我前面说尽量别搞混搭,但现实项目中,Lambda 架构还是很常见的。
简单来说:
- 批量层:每天算全量特征,保证数据完整。
- 实时层:实时算增量,保证新鲜度。
- 合并层:把批量结果和实时结果合并,输出最终特征。
我曾经踩过的坑:合并逻辑里,实时增量覆盖批量数据时,时间窗口没对齐,导致特征值跳变。后来加了「版本号」和「时间戳校验」才解决。
代码示例:Flink 实时窗口聚合
// 实时特征:过去5分钟用户点击次数
DataStream<ClickEvent> clicks = env.addSource(kafkaConsumer);
clicks
.keyBy(event -> event.getUserId())
.window(SlidingProcessingTimeWindows.of(
Time.minutes(5), // 窗口大小
Time.minutes(1))) // 滑动步长
.aggregate(new CountAggregate())
.map(count -> {
// 写入 Redis,供模型服务读取
redisClient.set("user:click:5min:" + count.getUserId(),
String.valueOf(count.getCount()));
return count;
});
个人建议:如果团队实时能力不强,先用「微批」过渡。比如每1分钟跑一次 Spark 任务,效果接近实时,但运维简单得多。
好了,这一章的核心就是:批量重吞吐,实时重延迟。选型时别贪心,想清楚你的特征到底需要多新鲜,再决定走哪条路。