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。风控决策服务再从Redis里读特征,传给模型打分。嗯,这里要注意——写Redis和读Redis要分开线程,不然写操作堵住了,读操作也跟着遭殃。
4.5 避坑指南:我踩过的那些坑
做实时特征这几年,我总结了几条血泪教训:
- 不要把所有特征都放一个Redis实例。 我曾经把所有用户的特征都塞进一个Redis集群,结果某个大客户搞活动,流量暴涨,直接把集群打挂了。后来按用户ID哈希分片,每个分片独立部署,问题解决。
- 滑动窗口的边界要处理干净。 比如“过去5分钟”,是从当前时间往前推5分钟,还是从整点开始算?我建议用滚动窗口(以事件时间为准),避免整点流量尖峰。
- 特征值要归一化。 模型对数值范围敏感,交易金额几万块和登录次数几十次,直接喂给模型会出问题。我一般用Z-score或者Min-Max归一化,在写入Redis前就处理好。
- 监控特征计算的延迟。 每个特征的计算耗时都要打点,超过阈值就告警。我见过一个案例,特征计算延迟从5ms涨到200ms,排查了半天才发现是Redis连接池不够用。
一句话总结:实时特征工程,本质是在时间、空间和精度之间找平衡。算得太细,延迟扛不住;算得太粗,特征没区分度。多试几次,找到适合你业务的“甜点”。
4.6 实战:一个完整的实时特征计算流程
最后,咱们看一个完整的例子。假设我们要计算“用户过去1小时内的交易次数”,并且这个特征要实时更新、实时查询。
- 数据接入:Kafka Topic
trade_events收到交易事件,包含user_id、amount、timestamp - 特征计算(Flink):
- 按
user_id分组 - 使用滑动窗口(窗口大小1小时,滑动步长1分钟)
- 每来一个事件,更新对应窗口的计数
- 按
- 特征存储(Redis):
- Key:
feat:user:{user_id}:trade_count_1h - Value: 当前窗口的计数(String类型)
- TTL: 2小时(比窗口多1小时,防止边界数据丢失)
- Key:
- 特征查询:风控决策服务直接GET这个Key,拿到数值传给模型
你看,整个链路其实不复杂。但每个环节都有细节——Flink的并行度设多少?Redis的淘汰策略用哪个?TTL设多久?这些都得根据实际流量来调。我一般先在测试环境压一把,看看瓶颈在哪,再上线。
好了,特征工程这块就聊到这儿。记住一句话:特征的质量,决定了模型的天花板。算得再快,特征本身没区分度,也是白搭。