4、特征数据模型:特征表设计、特征键设计、特征版本管理

聊到特征存储,很多人第一反应是「不就是个KV数据库吗?」。嗯,这话对了一半。特征存储确实大量依赖KV模型,但真正决定系统上限的,是你怎么设计那张特征表、怎么选主键、怎么管版本。

我见过太多团队,把特征表设计得跟业务表一样——三范式、外键、联合索引全招呼上。结果呢?线上延迟飙到几百毫秒,特征更新还经常死锁。说白了,特征存储是给机器读的,不是给人查的。设计思路得反过来。

4.1 特征表设计:宽表优先,列存为王

先讲表结构。特征表的核心矛盾是什么?是「写一次,读千万次」。一个特征被计算出来后,会被成千上万个请求读取。所以表设计必须为读优化。

我个人习惯用宽表。什么意思?就是把一个实体的所有特征放在同一行里。比如用户特征表:

-- 用户特征宽表示例
CREATE TABLE user_features (
    user_id         VARCHAR(64)   NOT NULL,
    feature_group   VARCHAR(32)   NOT NULL,  -- 特征分组,如 'profile', 'behavior'
    feature_version BIGINT        NOT NULL,  -- 版本号
    features        MAP<STRING, DOUBLE>,     -- 特征名-值映射
    updated_at      TIMESTAMP     NOT NULL,
    PRIMARY KEY (user_id, feature_group, feature_version)
);

注意这里用了 MAP<STRING, DOUBLE> 类型。为什么不用单独的列?因为特征数量是动态的。今天加个「近7天点击率」,明天加个「近30天转化率」,你总不能天天改表结构吧?

核心原则:特征表是「实体+分组+版本」的三维结构。实体ID决定行,分组决定列族,版本决定快照。

我在项目中遇到过一个问题:宽表虽然读得快,但写的时候如果特征很多(比如上千个),单行更新会锁住整行。后来我们用了列存方案——底层用Parquet或ORC格式,按列压缩存储。这样更新单个特征时,只重写那一列的数据块,不影响其他列。

存储方式 读性能 写性能 存储效率 适用场景
行存宽表 高(一次IO读整行) 低(整行锁) 低(稀疏特征浪费空间) 特征数少(<100),读多写少
列存宽表 中(按需读列) 中(列级更新) 高(列压缩,稀疏友好) 特征数多(100-10000),读写均衡
KV窄表 低(多次IO) 高(单行无锁) 中(冗余存储实体ID) 特征极少(<10),写多读少

你想想看,如果你的特征有500个,但每次请求只用到其中20个,列存能省下96%的IO。这个账很好算。

4.2 特征键设计:复合键的艺术

特征键(Feature Key)是检索的入口。设计不好,再好的存储引擎也白搭。

先说最简单的场景:单实体特征。比如用户特征,键就是 user_id。但实际业务中,特征往往是多实体的组合。比如「用户-商品」交叉特征,键怎么设计?

我建议用复合键,格式统一为:

feature_key = <实体类型>:<实体ID>:<特征分组>:<特征名>

-- 示例
user:9527:profile:age
user:9527:behavior:click_rate_7d
item:8848:price:current_price
user_item:9527:8848:cross:similarity_score

为什么这么设计?三个原因:

  1. 前缀扫描:按实体类型+ID前缀,可以一次扫出该实体的所有特征。比如 user:9527:* 就能拿到用户的所有特征。
  2. 分组隔离:不同分组的特征可以设置不同的TTL和优先级。比如「画像」特征一天更新一次,「行为」特征半小时更新一次。
  3. 避免热点:如果只用实体ID做键,所有请求都打在同一行上。加上分组和特征名,请求分散到不同行,负载更均匀。

避坑指南:我曾经把特征键设计成 user_id + feature_name 的拼接字符串,结果线上出现了一个用户有2000个特征,每次请求要扫2000行。后来改成 user_id + feature_group 的宽表结构,一次IO搞定。记住:特征键的粒度要「粗到一次IO够用,细到不产生热点」。

