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

【SQLite 内核】锁状态与 shared cache:五态锁阶梯与 writer starvation

文章导航

分类入口
databasestorage
标签入口
#sqlite#locking#concurrency#shared-cache#reserved-lock#pending-lock#writer-starvation#rollback-journal

源码下载

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

打开下载目录 →

目录

第 7 篇会讲 rollback journal 如何在写之前保存原始页内容,但”写之前”到底要先拿到什么锁、拿锁的过程会不会让读者等待、又会不会让写者永远排不上号,是官方 File Locking And Concurrency In SQLite Version 3 单独成篇的话题。这份文档写在 2004 年,专门解释 SQLite 3.0 相对 2.x 引入的新锁机制——目的之一就是消除写者饿死(writer starvation)问题。

常见误区是把”单写者”简化成”没有并发”或者”SQLite 是单线程的”:本文只处理同一数据库文件上的锁状态机本身,不涉及 VDBE 执行是否单线程(第 5 篇)、也不展开 WAL 的帧级并发模型(第 8 篇有专门篇幅)。要钉住三件事:

  1. UNLOCKED → SHARED → RESERVED → PENDING → EXCLUSIVE 五态各允许什么共存关系。
  2. PENDING 存在的工程动机——防止读者持续涌入导致写者永远等不到独占锁。
  3. 为什么 Pager 模块只跟踪四态而不是五态,以及 shared cache 模式在这套锁阶梯上加了什么。

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

篇目 核心内容
第 8 篇 · WAL 与 checkpoint -wal/-shm、帧格式、checkpoint 策略
第 9 篇 · 锁状态与 shared cache 五态锁阶梯、writer starvation、Pager 四态跟踪
第 10 篇 · 事务与隔离 DEFERRED/IMMEDIATE/EXCLUSIVE、隔离叙事

版本锚定:官方 File Locking And Concurrency In SQLite Version 3(sqlite.org/lockingv3.html)。文档正文明确写明:只描述 rollback-journal 模式的锁;WAL 模式的锁另有独立文档,本文第五节只给边界句,细节留给第 8 篇。锁状态机自 SQLite 3.0.0 起就是这套设计,覆盖本系列锚定的 3.45.x–3.46.x 与本机实测的 3.53.2


一、五态锁阶梯

官方文档把”从单个进程的视角看,数据库文件可以处于五种锁状态之一”写得很直接:

状态 含义 与其他状态共存关系
UNLOCKED 未持有任何锁;不可读不可写 默认状态,其他进程按自己的锁状态自由读写
SHARED 可读不可写 任意数量的进程可同时持有;持有期间无人可写
RESERVED 计划写,但当前仍只在读 全局只有一个 RESERVED;新的 SHARED 仍可获取
PENDING 等现有 SHARED 清空后升级为 EXCLUSIVE 拒绝新的 SHARED;已持有的 SHARED 允许继续
EXCLUSIVE 正在写文件 独占,不与任何其他锁共存

这五态由操作系统接口层(os_unix.c/os_win.c)完整跟踪,但Pager 模块本身只跟踪四态——这一点留给第四节展开。

stateDiagram-v2
  [*] --> UNLOCKED
  UNLOCKED --> SHARED : open read txn
  SHARED --> UNLOCKED : release
  SHARED --> RESERVED : intend to write<br/>(other SHARED still allowed)
  RESERVED --> SHARED : abort write intent
  RESERVED --> PENDING : ready to flush,<br/>wait for readers to drain
  PENDING --> EXCLUSIVE : last SHARED cleared
  EXCLUSIVE --> UNLOCKED : commit or rollback done
  note right of SHARED : any number of processes
  note right of RESERVED : at most one process,<br/>new SHARED still granted
  note right of PENDING : at most one process,<br/>new SHARED refused
  note right of EXCLUSIVE : exactly one process,<br/>no other lock coexists

1.1 RESERVED 与 PENDING 的关键差异

官方文档专门用一句话区分这两个最容易混淆的状态:

RESERVED differs from PENDING in that new SHARED locks can be acquired while there is a RESERVED lock.

也就是说,RESERVED 只表达”我打算写”,不阻止新读者进入;PENDING 表达”我马上要写,别再进来了”,只允许已经在场的读者读完离开。两者都限定全局至多一个持有者,但对”新读者能不能进”的态度完全相反。


二、写路径如何依次经过这五态

