4. 用户画像特征工程:基础属性、兴趣标签、行为统计特征的实时计算
好,咱们进入第四章。前面三章我们把流式架构和画像的底层逻辑讲清楚了。这一章,咱们要真正动手「造特征」了。
用户画像的特征工程,说白了就是三件事:你是谁(基础属性)、你喜欢什么(兴趣标签)、你做了什么(行为统计)。这三件事在离线场景下很好做,但放到实时流里,坑就多了。
我记得我第一次做实时画像时,天真地以为把离线代码改成 Flink SQL 就行。结果上线第一天,内存直接爆了。嗯,这里面的门道,咱们今天一条一条拆开讲。
4.1 基础属性:静态数据的实时化处理
基础属性包括性别、年龄、城市、注册渠道这些。它们变化频率低,但查询频率极高。你想想看,每次推荐请求都要拿这些字段做过滤或加权。
我的做法是:基础属性走「冷热分离 + 旁路缓存」。
- 热数据:放在 Redis 里,TTL 设 7 天,用 Hash 结构存,key 是 user_id,field 是属性名。
- 冷数据:放在 HBase 或 MySQL 里,作为兜底。
- 更新机制:用户修改资料时,通过消息队列(比如 Kafka)发送变更事件,Flink 消费后同时更新 Redis 和冷存储。
核心原则:基础属性不要全量加载到 Flink 状态里。我见过有人把几亿用户的性别存到 ValueState,结果 Checkpoint 一次要半小时。正确的做法是「实时查询 + 本地缓存」。
举个代码片段,这是我在项目里用的 Redis 查询封装:
// 伪代码:实时查询用户基础属性
public UserProfile getBaseProfile(String userId) {
// 1. 先查本地缓存(Caffeine,过期时间 5 分钟)
UserProfile cached = localCache.getIfPresent(userId);
if (cached != null) return cached;
// 2. 再查 Redis
Map<String, String> fields = redis.hgetAll("user:base:" + userId);
if (!fields.isEmpty()) {
UserProfile profile = convert(fields);
localCache.put(userId, profile);
return profile;
}
// 3. 最后查 HBase(兜底)
return hbaseClient.get(userId);
}
小技巧:Redis 的 Hash 结构里,字段名用短编码。比如 gender 用 g,age 用 a,city 用 c。一个 Hash 存 10 个字段,能省 40% 的内存。我在一个日活 5000 万的项目里试过,Redis 集群从 32 台降到了 20 台。
4.2 兴趣标签:从行为到标签的实时映射
兴趣标签是画像里最「活」的部分。用户今天看了篮球,明天看了美妆,标签就得跟着变。
实时兴趣标签的计算,我把它拆成三步:
- 行为接收:用户点击、收藏、购买等行为进入 Kafka。
- 标签映射:每个行为对应一个或多个标签,带权重。比如「点击篮球视频」→ 标签「篮球」权重 0.3,「运动」权重 0.1。
- 标签聚合:按时间窗口(比如 1 小时、1 天、7 天)累加权重,保留 Top N 标签。
这里有个关键点:标签要有时效衰减。用户上周喜欢篮球,这周没看,权重就该降。我习惯用「指数衰减」:
// 标签权重衰减公式
double decayedWeight = originalWeight * Math.pow(0.9, hoursSinceLastUpdate);
// Flink 中实现:每次更新时重新计算所有标签的衰减值
public void updateTags(MapState<String, Double> tagState, String tag, double increment) {
double current = tagState.get(tag) != null ? tagState.get(tag) : 0.0;
double decayed = current * decayFactor; // decayFactor 根据时间差计算
tagState.put(tag, decayed + increment);
}
我曾经踩过的坑:标签权重用 Double 存,结果序列化后精度丢失,导致排序出错。后来我改用「整数化存储」——权重乘以 1000 存成 Long。既省空间,又避免浮点问题。
兴趣标签的存储,我推荐用 Redis 的 Sorted Set。每个用户一个 key,member 是标签名,score 是权重。这样取 Top N 标签只需要一条 ZREVRANGE 命令,毫秒级返回。
4.3 行为统计特征:滑动窗口里的实时聚合
行为统计特征是最「重」的部分。比如「过去 7 天点击次数」「过去 24 小时购买金额」「近 1 小时浏览时长」。这些特征需要实时滑动窗口计算。
在 Flink 里,我一般用两种方式:
| 方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| SQL 滑动窗口 | 简单计数、求和 | 开发快,易维护 | 窗口对齐,不支持自定义 |
| ProcessFunction + 状态 | 复杂逻辑、自定义窗口 | 灵活,精确控制 | 代码量大,需管理状态 |
我个人更倾向第二种。为什么呢?因为业务上经常要「跨窗口特征」。比如「过去 7 天点击次数」和「过去 24 小时点击次数」其实可以共享同一份状态数据,只是聚合范围不同。
看一个实际例子:
// 用 ValueState 存储行为时间戳列表,实现任意时间范围的统计
public class BehaviorStatFunction extends KeyedProcessFunction<String, Behavior, UserStat> {
private ValueState<List<Long>> clickTimestamps;
@Override
public void processElement(Behavior value, Context ctx, Collector<UserStat> out) {
List<Long> timestamps = clickTimestamps.value();
if (timestamps == null) timestamps = new ArrayList<>();
// 添加当前行为时间戳
timestamps.add(value.getTimestamp());
// 清理超过 7 天的旧数据
long cutoff = ctx.timerService().currentProcessingTime() - 7 * 24 * 3600 * 1000L;
timestamps.removeIf(t -> t < cutoff);
clickTimestamps.update(timestamps);
// 计算不同时间范围的统计值
long now = ctx.timerService().currentProcessingTime();
long cnt1h = timestamps.stream().filter(t -> t > now - 3600_000L).count();
long cnt24h = timestamps.stream().filter(t -> t > now - 24 * 3600_000L).count();
long cnt7d = timestamps.size();
out.collect(new UserStat(value.getUserId(), cnt1h, cnt24h, cnt7d));
}
}
注意:上面的代码在数据量极大时会有性能问题。如果用户每秒产生上百次行为,List 会无限膨胀。我的优化方案是「分桶存储」——按小时分桶,每个桶只存计数,不存明细。这样状态大小从 O(n) 降到 O(168)(7天×24小时)。
4.4 特征融合:把三股数据拧成一股绳
基础属性、兴趣标签、行为统计,这三类特征最终要合并成一个完整的用户画像向量,供下游推荐模型使用。
我习惯在 Flink 里做「特征拼接」,输出一个扁平化的 JSON 或 Protobuf:
{
"user_id": "u12345",
"gender": 1,
"age": 28,
"city": "北京",
"tags": ["篮球:0.85", "运动:0.62", "科技:0.43"],
"stat_click_1h": 12,
"stat_click_24h": 89,
"stat_pay_7d": 3,
"stat_pay_amount_7d": 299.00
}
融合的时机也很重要。我建议在「特征请求时融合」,而不是「提前融合好存起来」。为什么呢?因为不同场景需要的特征组合不一样。推荐场景可能只需要标签和统计,广告场景需要基础属性+标签,搜索场景又不一样。提前融合会导致存储冗余。
下面这张图展示了实时画像特征工程的完整流程:
4.5 实时特征的质量保障
特征工程做完了,怎么保证质量?我总结了三个「必须做」:
- 必须做延迟监控:从行为发生到特征生效,延迟超过 5 分钟就要报警。我习惯在 Flink 里埋点,输出每条数据的处理延迟到 Prometheus。
- 必须做特征对比:实时特征和离线特征每天做一次全量对比。偏差超过 5% 的字段要排查。我曾经发现实时统计的「7 天点击次数」比离线少了 10%,原因是 Flink 状态没清理干净,旧数据被重复计数。
- 必须做降级方案:Redis 挂了怎么办?Flink 重启了怎么办?我的方案是:Redis 挂了走 HBase,HBase 也挂了走本地文件兜底。虽然慢,但不能让推荐系统拿不到特征。
避坑指南:实时特征不要直接暴露给线上模型。我建议加一层「特征校验服务」,对异常值做截断或替换。比如年龄字段出现 200 岁,直接替换成默认值。否则模型学到的 embedding 会跑偏,召回结果全是垃圾。