土法炼钢兴趣小组的算法知识备份

【SQLite 内核】事务与隔离:DEFERRED/IMMEDIATE/EXCLUSIVE 与单写者快照

文章导航

分类入口
databasestorage
标签入口
#sqlite#transaction#isolation#begin-immediate#serializable#snapshot#busy-snapshot#mvcc

源码下载

本文相关源码已整理,共 1 个文件。

打开下载目录 →

目录

第 9 篇钉住了 UNLOCKED → SHARED → RESERVED → PENDING → EXCLUSIVE 这条锁阶梯,但没回答一个 SQL 层面的问题:BEGIN 之后,事务到底在哪一刻真正去拿这些锁?答案取决于三个关键字——DEFERREDIMMEDIATEEXCLUSIVE——它们决定的不是隔离级别,而是取锁时机,官方 Isolation In SQLite 文档单独把”隔离”和”BEGIN 修饰符”分成两份文档来讲,本文把两者接在一起看。

常见误区是把 SQLite 的隔离叙事直接套进 Berenson et al. 那套服务器多版本并发控制(MVCC)的词汇体系,或者以为”快照”在 SQLite 里和 PostgreSQL 里是同一件事。本文只钉三件事:

  1. BEGIN DEFERRED / IMMEDIATE / EXCLUSIVE 三种模式各在什么时刻升级到第 9 篇的哪一级锁。
  2. 用 Berenson et al.(SIGMOD 1995)的隔离现象词汇,对照官方 Isolation In SQLite 给出的隔离叙事,并说明”单写者”约束如何让 SQLite 的 WAL 快照天然回避经典的 write skew 异象。
  3. 本机 3.53.2 三种 BEGIN 模式的提交冒烟结果,以及 COMMIT/ROLLBACK 回链第 7、8 篇的日志提交点。

本文是「SQLite 内核」系列第 10 篇(共 17 篇)。→ 系列目录

篇目 核心内容
第 9 篇 · 锁状态与 shared cache 五态锁阶梯、writer starvation
第 10 篇 · 事务与隔离 DEFERRED/IMMEDIATE/EXCLUSIVE、隔离叙事、单写者快照
第 11 篇 · 查询计划器与统计 sqlite_stat*、代价估计

版本锚定:官方 Transaction(sqlite.org/lang_transaction.html)与 Isolation In SQLite(sqlite.org/isolation.html)。学术对照锚点:Berenson, H., et al. A Critique of ANSI SQL Isolation Levels, SIGMOD 1995——只借用其隔离现象定义框架,不复述该文对 ANSI 四级隔离的批评细节。MVCC 概念性对照见站内 MVCC,本文不重写其 PostgreSQL 版本链、可见性判断与 SSI 内容。本文实测锚定本机 SQLite 3.53.2


一、三种 BEGIN 模式:语义与取锁时机

官方 Transaction 文档明确写道:三种模式差的是何时开始写事务、何时尝试拿锁,不是隔离级别本身(SQLite 的隔离级别始终是 SERIALIZABLE,见第三节)。

模式 取锁时机 对应第 9 篇锁状态 失败模式
DEFERRED(默认) 推迟到语句真正访问数据库;第一条语句是 SELECT 则只拿 SHARED,是写语句才直接找 RESERVED 视首条语句类型而定 若中途升级失败,返回 SQLITE_BUSY;在 WAL 模式下若快照已过期,返回 SQLITE_BUSY_SNAPSHOT(第四节)
IMMEDIATE BEGIN 语句本身就立刻尝试拿 RESERVED,不等第一条写语句 立即 RESERVED BEGIN IMMEDIATE 本身可能因为另一连接已持有 RESERVED 而返回 SQLITE_BUSY
EXCLUSIVE BEGIN 语句立刻开始写事务,语义上等同 IMMEDIATE 但要求更强 rollback 模式下相当于提前占住通往 EXCLUSIVE 的写路径,期间禁止其他连接读;WAL 模式下与 IMMEDIATE 完全相同 IMMEDIATE

官方原文对 EXCLUSIVEIMMEDIATE 的差异写得很精确:

EXCLUSIVE and IMMEDIATE are the same in WAL mode, but in other journaling modes, EXCLUSIVE prevents other database connections from reading the database while the transaction is underway.

