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 实时特征服务中的整体架构与数据流转关系:

实时特征计算架构总览 实时数据源 Kafka / Pulsar Flink 实时计算引擎 事件时间处理 | 状态管理 | 精确一次语义 KeyedProcessFunction / SQL / DataStream API 范式一:滑动窗口 HOP / TUMBLE 窗口 时间聚合统计 ReducingState 范式二:计数 ValueState / MapState 分组计数 / 条件计数 增量更新 范式三:最近 N 条 ListState / 循环队列 定长序列维护 FIFO 淘汰策略 实时特征输出 Redis / 在线 KV 存储 / 特征服务 API 架构要点: • 三种范式可混合使用:例如滑动窗口内嵌套计数,或最近 N 条中提取计数特征。 • 状态后端建议使用 RocksDB + 增量 checkpoint,以支持大状态场景下的低延迟恢复。

3.5 范式组合与实战建议

在实际的低延迟特征服务中,三种范式往往不是孤立使用的。以下是一些常见的组合模式:

  • 滑动窗口 + 计数:计算“过去 5 分钟用户点击次数”,本质是滑动窗口内的计数聚合。
  • 最近 N 条 + 滑动窗口:保留最近 100 条事件,同时计算其中在“过去 1 小时”内的事件均值。
  • 计数 + 条件过滤:只对满足特定条件的事件进行计数,例如“交易金额 > 1000 的次数”。
性能警告: 当使用最近 N 条范式且 N 较大(如 N > 1000)时,ListState 的序列化/反序列化开销会显著增加。建议对 N 设置上限,并考虑使用 MapState<Long, Event> 配合时间戳索引来优化访问性能。