PostgreSQL码农集散地

DuckDB 居然有事务引擎,鹅滴乖乖

大多数人对 DuckDB 的印象是"分析用的 SQLite",而 SQLite 的形象又停留在"玩具级事务"。事实是:这只鸭子从第一天起就带着完整的 MVCC、WAL、检查点和崩溃恢复——只是在单人场景里,大多数人从未触发过这套 machinery。本篇把它讲清楚,因为理解并发模型,才能理解 DuckDB 的适用边界在哪里。
采访式的问题最能暴露认知偏差:问一个数据工程师"DuckDB 是事务型数据库吗",多数人会愣一下。它当然不是 OLTP 服务器,但它的的确确是一个事务型数据库——有 ACID,有 MVCC,有隔离,有崩溃恢复。第 02 篇的架构图里,事务引擎是和存储引擎并列的常驻服务; src/transaction/ 目录下十几个文件,规模不大,但该有的一样不缺。这套机制平时隐形,一旦你的使用场景从"单人批处理"长到"多连接共享数据",它就从背景走进前台。

两层并发:文件锁与 MVCC

DuckDB 的并发模型分两层,各管一段。

并发模型:一把文件锁,一个写者,无数读者
进程级,一把文件锁。 同一个库文件同时只允许一个进程以读写模式打开,由本地文件系统的文件锁强制( src/common/local_file_system.cpp )。作者的实测把边界测得很清楚:
  • 两个进程同时写同一库文件:后到者直接报 IO Error,错误信息带着持有锁的进程 PID,还附一句"你可以用只读模式打开,比如 CLI 加 -readonly"——错误信息本身就是文档;
  • 两个进程同时 -readonly 打开:相安无事,都能读到同一份数据(实测双双返回 6,000,003 行);
  • 只读打开持有期间,第三个进程想读写打开:被拒,同样得到只读建议。
注意一个容易踩的细节:默认方式打开库文件(哪怕是只跑一条 SELECT)拿的是 写锁 。想并发读,必须显式声明只读。这个设计把"意外"降到了最低:不存在"我以为我只是在读"的灰色地带。 进程内,MVCC。 拿到写锁的进程内部,多个连接(connection)之间靠 MVCC 协作:读者看到一致快照,写者串行提交。这正是本地文件锁不管、也管不好的部分。

MVCC:版本留在旧地方,新版本去新地方

DuckDB 的多版本实现可以用一句话概括: 更新与删除不原地覆盖 。作者在 duck_transaction.hpp 里核对到的结构是:
  • 未提交的插入不先进行组,而是躺在事务私有的 LocalStorage 里;
  • 更新与删除产生的旧版本进 UndoBuffer(每个事务一条版本链);
  • 提交时,LocalStorage 里的新行才并入行组,UndoBuffer 里的旧版本进入"待清理"状态;
  • 每个事务持有一个 SnapshotView:它看得到自己未提交的写入,加上所有在它开始前已提交的事务的写入——二者并集之外的世界,对它不存在。

MVCC:一条记录的多个版本与三个事务的眼睛
用一个思想实验走一遍:T1 把某行的 i 从 1 改成 2。改的瞬间,旧值 1 进 UndoBuffer,新值 2 进 T1 的 LocalStorage。此时 T2(在 T1 提交前开始)读到的是 1——它活在旧世界;T1 自己读到的是 2——它当然看得见自己的手。T1 提交,commit_id 发布;之后开始的 T3 读到 2。旧版本 1 在所有可能看到它的快照都消失后,由后台清理线程(cleanup_queue)回收。回滚同样走这条链:UndoBuffer 反向应用,LocalStorage 丢弃,什么都没发生过。 这套设计的学术出处,官方"站在巨人肩上"页面写得明白:主内存多版本并发控制,参照 Neumann 等人的论文。DuckDB 的实现当然做了嵌入式裁剪——它没有 HyPer 那种完整的可串行化检测,语义是快照隔离:读一致,写串行。 快照隔离有一个教科书级的名声:防不住写偏斜(write skew) ——两个事务各读一半状态、各自写出互补的修改,单独看都合法,合起来破坏不变式。你的负载如果依赖"先读后写"的跨行不变式,要在应用层补校验,或者把这类逻辑收敛到单事务。

