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 兴趣标签:从行为到标签的实时映射

兴趣标签是画像里最「活」的部分。用户今天看了篮球,明天看了美妆,标签就得跟着变。

实时兴趣标签的计算,我把它拆成三步:

  1. 行为接收:用户点击、收藏、购买等行为进入 Kafka。
  2. 标签映射:每个行为对应一个或多个标签,带权重。比如「点击篮球视频」→ 标签「篮球」权重 0.3,「运动」权重 0.1。
  3. 标签聚合:按时间窗口(比如 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
}

融合的时机也很重要。我建议在「特征请求时融合」,而不是「提前融合好存起来」。为什么呢?因为不同场景需要的特征组合不一样。推荐场景可能只需要标签和统计,广告场景需要基础属性+标签,搜索场景又不一样。提前融合会导致存储冗余。

下面这张图展示了实时画像特征工程的完整流程:

实时用户画像特征工程流程 行为日志 (Kafka) 资料变更 (Kafka) 离线预计算 (HBase) Flink 实时处理层 行为解析 & 标签映射 滑动窗口统计 基础属性缓存 特征融合拼接 输出:完整用户画像向量 推荐系统 广告系统 搜索系统

4.5 实时特征的质量保障

特征工程做完了,怎么保证质量?我总结了三个「必须做」:

  • 必须做延迟监控:从行为发生到特征生效,延迟超过 5 分钟就要报警。我习惯在 Flink 里埋点,输出每条数据的处理延迟到 Prometheus。
  • 必须做特征对比:实时特征和离线特征每天做一次全量对比。偏差超过 5% 的字段要排查。我曾经发现实时统计的「7 天点击次数」比离线少了 10%,原因是 Flink 状态没清理干净,旧数据被重复计数。
  • 必须做降级方案:Redis 挂了怎么办?Flink 重启了怎么办?我的方案是:Redis 挂了走 HBase,HBase 也挂了走本地文件兜底。虽然慢,但不能让推荐系统拿不到特征。

避坑指南:实时特征不要直接暴露给线上模型。我建议加一层「特征校验服务」,对异常值做截断或替换。比如年龄字段出现 200 岁,直接替换成默认值。否则模型学到的 embedding 会跑偏,召回结果全是垃圾。