3. 特征工程与实时特征:离线特征与实时特征的区别、实时特征计算范式
在构建低延迟特征服务时,首先需要厘清离线特征与实时特征的本质差异。两者并非简单的“快与慢”之分,而是在数据源、计算时机、存储介质、一致性模型和更新频率上存在系统性区别。理解这些差异,是设计实时特征计算范式的前提。
3.1 离线特征与实时特征的核心区别
下表从六个关键维度对比两种特征的生产方式与使用场景:
| 对比维度 | 离线特征 | 实时特征 |
|---|---|---|
| 数据源 | Hive / HDFS / 离线数仓(T+1 分区) | Kafka / Pulsar / 实时消息流 |
| 计算时机 | 定时调度(每日/每小时批处理) | 事件到达即触发(毫秒级延迟) |
| 存储介质 | HBase / Redis 冷数据 / 离线 KV 存储 | Redis / 内存状态 / 在线 KV 存储 |
| 一致性模型 | 最终一致性(可重跑、可回溯) | 事件时间精确一次(Exactly-Once) |
| 更新频率 | 分钟级 ~ 天级 | 毫秒级 ~ 秒级 |
| 典型场景 | 用户画像标签、历史统计特征 | 实时点击率、滑动窗口均值、最近行为序列 |
架构决策提示: 在低延迟特征服务中,80% 的特征可以离线预计算,只有对时效性要求严格(P99 < 100ms)的特征才需要实时计算。切勿将所有特征都推入实时链路,否则会显著增加运维复杂度和资源成本。
3.2 实时特征计算范式
实时特征的计算模式可以归纳为三种基本范式:滑动窗口、计数、最近 N 条。这三种范式覆盖了绝大多数在线推理场景的特征需求,且都能在 Flink 中以低延迟方式实现。
3.2.1 滑动窗口(Sliding Window)
滑动窗口是实时特征中最常用的时间聚合模式。它按固定的窗口长度和滑动步长,持续计算最近一段时间内的统计量(均值、最大值、最小值、标准差等)。
- 窗口长度:决定特征的时间跨度,例如“过去 1 小时”。
- 滑动步长:决定特征的更新粒度,例如“每 5 分钟滑动一次”。
- 适用场景:实时监控指标、动态阈值、趋势检测。
以下是一个 Flink SQL 实现滑动窗口均值的示例:
-- 计算每个用户过去 1 小时、每 5 分钟滑动的平均交易金额
CREATE VIEW sliding_avg_feature AS
SELECT
user_id,
AVG(amount) AS avg_amount_1h,
HOP_START(event_time, INTERVAL '5' MINUTE, INTERVAL '1' HOUR) AS window_start,
HOP_END(event_time, INTERVAL '5' MINUTE, INTERVAL '1' HOUR) AS window_end
FROM transaction_stream
GROUP BY
user_id,
HOP(event_time, INTERVAL '5' MINUTE, INTERVAL '1' HOUR);
3.2.2 计数(Count-Based Feature)
计数特征不依赖时间窗口,而是基于事件发生的次数进行聚合。它可以是全局计数、分组计数或条件计数。
- 全局计数:例如“用户总登录次数”。
- 分组计数:例如“用户今日在某个商品类目下的点击次数”。
- 条件计数:例如“用户最近 10 次交易中失败次数”。
实现要点: 在 Flink 中,计数特征通常使用
ValueState<Long> 或 MapState<String, Long> 来维护。注意在状态后端启用增量 checkpoint,避免大状态导致恢复时间过长。
3.2.3 最近 N 条(Last N Records)
最近 N 条特征保留用户或实体最近发生的 N 个事件,用于序列建模或行为序列特征。它不进行聚合,而是保留原始事件的有序列表。
- 典型输出:最近 10 次点击的商品 ID 列表、最近 5 次搜索的 query 序列。
- 存储结构:Flink 中使用
ListState<Event>或定长循环队列。 - 注意事项:需要设置最大长度限制,防止状态无限增长。
// Flink DataStream API 实现最近 N 条特征
DataStream<UserEvent> input = ...;
input
.keyBy(event -> event.getUserId())
.process(new KeyedProcessFunction<Long, UserEvent, LastNFeature>() {
private transient ListState<UserEvent> lastNState;
@Override
public void open(Configuration parameters) {
ListStateDescriptor<UserEvent> descriptor =
new ListStateDescriptor<>("last-n", UserEvent.class);
lastNState = getRuntimeContext().getListState(descriptor);
}
@Override
public void processElement(UserEvent event, Context ctx, Collector<LastNFeature> out) {
// 添加新事件
lastNState.add(event);
// 维护固定长度(例如 N=10)
Iterable<UserEvent> allEvents = lastNState.get();
List<UserEvent> eventList = new ArrayList<>();
allEvents.forEach(eventList::add);
if (eventList.size() > 10) {
// 移除最旧的事件
eventList.remove(0);
lastNState.update(eventList);
}
// 输出特征
out.collect(new LastNFeature(event.getUserId(), eventList));
}
});
3.3 三种范式的选择矩阵
在实际项目中,需要根据特征的计算逻辑和延迟要求选择合适的范式:
| 特征需求 | 推荐范式 | Flink 核心 API | 状态类型 |
|---|---|---|---|
| 过去 1 小时平均响应时间 | 滑动窗口 | SQL HOP / Window API | ReducingState / AggregatingState |
| 用户今日登录次数 | 计数 | ValueState / MapState | ValueState<Long> |
| 最近 5 次购买的商品 ID | 最近 N 条 | ListState / 自定义循环队列 | ListState<Event> |
| 过去 10 分钟点击率(CTR) | 滑动窗口 + 计数 | SQL HOP + COUNT / SUM | AggregatingState |
3.4 实时特征计算架构总览
下图展示了三种计算范式在 Flink 实时特征服务中的整体架构与数据流转关系:
3.5 范式组合与实战建议
在实际的低延迟特征服务中,三种范式往往不是孤立使用的。以下是一些常见的组合模式:
- 滑动窗口 + 计数:计算“过去 5 分钟用户点击次数”,本质是滑动窗口内的计数聚合。
- 最近 N 条 + 滑动窗口:保留最近 100 条事件,同时计算其中在“过去 1 小时”内的事件均值。
- 计数 + 条件过滤:只对满足特定条件的事件进行计数,例如“交易金额 > 1000 的次数”。
性能警告: 当使用最近 N 条范式且 N 较大(如 N > 1000)时,ListState 的序列化/反序列化开销会显著增加。建议对 N 设置上限,并考虑使用
MapState<Long, Event> 配合时间戳索引来优化访问性能。