提交的一生:从 BEGIN 到清理

把一次写入事务的生命周期展开,四个阶段: BEGIN :分配事务 ID 与快照视图。此后这个事务的所有读取都锚定在这个视图上。 写入 :插入行进 LocalStorage;更新与删除把旧版本压入 UndoBuffer;序列使用、目录变更各有各的记录通道。这些结构都在内存里,进程崩溃即消失——没关系,还没提交。 COMMIT :这一步最重,做三件事——本地表并入行组(把新行写进行组结构,可能触发新的段分配);WAL 写入并同步(commit 标记落盘才算持久, wal_sync_offset 记录同步位置);commit_id 发布(从这一刻起,全世界的读者开始看得见这批写入)。三件事的次序有讲究:数据先落地、日志先同步、可见性最后开放——任何时刻崩溃,恢复逻辑都知道该捡回什么。 清理 :提交后旧版本不会立刻消失,要等所有可能引用它的快照走完。后台线程巡检清理队列,回收无人引用的版本。这就是为什么大删除之后空间不会立刻释放——给它一点时间,或者跑一次 CHECKPOINT。

崩溃恢复:WAL 与检查点的合谋

持久性的故事在第 06 篇已讲了一半(文件格式),这里补上恢复逻辑。作者的实测抓到了三个关键行为:干净退出自动检查点,目录下不留 .wal ; kill -9 杀掉正在写入的进程(无任何清理机会),重开数据库数据完整; wal_autocheckpoint 默认 16 MB,WAL 涨到这个规模就自动把变更固化进主文件并清空。 恢复的算法因此很简单:打开文件时如果发现 .wal ,从头重放,直到最后一条完整的 commit 记录;未完成的事务(没有 commit 标记)自然被丢弃。检查点迭代数记在 DatabaseHeader 里,每次 checkpoint 加一——这个数字也是存储格式兼容性判断的输入之一。

单写者:特性还是妥协?

每次讲 DuckDB 的并发,都会落到这个问题。作者的看法: 是特性,但有明确的适用半径。 说它是特性,因为单写者模型砍掉了分布式系统里最昂贵的一类问题:没有锁协调、没有冲突检测、没有两阶段提交、没有脑裂。换来的是极简的实现、可预测的行为、以及"写锁冲突就明说"的诚实错误。对嵌入式场景——一个应用进程拥有它的数据——这个交换是压倒性的正确。 说它有半径,因为一旦场景长到"多个应用实例要写同一份数据",单文件写锁就成了硬墙。撞墙时的出路不是换 DuckDB,而是在它的上层盖房子:第 08 篇会讲 Quack 协议(把 DuckDB 本身变成服务器,多个客户端连同一个实例)和 DuckLake(元数据存 SQL 数据库、数据存 Parquet,多个 DuckDB 实例通过中心 catalog 协调并发写)。MotherDuck 云服务也是同一思路的托管版。 DuckDB 没有假装自己能解决多写者问题,它把解决方案做成了扩展和格式 ——这种克制比大包大揽更接近工程上的成熟。

冷静的部分

三个必须知道的边界。其一,隔离级别是快照隔离,写偏斜要靠应用层;其二,清理有时延,大删除后空间释放不即时,长时间跑批的库要关注 WAL 与临时文件的大小;其三,文件锁是操作系统行为——网络文件系统(NFS 等)上 flock 的语义可能不符合预期,生产部署别把库文件放在语义不稳的存储上。这三个边界都不致命,但不知道就会踩。 事务篇收尾,系列的引擎部分(解析、优化、执行、存储、事务)就齐了。下一篇转向外面: DuckDB 怎么从一个引擎长成一个生态——扩展机制、DuckLake、Quack,以及"库"与"服务"之间新出现的中间形态。