4.4 锁与隔离三库对照


4.4 锁与隔离:三库并发模型对照

本节摘要:SQLite 的并发控制是"文件锁加单写者",InnoDB 是"行锁加间隙锁加 MVCC",PostgreSQL 是"纯 MVCC 加谓词锁"。本节把三套体系摆上同一张桌子:锁的粒度、隔离级别、写写冲突的处理方式,并重点回答工程上最常见的两个问题——BUSY 错误怎么处理、事务该用 DEFERRED 还是 IMMEDIATE 开启。

锁的粒度差了四个数量级

SQLite 的锁对象是整个数据库文件。文件锁状态机从 SHARED(读)到 RESERVED(打算写)到 PENDING 到 EXCLUSIVE(真正刷页),一步比一步排他。没有行锁、没有页锁——最小的锁就是库级。InnoDB 锁到,还配上间隙锁防幻读;PostgreSQL 表面上只有表级锁与行级锁,实质靠 MVCC 让读写几乎互不相见。三套体系的差异写进了一张对照表:

维度 SQLite MySQL InnoDB PostgreSQL
锁对象 整个库文件 索引记录加间隙 行版本加表级意向锁
并发写入 同一时刻至多一个写事务 多写事务并行,冲突时等待或死锁 多写事务并行,冲突时等待或中止
读写关系 回滚模式互斥;WAL 并行 快照读永不阻塞 快照读永不阻塞
默认隔离级别 串行化语义(快照) REPEATABLE READ READ COMMITTED
死锁 不会发生——单写者天然无环 有,检测后回滚代价小的一方 有,检测后中止一方
等待超时 busy_timeout 手动设 innodb_lock_wait_timeout lock_timeout 可设

表里最值得咀嚼的是"死锁"一行。SQLite 永远不会有死锁,因为任何时刻最多一个写事务——没有等待环就没有死锁,这是单写者模型常被忽略的福利。代价则是写吞吐上限:两个写事务只能一个做完另一个开始,谁也别想并行改库。

图:三库锁粒度与隔离级别矩阵

图:三库锁粒度与隔离级别矩阵

BUSY:单写者世界的日常

写写冲突在 SQLite 里不是异常而是日常,工程上三件事必须做对。

第一,开启 busy_timeout。默认值是零——撞上写锁立即返回 SQLITE_BUSY。多数应用应该加一句:

PRAGMA busy_timeout = 5000; -- 撞锁时挂起等待最多 5 秒再报错

C API 里也可以用 sqlite3_busy_timeout(db, 5000) 达到同样效果。注意 WAL 模式下 BUSY 的主要来源从"读者挡写者"变成了"写者挡写者"——单写者原则没变。

第二,写事务用 IMMEDIATE 开启BEGIN DEFERRED(默认)把取写锁推迟到第一条写语句,于是"先读后写"的事务容易在最后一刻撞 BUSY;BEGIN IMMEDIATE 在事务开始就取写锁,把冲突前置、避免做了半天工作后功亏一篑。多连接写同一库的场景,IMMEDIATE 是标准答案。

第三,重试要整事务回退。BUSY 返回后,未提交事务的全部修改都作废,重试必须从 BEGIN 重新开始,而不是从出错的那条语句继续。这一点与 InnoDB 的死锁重试纪律相同,但 SQLite 没有死锁回滚机制替你善后,全靠应用自律。

对照地看,InnoDB 与 PostgreSQL 的冲突发生在行级:两个事务改同一行才等待,改不同行互不干扰;等待成环时死锁检测器杀掉一个。服务端的并发上限因此高得多,但代价是锁管理器的全部复杂度、间隙锁的语义陷阱,以及"死锁"这个应用必须处理的额外状态。嵌入式场景里写冲突本来就少(单设备单应用),把复杂度换成简单性是合算的买卖。

快照隔离的一个易错点

WAL 模式下,读事务看到的是开始时刻的快照——这意味着长读事务不仅挡检查点(4.3 节),还会让应用读到越来越旧的数据而毫无察觉。轮询型任务里,务必控制读事务的生命周期;用 sqlite3_snapshot 系 API 或简单地把读拆短,都比"读完一个大事务"更健康。InnoDB 的长事务危害(undo 链膨胀)与 PostgreSQL 的长事务危害(VACUUM 停摆、事务 ID 回卷风险)形态不同但精神一致:三个引擎都会惩罚长事务,只是惩罚的表现各不相同

重试循环的标准写法

BUSY 处理的工程模板,值得原样抄进代码库:

int rc; do { rc = sqlite3_exec(db, "BEGIN IMMEDIATE", 0, 0, 0); if (rc != SQLITE_OK) { sqlite3_sleep(50); continue; } rc = run_batch(db); /* 业务写入 */ if (rc == SQLITE_OK) { rc = sqlite3_exec(db, "COMMIT", 0, 0, 0); } else { sqlite3_exec(db, "ROLLBACK", 0, 0, 0); /* 整事务回退后重试 */ } } while (rc == SQLITE_BUSY || rc == SQLITE_LOCKED);

三个细节决定这段代码的健壮性。退避要有抖动:多个连接同时重试时固定间隔会造成同步振荡,随机化 sleep 区间能打散冲突。次数要有上限:超过上限把 BUSY 升级为业务告警,无限重试会把一个锁冲突放大成整个系统的雪崩。读事务不必套这个模板:WAL 下读不冲突,给读加重试是浪费;模板只服务写路径。

**SQLite 有 SELECT FOR UPDATE 吗?**没有,也不需要。FOR UPDATE 的语义是"提前锁定将要更新的行",前提是多行锁体系;SQLite 单写者,写事务本身就独占一切——BEGIN IMMEDIATE 就是它的 FOR UPDATE。从服务端迁移代码时,把所有 FOR UPDATE 语句删掉、把事务改成 IMMEDIATE 开启,语义即等价。PostgreSQL 的 SELECT FOR UPDATE SKIP LOCKED 队列模式同理:在 SQLite 里直接用"单写者队列表加轮询写连接"替代,模型更简单。

本节要点回顾

  • SQLite 锁的是库文件,InnoDB 锁行加间隙,PostgreSQL 靠版本可见性——粒度差四个数量级。
  • 单写者 = 永无死锁 + 写吞吐封顶;多写者 = 高并发上限 + 锁管理复杂度与死锁处理义务。
  • BUSY 处理三件套:busy_timeout、BEGIN IMMEDIATE、整事务重试。
  • WAL 快照隔离让读永远一致,但长读事务会拖住检查点并读到旧世界——三库都惩罚长事务。

并发问题到此收束。下一章转向性能的主战场:索引原理与优化。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U