编程 SQLite 单写锁的解法:BEGIN CONCURRENT 的页级冲突与 Turso 行级 MVCC 取舍

2026-09-04 00:04:54

SQLite 单写锁的解法:BEGIN CONCURRENT 的页级冲突与 Turso 行级 MVCC 取舍

SQLite 默认同一时刻只允许一个写者,即使开启 WAL 也没放宽这个约束。多个连接抢着写时,其中一方通常会拿到 SQLITE_BUSY,应用层看到的就是 database is locked。写频率一上来,这个单写锁就是直接瓶颈。

BEGIN CONCURRENT:推迟加锁,提交时做页级冲突检测

SQLite 源码树里有一个实验分支,支持在 WAL/wal2 模式下用 BEGIN CONCURRENT 开启写事务。它与普通写事务的区别是:真正拿锁的动作被推迟到 COMMIT,所以多个 BEGIN CONCURRENT 事务可以同时执行写操作,只在提交阶段串行。

提交时要做一次乐观检查:

  • 如果自事务开始后,它读过的数据库页没有被其他并发事务修改,正常提交;
  • 如果读过的页变了,说明它工作的数据集和其他事务有重叠,无法提交,返回 SQLITE_BUSY_SNAPSHOT
  • 收到这个错误后只能 ROLLBACK,然后重试整个事务。

示例:

BEGIN CONCURRENT;
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT;
-- 若冲突:SQLITE_BUSY_SNAPSHOT
-- 此时事务没法提交,ROLLBACK 后整体重试

SQLITE_BUSY_SNAPSHOT 会通过 sqlite3_log 输出,日志里会指出冲突发生在哪个页、哪张表或索引,能用来定位热点页。

冲突粒度决定并发上限

每个表、每个索引都是独立的 B-tree,分布在离散数据库页上:

  • 写不同表集合的事务永远不会冲突;
  • 写同一张表或索引时,只有主键/索引键落在同一数据库页附近才冲突。

例如主键是 a 的大表,写相邻的两行大概率在同一个页里,冲突;写 a 相差很远的两行则不冲突。

这对 autoincrementINTEGER PRIMARY KEY 很不友好:单调递增的键会让所有新行都落在同一页,所有并发插入都在打同一个热点页,基本没法并行。要尽量显式给主键赋随机值。WITHOUT ROWID 表如果没有显式的 INTEGER PRIMARY KEY,同样会踩这个坑。时间戳索引也麻烦——并发事务可能把相邻时间戳写进同一页,这种场景可能需要重构 schema,比如换成更高基数的前缀键。

快照建立时机:BEGIN 不等于立即可见

BEGIN CONCURRENT 推迟的是 RESERVED 锁,但数据库快照仍然在事务首次访问数据库的时刻建立,而不是 BEGIN 执行时刻。事务在首个 SELECT/INSERT 之前保持 inactive,和 BEGIN DEFERRED 类似。

假设在 T1 执行 BEGIN CONCURRENT,但直到 T2 才发起第一个查询,那么 T1~T2 之间其他事务的提交对这个事务不可见。如果可见性窗口必须从 BEGIN 算起,用 BEGIN IMMEDIATE 会在开始时冻结视图,但也意味着刚开始就要持锁。

折中技巧是 BEGIN CONCURRENT 后立刻执行一个空读:

BEGIN CONCURRENT;
SELECT 1;          -- 立刻触发数据库访问,固定快照
-- 后面的写操作照常执行
COMMIT;

这样快照固化在事务开始时间,同时保留了推迟加锁的优点。

工程取舍

BEGIN CONCURRENT 仍是实验分支,不在 SQLite 主线上,没有迹象近期会合入主线,需要自己编译特定分支才能用。

优点是:

  • 事务开启开销低,多个写事务能真正并行推进;
  • 页级版本追踪开销不高;
  • 锁只在提交阶段拿,比普通 WAL 写事务的锁持有时间更短。

缺点是:

  • 冲突粒度到页还是太粗,很多本不冲突的写入也会互相撞;
  • autoincrement/rowid 主键不友好;
  • 对随时间递增的索引(如日期时间戳)不友好;
  • 应用代码必须处理 SQLITE_BUSY_SNAPSHOT 并准备重试;
  • 如果提交阶段争用很严重,最好在应用层用互斥量保证同一时刻只有一个写者在 COMMIT 一个 BEGIN CONCURRENT 事务。

Turso:同样的语法,行级 MVCC

Turso 把 BEGIN CONCURRENT 的语法借了过来,但底层不是这个实验分支,而是一个 MVCC 引擎。默认配置仍然是单连接写,开启 MVCC 后可多连接同时写:

PRAGMA journal_mode = mvcc;

它的冲突检测是行级的,参考了 SQL Server Hekaton 的无锁乐观 MVCC 方案,再适配到 SQLite 的 B-tree 存储。对应用来说,同样是写多个事务,但冲突窗口从页级缩小到了真正被改写的行。

对比

实现同时写者加锁时机冲突粒度重试位置及错误
SQLite 默认1 个事务开始整库开始时 SQLITE_BUSY
SQLite BEGIN CONCURRENT 分支多个(乐观)COMMITCOMMIT 时页冲突,SQLITE_BUSY_SNAPSHOT,需 ROLLBACK 重试
Turso MVCC多个(乐观)COMMITCOMMIT 时行冲突,需重试

结论

如果继续用原生 SQLite,BEGIN CONCURRENT 只在能控制主键分布、避开递增热点页的场景下有效,且必须接受页级冲突带来的重试成本。若能把存储替换成 Turso 这类 MVCC 引擎,语法可以基本不变,冲突面却会从页缩小到行。取舍归根结底是:自己编译分支、处理页级冲突,还是换一个行级冲突的后端。

参考

复制全文 生成海报 BEGIN CONCURRENT SQLite 并发写 MVCC Turso

推荐文章

程序员茄子在线接单