DuckDB 最初定位为进程内(in-process)数据库,其设计哲学强调零协议开销的 API 调用。然而随着多进程并发写入场景需求的增长,社区迫切需要一套成熟的客户端 - 服务端通信机制。DuckDB 团队在 2026 年正式推出的 Quack 协议中,选择了一条独特的技术路径:基于 HTTP 构建,但复用 DuckDB 自研的高效二进制序列化格式来完成请求与响应的编解码。本文将聚焦这一二进制协议的内部实现,从帧编码结构、命令字设计、序列化原语到零拷贝传输机制逐一拆解。
帧头设计:固定首部与元数据布局
任何二进制协议的首要任务是解决 “接收端如何知道一条消息从哪里开始、到哪里结束” 这一根本问题。Quack 协议继承了经典网络协议的设计思路,采用固定长度的帧头(Frame Header)加上变长载荷(Payload)的结构。帧头通常由以下几个字段组成:
总帧长字段(Total Frame Length):一个固定占用的无符号整数,用于告知接收端需要读取多少字节才能完整接收本帧。这是处理粘包与拆包问题的关键 —— 无论载荷是单个查询语句还是数百万行的结果集,接收端只需按照此字段指定的长度读取字节流即可。Quack 协议在实现中采用了 32 位无符号整数作为总帧长,能够承载最大约 4 GB 的单帧数据,这对于实际业务场景已经绰绰有余。
操作码字段(Opcode):一个 16 位无符号整数,标识本帧所承载的具体命令类型。操作码是命令路由的核心依据,接收端通过解析此字段即可确定应当将载荷分发给哪个处理器(Handler)进行后续处理。在 DuckDB 的内部实现中,操作码空间的分配遵循功能分组原则:低段操作码保留给核心数据库操作,高段操作码预留给扩展插件自定义消息类型。
会话标识字段(Session ID):64 位无符号整数,用于关联客户端请求与对应的服务端响应上下文。DuckDB 的客户端 - 服务端交互模型支持多路复用(Multiplexing),同一 TCP 连接上可能同时存在多个逻辑会话,会话标识确保响应数据能够准确路由回原始发起者。
版本与保留字段:协议头中还包含若干保留位,供未来功能扩展使用。当前实现中这些字段统一置零,以确保协议的向前兼容性。
命令字设计:从请求类型到处理器映射
Quack 协议的核心命令字集合直接映射到 SQL 语句执行的生命周期。典型的命令字包括:
PREPARE(0x0001):客户端发送预编译语句请求,服务端接收语句文本后在内部完成语法解析与查询计划生成,并将生成的语句标识符返回给客户端。预编译机制是防止 SQL 注入与提升重复查询性能的关键手段。
BIND(0x0002):客户端向服务端绑定预编译语句所需的参数值。参数值以二进制形式编码在载荷中,服务端据此完成查询计划的参数替换。
EXECUTE(0x0003):触发执行已绑定的预编译语句。服务端开始实际执行查询,并将结果集的元数据与首批数据行返回给客户端。
DESCRIBE(0x0004):查询已预编译语句的元数据信息,包括结果集列名、数据类型与可空性等属性。
FETCH(0x0005):客户端向服务端获取大数据量结果集的后续分块(Chunk)。Quack 协议默认采用流式传输策略,大结果集被切分为若干固定大小的数据块逐批交付。
DISCARD(0x0006):清理会话状态,包括取消预编译语句、关闭游标等资源。
DATA_CHUNK(0x0007):专门用于批量数据传输的消息类型,支持高效的批量插入与结果集拉取操作。
命令路由的实现逻辑相对直接:接收到帧后,首先解析操作码字段,然后根据操作码查表找到对应的处理器函数指针,最后将载荷传递给该处理器。处理器链的设计允许同一操作码注册多个处理器(按优先级排序),这为插件扩展提供了灵活的钩子机制。
二进制序列化原语:LEB128 与 Field-ID 编码
Quack 协议的核心竞争力在于其底层序列化格式直接复用 DuckDB 存储层多年打磨的二进制编码方案。这套格式与 Protocol Buffers 有着相似的设计哲学,但在实现细节上有显著差异。
原始值类型方面,协议定义了五种基础类型:带符号变长整数(SVLI)、无符号变长整数(UVLI)、长度前缀二进制串(Blob)、32 位浮点数与 64 位浮点数。变长整数编码采用 LEB128(Little Endian Base 128)方案,这是一种广泛应用于 ELF 文件与 WebAssembly 的压缩编码技术。在 LEB128 中,小数值仅占用 1 个字节,中等数值占用 2 个字节,极大数值才会占用更多字节。这对于存储数据库中大量的小整型 ID 与偏移量极为有利 —— 典型场景下能够节省 50% 以上的整数存储空间。
复杂类型分为 List 与 Object 两种。List 类型以 UVLI 编码元素数量在前,随后依次序列化各元素。Object 类型则是 (field_id, value) 序列的线性排列,其中 field_id 为 16 位无符号整数。DuckDB 的序列化规范要求字段 ID 必须严格按升序排列,不得出现跳号或重复。这一约束直接服务于单遍(Single-Pass)反序列化实现:接收端只需顺序扫描字节流,无需回溯或随机访问即可完成整个对象的还原。相比之下,Protocol Buffers 要求字段以固定格式编码且支持乱序解析,虽然灵活性更高但反序列化开销也更大。
字段 ID 分配策略采用了继承友好的阶梯式分配:根对象的字段从 100 开始,每深入一层嵌套结构则增加 100。这种策略确保了基类与派生类的字段 ID 不会出现冲突。例如,某张表对象(100 起始)下的列定义(200 起始)能够与表级别的其他元数据(101-199)清晰区分。
对象终结符使用特殊的 MESSAGE_TERMINATOR_FIELD_ID(0xFFFF),标识一个 Object 的结束位置。解析器遇到此标记时即可停止当前对象的读取,转而处理下一个字段或对象。这一设计简化了流式解析的实现难度 —— 解析器无需预先知道对象的完整长度,只需逐字段扫描直至遇到终结符即可。
可选字段与默认值是协议向前兼容的核心机制。每个序列化字段都可以关联一个默认值,序列化端在值为默认值时会选择跳过该字段以节省空间;反序列化端遇到缺失字段时则自动填充默认值。这意味着协议可以在不破坏现有客户端的前提下新增可选字段,旧版解析器会忽略新字段而保持正常工作。
零拷贝传输:大数据块的高效流动
数据库协议的终极挑战之一是大数据量的传输效率。传统方案往往需要将数据从内核缓冲区复制到用户态缓冲区,再复制到协议编码缓冲区,最后才写入 socket—— 每一步复制都意味着 CPU cycles 的消耗与内存带宽的浪费。Quack 协议在设计时明确将零拷贝(Zero-Copy)作为核心目标。
结果集分块策略是实现零拷贝的基础。服务端在执行查询时,结果集被切分为固定大小的 Chunk(默认配置通常为 64KB 或 128KB)。每个 Chunk 对应一个独立的 DATA_CHUNK 帧,载荷直接由底层存储层的 Arrow 列式数据块映射而来,无需额外的内存分配或数据搬移。
底层存储格式与 Arrow 列式存储深度契合。DuckDB 内部使用 Arrow 格式存储查询中间结果与最终结果集,Quack 协议在序列化时直接引用这些 Arrow 内存区域。客户端接收帧后,可以直接将这些内存区域映射为自己的 Arrow 数组,无需进行格式转换 —— 这正是零拷贝得以实现的技术前提。
批量插入优化同样受益于这一设计。当客户端通过 Quack 协议向服务端批量插入数据时,数据同样以 Arrow 列式格式封装在 DATA_CHUNK 帧中发送。服务端接收后直接将这些 Arrow 数组写入存储层,省去了传统的行式解析再转列为存储格式的过程。
内存映射策略在多线程场景下尤为关键。Quack 协议支持在单一连接上并行传输多个 Chunk,这些 Chunk 可以来自不同的查询结果或不同的数据批次。通过合理的内存区域隔离与引用计数管理,不同线程可以安全地并发处理各自的数据块而无需相互等待。
性能优化:单次往返与连接复用
Quack 协议在协议层面对延迟敏感场景做了针对性优化。传统数据库协议(如 PostgreSQL 的 Extended Query Protocol)在执行一条简单查询时需要经历多次往返:首先发送 Parse 消息,然后发送 Bind 消息,最后发送 Execute 消息 —— 整个过程至少需要三次往返才能获得结果。
Quack 协议通过合并消息序列大幅削减往返次数。对于预编译语句的重复执行,客户端可以预先通过 PREPARE 消息完成语句的解析与计划生成,获得语句句柄后,后续的 BIND + EXECUTE 可以合并为单帧发送。服务端处理完执行后将结果直接返回,整个过程仅需一次往返。对于更简单的场景,协议还支持快捷执行路径(Fast-Path Execute):客户端直接发送包含完整 SQL 文本的 EXECUTE 消息,服务端即时完成解析、执行与结果返回,无需任何预备步骤。
连接复用是另一个重要的效率提升点。Quack 协议在单一 TCP 连接上支持多路复用,客户端可以在同一连接上并发发起多个请求,服务端通过帧头中的会话标识与会话内部的消息序列号实现请求与响应的准确匹配。这避免了为每个查询建立独立连接的开销,对于短查询占比高的工作负载效果尤为显著。
协议扩展性:插件自定义消息
Quack 协议的设计充分考虑了可扩展性。除了核心命令字集合外,协议在帧头与载荷格式中预留了扩展点,允许通过配置或插件注册自定义命令处理器。例如,某业务场景可能需要在协议层面增加批量删除、同步心跳或自定义监控指标上报等功能,只需在服务端注册新的命令字处理器即可,无需修改核心协议栈。
这种扩展性还体现在序列化格式的可定制化上。虽然默认使用 DuckDB 原生的二进制序列化格式,但协议设计允许在特定场景下替换为其他序列化方案(如 FlatBuffers 或 Cap'n Proto),前提是通信双方都支持该格式。这为企业引入 Quack 协议到异构系统环境中提供了灵活性。
资料来源:DuckDB 官方存储文档(Max xen 的 Gist)与 Quack 协议发布博客。
内容声明:本文无广告投放、无付费植入。
如有事实性问题,欢迎发送勘误至 i@hotdrydog.com。