4. 特征工程:实时特征计算、滑动窗口、特征存储(Redis)

特征工程,说白了就是把原始数据变成模型能“吃”的养料。在实时风控里,这一步尤其关键——你只有几十毫秒的时间窗口,得把用户过去5分钟、1小时、甚至7天的行为,浓缩成几个数字。嗯,这活儿看着简单,做起来坑不少。

我刚开始做实时特征时,犯过一个低级错误:把用户所有历史数据都拉到内存里算。结果呢?服务直接OOM,线上告警响成一片。后来才明白,实时特征的核心就三个字——算得快、存得巧、拿得到。

4.1 实时特征计算的三种模式

实时特征计算,不是所有场景都适合用同一套方案。我个人习惯把它分成三类:

模式 适用场景 延迟要求 典型例子
单事件特征 当前请求本身的信息 < 5ms 本次交易金额、IP归属地
滑动窗口聚合 最近一段时间的行为统计 < 20ms 过去5分钟登录次数
跨实体关联 设备、账号、IP之间的关联 < 50ms 该设备关联的账号数

单事件特征最简单,直接从请求里取字段就行。真正有挑战的是滑动窗口——你想想看,用户每秒可能产生多个事件,你要在毫秒级算出“过去1小时内的交易总额”,还不能漏掉任何一个事件。

4.2 滑动窗口:时间就是金钱

滑动窗口的实现,我踩过不少坑。最直接的做法是:把每个事件的时间戳存下来,查询时遍历过滤。但数据量一大,这招就废了——存几百万个时间戳,查询一次要几十毫秒,风控根本等不起。

更好的方案是预聚合 + 时间分桶。把时间切成固定大小的桶(比如1分钟一个桶),每个桶里存聚合值。查询时只需要取最近N个桶的数据,加起来就行。

核心思路:用空间换时间。预聚合让查询复杂度从O(n)降到O(1),代价是多了点内存开销。

举个例子,计算“过去5分钟的交易总额”:

// 时间分桶:每分钟一个桶,保留最近10分钟的数据
// 桶结构:{ timestamp: 分钟级时间戳, sum: 该分钟交易总额, count: 该分钟交易笔数 }

// 事件到达时,更新对应桶
function onEvent(event) {
    let bucketKey = floor(event.timestamp / 60) * 60;
    let bucket = redis.hget("trade_buckets", bucketKey);
    if (!bucket) {
        bucket = { sum: 0, count: 0 };
    }
    bucket.sum += event.amount;
    bucket.count += 1;
    redis.hset("trade_buckets", bucketKey, bucket);
    redis.expire("trade_buckets", 600); // 10分钟过期
}

// 查询过去5分钟的总交易额
function getLast5MinSum() {
    let now = Date.now() / 1000;
    let start = floor(now / 60) * 60 - 5 * 60;
    let end = floor(now / 60) * 60;
    
    let buckets = redis.hgetall("trade_buckets");
    let total = 0;
    for (let key in buckets) {
        if (key >= start && key <= end) {
            total += buckets[key].sum;
        }
    }
    return total;
}

小技巧:桶的大小要根据业务调整。交易频率高的场景用10秒桶,低频场景用1分钟桶。我曾经把桶设得太细(1秒),结果Redis里存了上万个key,查询反而变慢了。

4.3 特征存储:为什么选Redis?

实时特征存储,我几乎只用Redis。原因很简单:快、数据结构丰富、自带过期机制。

但Redis也不是万能药。我记得有一次,团队把用户的所有特征都塞进一个Hash里,结果单个Hash越来越大,读写性能直线下降。后来才意识到,Redis的Hash在元素超过512个时,内部编码会从ziplist变成hashtable,性能会掉一个档次。

所以,我一般这样设计特征存储:

  • Key设计:用业务前缀 + 实体ID + 特征类型,比如 feat:user:12345:login_count
  • Value选择:
    • 简单计数器 → String(INCRBY)
    • 滑动窗口聚合 → Sorted Set(按时间戳排序)
    • 多维度特征 → Hash(字段数控制在500以内)
  • 过期策略:所有特征都设置TTL,避免僵尸数据占内存

注意:Redis的过期删除是惰性的,不会精确到秒。如果特征过期后还有少量查询,可以考虑用主动删除 + 定时清理的双重策略。

4.4 实时特征计算的整体架构

说了这么多,咱们把整个链路串起来看看。下面这张图,是我在项目中常用的实时特征计算架构:

实时特征计算架构图 数据源(Kafka) 特征计算层 单事件特征 | 滑动窗口聚合 | 跨实体关联 时间分桶 | 预聚合 | 增量更新 Flink / Spark Streaming 特征存储(Redis) String | Sorted Set | Hash TTL过期 | 主从复制 | 持久化 特征服务(API) 关键设计点 • 时间分桶粒度可配置 • 特征Key带业务前缀 • 所有特征设置TTL • 读写分离(可选)

整个流程是这样的:数据从Kafka进来,经过Flink或Spark Streaming做实时计算,算出各种特征后写入Redis。风控决策服务再从Redis里读特征,传给模型打分。嗯,这里要注意——写Redis和读Redis要分开线程,不然写操作堵住了,读操作也跟着遭殃。

4.5 避坑指南:我踩过的那些坑

做实时特征这几年,我总结了几条血泪教训:

  • 不要把所有特征都放一个Redis实例。 我曾经把所有用户的特征都塞进一个Redis集群,结果某个大客户搞活动,流量暴涨,直接把集群打挂了。后来按用户ID哈希分片,每个分片独立部署,问题解决。
  • 滑动窗口的边界要处理干净。 比如“过去5分钟”,是从当前时间往前推5分钟,还是从整点开始算?我建议用滚动窗口(以事件时间为准),避免整点流量尖峰。
  • 特征值要归一化。 模型对数值范围敏感,交易金额几万块和登录次数几十次,直接喂给模型会出问题。我一般用Z-score或者Min-Max归一化,在写入Redis前就处理好。
  • 监控特征计算的延迟。 每个特征的计算耗时都要打点,超过阈值就告警。我见过一个案例,特征计算延迟从5ms涨到200ms,排查了半天才发现是Redis连接池不够用。

一句话总结:实时特征工程,本质是在时间、空间和精度之间找平衡。算得太细,延迟扛不住;算得太粗,特征没区分度。多试几次,找到适合你业务的“甜点”。

4.6 实战:一个完整的实时特征计算流程

最后,咱们看一个完整的例子。假设我们要计算“用户过去1小时内的交易次数”,并且这个特征要实时更新、实时查询。

  1. 数据接入:Kafka Topic trade_events 收到交易事件,包含 user_id、amount、timestamp
  2. 特征计算(Flink):
    • 按 user_id 分组
    • 使用滑动窗口(窗口大小1小时,滑动步长1分钟)
    • 每来一个事件,更新对应窗口的计数
  3. 特征存储(Redis):
    • Key: feat:user:{user_id}:trade_count_1h
    • Value: 当前窗口的计数(String类型)
    • TTL: 2小时(比窗口多1小时,防止边界数据丢失)
  4. 特征查询:风控决策服务直接GET这个Key,拿到数值传给模型

你看,整个链路其实不复杂。但每个环节都有细节——Flink的并行度设多少?Redis的淘汰策略用哪个?TTL设多久?这些都得根据实际流量来调。我一般先在测试环境压一把,看看瓶颈在哪,再上线。

好了,特征工程这块就聊到这儿。记住一句话:特征的质量,决定了模型的天花板。算得再快,特征本身没区分度,也是白搭。