TensorZero ClickHouse 存储指南:UUIDv7 排序陷阱与 UInt128 转换模式
【免费下载链接】tensorzeroTensorZero is an open-source LLMOps platform that unifies an LLM gateway, observability, evaluation, optimization, and experimentation.项目地址: https://gitcode.com/GitHub_Trending/te/tensorzero
导读
TensorZero 使用 ClickHouse 存储推理(Inference)、会话(Episode)、反馈(Feedback)等海量可观测性数据,而这一切都建立在 UUIDv7 主键之上。然而在 ClickHouse 中,直接对 UUID 列执行ORDER BY并不会得到按时间先后排列的结果,这会直接破坏"最近的推理排在最前面"这类最基本的查询语义。本文基于 crates/tensorzero-core/src/db/clickhouse/AGENTS.md 中的工程规范,结合 TensorZero 的数据库迁移与查询实现源码,完整讲解UUIDv7 + ClickHouse 的排序问题成因、UInt128存储模式、toUInt128/uint_to_uuid双向转换协议,以及如何在建表、写入、读取、排序分页四个环节落地这一模式。读完本文,你将能够在自己的 ClickHouse 表设计中正确复刻 TensorZero 的做法,避免"数据都在、排序全乱"的经典坑。
一、问题:ClickHouse 不保证 UUIDv7 列的时间顺序
TensorZero 的所有核心实体都使用 UUIDv7 作为主键(如ChatInference.id、episode_id)。UUIDv7 的设计亮点在于:ID 内嵌了毫秒级时间戳,因此按 UUIDv7 排序天然等价于按创建时间排序。这为分布式环境下免协调地生成"近似时间有序"的主键提供了可能,也使得数据库可以依赖主键顺序直接提供时间序扫描。
但 AGENTS.md 明确指出了一个关键事实:
ClickHouse does not preserve chronological ordering for UUIDv7 values when you sort/order by a
UUIDcolumn directly. (当直接对 UUID 列排序时,ClickHouse 不会保持 UUIDv7 值的 chronological 顺序。)
在 TensorZero 的迁移代码注释中,对这一现象给出了更精确的定位。见 migration_0013.rs:
As ClickHouse stores UUIDs big-endian which for UUIDv7 gives a sorting order that ignores the embedded timestamps. (由于 ClickHouse 以 big-endian 方式存储 UUID,对 UUIDv7 而言得到的排序顺序会忽略内嵌的时间戳。)
也就是说,问题不在于 UUIDv7 本身,而在于ClickHouse 的 UUID 内存表示/比较方式与 UUIDv7 的字节布局不匹配,导致按 UUID 列排序时时间戳并没有成为比较的最高位。后果是:如果直接ORDER BY id,查询结果的时间顺序是混乱的,任何依赖"最新优先"的列表、分页、聚合都会出错。
二、核心规范:排序键存UInt128,不存UUID
针对上述问题,TensorZero 的团队规范给出了一个简洁而严格的约定(直接继承自 AGENTS.md):
For tables where a UUIDv7 value is part of the ordering key, store it as
UInt128(for example,run_id_uint) instead ofUUID. (对于排序键中包含 UUIDv7 值的表,将其存储为UInt128(例如run_id_uint)而不是UUID。)
2.1 三条铁律
在读写时,必须遵循以下双向转换协议:
| 环节 | 操作 | 表达式 | 方向 |
|---|---|---|---|
| 写入 | UUID → UInt128 | toUInt128({id:UUID}) | UUID 转为排序友好的整数 |
| 读取 | UInt128 → UUID | uint_to_uuid(id_uint) | 还原原始 UUID 供应用层使用 |
| 排序 | 直接按整数列 | ORDER BY id_uint | 永远不按 UUID 列排序 |
2.2 命名约定
从源码可以总结出明确的命名规律:凡是承担排序职责的 UInt128 列,都以_uint后缀命名,例如:
id_uint—— 推理 ID 的 UInt128 表示episode_id_uint—— 会话 ID 的 UInt128 表示run_id_uint—— 批处理/评测运行 ID 的 UInt128 表示target_id_uint—— 反馈目标 ID 的 UInt128 表示
以InferenceById表为例(migration_0013.rs):
CREATE TABLE IF NOT EXISTS InferenceById ( id_uint UInt128, function_name LowCardinality(String), variant_name LowCardinality(String), episode_id UUID, -- must be a UUIDv7 function_type Enum8('chat' = 1, 'json' = 2) ) ENGINE = MergeTree ORDER BY id_uint;注意这里的关键细节:排序键是id_uint(UInt128),而不是episode_id(UUID)。episode_id仍以 UUID 类型保留,因为它只是普通数据列,不参与排序。
复合排序键的场景同样适用。InferenceByEpisodeId表以(episode_id_uint, id_uint)作为ORDER BY(migration_0013.rs):
CREATE TABLE IF NOT EXISTS InferenceByEpisodeId ( episode_id_uint UInt128, id_uint UInt128, function_name LowCardinality(String), variant_name LowCardinality(String), function_type Enum8('chat' = 1, 'json' = 2) ) ENGINE = MergeTree ORDER BY (episode_id_uint, id_uint);三、写入路径:用toUInt128在物化视图中完成转换
光把目标表的主键改成 UInt128 还不够,写入端必须同步把 UUID 转成 UInt128。在 TensorZero 中,InferenceById/InferenceByEpisodeId并不是由应用直接写入的,而是由**物化视图(Materialized View)**从ChatInference、JsonInference两张明细表实时派生出来的。转换就发生在物化视图的SELECT中。
以ChatInferenceByIdView为例(migration_0013.rs):
CREATE MATERIALIZED VIEW IF NOT EXISTS ChatInferenceByIdView TO InferenceById AS SELECT toUInt128(id) as id_uint, function_name, variant_name, episode_id, 'chat' AS function_type FROM ChatInference;JsonInferenceByIdView、ChatInferenceByEpisodeIdView、JsonInferenceByEpisodeIdView遵循完全相同的模式,只是把id、episode_id分别映射到id_uint、episode_id_uint(见 migration_0013.rs)。
这一设计带来两点收益:
- 应用层无感知:业务代码仍然提交 UUID 主键,转换由数据库侧自动完成;
- 物化视图幂等可追溯:视图列与目标表列一一对应,任何一端字段变更都会在建表/迁移时被显式发现。
从源码结构看,toUInt128({id:UUID})这种带参数类型的写法还用于查询侧(见第四节),ClickHouse 会以服务端参数的形式把 UUID 字符串安全地绑定到查询中,避免 SQL 注入。
四、读取路径:uint_to_uuid用户自定义函数
读取时,UInt128 需要还原为 UUID 才能与应用的 ID 表示对齐。TensorZero 通过安装一个全局用户自定义函数(UDF)uint_to_uuid完成这一转换。该函数在迁移 0013/0020 中随表一起安装(migration_0013.rs):
CREATE FUNCTION IF NOT EXISTS uint_to_uuid AS (x) -> reinterpretAsUUID( concat( substring(reinterpretAsString(x), 9, 8), substring(reinterpretAsString(x), 1, 8) ) );这个实现非常值得细读:它将 UInt128 先reinterpretAsString得到 16 字节,然后把前 8 字节与后 8 字节对调后再reinterpretAsUUID。这正对应了迁移注释中提到的 ClickHouse big-endian UUID 存储与原生整数表示之间的字节序差异——uint_to_uuid负责把字节序"掰回来",保证uint_to_uuid(toUInt128(uuid))是恒等变换。
4.1 读取端还原 UUID
TensorZero 的查询层严格遵循"排序用_uint列、对外输出用uint_to_uuid还原"的分工。例如列出推理元数据时(inference_queries.rs):
SELECT uint_to_uuid(id_uint) as id, function_name, variant_name, episode_id, function_type, if(isNull(snapshot_hash), NULL, lower(hex(snapshot_hash))) as snapshot_hash FROM InferenceById {where_clause} ORDER BY id_uint {order_direction} LIMIT {limit:UInt64} FORMAT JSONEachRow这段查询是整篇文章规范的最佳缩影:
- 输出:
uint_to_uuid(id_uint) as id—— 应用拿到的仍是标准 UUID; - 排序:
ORDER BY id_uint {order_direction}—— 排序永远作用在 UInt128 上; - 返回格式:
FORMAT JSONEachRow—— 每行 JSON 序列化,便于 Rust 侧按行反序列化。
4.2 按 UUID 查询时反向转换
当查询条件以 UUID 形式传入时,WHERE 子句中使用toUInt128({id:UUID})把参数转回 UInt128 再与索引列比对,从而命中主键索引。见 resolve_uuid.rs:
SELECT function_name, function_type, variant_name, episode_id FROM InferenceById WHERE id_uint = toUInt128({id:UUID}) LIMIT 1 FORMAT JSONEachRow SETTINGS max_threads=1EpisodeById的查询同理(resolve_uuid.rs):
SELECT 1 FROM EpisodeById WHERE episode_id_uint = toUInt128({id:UUID}) LIMIT 1 FORMAT JSONEachRow SETTINGS max_threads=14.3 从 UInt128 反推时间戳
由于 UUIDv7 内嵌时间戳,而uint_to_uuid能无损还原 UUID,因此可以从_uint列直接提取时间信息。TensorZero 在物化视图和统计聚合中大量使用UUIDv7ToDateTime(uint_to_uuid(id_uint))这样的组合表达式。例如在反馈按变体(variant)聚合的物化视图中(migration_0039.rs):
CREATE MATERIALIZED VIEW IF NOT EXISTS FloatMetricFeedbackByVariantStatisticsView TO FeedbackByVariantStatistics AS SELECT function_name, variant_name, metric_name, toStartOfMinute(UUIDv7ToDateTime(uint_to_uuid(id_uint))) as minute, avgState(value) as feedback_mean, varSampStableState(value) as feedback_variance, count() as count FROM FloatMetricFeedbackByVariant GROUP BY function_name, metric_name, variant_name, minute;这套"UInt128 列 →uint_to_uuid→UUIDv7ToDateTime"的链路,让 UInt128 存储不仅能排序,还能零成本地做时间窗口聚合——这正是 observability、评测统计等功能的地基。
五、排序与分页:UInt128 上的游标实践
在 UInt128 排序键上实现游标分页(keyset pagination)非常自然,因为 UInt128 就是单调的数值。TensorZero 的推理列表分页逻辑(inference_queries.rs):
Some(PaginationParams::After { id }) => { query_params.insert("cursor_id".to_string(), id.to_string()); where_clauses.push("id_uint > toUInt128({cursor_id:UUID})".to_string()); "ASC" // For after, we order ASC and then reverse } None => "DESC", // Default: most recent first要点:
After游标分页:id_uint > toUInt128({cursor_id:UUID})直接用主键过滤出游标之后的记录,随后升序取LIMIT,最后在 Rust 侧反转结果,保证"最新在前"的语义;- 默认行为:无游标时按
id_uint DESC,天然返回最新推理; - 与 UUID 直接比较的区别:若用
id > {cursor_id:UUID},由于字节序问题,比较结果与时间序不一致,分页会错乱、重复或漏数据。
会话(Episode)列表的聚合分页同样完全构建在episode_id_uint上。见 episode_queries.rs:内层按episode_id_uint ASC取过量数据、GROUP BY episode_id_uint统计推理数、外层再ORDER BY episode_id_uint DESC输出,输出列则用uint_to_uuid(episode_id_uint) as episode_id、uint_to_uuid(max(id_uint)) as last_inference_id还原 UUID,并借助UUIDv7ToDateTime(uint_to_uuid(min(id_uint)))计算会话起止时间。
六、迁移与工程落地:从 MergeTree 到 ReplacingMergeTree
这一模式在 TensorZero 的 ClickHouse 迁移体系中经过了两次迭代,源码注释完整记录了演进过程:
- 迁移 0013:首次引入 UInt128 排序键 +
uint_to_uuidUDF,使用普通MergeTree引擎(migration_0013.rs),并声明"应取代更早的 0007、0010"; - 迁移 0020:为保障迁移幂等性,改用
ReplacingMergeTree,并明确id_uint为去重版本键(migration_0020.rs):
let table_engine_name = self.clickhouse.get_maybe_replicated_table_engine_name( GetMaybeReplicatedTableEngineNameArgs { table_engine_name: "ReplacingMergeTree", table_name: &create_table_name, engine_args: &["id_uint"], }, );迁移 0020 还处理了一个微妙场景:如果InferenceById已存在(由旧迁移创建),则先以随机后缀创建新表,再用EXCHANGE TABLES原子切换、随后DROP旧表(migration_0020.rs),避免迁移中断时数据丢失。
迁移逻辑本身也体现了对本模式的强校验:should_apply阶段会执行SHOW CREATE TABLE InferenceById并断言结果包含UInt128,还会查询system.functions确认uint_to_uuid已安装(migration_0020.rs)。这意味着一旦表结构偏离"UInt128 排序键"规范,迁移系统会主动报错,而不是静默带病运行。
6.1 集成测试的背书
E2E 测试直接验证了"UInt128 派生表不丢数据、转换无损"。见 tests/e2e/clickhouse.rs:
// Check that existing rows are inserted into InferenceById and InferenceByEpisodeId let final_inference_by_id_count: u64 = count_table_rows(clickhouse, "InferenceById FINAL").await; assert_eq!( final_inference_by_id_count, final_chat_count + final_json_count, "Didn't insert all data into InferenceById" );测试随后从ChatInference采样一行,用SELECT toUInt128(id) as id_uint, toUInt128(episode_id) as episode_id_uint ...(clickhouse.rs)取出 UInt128 表示,再到InferenceById/InferenceByEpisodeId中按id_uint、episode_id_uint精确回查并逐字段比对,证明物化视图链路中的转换正确无误。注意查询用的是InferenceById FINAL——这是 ReplacingMergeTree 语义下的必然要求,读取聚合时需要通过FINAL或聚合函数合并同版本行。
七、适用范围与注意事项
从 TensorZero 的使用面看,UInt128模式并非无差别套用,而是有清晰的边界:
- 仅排序键需要
_uint列。普通数据列(如InferenceById.episode_id)仍保留 UUID 类型,避免冗余;只有参与ORDER BY/索引的列才需要 UInt128 双轨。 - 读取一律还原 UUID。对外接口、Rust 反序列化结构(如 resolve_uuid.rs 中的
InferenceRow { episode_id: Uuid })都使用Uuid类型,UInt128 只是存储/排序形态,不应泄漏到应用层。 - 物化视图场景下注意时间窗口。非全新初始化(非 clean start)时,迁移会用
UUIDv7ToDateTime(id) >= ...或UUIDv7ToDateTime(uint_to_uuid(id_uint)) >= ...限定视图摄入窗口,并配合ViewOffsetDeadline等待后回填历史数据(见 migration_0039.rs),保证存量数据不丢失、增量不重复。 uint_to_uuid是全局函数。迁移注释特别说明:回滚时不删除该函数,因为 ClickHouse 用户自定义函数是全局作用域,并发迁移中删除会相互破坏(migration_0013.rs)。- 新表设计应默认遵循。凡新表需要"按 UUIDv7 时间序扫描"(推理列表、会话列表、反馈时间线、评测运行列表等),应直接采用
*_uint UInt128+ORDER BY *_uint的写法,避免上线后再靠迁移回填。
八、可复用的检查清单
如果你在 TensorZero 内扩展新表,或在其他项目里踩到同类问题,可以按以下清单自查:
- 排序键中的 UUIDv7 字段是否已另存为
UInt128(*_uint列)? - 建表
ORDER BY是否指向*_uint列而非 UUID 列? - 写入路径(含物化视图)是否用
toUInt128({id:UUID})完成转换? - 读取路径是否用
uint_to_uuid(id_uint)还原 UUID 后再输出? - 按 ID 过滤时是否用
id_uint = toUInt128({id:UUID})以命中主键索引? - 游标分页是否基于
*_uint的数值比较(>/<)而非 UUID 比较? - 需要反推时间时是否走
UUIDv7ToDateTime(uint_to_uuid(*_uint))? - 使用 ReplacingMergeTree 时,读取聚合是否带上
FINAL或等价合并?
TensorZero 的这一套约定,本质上是把"UUIDv7 想表达的时间序"与"ClickHouse UUID 列实际提供的字节序"之间的一次显式对齐:用整数列承载排序语义,用 UDF 保证双向无损转换,用物化视图和迁移把复杂度收敛在数据库层。理解了toUInt128/uint_to_uuid这条双向通道,你就能在任何 ClickHouse 数据模型中安全地拥抱 UUIDv7 带来的时间有序性收益。
【免费下载链接】tensorzeroTensorZero is an open-source LLMOps platform that unifies an LLM gateway, observability, evaluation, optimization, and experimentation.项目地址: https://gitcode.com/GitHub_Trending/te/tensorzero
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考