基于 LMDB 实现零拷贝的 SQL 计算

💡 原文中文,约14200字,阅读约需34分钟。
📝

内容提要

文章探讨基于LMDB的零拷贝SQL计算:利用LMDB只读事务中页地址稳定、大值连续存储的特性,让字符串以“指针+长度”形式从存储一路借用到查询结果,避免SQLite中取列和映射结构体时的两次拷贝。基准测试显示,LMDB SQL分配量与数据大小无关,长字符串及多重物化算子下优势明显,短数据则与SQLite相当。

🔎

延伸解读

零拷贝成立的关键:LMDB 只读事务的地址稳定性

文章指出,SQLite 即使开启 mmap,取列时仍要把字符串从页拷进寄存器缓冲区,映射结构体时再拷一次。LMDB 的只读事务通过读者表保护快照,使读到的页在事务结束前地址不变,且大值在溢出页上连续存放。因此字符串可以“指针+长度”的形式从存储一路借用到查询结果,省掉第二次拷贝。这一保证依赖 LMDB 的写时复制和单写者设计,并非所有 mmap 存储都能提供。

收益随字符串长度和物化算子数量放大

基准测试显示,LMDB SQL 的分配量与数据大小无关,始终约 0.4 KiB;SQLite 的分配量则随返回字节数增长。64 B 短字符串时 SQLite 反而更快,因为拷贝几十字节代价可忽略。但到 256 KiB 时,SQLite 耗时 66.14 µs,LMDB SQL 仅 2.38 µs。多重物化算子下差距进一步放大,SQLite 分配量可达返回数据的 13 到 17 倍。这说明零拷贝的优势集中在长字符串和复杂查询场景。

适用范围与代价:短行、写事务和长事务的局限

文章明确,短行、逐行处理的负载两边差不多,SQLite 还略快;写事务里字符串照样要拷贝,因为页可能被改动,字段会退化成 Cow::Owned。TPC-C 这类负载看不出变化。此外,借用的结果活多久,只读事务就要开多久,而 LMDB 的旧页在有读者引用时不能回收,长时间持有结果又伴随大量写入,数据库文件会膨胀。这些限制决定了该方案更适合读多写少、结果生命周期短的场景。

❓

Q&A

为什么在SQLite中取列并映射到结构体时会发生两次字符串拷贝?

第一次拷贝发生在SQLite执行器通过OP_Column从B树页中解出列值时,即使启用了mmap,也会将字符串拷贝到寄存器缓冲区;第二次拷贝发生在应用程序将寄存器缓冲区中的值映射到结构体字段时,例如rusqlite的row.get()会将字符串拷贝到拥有的String中。

LMDB如何保证只读事务中读到的数据地址稳定,从而支持零拷贝?

LMDB使用写时复制(影子分页)和读者表保护快照:已提交的页从不原地修改,只读事务登记自己看到的版本,可能读到的旧页不会被回收复用,因此只读事务读到的值在事务结束前一直有效、地址不变。

在LMDB上实现SQL时,字符串在value中如何编码才能支持零拷贝?

字符串以“长度+原始字节”的形式连续存放,解码时读出长度即可得到指向LMDB页的(指针,长度),正好是一个&str。LMDB保证大值所在的溢出页连续,因此无论字符串多长,字节在映射中都是连续的一整块,可以直接借出。

执行器中的物化算子(如排序、分组)如何避免拷贝字符串?

物化算子保存值时,对于Mapped字符串只拷贝(指针,长度),不拷贝内容。因为整条查询在同一个只读事务里,这些页不会失效,所以只要没有触发spill,算子就能在内存容器中以借用的方式保存值。

基准测试中,LMDB SQL与SQLite在分配量和耗时上有何差异?

LMDB SQL的分配量与数据大小无关(约0.4 KiB),耗时基本恒定(2.4-4.4 µs);SQLite的分配量随返回数据量增长,耗时也随字符串长度增加而增长。在长字符串和多重物化算子下,LMDB SQL优势明显;短数据时两者相当,SQLite甚至略快。

使用LMDB零拷贝SQL计算有哪些限制或适用场景?

短行、逐行处理的负载优势不大;写事务中字符串仍需拷贝;聚合算子会持有借用的行,导致对应页需留在内存,长时间持有结果且伴随大量写入时,数据库文件可能膨胀。适用于读多写少、字符串较长、需要多重物化的查询场景。

🏷️

标签

➡️

继续阅读