DuckDB 删和改撞上同一行,改的那笔悄悄没了
夜里跑一份 ETL,一个连接把队列里前 500 个任务标成 done,另一个连接顺手把这些还没跑的老任务删掉。两个事务都返回成功,没有任何报错。
第二天对账,这 500 行不在 done 里,也不在 pending 里。它们直接没了。
DuckDB 里删和改撞上同一行不冲突,两边都提交成功,其中一笔写就这样消失。
这句话你可以截图发出去,因为官方文档写的正好是相反的。
● ● ●
两条连接,试一下就出来
用 2.0.0 的 Python 客户端,一个文件库,两条连接:
import duckdb
a = duckdb.connect("t.db"); b = duckdb.connect("t.db")
a.execute("BEGIN"); a.execute("UPDATE t SET v = 100 WHERE id = 1")
b.execute("BEGIN"); b.execute("DELETE FROM t WHERE id = 1")
a.execute("COMMIT") # ok
b.execute("COMMIT") # ok
跑完 SELECT * FROM t,id=1 那行没了。v=100 那笔更新,和它的 COMMIT,都是假动作。
把两个事务的提交顺序反过来,结果一样。我各跑了 5 次,10 次全部正常返回,一次冲突错都没抛。
两条连接同时删和改同一行
● ● ●
四种撞法,只有两种会被拦住
同一个表、同一行,我试了所有组合:
Conflict on update! | ||
Conflict on tuple deletion! | ||
| DELETE vs UPDATE(任一方向) | 两边都成功,无报错 | 行消失,UPDATE 丢失 |
前两行是 DuckDB 现在就做对的部分:写冲突会在语句执行时就抛出来。第三行是漏洞。第四行先记着,后面会讲它为什么也变了。
我另外做了一遍放大版:1000 行任务表,A 把 id ≤ 500 的行改成 done,B 把 id ≤ 500 且 status = pending 的行删掉。两个事务都提交成功,最后剩 500 行,其中 status = done 的是 0 行。500 次 UPDATE 报告成功,对应的行一行不剩。
1000 行任务表的实验结果
● ● ●
不是 2.0 才有的
我手上装了三个版本,同一个脚本跑出来的结论一模一样:
1.5.6(2026-09-28 发布):复现 2.0.0(2026-10-21 发布):复现 2.1.0.dev2610300858(10 月 30 日早上的 nightly):还复现
所以这跟 2.0 的存储层改动无关,是个更早就存在的隔离缺口。
● ● ●
文档写的是另一回事
DuckDB 并发文档里原话是:
Optimistic concurrency control comes into play when two threads attempt to edit (update or delete) the same row at the same time. In that situation, the second thread to attempt the edit will fail with a conflict error.
翻译过来就是:两个线程同时编辑(更新或删除)同一行,第二个线程会失败并收到冲突错误。
实测里"第二个线程"拿到了 COMMIT ok。文档承诺的隔离级别,引擎少做了一格。这也是我写这篇的原因:这类缺口不会在你的监控里报警,只会在某天的对账报表上表现出来。
● ● ●
这个问题报上去 18 个月
GitHub issue #17223,标题叫 Incorrect transaction isolation between UPDATE and DELETE,2025 年 4 月 23 日开的,附了可复现的 Python 脚本,跟上面那段几乎一样。
之后没有任何人回复。2026 年 4 月 24 日,自动机器人按"365 天没有活动"给它打了 stale 标签,5 月 24 日把它关掉了。一个真实的正确性 bug,就这么在仓库里躺了 13 个月,然后被机器人收走。
从报 bug 到修好的时间线
真正修它的是 PR #26676,作者 ywelsch,10 月 7 日开,10 月 30 日 16:46 UTC 由 Mytherin 合入 main。19 个提交,+2294/−242 行,动了 22 个文件,其中新加的测试 test/sql/transactions/delete_update_conflict.test 就有 877 行。
做法是把"最后一个写这一行的是谁"记进 row group 的版本信息里,和删除标记放同一个地方,每次写行时检查一遍:
另一个事务删了这行,UPDATE 报 Conflict on update!另一个事务改过这行而自己看不见,DELETE 报 Conflict on tuple deletion!
PR 作者在描述里写得很直白:DELETE 和 UPDATE 在同一行上,两个方向都不冲突,两个都提交,其中一笔写会被静默丢掉。修复后所有写写冲突都按行判,和 PostgreSQL 的 REPEATABLE READ 一致。
我把 main 拉下来编译了一遍(HEAD 71f47d41,10-30 14:56 UTC),跑官方新加的那个测试文件,581 条断言全过;顺手跑了整个 transactions 目录,82 个用例、2396 条断言也全过。里面正是这篇文章在说的两种方向:未提交的 UPDATE 之后 DELETE 报 Conflict on tuple deletion,未提交的 DELETE 之后 UPDATE 报 Conflict on update。
● ● ●
修好之后,冲突会变多
这条要单独拎出来,因为它影响的是现在跑得好好的代码。
现在的冲突检查是按列做的:A 改 v 列、B 改 s 列,两边都能过。修复把检查改成按行:同一行只要被另一个看不见的事务写过,就算冲突,不管改的是哪一列。
对读多写少的分析场景,这个改动没感觉。但如果你有程序在一个宽表上并行更新不同列(典型的分散更新、多 worker 各管一摊),升级到带修复的版本之后会开始收到 Conflict on update!,需要按普通写冲突那样加重试。
PR 上挂着 Needs Documentation 标签,文档还没改。看到这句话的时候,文档里大概率仍然写着"第二个线程会失败",而现在这句话才终于成立。
● ● ●
你现在能做什么
修复在 main,不在任何发布版里。2.0.0 和今天的 nightly 都还能复现,所以你现在跑的库仍然有这个缺口。能做的有四条:
- 01别让删和改落在同一行上。
用软删除(打 deleted_at 标记),或者给删除加条件把它限制在已终态、且超过某个时间窗的行上。这是成本最低的一条。 - 02写操作串行化。
同一个 DuckDB 文件同一时刻只留一个写连接,靠队列而不是靠并发去跑多 worker。 - 03关键迁移做写后校验。
更新语句带上 status 条件,再读回确认。返回 500 行不代表 500 行还在。 - 04给写事务加乐观重试。
捕获 Conflict on开头的错误重跑整个事务。修复版本上线后这条会用得更多。
● ● ●
回到开头那 500 个任务
如果我当时用的是"更新成功就当完成了"的写法,那 500 个任务会永远停在系统外部:没有 done,没有 pending,日志里全是成功。DuckDB 没算错,它只是没把这两笔写当成一次冲突。
一个数据库的并发正确性,只能靠测试用例证明,不能靠文档承诺。你的库里如果没有跑过"删和改撞上同一行"这条用例,那这部分就等于没有测。
● ● ●
参考来源
- 01
DuckDB 并发文档(2.0):https://duckdb.org/docs/2.0/connect/concurrency - 02
Issue #17223 Incorrect transaction isolation between UPDATE and DELETE:https://github.com/duckdb/duckdb/issues/17223 - 03
PR #26676 Detect write-write conflicts between DELETE and UPDATE of the same row:https://github.com/duckdb/duckdb/pull/26676 - 04
新增测试文件 delete_update_conflict.test:https://github.com/duckdb/duckdb/blob/main/test/sql/transactions/delete_update_conflict.test - 05
本机实测:duckdb 1.5.6 / 2.0.0 / 2.1.0.dev2610300858,Python 客户端双连接,文件库