还有一个细节:键的排序。如果底层用HBase或Cassandra,键是按字典序存储的。把变化频繁的部分放在键的前面,会导致写放大。我一般把实体ID放在最前面,因为它是均匀分布的;把特征名放在最后,因为它是变化最频繁的。

4.3 特征版本管理:时间旅行与回滚

版本管理是特征存储里最容易踩坑的地方。你想想看,模型训练时用的特征版本,和线上推理时用的特征版本,必须严格一致。否则模型效果会莫名其妙地变差。

我常用的版本策略有三种:

策略 实现方式 优点 缺点
单调递增版本号 每次更新特征时,版本号+1 实现简单,版本唯一 无法回退,版本号可能溢出
时间戳版本 使用特征计算完成的时间戳 天然有序,支持时间旅行 时钟不同步会导致版本混乱
双缓冲版本 同时保留新旧两个版本,通过开关切换 秒级回滚,零停机 存储翻倍,需要协调器

我个人最推荐双缓冲版本。为什么?因为线上出问题时,你根本没时间重建特征。我记得有一次,我们新上线的特征计算逻辑有bug,导致所有用户的「购买力评分」都变成了负数。如果用单调版本号,得重新跑全量数据,至少两小时。但双缓冲方案下,我们直接切回旧版本,30秒就恢复了。

双缓冲的具体实现:

-- 双缓冲版本表示例
CREATE TABLE user_features_v2 (
    user_id         VARCHAR(64)   NOT NULL,
    feature_group   VARCHAR(32)   NOT NULL,
    -- 同时保留两个版本
    features_v1     MAP<STRING, DOUBLE>,
    features_v2     MAP<STRING, DOUBLE>,
    active_version  TINYINT       NOT NULL DEFAULT 1,  -- 1 或 2
    updated_at      TIMESTAMP     NOT NULL,
    PRIMARY KEY (user_id, feature_group)
);

-- 读取时根据 active_version 选择
SELECT 
    CASE WHEN active_version = 1 
         THEN features_v1 
         ELSE features_v2 
    END AS features
FROM user_features_v2
WHERE user_id = '9527' AND feature_group = 'behavior';

小技巧:双缓冲的切换可以做成灰度发布。先切1%的流量到新版本,观察指标没问题再全量切。如果发现问题,直接切回旧版本,影响范围极小。

还有一个容易被忽略的点:版本对齐。训练时用的特征版本,和线上推理时用的特征版本,必须用同一个版本号。我见过一个案例:训练数据用的是T+1的离线特征,线上用的是实时特征,结果模型在线上效果比离线差了一大截。后来我们强制要求:训练和推理必须使用同一批特征快照,版本号作为参数传入模型。

4.4 整体数据模型架构

说了这么多,来张图把整个数据模型串起来。下面这张SVG图展示了特征从计算到存储再到检索的完整链路:

特征数据模型架构图 特征数据模型架构 特征计算引擎 实时特征管道 离线特征批处理 特征宽表(列存) 实体ID user_id / item_id 特征分组 profile / behavior 特征MAP name → value 版本号 v1 / v2 双缓冲 特征键设计(复合键) 实体类型:实体ID:分组:特征名 前缀扫描 → 一次IO获取所有特征 版本对齐 → 训练/推理一致 模型训练(离线) 在线推理(实时) 特征回刷/修复 核心:宽表存储 + 复合键检索 + 双缓冲版本管理 → 保证特征一致性

从这张图可以看得很清楚:特征计算引擎产出特征后,写入列存宽表;检索时通过复合键前缀扫描,一次IO拿到所有特征;版本管理用双缓冲保证秒级回滚。整个链路的核心就是一致性——训练和推理用同一份特征快照。

最后说一句:特征数据模型没有银弹。宽表、列存、复合键、双缓冲,这些工具你得根据业务场景组合使用。但有一条原则永远不会变:特征存储是为检索服务的,所有设计都要围绕「快」和「一致」来展开。