4、特征生产管道:从数据源接入到特征计算,构建端到端的实时管道

实时特征生产,说白了就是一条数据流水线。数据从源头流进来,经过清洗、加工、计算,最后变成可用的特征。这条管道如果设计不好,后面整个特征资产化就是空中楼阁。

我刚开始做实时特征时,犯过一个低级错误。当时觉得管道嘛,连起来就行。结果上线第一天,数据源一波动,整条管道全堵死了。嗯,从那以后,我对管道设计再也不敢马虎。

4.1 管道整体架构:三层模型

我个人习惯把实时特征管道拆成三层:接入层、处理层、输出层。每一层各司其职,互不干扰。

核心原则:每一层只做一件事,层与层之间通过缓冲区解耦。

实时特征管道三层架构 接入层 Kafka / Pulsar / Flume 数据源接入 · 协议转换 · 流量削峰 处理层 Flink / Spark Streaming 数据清洗 · 特征计算 · 状态管理 输出层 Redis / HBase / ClickHouse 特征存储 · 在线服务 · 离线回填

4.2 接入层:数据源怎么接?

接入层是管道的入口。数据源五花八门,有消息队列、数据库CDC、日志文件、甚至HTTP接口。你想想看,如果接入层不稳定,后面再好的计算逻辑也是白搭。

我建议统一使用消息队列作为缓冲层。不管数据源是什么,先扔进Kafka或Pulsar。这样做有几个好处:

  • 解耦数据源和计算层——数据源挂了,计算层不受影响
  • 流量削峰——突发流量先积压在队列里,计算层慢慢消费
  • 回溯重放——出问题时可以从某个offset重新消费

实战经验:我在项目中遇到过数据源突然暴增10倍流量。幸好前面挂了Kafka,消息积压了但没丢数据。等我们扩容完消费者,慢慢追上了进度。如果没有缓冲层,那天的特征管道就全崩了。

接入层还需要做一件事:协议转换。不同数据源的格式千差万别,JSON、Avro、Protobuf、CSV……统一转换成内部Schema再往下游推。我习惯在接入层做一次轻量校验,格式不对的直接发到死信队列,不污染下游。

4.3 处理层:特征计算的核心战场

处理层是整条管道最复杂的地方。这里要完成数据清洗、特征计算、状态维护三件事。

4.3.1 数据清洗

数据进来第一件事,不是算特征,而是洗数据。脏数据会毁掉所有特征。常见的清洗操作包括:

  • 去重——同一事件被重复发送,需要根据事件ID去重
  • 补缺——缺失字段用默认值或历史值填充
  • 格式标准化——时间戳统一成毫秒级Unix时间戳
  • 异常过滤——明显不合理的数据直接丢弃

注意:清洗逻辑不要写死在代码里。我建议把清洗规则做成可配置的,用规则引擎或配置中心管理。这样改规则不用重启管道。

4.3.2 特征计算模式

特征计算分两种模式:无状态计算和有状态计算。

计算模式 典型场景 实现方式 注意事项
无状态 字段映射、简单变换 Map操作 吞吐高,但功能有限
有状态 滑动窗口、累计计数 KeyedState + Timer 需要管理状态大小和过期
关联计算 实时关联维表 AsyncIO + 缓存 注意维表更新频率

举个例子,计算用户过去5分钟的点击次数。这就是典型的有状态计算。Flink里用KeyedState保存每个用户的点击计数,再配合Timer在窗口结束时输出结果。

// Flink 滑动窗口特征计算示例
DataStream<ClickEvent> clicks = env.addSource(kafkaConsumer);

clicks
    .keyBy(event -> event.getUserId())
    .window(SlidingProcessingTimeWindows.of(
        Time.minutes(5),   // 窗口大小
        Time.minutes(1)    // 滑动步长
    ))
    .aggregate(new ClickCountAggregator())
    .map(clickCount -> {
        // 输出特征:userId + 窗口时间 + 点击次数
        return new Feature("user_click_count_5min",
            clickCount.getUserId(),
            clickCount.getCount());
    });

这段代码看着简单,但实际生产中有很多坑。我曾经遇到过状态无限膨胀的问题——用户量太大,KeyedState撑爆了内存。后来加了TTL(Time-To-Live)才解决。

避坑指南:有状态计算一定要设置状态过期时间。Flink里用StateTtlConfig配置,默认不过期。我建议根据业务场景设置,比如窗口类特征的状态过期时间设为窗口长度的2倍。

4.3.3 维表关联

很多特征需要关联维表。比如用户画像特征,需要实时查询用户的基础信息。直接查数据库?不行,延迟太高。

我常用的方案是:维表缓存 + 异步IO。把维表数据加载到本地缓存,Flink用AsyncIO异步查询。缓存没命中时再回源查数据库,同时异步更新缓存。

// 异步维表关联示例
DataStream<UserEvent> events = ...;
DataStream<UserProfile> profiles = AsyncDataStream
    .unorderedWait(events, new AsyncUserProfileFunction(), 
        1000, TimeUnit.MILLISECONDS, 100);

// AsyncUserProfileFunction 内部实现
// 1. 查本地缓存
// 2. 缓存命中直接返回
// 3. 缓存未命中,异步查Redis/MySQL
// 4. 结果写入缓存,返回下游

4.4 输出层:特征去哪了?

特征计算完,要送到该去的地方。输出层有三个出口:

  • 在线服务——写入Redis或HBase,供实时推荐、风控等场景使用
  • 离线回填——写入HDFS或ClickHouse,供离线训练和报表使用
  • 实时监控——写入Kafka,供下游监控系统消费

输出层最容易忽略的是一致性。同一个特征,在线和离线两个出口的值可能不一样。为什么?因为写入时间点不同,或者计算逻辑有细微差异。

我的做法:在线和离线共用同一套计算逻辑,输出时打上相同的版本号。这样两边对账时,版本号一致的特征值应该完全一样。如果不一样,说明管道有问题。

4.5 管道监控:看不见的守护者

管道建好了,不代表万事大吉。实时管道随时可能出问题:数据延迟、计算背压、状态异常……没有监控,出了问题你都不知道。

我建议至少监控以下指标:

指标 监控方式 告警阈值
数据延迟 事件时间 - 处理时间 > 30秒告警
消费积压 Kafka Lag > 10000条告警
计算背压 Flink WebUI 背压比率 > 0.8
状态大小 State Size > 1GB告警
输出成功率 写入成功/失败计数 成功率 < 99%

我曾经遇到过一个问题:管道运行正常,但特征值一直不对。查了半天,发现是维表更新了,但缓存没刷新。从那以后,我加了一个监控——维表版本号对比。缓存里的版本号和数据库里的版本号不一致,立刻告警。

血的教训:不要只监控管道是否在运行,还要监控数据质量。管道在跑,但跑出来的全是脏数据,比管道停了更可怕。

4.6 总结:管道设计的几个原则

说了这么多,总结几条我自己的经验:

  1. 分层解耦——接入、处理、输出三层分开,每层独立扩缩容
  2. 缓冲为王——层与层之间用消息队列缓冲,防止级联故障
  3. 状态可控——有状态计算一定要设TTL,定期清理
  4. 监控全覆盖——不光监控管道健康,还要监控数据质量
  5. 可回溯——每条特征都能追溯到原始数据,方便排查问题

实时特征管道不是搭积木,搭完就完事了。它需要持续维护、持续优化。嗯,这就是我这些年踩坑踩出来的经验,希望对你有用。