也就是说,“EXCLUSIVE 更强”这句话只在 rollback journal 模式下成立;一旦切到 WAL,EXCLUSIVE 不再比 IMMEDIATE 多做任何事——这是本文与第 8 篇 WAL 差异的第一个具体交叉点。

flowchart TD
  begin["BEGIN"] --> mode{"which modifier?"}
  mode -->|"DEFERRED (default)"| defer["No lock yet"]
  defer -->|"first stmt is SELECT"| sh["Acquire SHARED"]
  defer -->|"first stmt is INSERT/UPDATE/DELETE"| res1["Acquire RESERVED directly"]
  sh -->|"later write stmt"| upgrade["Upgrade SHARED to RESERVED,<br/>may return SQLITE_BUSY"]
  mode -->|"IMMEDIATE"| res2["Acquire RESERVED now,<br/>may return SQLITE_BUSY"]
  mode -->|"EXCLUSIVE"| res3["Acquire RESERVED now;<br/>rollback mode also blocks new readers<br/>(same as IMMEDIATE in WAL mode)"]

1.1 为什么 DEFERRED 是默认值

官方文档给出的理由是”尽可能推迟阻塞读访问的时刻”:DEFERRED 让一个事务在只读阶段完全不影响其他连接的写权限,只有在真正需要写的那一刻才去竞争 RESERVED。这与第 9 篇”SHARED 时仍可有人准备写”的锁阶梯是同一套设计哲学——默认行为尽量把”独占倾向”往后推。


二、实测:三种模式在本机均可提交

单连接冒烟测试,不涉及并发冲突(并发冲突已在第 9 篇用双连接实验验证)。复现脚本:reproduce/10-transaction-modes.sh。以下输出经本机 sqlite3 3.53.2 实际执行:

=== BEGIN DEFERRED ===
=== BEGIN IMMEDIATE ===
=== BEGIN EXCLUSIVE ===
=== final table state ===
1|deferred
2|immediate
3|exclusive

三条 BEGIN ... ; INSERT ...; COMMIT; 序列都顺利完成,最终表里三行都写入成功,没有任何一种模式报错。这只证明”语法与最基本的提交路径可用”,不构成并发行为的证据——并发冲突的真实边界锚定在第 9 篇的双连接实验,本文不重复伪造一个”看起来更热闹”但实际是单连接跑三次的假并发场景。


三、与 Berenson et al.(SIGMOD 1995)隔离词汇的对照

Berenson 等人在 A Critique of ANSI SQL Isolation Levels 里指出,ANSI SQL-92 用”dirty read / non-repeatable read / phantom”三种现象定义隔离级别过于模糊,容易被不同厂商用不兼容的方式实现却都自称”SERIALIZABLE”;他们重新用更精确的现象集合(包括 P0–P3 与快照隔离特有的 A5A/A5B)定义了隔离级别,并指出 Snapshot Isolation(SI)虽然能避免 ANSI 意义上的三种异常,却仍会放过write skew——两个事务各自读到对方尚未提交的旧快照、各自基于该快照做出互斥的写决定,最终数据违反了本该维持的约束,但两个事务在 SI 下都能顺利提交。

官方 Isolation In SQLite 对 SQLite 隔离行为的定性非常直接:

Transactions in SQLite are SERIALIZABLE. SQLite implements serializable transactions by actually serializing the writes. There can only be a single writer at a time to an SQLite database.

这句话本身回答了”SQLite 怎么做到 SERIALIZABLE”这个问题——不是靠 Berenson 论文语境下常见的基于快照做写写冲突检测再决定是否回滚(例如 PostgreSQL 的 SSI,站内 MVCC 有完整拆解),而是靠从根上不允许第二个写事务并发存在。同一时刻只有一个写者,天然不存在”两个并发写事务基于各自快照做出冲突决定”的场景,write skew 依赖的前提(≥2 个并发写事务)在 SQLite 里不成立——不是 SQLite 侦测到了 write skew 再阻止,而是并发模型本身没有给它出现的空间。

