在构建为在线模型推理服务的特征存储时,一个核心的技术挑战是如何高效地计算并提供具有高基数(high-cardinality)实体的时间窗口特征。例如,我们需要为数百万用户计算“过去5分钟内的点击次数”或“过去1小时内的平均交易额”。这类特征对实时风控、推荐系统等场景至关重要,它们要求特征的读取延迟在毫秒级,同时数据必须足够新鲜。
问题的症结在于“高基数”和“时间窗口”的组合。当实体ID(如user_id)的数量达到千万甚至上亿级别时,为每个实体维护一个滑动的聚合值,对计算和存储系统都构成了严峻的考验。
方案A:基于内存缓存的朴素实现(如Redis)
一个常见的初步构想是使用 Redis。我们可以为每个用户的一个时间窗口特征维护一个键,例如 feature:user_clicks_5m:user_123。当新的用户行为事件到来时,通过 INCR 和 EXPIRE 组合来更新计数值。或者,使用 Sorted Set,将事件时间戳作为 score,成员是事件ID,通过 ZREMRANGEBYSCORE 移除窗口外的数据,再用 ZCARD 得到计数值。
这种方法的优势是读取速度极快。但在生产环境中,它暴露了几个致命问题:
- 内存成本失控:对于千万级用户,每个用户哪怕只维护几个窗口特征,内存消耗也是巨大的。特别是当窗口较大或事件频率较高时,Sorted Set会存储大量成员,内存占用会急剧膨胀。
- 窗口计算不精确:依赖
EXPIRE的滑动窗口是“翻滚式”的,而非“滑动式”的。使用 Sorted Set 虽然可以实现精确的滑动窗口,但每次读写都需要进行范围计算和删除,操作变得复杂且原子性难以保证。 - 聚合能力有限:Redis 只能做简单的
COUNT,SUM。对于AVG,STDDEV等更复杂的聚合,需要将所有原始数据拉到客户端计算,或者通过 Lua 脚本实现,这都违背了低延迟的初衷。 - 数据持久性与恢复:尽管 Redis 提供 AOF 和 RDB,但在大规模实时写入场景下,故障恢复一直是个痛点,可能会丢失关键的特征数据。
方案B:成熟的流计算引擎与KV存储(如Flink + RocksDB/HBase)
更进一步的方案是引入 Flink 或 Spark Streaming 这样的专业流计算引擎。它们内置了强大的窗口化和状态管理能力。我们可以用 Flink 从 Kafka 消费事件,在TaskManager的内存(或RocksDB状态后端)中为每个实体维护窗口状态,并周期性地将计算结果输出到外部的低延迟KV存储(如HBase, Cassandra)中。
这个架构在理论上非常完善,也是许多大公司的选择。但它的权衡点在于:
- 运维复杂度:维护一个高可用的 Flink 集群本身就是一项专业工作,涉及资源调度、状态管理、Checkpoint与Savepoint、版本升级等一系列复杂问题。对于中小型团队来说,这是一个沉重的负担。
- 端到端延迟:数据链路变长了:
Kafka -> Flink -> KV Store -> Service API。虽然 Flink 内部计算很快,但数据写入外部 KV 存储再被读取,整个链路的 P99 延迟可能难以控制在10ms以内。 - 数据孤岛与调试困难:特征计算的中间状态被封装在 Flink 内部,而最终结果存储在 KV 数据库中。这种分离使得数据分析和问题排查变得困难。我们无法轻易地用 SQL 去探查某个用户在某个时间段的原始事件,也无法灵活地对历史数据进行回溯或重新计算。
最终选择:Kafka + TimescaleDB 的精简架构
我们的目标是寻找一个既能满足性能要求,又在架构上尽可能简洁、可维护性高的方案。最终,我们将目光投向了 Kafka + TimescaleDB 的组合。
- Kafka: 作为数据总线,提供高吞吐、持久化的事件流。这是业界标准,无需赘述。
- TimescaleDB: 这是关键的技术选型。它是一个基于 PostgreSQL 的时序数据库扩展。它不仅仅是一个时序数据库,它保留了 PostgreSQL 完整的 SQL 能力、成熟的生态和事务保证。
这个架构的核心优势在于,我们将“流式计算”的复杂性从一个独立的计算引擎转移到了数据库内部。TimescaleDB 的两个核心特性——Hypertable 和 Continuous Aggregates——是实现这一目标的关键。
graph TD
subgraph "事件源 (Event Sources)"
App/Web -->|User Action Event| KafkaTopic[Kafka: user_events]
end
subgraph "特征处理层 (Feature Processing Layer)"
KafkaTopic --> ConsumerService[Go Consumer Service]
ConsumerService -->|Batch Insert| TimescaleDB[(TimescaleDB)]
end
subgraph "TimescaleDB 内部机制"
TimescaleDB -- "Hypertable" --> RawEvents[raw_events Table]
RawEvents -- "Continuous Aggregates" --> MaterializedViews[Materialized Views
e.g., user_clicks_5m]
end
subgraph "特征服务层 (Feature Serving Layer)"
MLModel[ML Model Inference] -->|GET /features?user_id=...| FeatureAPI[Feature Retrieval API]
FeatureAPI -->|Low-latency SQL Query| MaterializedViews
end
这个架构的逻辑是:
- 所有原始事件被一个简单的消费者服务从 Kafka 读取,然后批量写入 TimescaleDB 的一张
raw_events表中。 -
raw_events表被转换为 TimescaleDB 的 Hypertable,使其能够高效处理海量时间序列数据。 - 我们使用 Continuous Aggregates 功能,定义一个物化视图。例如,
user_clicks_5m,它会自动、增量地在后台计算每个用户每分钟的点击次数。 - 在线特征服务 API 直接查询这个物化视图,而不是原始数据表。由于结果是预计算好的,查询延迟极低。
核心实现概览
1. TimescaleDB Schema 设计
首先是在 TimescaleDB 中建立数据表。假设我们的事件是记录用户点击行为。
-- 确保 TimescaleDB 扩展已安装
CREATE EXTENSION IF NOT EXISTS timescaledb;
-- 1. 创建原始事件表
CREATE TABLE user_events (
event_time TIMESTAMPTZ NOT NULL,
user_id BIGINT NOT NULL,
event_type VARCHAR(50),
properties JSONB
);
-- 2. 将其转换为 Hypertable,按时间分区
-- chunk_time_interval 设为 1 天,意味着每个分区存储一天的数据。
SELECT create_hypertable('user_events', 'event_time', chunk_time_interval => INTERVAL '1 day');
-- 3. 为高频查询字段创建索引
-- 联合索引对于按用户和时间范围查询至关重要
CREATE INDEX ON user_events (user_id, event_time DESC);
-- 4. [核心] 创建 Continuous Aggregate 来预计算5分钟窗口的点击数
CREATE MATERIALIZED VIEW user_clicks_5m
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 minute', event_time) AS bucket,
user_id,
COUNT(*) AS click_count
FROM
user_events
WHERE
event_type = 'click'
GROUP BY
bucket, user_id;
-- 5. 配置刷新策略,让视图每分钟自动更新
SELECT add_continuous_aggregate_policy('user_clicks_5m',
start_offset => INTERVAL '10 minutes', -- 开始刷新距离现在10分钟前的数据
end_offset => INTERVAL '1 minute', -- 结束刷新距离现在1分钟前的数据
schedule_interval => INTERVAL '1 minute'); -- 每分钟执行一次刷新任务
这里的 user_clicks_5m 就是我们的特征视图。time_bucket('1 minute', ...) 将时间戳对齐到分钟。查询时,我们只需要获取最近的5个 bucket 数据并聚合,就能得到精确的5分钟滑动窗口值。刷新策略中的 offset 配置是为了处理事件延迟到达的情况。
2. Kafka 消费者服务 (Golang)
消费者的任务很简单:消费数据,然后高效地写入 TimescaleDB。这里使用 Go 和 pgx 库的批量复制功能,性能极高。
package main
import (
"context"
"encoding/json"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/segmentio/kafka-go"
)
const (
dbConnectionString = "postgres://user:password@host:port/dbname"
kafkaBroker = "localhost:9092"
kafkaTopic = "user_events"
kafkaGroupID = "feature-ingest-consumer"
batchSize = 1000 // 批量插入的大小
batchTimeout = 5 * time.Second
)
// Event represents the structure of our Kafka message
type Event struct {
EventTime time.Time `json:"event_time"`
UserID int64 `json:"user_id"`
EventType string `json:"event_type"`
Properties json.RawMessage `json:"properties"`
}
func main() {
// 初始化数据库连接池
dbpool, err := pgxpool.New(context.Background(), dbConnectionString)
if err != nil {
log.Fatalf("Unable to connect to database: %v\n", err)
}
defer dbpool.Close()
// 初始化 Kafka Reader
r := kafka.NewReader(kafka.ReaderConfig{
Brokers: []string{kafkaBroker},
GroupID: kafkaGroupID,
Topic: kafkaTopic,
MinBytes: 10e3, // 10KB
MaxBytes: 10e6, // 10MB
CommitInterval: time.Second,
})
defer r.Close()
log.Println("Consumer service started...")
ctx, cancel := context.WithCancel(context.Background())
// 优雅停机
go func() {
sigterm := make(chan os.Signal, 1)
signal.Notify(sigterm, syscall.SIGINT, syscall.SIGTERM)
<-sigterm
log.Println("Termination signal received. Shutting down...")
cancel()
}()
var eventBuffer []*Event
ticker := time.NewTicker(batchTimeout)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
// 如果有剩余的 buffer,在退出前最后写入一次
if len(eventBuffer) > 0 {
insertBatch(context.Background(), dbpool, eventBuffer)
}
log.Println("Consumer service stopped.")
return
case <-ticker.C:
// 超时触发写入
if len(eventBuffer) > 0 {
insertBatch(ctx, dbpool, eventBuffer)
eventBuffer = nil // 清空 buffer
}
default:
// 读取 Kafka 消息
m, err := r.FetchMessage(ctx)
if err != nil {
if err == context.Canceled {
continue
}
log.Printf("Error fetching message: %v\n", err)
continue
}
var evt Event
if err := json.Unmarshal(m.Value, &evt); err != nil {
log.Printf("Failed to unmarshal message: %v. Skipping.\n", err)
r.CommitMessages(ctx, m) // 即使解析失败,也需要提交offset,防止阻塞
continue
}
eventBuffer = append(eventBuffer, &evt)
// 达到 batch size 触发写入
if len(eventBuffer) >= batchSize {
insertBatch(ctx, dbpool, eventBuffer)
r.CommitMessages(ctx, m) // 在成功写入DB后提交Kafka offset
eventBuffer = nil // 清空 buffer
}
}
}
}
// insertBatch 使用 pgx 的 CopyFrom 接口进行高效的批量插入
func insertBatch(ctx context.Context, pool *pgxpool.Pool, events []*Event) {
if len(events) == 0 {
return
}
startTime := time.Now()
rows := make([][]interface{}, len(events))
for i, e := range events {
rows[i] = []interface{}{e.EventTime, e.UserID, e.EventType, e.Properties}
}
copyCount, err := pool.CopyFrom(
ctx,
pgx.Identifier{"user_events"},
[]string{"event_time", "user_id", "event_type", "properties"},
pgx.CopyFromRows(rows),
)
if err != nil {
log.Printf("Batch insert failed: %v\n", err)
// 在真实项目中,这里应该有重试逻辑或死信队列机制
return
}
log.Printf("Successfully inserted %d rows in %v\n", copyCount, time.Since(startTime))
}
代码要点分析:
- 批量写入: 绝对不要逐条插入数据库。
pgx的CopyFrom方法使用了PostgreSQL的COPY协议,是最高效的写入方式。 - 双重触发机制: 写入操作由两个条件触发:达到批次大小(
batchSize)或超时(batchTimeout)。这确保了即使在流量较低时,数据也能被及时地写入,避免过高的延迟。 - 优雅停机: 监听系统信号(
SIGINT,SIGTERM),在退出前将缓冲区内剩余的数据写入数据库,避免数据丢失。 - 错误处理: 对于无法解析的消息,选择跳过并提交位移,防止坏数据阻塞整个消费流程。对于数据库写入失败,生产环境应加入重试和告警。
3. 低延迟特征获取 API
API 服务的任务是根据传入的 user_id,快速从物化视图中查询出特征。
-- API 服务需要执行的查询
-- 获取用户最近5分钟的点击总数
SELECT
SUM(click_count) AS total_clicks_5m
FROM
user_clicks_5m
WHERE
user_id = $1 -- e.g., 12345
AND
bucket >= NOW() - INTERVAL '5 minutes';
这个查询的性能极高,因为它扫描的是预聚合后的、数据量小得多的物化视图 user_clicks_5m,并且只会触及最近几个时间桶(分区)。对于一个给定的 user_id,查询通常能在个位数毫秒内返回。在真实项目中,这个SQL会被封装在一个Go或Python的Web服务中,例如使用 Gin 或 FastAPI。
架构的扩展性与局限性
扩展性
- 新特征: 添加新的时间窗口特征非常简单,只需定义一个新的 Continuous Aggregate 视图即可,无需修改消费者代码或线上服务。这对于需要快速迭代特征的算法团队来说极为友好。
- 离线训练: 由于所有原始事件都存储在
user_events表中,我们可以轻松地使用 SQL 查询来生成训练数据集,无需再维护一套独立的离线数仓(对于某些场景而言)。数据源的统一大大简化了离线/在线一致性问题。 - 横向扩展: Kafka 和 TimescaleDB(通过流复制和读写分离)都可以进行水平扩展,以应对流量的增长。
局限性
- 特征新鲜度: Continuous Aggregates 的刷新是有延迟的(由
schedule_interval和end_offset控制),通常在分钟级别。这意味着特征的最新值可能会有1-2分钟的延迟。对于需要亚秒级新鲜度的场景(例如,高频交易风控),这个架构可能不适用。在这种情况下,可能需要在API层之上再加一层内存缓存(如Redis)来合并最新的流式数据和数据库中的准实时数据,形成一个混合方案。 - 复杂特征: 此架构最适合处理基于固定时间窗口的数值型聚合特征(
COUNT,SUM,AVG等)。对于更复杂的特征,如会话重建(Sessionization)或需要复杂状态机计算的特征,使用 Flink 等流计算引擎仍然是更合适的选择。 - 写入放大: 虽然Continuous Aggregates是增量更新的,但它仍然会在后台执行写入操作。在高写入吞吐下,数据库的IO压力需要被密切监控。合理的硬件配置和PostgreSQL参数调优是保证系统稳定性的前提。
最终,没有任何架构是银弹。Kafka + TimescaleDB 的组合为高基数时间窗口特征存储问题提供了一个运维成本相对较低、开发体验友好且性能表现优异的解决方案。它通过将计算下推到数据库,精简了技术栈,特别适合那些希望快速启动实时ML应用但又不想过早陷入分布式计算系统复杂性的团队。