第 7 篇会展开 rollback journal 的写时拷贝格式,这里只钉锁状态的时间线(官方文档第 5.0 节):

  1. 进程要写数据库,先按读路径拿到 SHARED 锁(顺带检查是否存在需要回滚的 hot journal)。
  2. 确认要写之后,尝试把 SHARED 升级为 RESERVED。若已有另一个 RESERVED,本次写以 SQLITE_BUSY 失败——RESERVED 全局唯一是这里失败的直接原因。
  3. 拿到 RESERVED 后创建 rollback journal,把即将修改的原始页内容写入其中;此时其他连接仍可持有/新获取 SHARED 继续读。
  4. 内存缓存写满、或事务准备提交时,先确保 journal 已经 fsync 落盘,再申请 PENDING,接着等现有 SHARED 清空后升到 EXCLUSIVE
  5. 在 EXCLUSIVE 下把脏页写回数据库文件,删除(或截断/清零)journal,随后释放 PENDING 和 EXCLUSIVE。
flowchart LR
  s0["SHARED<br/>(read)"] --> s1["RESERVED<br/>(intend to write,<br/>create journal)"]
  s1 -->|"cache full or commit"| s2["PENDING<br/>(wait for readers to drain)"]
  s2 -->|"last SHARED released"| s3["EXCLUSIVE<br/>(write pages, delete journal)"]
  s3 --> s4["release PENDING + EXCLUSIVE"]

这条时间线与第 3 篇 Pager 脏页生命周期是同一件事的两个视角:Pager 讲”脏页什么时候落盘”,本篇讲”落盘前后拿的是哪一级锁”。


三、实测:RESERVED 唯一性与读写共存的真实冲突

用两个独立的 sqlite3 CLI 进程对同一个 rollback-journal 模式数据库操作:session A 执行 BEGIN IMMEDIATE(直接跳到 RESERVED,见第 10 篇),并通过 FIFO 保持连接不提交;session B 分别尝试写和读。

复现脚本:reproduce/09-locking-conflict.sh。以下输出经本机 sqlite3 3.53.2 实际执行:

=== session B: write while A holds RESERVED ===
Error in 2nd command line argument: database is locked
=== session B: read while A holds RESERVED ===
1|seed
=== final table state ===
1|seed
sequenceDiagram
  participant A as Session A
  participant DB as sk-lock.db
  participant B as Session B
  A->>DB: BEGIN IMMEDIATE (acquire RESERVED)
  Note over A,DB: A holds RESERVED, keeps connection open via FIFO
  B->>DB: INSERT (implicit tx needs RESERVED)
  DB-->>B: SQLITE_BUSY, "database is locked"
  B->>DB: SELECT (only needs SHARED)
  DB-->>B: rows returned, no block
  A->>DB: COMMIT

这组真实输出直接对应第一节表格里两条规则:session B 的写请求需要 RESERVED,而 RESERVED 全局唯一,A 已经持有,所以 B 立即失败,不是等待超时;session B 的读请求只需要 SHARED,与 A 的 RESERVED 兼容,立即成功返回。session A 从始至终没有做任何实际写入(只有 BEGIN IMMEDIATECOMMIT),所以最终表内容只有种子行 seed,这也验证了脏页真的没有意外落盘。

本文不伪造 PENDING 阶段单独的冲突输出:PENDING 是”马上要升级到 EXCLUSIVE”前的极短暂窗口,Pager 也不单独跟踪它(第四节),用 CLI 层的单条语句难以稳定捕获这个瞬间;PENDING 的行为定义直接引用官方文档(第一、五节),不靠单机实验去”演出”一个本质上是纳秒级的状态。


四、Pager 只跟踪四态,为什么不单独跟踪 PENDING

官方文档在第 3.0 节末尾专门说明:

The operating system interface layer understands and tracks all five locking states described above. The pager module only tracks four of the five locking states. A PENDING lock is always just a temporary stepping stone on the path to an EXCLUSIVE lock and so the pager module does not track PENDING locks.

也就是说,UNLOCKED / SHARED / RESERVED / EXCLUSIVE 四态才是 Pager 关心的持久状态——每一种都对应一段”外部可观察、其他连接需要据此决策”的时间窗口。PENDING 不是这样的状态:它只是 Pager 在申请 EXCLUSIVE 途中,OS 锁接口层内部经过的一步,出现的唯一目的是让已经在场的 SHARED 读者能正常读完,而不是给 Pager 自身的状态机增加一个需要长期维护的分支。换句话说,PENDING 是操作系统锁原语层面的实现细节,用来实现”RESERVED → EXCLUSIVE 之间不饿死写者”这条语义,但从 Pager 对上层(B-Tree、VDBE)暴露的状态视图看,直接问”现在是不是 EXCLUSIVE”就够了,中间是否经过 PENDING 不影响 Pager 要不要触发相应的回调。