需要澄清边界:这不是说”SQLite 比 SSI 更先进”,而是两种完全不同的工程路径达到同一个隔离标签。SSI(Cahill et al., SIGMOD 2008,MVCC 一文已引用)允许多个写事务并发执行、事后用冲突图检测危险结构再选择性回滚,付出的代价是更复杂的运行时机制与可能的误报回滚;SQLite 用写串行化换来了实现的绝对简单,代价是写事务之间完全没有并发度——第 9 篇”单写者是否应让位于细粒度锁”这一开放问题,在隔离语义这一层的具体表现就是”是否值得为写并发引入 SSI 级别的复杂度”。


四、单写者约束下,「快照」是另一种东西

WAL 模式下,官方文档明确使用了”snapshot isolation”这个词:

In WAL mode, SQLite exhibits “snapshot isolation”. When a read transaction starts, that reader continues to see an unchanging “snapshot” of the database file as it existed at the moment in time when the read transaction started.

这与服务器端 MVCC(站内 MVCC 一文 拆解的 PostgreSQL 版本链 + 事务快照可见性判断)在读者视角上确实相似:读事务锁定一个时间点,之后别的连接的提交对它不可见。但差异出现在写者这一侧——PostgreSQL 允许多个写事务基于各自快照并发推进,最后用锁或 SSI 裁决冲突;SQLite 因为全局只有一个写者,“快照”对写事务而言更接近”我这次写必须基于最新状态,否则直接失败”,而不是”我可以基于旧快照写,回头再检测冲突”。

官方文档给出的具体机制是 SQLITE_BUSY_SNAPSHOT

X starts a transaction that will initially only read, then Y makes a change… Then X tries to make a change… The attempt by X to escalate its transaction from a read transaction to a write transaction fails with an SQLITE_BUSY_SNAPSHOT error because the snapshot of the database being viewed by X is no longer the latest version.

sequenceDiagram
  participant X as Connection X
  participant Y as Connection Y
  participant DB as WAL database
  X->>DB: BEGIN; SELECT ... (snapshot pinned)
  Y->>DB: UPDATE ...; COMMIT (new WAL frame)
  Note over X,DB: X's snapshot is now stale
  X->>DB: UPDATE ... (try to escalate to write)
  DB-->>X: SQLITE_BUSY_SNAPSHOT
  Note over X: X must ROLLBACK and BEGIN again<br/>to see Y's committed change

这条路径解释了为什么想写的事务通常应该用 BEGIN IMMEDIATE 而不是普通 BEGIN:官方文档给出的建议是——

If X starts a transaction that will initially only read but X knows it will eventually want to write… then X can issue BEGIN IMMEDIATE… If the BEGIN IMMEDIATE operation succeeds, then no subsequent operations in that transaction will ever fail with an SQLITE_BUSY error.

BEGIN IMMEDIATE 直接在事务开始时把自己钉在”当前唯一写者”的位置上,绕开了”先读后写,中途被别人抢先提交导致快照过期”的失败模式——这是三种 BEGIN 模式的语义差异(第一节)在隔离行为上的直接后果,不是两个孤立的话题。

4.1 两种失败码分别对应哪种模式

错误码 触发条件 更常见于
SQLITE_BUSY 尝试获取 RESERVED/EXCLUSIVE 时,另一连接已持有冲突锁(第 9 篇文件级锁) rollback journal 模式;BEGIN IMMEDIATE/EXCLUSIVE 直接冲突
SQLITE_BUSY_SNAPSHOT 事务已经基于某个快照读过数据,之后试图升级为写事务,但快照已被其他已提交事务超越 仅 WAL 模式;BEGIN DEFERRED 先读后写最容易触发

两者都表示”暂时不能写”,但含义不同:前者是”锁被别人占着,等一等或重试可能就好”;后者是”你依据的那份数据已经过期,重试也没用,必须先结束当前事务重新开始一个”。混淆两者会导致错误的重试策略——对 SQLITE_BUSY_SNAPSHOT 简单地”睡一下再重试同一个事务”不会有效,必须先 ROLLBACK 再重新 BEGIN


五、COMMIT / ROLLBACK 与日志提交点

