@clickhouse/rowbinary: 当你的库同时也是一个解析器编译器
摘要
ClickHouse 发布了 @clickhouse/rowbinary,一个用于读写 RowBinary 格式的 Node.js 库,它还包括一个技能文件,使编码代理能够生成针对查询的高性能解析器,速度比通用解析器快1.5到3.4倍。
<p>我过去做过一些“正统的”解析器生成工作,发现一个好用的生成器需要做出太多带偏见的决策,以至于感觉像是在编写一个… 具有自然语言翻译级别细微差别的小型机器。因此,尝试使用现代编码LLM来满足每个挑剔的用户。</p>
<p><a href="https://lobste.rs/s/fxtvgm/clickhouse_rowbinary_when_your_library">评论</a></p>
查看缓存全文
缓存时间: 2026/07/15 09:42
# @clickhouse/rowbinary:当你的库同时也是解析器编译器 | ClickHouse
来源:https://clickhouse.com/blog/clickhouse-rowbinary-library-parser-compiler
我们发布了@clickhouse/rowbinary (https://www.npmjs.com/package/@clickhouse/rowbinary),这是一个用于 ClickHouse 的 RowBinary (https://clickhouse.com/docs/interfaces/formats/RowBinary)、RowBinaryWithNames 和 RowBinaryWithNamesAndTypes 格式的 Node.js 读写器。你可以像使用任何其他库一样导入并调用其通用解析器。但它同时也作为 Agent Skill 打包:将编码代理指向打包的 `SKILL.md`,然后代理不再调用库,而是读取它并编写一个专门针对你查询的精确列类型的解析器。这些生成的解析器运行速度比在循环中组合库函数快 1.5–3.4 倍,每个生成成本约 0.20 美元,并且避免了模型凭记忆编写二进制解码器时可能产生的静默数据损坏错误。
RowBinary 是从 ClickHouse 获取数据最有效的方式之一,也是从 JavaScript 消费时最令人头疼的格式之一。线上的格式本身很简单:小端序原语、LEB128 (https://en.wikipedia.org/wiki/LEB128) 变长编码,没有逐行开销。痛苦在读取端。每个叶子类型都有自己的读取模式。`Nullable`、`Array`、`Map`、`Tuple` 和 `LowCardinality` 可以任意嵌套。`DateTime64` 需要精度感知的缩放和可选的时区。`Variant`、`Dynamic` 和 `JSON` 类型是自描述和递归的:每个值携带自己的类型标签并分派回同一个解析机制。
因此,大多数应用程序退回到 JSON 格式,这会消耗实际的 CPU 时间,并且更糟糕的是,会静默地将 `UInt64` 值超过 `Number.MAX_SAFE_INTEGER` 四舍五入为 `float64`,除非你采用缓慢的 stringify-再-reparse 路径。那些确实采用 RowBinary 的团队最终会得到一个通用的、类型分派的解析器:每个类型一个函数,在运行时每个单元进行分派。它易于维护但难以做快,因为每个单元都要付出分派成本,并且 V8 的内联器会在巨型 (https://people.dsv.su.se/~beatrice/python/dls15_large_images.pdf) 调用点上放弃。你在高 QPS 下真正想要的是一个针对你的查询进行了单态化 (https://en.wikipedia.org/wiki/Monomorphization) 的解析器:正确的读取操作按正确的顺序内联到你的精确列中,没有任何分派。没有人会为每个他们发布的查询手动编写这些。
这是这个包所填补的空白。该库为你提供了针对整个 ClickHouse 类型系统的正确且经过测试的读取原语。该 Skill 教会编码代理将这些原语组合成你永远不会自己编写的手工调整、针对查询的解析器。
该包有两个层次。第一个是类型特定读取原语的库:每个叶子类型一个小的函数,加上 `Nullable`、`Array`、`Map`、`Tuple` 以及其他类型代数的可组合包装器,提供全缓冲和分块流两种变体。每个原语都写成可单态化的(小、单一用途、没有巨型分派),以便可以内联到查询特定的解析器中,而不会破坏 V8 的优化器。这一层本身就是一个完全可用的库:导入它,调用 `parseRowBinary(...)`,得到正确结果。它还导出了写入器、双向流,以及一个基于 @clickhouse/datatype-parser (https://www.npmjs.com/package/@clickhouse/datatype-parser) 的动态 RowBinaryWithNamesAndTypes 管道。
第二个层次是 SKILL.md (https://github.com/ClickHouse/clickhouse-js/blob/main/skills/clickhouse-js-node-rowbinary/SKILL.md)。它不是描述如何调用 API,而是教会编码代理一种策略,用于将原语组合成针对给定查询的列类型的定制解析器。库源代码中的注释解释了每个块看起来如此的原因,以及哪些轴是安全可以更改的:缓冲区所有权、64 位整数的 `BigInt` 与 `number`、`Date` 映射器钩子、`Decimal` 缩放处理、`Array` 物化策略、定宽列的快速路径。该库是参考实现,注释是设计理由,而 `SKILL.md` 是代码生成策略。
安装:
```bash
npm i @clickhouse/rowbinary
```
该 Skill 已注册在包的 `agents.skills` 字段中,因此任何扫描 `node_modules` 以查找技能的代理都会自动找到它。你也可以直接添加:
```bash
npx skills add ClickHouse/clickhouse-js --skill clickhouse-js-node-rowbinary
```
以我们基准测试中的订单模式为例:
```typescript
id UInt8
uid UUID
price Decimal64(2)
status Enum8('new' = 1, 'shipped' = 2, 'done' = 3)
```
组合库的公共 API 会给你一个正确的解析器,而且这是显而易见的方式:
```typescript
export const readOrderRow: Reader = (s) => ({
id: readUInt8(s),
uid: formatUUID(readUUID(s)),
price: readDecimal64(2)(s), // 每行重建闭包
status: readInt8(s),
});
```
但每个字段都做自己的边界检查,`readDecimal64(2)` 每行构建一个新的闭包,而 `formatUUID` 通过 `BigInt`。加载 Skill 后,代理注意到每一列都是定宽的,并发出如下代码:
```typescript
export const readOrderRowFast: Reader = (s) => {
const { buf, view } = s;
// 每一列都是定宽:1 + 16 + 8 + 1 = 26 字节。
// 整行一次边界检查,然后在恒定偏移处读取。
const o = advance(s, 26);
const id = buf[o]!;
const uid = formatUUIDTable(buf.subarray(o + 1, o + 17)); // 使用表,而非 BigInt
const price: DecimalValue = [view.getBigInt64(o + 17, true), 2];
const status = view.getInt8(o + 25);
return { id, uid, price, status };
};
```
整个 26 字节行只需一次边界检查,在恒定偏移处读取,Decimal 缩放内嵌,并使用查找表 UUID 格式化器。输出与组合版本字节完全相同,速度快 3.41 倍。由于解析器已经是专门化的,`.map()` 风格的转换是免费的:重命名列、派生字段或删除从不使用的列,转换会以零额外成本进入读取循环,且无需为库添加零额外选项。
在采用之前,有几个值得一提的属性:
- **生成的解析器是常规代码。** 由人提交,并像任何其他模块一样经过审查、测试和基准测试。Skill 产生的是你可以阅读的源代码,而不是你信任的二进制文件。该库附带了一个全面的测试套件,你可以借用它来覆盖现在由应用拥有的读取器。
- **Skill 是可审计的。** 它是一个 markdown 文件加上密集注释、经过测试的 TypeScript,全部在 npm tarball 中。
- **通用读取器仍然有效。** 完全跳过代理,直接调用 `parseRowBinary(...)` 以获得已知正确的路径。Skill 是倍增的,而不是替代品。
- **它最适合能够读取整个库并遵循多步骤引用指令的模型。** 小模型效果会下降,但通过聚焦的子代理,它们仍然可用:Haiku 的通过率从 52% 跃升至 86%。
模式驱动的二进制格式有一个标准剧本用于生成单态化的解析器:构建一个代码生成编译器。这是 `protoc`、`flatc` 和 Cap'n Proto 都采用的形状:获取模式,运行编译器,为每个目标语言发出专门的代码。它很有效,但代码生成编译器是一个真正的软件,包含模式语言的解析器、IR、每个目标语言的后端、选项矩阵、发布节奏和维护者队列。用户想要的每一种自定义程度(不同的十进制库、自定义 `Date` 映射器、`Int64` 的 `BigInt` 与 `number`、积极与惰性的 `Array` 物化、`LowCardinality` 的字符串内部化表)都必须设计为标志、命名、记录、跨版本保持稳定,并针对每个其他标志进行测试。
我们本可以为 JavaScript 中的 RowBinary 构建一个。相反,我们将编译器分解为代理可以在推理时读取和重新组合的工件:密集注释的原语和一个 markdown 文件。自定义表面不再是固定的标志列表,而是变成与已阅读你库的模型的对话。如果你希望 `Decimal128` 映射到自定义的大十进制库,或者 `DateTime64(9)` 以纳秒形式的 `BigInt`,或者通过字符串内部化表物化的 `LowCardinality(String)`,这些都不需要我们提供标志。代理可以在几百个 token 内处理它们,因为它理解了 `decimal.ts` 中的注释。
我们针对每个模式在 50k 行上对三种解码路径进行了基准测试:正确的 JSON 路径(服务器端宽整数字符串化,客户端重新解析为 `BigInt`)、从库 API 组合的通用 RowBinary 读取器,以及代理生成的单态化解析器。硬件和版本列在帖子的末尾。RowBinary 在 Apple M4 Max 上针对宽整数金融账本模式比 JSON 快 3.3 倍(基准测试源代码 (https://github.com/ClickHouse/clickhouse-js/blob/8c51d9a12e67e82ef431e8158d5b77ce26e40e3a/skills/clickhouse-js-node-rowbinary/tests/ledger.bench.ts)),在 CI 中 4 核 AMD EPYC 7763 上快 2.5 倍(运行记录 (https://github.com/ClickHouse/clickhouse-js/actions/runs/28439460847/job/84273435913#step:9:40))。随着新硬件的发展,差距会拉大,因为 RowBinary 的工作是指针算术和连续内存读取,而 JSON 则是分支密集的标记化和 `BigInt` 分配。IoT 模式在两个机器上保持约 2.1 倍。请注意,这些数字是与*正确的* JSON 路径进行比较的。裸 `JSONEachRow` 看起来接近 1.8 倍,但它静默地将每个 `UInt128`、`Int128` 以及任何超过 `Number.MAX_SAFE_INTEGER` 的 `UInt64` 四舍五入为 `float64`。解码成功,数字错误,直到你将总数与服务器比较时才会引发任何抱怨。
代理生成的解析器比组合读取器又快了 1.5–3.4 倍:
| 模式 | 形状 | 相比组合读取器的加速比 |
|------|------|------------------------|
| 金融账本 | 宽整数 (`UInt128`, `Int128`) | 1.55x |
| IoT 遥测 | `Float64`/整数 | 2.46x |
| 订单 | 定宽、反规范化 | 3.41x |
我们测试的每个数值密集型模式都达到了 1.5 倍或更好。不过,RowBinary 并非在所有地方都胜出。在一个字符串密集的日志模式上,`JSONCompactEachRow` 甚至击败了优化的 RowBinary 读取器,并且 Skill 自身的指导也建议那里不要使用 RowBinary。Skill 知道自己的边界。
生成成本,在四个数值密集型模式上使用 Claude Sonnet 4.6 的平均值:约 230k token 输入(几乎全部从代理循环中的提示缓存提供;每次调用的唯一 Skill 占用约 28k),约 2.1k token 输出,**每个解析器 0.20 美元**(带缓存),未缓存的上限为 0.72 美元。每次部署重新生成完全在预算内。
评估还显示了为什么 Skill 比速度更重要。RowBinary 将 UUID 存储为两个小端序的 `UInt64` 半部分,每个相对于文本形式是字节反转的。要求仅凭记忆编写该解码器,没有文档、工具或测试-修复循环,Sonnet 4.6 在 5 次运行中有 3 次字节顺序错误,每次都产生相同的静默损坏输出:按顺序的 16 个线上字节的十六进制表示,格式化为一个看起来完全合理的 UUID 字符串。Opus 4.8 在 5/5 次中可靠。加载 Skill 后,每次运行都固有正确,因为参考原语就在库中,距离代理正在读的地方只有两个函数调用。
那个失败模式就是我们关心这个更甚于速度的原因。ClickHouse 用户对这些解析器运行数十亿行。一个将 `UInt128` 四舍五入为 `float64` 的 JSON 路径会在数百万条记录上产生稍有不同的总数,六周后当财务报告不平衡时才会有人注意到。一个按源顺序将线上字节十六进制化产生通过模式验证的 UUID 字符串的解码器,会静默地破坏连接谓词。这些是使团队完全拒绝将 AI 生成的代码放在数据路径上的错误。Skill 通过使生成的解析器成为我们维护的官方、经过测试、经过代码审查的原语的重新组合来解决这些问题。当你在 PR 中审查生成的解析器时,你是在审查从我们编写的代码组装而来的代码。
一个合理的反对意见是,一个足够有能力的编码代理应该能够无需任何 Skill 直接编写这个解析器。它可以,这就是 Skill 被构建的方式。Claude Code 配合 Opus,以 RowBinary 规范和 ClickHouse 源代码作为参考,产生了一个工作的读取器。它花费了数百万 token,大约一天连续的提示,几轮测试编写和基准调整,以及人类审查每一次迭代。Skill 是那一天工作的编译产物。它内嵌了累积的经验教训(LEB128 读取、Decimal 缩放处理、哪些整数宽度需要 `BigInt`、V8 如何内联以及不内联等),而模型否则每次都必须从头发现这些。编译器也是类似的摊销工程:有人花了数月教会它类型系统和代码生成规则,以便每个下游用户都能廉价地获得专门化的输出。这里,投资是一天的人机协作,冻结在 markdown 和注释代码中,每个下游用户只需花费 0.20 美元的推理调用。
Agent Skills 在 2025 年 10 月作为一种格式落地,并在 2025 年 12 月成为开放标准。到目前为止最常见的模式是:在 npm 包旁边发布一个 `SKILL.md`,以便代理正确调用你的 API。Vercel Labs 的 `skills` CLI、antfu 的 `skills-npm`、`npm-agentskills` 等。这些很有用,我们已经提供了一个类似形状的:clickhouse-js-node-troubleshooting (https://github.com/ClickHouse/clickhouse-js/tree/main/skills/clickhouse-js-node-troubleshooting),一个在出现问题时代理查阅的 runbook。
`@clickhouse/rowbinary` 是一个不同的形状:库作为参考而非库作为 API。代理不是将库视为要调用的黑盒,而是将其视为每个查询可 fork 的透明参考实现。如果你要编写这样的 Skill,这改变了你编写代码的方式。用于被调用的代码需要诚实的描述。用于被阅读的代码需要密集的注释、内部一致性,以及不加不重现的聪明。
我们期望这种模式会泛化。任何你原本会使用代码生成编译器的地方(狭窄、模式驱动、性能敏感的输出),现在有了一个替代方案,只需要一个 markdown 文件和一个推理调用。如果你发现 Skill 产生错误输出的地方,库中的注释是修复的去向;请提交 issue (https://github.com/ClickHouse/clickhouse-js/issues)。
**本地:** Apple M4 Max,Node v24.6.0,macOS 26.5.1,ClickHouse 26.1.1.200。
**CI:** AMD EPYC 7763(4 vCPU),Node v24.17.0,ClickHouse 26.6.1.1193(基准工作流 (https://github.com/ClickHouse/clickhouse-js/blob/8c51d9a12e67e82ef431e8158d5b77ce26e40e3a/.github/workflows/bench-skill-rowbinary.yml),最新运行 (https://github.com/ClickHouse/clickhouse-js/actions/runs/28439460847))。源代码固定在 `8c51d9a` (https://github.com/ClickHouse/clickhouse-js/tree/8c51d9a12e67e82ef431e8158d5b77ce26e40e3a)。每个模式 50k 行,通过 `npm run bench` 运行。
相似文章
完整的 ClickHouse OLAP 引擎,编译为 WebAssembly
chDB 是一个完整的 ClickHouse OLAP 引擎,编译为 WebAssembly,可通过 wasm.chdb.io 的 SQL 终端直接在浏览器中执行 SQL 查询。
基于SQL的光线追踪器
完全使用ClickHouse SQL查询实现的光线追踪器,无需任何外部代码或UDF即可将图像渲染为PNG格式。演示了程序化地形、CSG几何体以及并行像素计算。
Helicone 允许用户直接向共享 ClickHouse 编写 SQL
Helicone 实现了一项名为 HQL 的功能,允许客户针对共享的 ClickHouse 数据库编写原始 SQL 查询。他们利用 ClickHouse 的行策略和自定义设置来实施多租户隔离,确保每个组织只能看到自己的数据。
@boshen_c: 是时候揭晓这项工作的幕后人物了!@Shanshrew 创建了一种新型解析器架构,速度提升 2-3 倍。Pl…
由 Shanshrew 开发的新型解析器架构比当前最快的 JS/TS 解析器快 2-3 倍,并正在集成到 Oxc 中。
我重写了PostHog的SQL解析器,速度提升70倍,而几乎没看代码
PostHog的工程师使用了多次长时间运行的Claude Code会话,重写了他们的SQL解析器,相比之前基于ANTLR的解析器实现了70倍的加速,而他们自己几乎没看代码。