核心概念与数据模型:特征实体、特征视图、特征服务
好,咱们进入第二章。这一章讲的是整个实时特征存储的「骨架」——也就是那几个核心概念和它们的数据模型。说实话,我见过不少团队,工具选得挺好,但就是因为这几个概念没理清楚,最后系统搞得一团糟。所以咱们先把地基打牢。
特征实体(Entity)——你是谁?
特征实体,说白了就是「被描述的对象」。在推荐系统里,它是用户和物品;在风控场景里,它是设备、IP、银行卡;在 IoT 场景里,它就是传感器。
每个实体都有一个唯一标识符,我们叫它 Entity ID。比如用户 ID、设备指纹、订单号。这个 ID 是后续所有特征查询的入口。
核心要点:实体不仅仅是 ID,它还包括实体类型和元数据。同一个 ID 在不同业务线可能代表不同含义,所以实体类型必须显式声明。
我举个例子。我曾经在一个金融项目里,发现「用户 ID」在交易系统和风控系统里居然用了不同的编码方式。结果特征拼接时全乱了。嗯,从那以后我定了个规矩:实体定义必须在系统启动时就注册好,不允许运行时动态创建。
一个典型的实体定义长这样:
// 伪代码:实体注册
Entity userEntity = Entity.builder()
.name("user")
.description("平台注册用户")
.idField("user_id")
.tags(Arrays.asList("online", "core"))
.build();
featureStore.registerEntity(userEntity);
你想想看,如果连「谁」都没定义清楚,后面所有特征都成了无根之木。所以第一步,先把实体列表拉出来,一个一个注册好。
特征视图(Feature View)——特征怎么组织?
特征视图是特征存储里最核心的抽象。它把一组逻辑相关的特征打包在一起,并且定义了这些特征从哪里来、怎么算、怎么存。
我个人习惯把特征视图理解成「数据库里的一个逻辑表」。每个特征视图包含:
- 特征列表:具体有哪些特征字段,比如用户的年龄、性别、最近30天消费金额
- 数据源:特征数据从哪来?Kafka 流?离线 Hive 表?实时 API?
- 时间戳字段:每条特征记录对应的时间点
- TTL:特征数据存活时间,过期自动清理
- 实体关联:这个视图属于哪个实体
来看一个实际的特征视图定义:
// 伪代码:特征视图注册
FeatureView userTransactionView = FeatureView.builder()
.name("user_transaction_features")
.entity("user")
.features(Arrays.asList(
FeatureSpec.of("total_amount_7d", FloatType),
FeatureSpec.of("transaction_count_7d", IntType),
FeatureSpec.of("avg_amount_30d", FloatType)
))
.source(StreamSource.builder()
.topic("user-transaction-features")
.format(Avro)
.build())
.ttl(Duration.ofHours(24))
.timestampField("event_time")
.build();
我的经验:特征视图的粒度要适中。太细了(每个特征一个视图)会导致查询时 JOIN 爆炸;太粗了(所有特征塞一个视图)又不好维护。我一般按「业务域」来划分,比如用户基础信息、用户交易行为、用户风险画像,各一个视图。
特征服务(Feature Service)——对外怎么暴露?
特征服务是面向业务方的接口层。它把多个特征视图组合成一个逻辑服务,对外提供统一的特征查询入口。
为什么要多这一层?直接查特征视图不行吗?
行,但不好。你想想看,推荐系统在线推理时,可能需要同时获取用户特征、物品特征、上下文特征。如果业务方要自己拼三个视图的查询,那代码就乱套了。特征服务就是干这个的——把多个视图的查询封装成一个接口。
| 概念 | 类比数据库 | 职责 |
|---|---|---|
| 特征实体 | 表的主键 | 定义「谁」 |
| 特征视图 | 逻辑表 | 定义「有什么特征」 |
| 特征服务 | 视图或存储过程 | 定义「怎么查」 |
特征服务的定义示例:
// 伪代码:特征服务注册
FeatureService recommendationService = FeatureService.builder()
.name("recommendation_features")
.features(Arrays.asList(
"user_transaction_features", // 引用特征视图
"user_profile_features",
"item_embedding_features",
"context_time_features"
))
.description("推荐系统在线推理所需的全部特征")
.build();
注意:特征服务不要做得太「重」。它只是一个编排层,不应该包含业务逻辑。我曾经见过有人把排序规则也写进特征服务里,结果每次改排序都要重新部署特征服务,太痛苦了。
时间戳与版本控制——数据怎么保鲜?
实时特征存储和普通数据库最大的区别,就是时间维度。每条特征记录都绑定一个时间戳,表示「这个特征值在什么时间点是有效的」。
为什么要这么设计?因为特征会变。用户的消费能力昨天是 1000,今天可能是 2000。如果模型训练时用了今天的特征,但推理时拿到的是昨天的值,那预测结果就错了。
时间戳的典型用法:
- 事件时间:特征数据实际产生的时间(比如用户下单的时间)
- 处理时间:特征数据被写入存储的时间
- 查询时间:业务方请求特征的时间
版本控制则解决另一个问题:同一个实体、同一个特征、同一时刻,可能有多个值。比如模型训练时用了 v1.0 的特征计算逻辑,但线上已经升级到 v2.0 了。如果不做版本控制,训练和推理的特征就不一致。
我建议的版本策略:
- 特征视图版本:每次修改特征计算逻辑,版本号 +1
- 数据记录版本:每条特征记录携带版本号,查询时默认取最新版本
- 时间旅行查询:支持指定时间戳和版本号,回查历史特征值
// 伪代码:带版本的特征写入
FeatureRecord record = FeatureRecord.builder()
.entityId("user_12345")
.featureView("user_transaction_features")
.values(Map.of(
"total_amount_7d", 3500.0f,
"transaction_count_7d", 12
))
.timestamp(Instant.now())
.version(2) // 显式指定版本
.build();
featureStore.write(record);
避坑指南:版本号不要用时间戳代替。我见过有人直接用毫秒时间戳当版本号,结果并发写入时两个请求的时间戳一样,数据覆盖了。版本号应该用单调递增的整数,或者用 Git commit SHA 这种全局唯一标识。
知识体系总览
下面这张图把四个核心概念的关系串起来了。你可以看到数据从源端流入,经过特征视图组织,通过特征服务暴露给业务方,整个过程由时间戳和版本控制来保证一致性。
从这张图可以看得很清楚:数据从源端进来,按特征视图组织,通过特征服务统一暴露,实体贯穿始终,时间戳和版本控制则像一条隐形的线,把所有环节串起来。
嗯,这一章的内容就到这里。概念虽然多,但每个都有明确的定位。你只要记住一句话:实体定义「谁」,视图定义「有什么」,服务定义「怎么查」,时间戳和版本保证「对不对」。后面几章我们会深入每个概念的实现细节,到时候你就知道这些抽象有多重要了。