官方 Transaction 文档指出一个容易被忽视的细节:SQL 层的 COMMIT 命令本身不直接把数据写盘,它只是把连接切回 autocommit 模式,真正的落盘发生在该命令执行完毕、autocommit 逻辑接管之后;如果这一步因为其他连接仍持有 SHARED 锁而失败,COMMIT 会自动把 autocommit 重新关闭,让应用可以稍后重试——这一失败-重试路径与第 9 篇”PENDING 等 SHARED 清空”是同一个等待窗口的两种描述方式。

本文不重复两篇的日志格式细节,只钉住:无论走哪种日志模式,COMMIT/ROLLBACK 在 SQL 语义层的行为是一致的(要么全部生效要么全部不生效),差异全部封装在 Pager 与日志层内部,事务语义本身不随 journal_mode 变化。

有一个边界官方文档单独强调:如果 PRAGMA journal_mode=OFF(完全关闭 rollback journal),ROLLBACK 的行为是未定义的——这不是”性能优化的副作用可以忽略”,而是显式放弃了事务回滚能力去换写入速度,选择这个模式的应用不应该再依赖 ROLLBACK 语义。这条边界与本文其余部分讨论的”事务如何保证 SERIALIZABLE”是两个不同的承诺层级:前者是隔离与原子性,后者是”要不要保留回滚这项能力”,两者可以独立配置,但关掉后者会连带影响前者。


六、常见误解

  1. BEGIN IMMEDIATE/EXCLUSIVE 提升了隔离级别。」 不对。SQLite 的隔离级别始终是 SERIALIZABLE(Isolation In SQLite 明确写明),三种修饰符改变的只是取锁时机,不产生新的隔离级别。把它们理解成”更强的隔离”是把锁时机和隔离语义混为一谈。

  2. 「SQLite 的快照和 PostgreSQL 的 MVCC 快照是同一回事,可以直接套用 write skew 的分析。」 两者在读者视角上相似,但 write skew 依赖至少两个并发写事务基于各自旧快照做出冲突决定;SQLite 全局单写者,这个前提本身不成立。讨论 SQLite 的隔离异常边界时不能照搬服务器 MVCC 的异象清单。

  3. COMMIT 执行完,数据肯定已经落盘。」 COMMIT 可能因为其他连接仍持有 SHARED 锁而失败并自动回到未提交状态,应用需要检查返回码并重试,不能假设 COMMIT 语句执行完毕就等于事务已经持久化成功。

  4. DEFERREDIMMEDIATE 更安全,因为它推迟了加锁。」 如果一个事务明确知道自己稍后要写,DEFERRED 反而更容易在”先读后写”的窗口里被别的连接抢先提交,导致后续写操作以 SQLITE_BUSY(rollback 模式)或 SQLITE_BUSY_SNAPSHOT(WAL 模式)失败;官方建议这种场景直接用 BEGIN IMMEDIATE。“推迟加锁”本身不等于”更安全”,要看事务的实际访问模式。

  5. 「遇到 SQLITE_BUSY_SNAPSHOT 睡一下重试就行,和 SQLITE_BUSY 处理方式一样。」 见第四节 4.1:SQLITE_BUSY_SNAPSHOT 意味着当前事务依据的快照已经过期,必须 ROLLBACK 后重新 BEGIN 才能看到最新数据;单纯重试同一个未结束的事务不会解决问题,因为快照不会自己刷新。


七、学术谱系、工程间隙与开放问题

谱系:Berenson et al.(SIGMOD 1995)定义的隔离现象词汇是理解任何数据库隔离叙事的通用坐标系,SQLite 官方文档虽未直接引用该论文,但”SERIALIZABLE”这个标签本身正是该论文批评的对象之一——论文指出许多系统声称提供 SERIALIZABLE 却各自实现不同的具体保证。SQLite 在这一点上给出的定性罕见地直白:靠写串行化实现,不是靠快照+冲突检测。这条谱系不是”SQLite 参考了 Berenson 的方法”,而是”Berenson 的词汇体系可以用来精确描述 SQLite 到底做了什么、没做什么”。

