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
为什么这么设计?三个原因:
- 前缀扫描:按实体类型+ID前缀,可以一次扫出该实体的所有特征。比如
user:9527:*就能拿到用户的所有特征。 - 分组隔离:不同分组的特征可以设置不同的TTL和优先级。比如「画像」特征一天更新一次,「行为」特征半小时更新一次。
- 避免热点:如果只用实体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图展示了特征从计算到存储再到检索的完整链路:
从这张图可以看得很清楚:特征计算引擎产出特征后,写入列存宽表;检索时通过复合键前缀扫描,一次IO拿到所有特征;版本管理用双缓冲保证秒级回滚。整个链路的核心就是一致性——训练和推理用同一份特征快照。
最后说一句:特征数据模型没有银弹。宽表、列存、复合键、双缓冲,这些工具你得根据业务场景组合使用。但有一条原则永远不会变:特征存储是为检索服务的,所有设计都要围绕「快」和「一致」来展开。