
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 aUUIDcolumn directly. 当直接对 UUID 列排序时ClickHouse 不会保持 UUIDv7 值的 chronological 顺序。在 TensorZero 的迁移代码注释中对这一现象给出了更精确的定位。见 migration_0013.rsAs 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.mdFor tables where a UUIDv7 value is part of the ordering key, store it asUInt128(for example,run_id_uint) instead ofUUID. 对于排序键中包含 UUIDv7 值的表将其存储为UInt128例如run_id_uint而不是UUID。2.1 三条铁律在读写时必须遵循以下双向转换协议环节操作表达式方向写入UUID → UInt128toUInt128({id:UUID})UUID 转为排序友好的整数读取UInt128 → UUIDuint_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.rsCREATE 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_uintUInt128而不是episode_idUUID。episode_id仍以 UUID 类型保留因为它只是普通数据列不参与排序。复合排序键的场景同样适用。InferenceByEpisodeId表以(episode_id_uint, id_uint)作为ORDER BYmigration_0013.rsCREATE 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.rsCREATE 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 通过安装一个全局用户自定义函数UDFuint_to_uuid完成这一转换。该函数在迁移 0013/0020 中随表一起安装migration_0013.rsCREATE 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 读取端还原 UUIDTensorZero 的查询层严格遵循排序用_uint列、对外输出用uint_to_uuid还原的分工。例如列出推理元数据时inference_queries.rsSELECT 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.rsSELECT function_name, function_type, variant_name, episode_id FROM InferenceById WHERE id_uint toUInt128({id:UUID}) LIMIT 1 FORMAT JSONEachRow SETTINGS max_threads1EpisodeById的查询同理resolve_uuid.rsSELECT 1 FROM EpisodeById WHERE episode_id_uint toUInt128({id:UUID}) LIMIT 1 FORMAT JSONEachRow SETTINGS max_threads14.3 从 UInt128 反推时间戳由于 UUIDv7 内嵌时间戳而uint_to_uuid能无损还原 UUID因此可以从_uint列直接提取时间信息。TensorZero 在物化视图和统计聚合中大量使用UUIDv7ToDateTime(uint_to_uuid(id_uint))这样的组合表达式。例如在反馈按变体variant聚合的物化视图中migration_0039.rsCREATE 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.rsSome(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.rslet 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, Didnt 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 UInt128ORDER 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),仅供参考