工程间隙:Berenson 论文与后续 SSI 文献(如 Cahill et al., SIGMOD 2008,MVCC 一文 已展开)讨论的场景默认”多个写事务可以并发提出冲突的修改,系统需要检测并处理”,这个前提在 SQLite 里从设计上就不存在——不是 SQLite 解决了这个问题,而是它选择不进入这个问题域。把 SSI 的复杂度和 SQLite 的简单实现直接比较”谁更好”没有意义,两者面对的并发规模假设完全不同:SSI 假设服务器端多写者高并发,SQLite 假设嵌入式单写者场景。

开放问题:第 1、9 篇都点出”单写者是否应让位于细粒度锁”,本篇在隔离语义层给出这个问题的另一个视角——如果 SQLite 未来允许多个写事务并发(哪怕只在同一进程内的多线程之间),它现在靠”写串行化”直接获得的 SERIALIZABLE 保证还能不能不引入 SSI 级别的冲突检测就继续维持? 官方文档与本系列都没有给出答案;这不是”即将改变”的预告,而是一个至今没有官方路线图去回答的架构问题,留给读者对照 MVCC 一文的 SSI 部分自行判断代价。

这个开放问题在 BUSY_SNAPSHOT 的失败模式上已经有一个可观察的早期信号:应用如果频繁遇到 SQLITE_BUSY_SNAPSHOT,说明工作负载里”先读后写”的事务与并发写者的冲突频率已经不低——这正是单写者模型开始显现代价的地方。官方给出的应对是工程建议(改用 BEGIN IMMEDIATE、缩短事务、减少读写交叠),不是架构层面的解法;这条建议能不能覆盖所有场景,还是需要更细粒度的并发控制,本文按现状写,不预判答案。


八、小结

  1. BEGIN DEFERRED(默认)推迟到首次真实读/写才取锁;BEGIN IMMEDIATE 立刻尝试 RESERVED;BEGIN EXCLUSIVE 语义上更强但只在 rollback journal 模式下真的多阻塞读者,WAL 模式下与 IMMEDIATE 完全等价——本机 3.53.2 三种模式均可正常提交(冒烟验证,非并发实验)。
  2. SQLite 用”写串行化”而不是”快照 + 冲突检测”实现 SERIALIZABLE:官方 Isolation In SQLite 明确定性;对照 Berenson et al.(SIGMOD 1995)的隔离现象词汇,write skew 依赖的”≥2 并发写事务”前提在单写者模型下不成立,不是被侦测阻止,而是从未出现。
  3. WAL 模式下的”快照”只对读者成立,写者一旦发现快照过期就返回 SQLITE_BUSY_SNAPSHOT,这与服务器端 MVCC 允许多写者基于旧快照推进、事后裁决冲突的模型是两种不同的工程路径,不能直接套用同一套异象分析;COMMIT/ROLLBACK 的落盘时刻仍然由第 7、8 篇的日志格式决定。

参考资料

规范与官方文档(A 级)

  1. SQLite Documentation, Transaction(sqlite.org/lang_transaction.html)——BEGIN 修饰符语义、COMMIT/ROLLBACK 的 autocommit 机制。
  2. SQLite Documentation, Isolation In SQLite(sqlite.org/isolation.html)——SERIALIZABLE 定性、WAL 快照隔离、SQLITE_BUSY_SNAPSHOT 示例。

论文(A 级,讨论锚点)

  1. Berenson, H., Bernstein, P., Gray, J., Melton, J., O’Neil, E. & O’Neil, P. A Critique of ANSI SQL Isolation Levels. SIGMOD 1995(隔离现象词汇、Snapshot Isolation 与 write skew 的定义框架)。

实验(A 级,本机实测)

  1. 本机 sqlite3 3.53.2:三种 BEGIN 模式提交冒烟测试,脚本 reproduce/10-transaction-modes.sh,输出见第二节。
  2. 本机 sqlite3 3.53.2:第 9 篇双连接 RESERVED 锁冲突实测(本文第二节引用其结论,不重复运行)。

站内

  1. MVCC——PostgreSQL 版本链、快照可见性判断、write skew 与 SSI 的完整拆解,本文只做概念性对照,不重写。
  2. 锁状态与 shared cache嵌入式行存全景——单写者与细粒度锁开放问题的前序讨论。
  3. 本系列 indexPLAN.md

上一篇锁状态与 shared cache 下一篇查询计划器与统计

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。


By .