五、shared cache 模式边界一句

官方 SQLite Shared-Cache Mode 文档描述的是另一层锁:多个连接共享同一份内存缓存和 schema 时,SQLite 在事务级、表级、schema 级各加一层”read-lock/write-lock”来协调这些连接之间的访问,与本文讨论的文件级五态锁并存但不是同一套机制。该文档同时明确写道:

Shared-cache mode is an obsolete feature. The use of shared-cache is discouraged. Most use cases for shared-cache are better served by WAL mode.

shared cache 最初是 2006 年为 Symbian 手机联系人数据库在同步时仍需响应来电查询这类场景设计的;WAL(约 2010 年起)用”读写不互斥”的方式解决了同一个问题,且不牺牲事务隔离。

shared cache 文档定义的三层锁(事务级、表级、schema 级)里,表级锁的规则值得单独摘一句,因为它与本文第一节的文件级锁阶梯是不同的粒度

At any one time, a single table may have any number of active read-locks or a single active write lock. To read data from a table, a connection must first obtain a read-lock. To write to a table, a connection must obtain a write-lock on that table.

这套”每表一把读写锁”的模型比文件级五态锁更细,但只在同进程内共享同一份 cache 的连接之间生效——跨进程的连接仍然要走第一节的文件级锁。本文只画这条边界,不深拆表级锁的授予/等待队列——采用 WAL 之后,大多数原本靠 shared cache 解决的场景已经有更简单的路径。

5.1 三层锁模型对照

层级 粒度 覆盖范围 本篇/其他篇
文件级五态锁 整个数据库文件 跨进程、跨连接,所有模式默认生效 本篇第一、二节
shared cache 表级锁 单张表 仅同进程内共享同一 cache 的连接之间 本篇第五节;官方标注过时
WAL wal-index / 共享内存锁 WAL 帧位置 + checkpoint 阶段 跨进程,仅 WAL 模式生效 第 8 篇

三层锁模型不是互斥选项的三选一,而是默认层(文件级)永远在场,另外两层是否叠加取决于 journal_mode 与 shared cache 开关。理解这一点可以避免”开了 shared cache 就不再有文件级锁”之类的错误推断——文件级锁始终是第一道门。


六、与第 7、8 篇的闭合


七、常见误解

  1. 「SQLite 是单线程的,没有并发。」 锁状态机本身允许任意数量的 SHARED 读者同时存在,也允许多线程/多进程分别持有各自的连接并发访问;“单写者”约束的是同一文件上的写事务互斥,不是”整个引擎只能有一个执行上下文”。第 1 篇已经点出这一点,本文用锁阶梯把它坐实。

  2. 「没有并发这种东西,SQLite 场景都是串行的。」 第三节的实测已经证明:session B 在 A 持有 RESERVED 期间仍能正常读;多个读事务可以与一个准备写的事务重叠运行。真正互斥的只是”多个写事务同时提交”,而不是”读和写不能重叠”。

  3. 「SHARED 时不能有人准备写。」 正相反:RESERVED 的定义就是”在只读的同时表达写意图”,而且官方文档明确写出 RESERVED 期间新的 SHARED 仍能获取。只有到 PENDING 才会拒绝新读者,到 EXCLUSIVE 才会连已有的读者都清空。把 SHARED 和”不能有人准备写”划等号,混淆了”当前是否已经在改数据”和”是否已经表达了写意图”。

  4. 「shared cache 就是 SQLite 版的多进程共享缓冲池,等价于 PG 的 shared_buffers。」 shared cache 只在同进程内的多个连接之间共享缓存与一套表级锁协议,且官方已标注为过时功能;它不是跨进程的共享内存缓冲池(那件事 SQLite 从不做,见第 3 篇),也不建议在新代码里使用。


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

谱系File Locking And Concurrency In SQLite Version 3 本身是一份工程规范文档,而不是学术论文,但它明确交代了自己要解决的具体前序问题——SQLite 2.x 的锁机制会导致 writer starvation(第 5.1 节),3.0 引入 PENDING 锁正是针对这个问题的工程修复,而不是从零设计。这与经典并发控制文献里”读写锁需要额外机制防止某一方无限期等待”的一般性问题是同一类,但 SQLite 的解法足够简单:不是引入优先级队列或公平调度,只是多加一级锁状态,让”新读者”和”现有读者”在写者等待期间被区别对待。

