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 相差很远的两行则不冲突。
这对 autoincrement 和 INTEGER 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 分支 | 多个(乐观) | COMMIT | 页 | COMMIT 时页冲突,SQLITE_BUSY_SNAPSHOT,需 ROLLBACK 重试 |
| Turso MVCC | 多个(乐观) | COMMIT | 行 | COMMIT 时行冲突,需重试 |
结论
如果继续用原生 SQLite,BEGIN CONCURRENT 只在能控制主键分布、避开递增热点页的场景下有效,且必须接受页级冲突带来的重试成本。若能把存储替换成 Turso 这类 MVCC 引擎,语法可以基本不变,冲突面却会从页缩小到行。取舍归根结底是:自己编译分支、处理页级冲突,还是换一个行级冲突的后端。