工程间隙:文档描述的是理想情况下的状态转换;真实系统里,写者从 PENDING 升级到 EXCLUSIVE 仍然依赖”现有 SHARED 最终会释放”这个假设——如果某个读连接因为应用逻辑错误长期不释放读事务(例如忘记 sqlite3_finalize),PENDING 会无限期卡住,写者仍然会被饿死,只是触发条件从”读者持续涌入”变成了”某个读者赖着不走”。文档没有承诺后一种场景下的行为,需要应用层自己管好语句生命周期。

另一处间隙是网络文件系统:官方文档在”How To Corrupt Your Database Files”一节直言 POSIX advisory locking 在不少 NFS 实现上”buggy or even unimplemented”,Windows 下的网络文件系统也有类似报告。这意味着本文钉住的五态锁阶梯假设底层文件锁原语可靠;一旦这个假设在特定网络存储上不成立,锁状态机本身画得再精确也保护不了数据——官方给出的建议直接是”不要在网络文件系统上跑 SQLite”,而不是承诺修复某个具体 NFS 实现。

开放问题:第 1、4 篇已经点出——SQLite 长期坚持文件级单写者锁,而不是细粒度的页级或行级锁;本文钉住的五态锁阶梯正是这条选择在”并发接口”层面的具体形态。社区对是否应该引入更细粒度的锁存在长期但不激烈的讨论(多数讨论落在 shared cache 表级锁与 WAL 的多读者模型上,而不是重新设计文件级锁),官方文档与本系列都没有给出”何时应该迁移到细粒度锁”的量化阈值——这是第 10 篇讨论隔离语义时仍然悬而未决的同一个问题的另一面。


九、小结

  1. 官方锁文档定义的五态是 UNLOCKED → SHARED → RESERVED → PENDING → EXCLUSIVE:SHARED 可多个共存,RESERVED 全局唯一但仍容许新 SHARED,PENDING 拒绝新 SHARED 但放行已有的,EXCLUSIVE 完全独占;本机双连接实测直接验证了 RESERVED 的唯一性与读写共存边界。
  2. PENDING 锁是 SQLite 3.0 对 2.x writer starvation 问题的直接工程修复;Pager 模块本身只跟踪 UNLOCKED/SHARED/RESERVED/EXCLUSIVE 四态,因为 PENDING 只是通往 EXCLUSIVE 的瞬时步骤,不是需要长期维护的状态。
  3. 本文只覆盖 rollback journal 模式的锁;shared cache 加了一层表级锁但已被官方标注为过时,WAL 模式用 wal-index 与独立共享内存锁换来读写不互斥——两者的完整机制留给第 8、10 篇。

参考资料

规范与官方文档(A 级)

  1. SQLite Documentation, File Locking And Concurrency In SQLite Version 3(sqlite.org/lockingv3.html)——五态定义、写路径锁序列、writer starvation(第 5.1 节)。
  2. SQLite Documentation, SQLite Shared-Cache Mode(sqlite.org/sharedcache.html)——表级/schema 级锁、过时说明。
  3. SQLite Documentation, Write-Ahead Logging(sqlite.org/wal.html)——WAL 读写不互斥的优势描述(第 2 节),细节见第 8 篇。

实验(A 级,本机实测)

  1. 本机 sqlite3 3.53.2:双连接 RESERVED 锁冲突实测,脚本 reproduce/09-locking-conflict.sh,输出见第三节。

站内

  1. Pager 与 Page Cache——脏页生命周期与锁的耦合点。
  2. 嵌入式行存全景——单写者约束与细粒度锁开放问题的最初提出。
  3. 本系列 indexPLAN.md

上一篇WAL 与 checkpoint 下一篇事务与隔离

同主题继续阅读

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

2026-07-18 · database / storage

【SQLite 内核】Rollback Journal 模式:写前拷贝与提交点

钉住 SQLite 默认的原子提交机制:写前把原页拷进 -journal、DELETE/TRUNCATE/PERSIST 三种提交点如何实现、cache spill 何时把 journal 变成真正的 hot journal;用本机 3.53.2 实测崩溃恢复全过程,WAL 对照留给第 8 篇。


By .