关注「索引目录」公众号,获取更多干货。
我们探讨了 SQLite 如何通过引入内部锁跟踪结构(如和)来解决进程内锁定问题。unixinodeinfounixFile
这种设计确保了在单个进程中多次打开同一个数据库文件时数据的正确性。
今天的阅读材料将进一步推进这一讨论,揭示了当线程出现时会发生什么,以及为什么 SQLite 在 Linux 上必须做出非常具体的设计选择。
多线程和 Linux 锁定模型
乍一看,多线程不应该从根本上改变文件锁定语义。
毕竟,线程属于同一个进程,而 POSIX 文件锁是在进程级别定义的。
然而,这种假设在采用 LinuxThreads 的 Linux 系统上并不成立。
在 LinuxThreads 下:
-
一个线程获取的锁不能被同级线程覆盖。 -
只有所有者线程才能操作其锁。 -
这种行为不符合 POSIX 标准。
这导致 SQLite 假定锁是进程级的,而 LinuxThreads 的锁所有权是线程级的,两者之间存在严重的不匹配。
因此,同一进程中的两个线程可能都认为自己持有兼容的锁,但却无法正确地升级或释放锁。
这种不一致使得正确的锁管理成为不可能。
SQLite 的回应:放弃 LinuxThreads
从SQLite 3.7.0开始,SQLite 团队正式停止支持 LinuxThreads。
相反,SQLite 依赖于NPTL(原生 POSIX 线程库)。
多线程环境下的文件关闭问题
即使使用 NPTL,多线程也会引入另一个微妙的问题,即文件描述符关闭。
在 Linux 系统中,当一个文件描述符关闭时,进程持有的该 inode 上的所有锁都会被释放,无论这些锁是由哪个线程获取的,也无论之前使用的是哪个文件描述符。
从内核的角度来看,这种行为是正确的,锁是由进程拥有的,但这对于 SQLite 来说是危险的。
危险的局面
想象:
-
Thread T1 通过一个 SHARED 锁保持连接 unixFile -
线程 T2 通过另一个线程持有保留锁 unixFile -
线程 T1 关闭其文件描述符
Linux 将:
-
释放该 inode 上的所有锁 -
包括T2持有的保留锁
从 SQLite 的角度来看,这将是灾难性的,锁会在事务仍在进行时消失。
延迟文件关闭:SQLite 唯一可行的选择
SQLite 使用一种称为延迟文件关闭的技术来解决这个问题。
而不是立即调用close(fd):
unixFileSQLite 会检查同一 inode 上的其他对象是否仍然持有锁。 -
如果情况属实,则交易将延期完成。 -
文件描述符被放入延迟列表中
只有当该 inode 上的所有锁都已释放且最后一个线程对该文件的操作完成后,文件描述符才真正关闭。
此设计:
-
防止意外丢失锁具 -
保持正确性 -
代价是暂时保留额外的文件描述符
SQLite 的开发者们接受了这种权衡,因为在 Linux 上没有更安全的替代方案。
注意:如果文件重新打开,延迟文件描述符可以稍后被重用,从而减少资源浪费。
SQLite 锁 API:分页器与操作系统之间的边界
到目前为止,我们已经讨论了SQLite 跟踪的内容。现在我们来看看锁转换是如何实际发生的。
SQLite 公开了两个用于锁管理的内部 API:
sqlite3OsLocksqlite3OsUnlock
在 Unix 系统中,这些功能在 SQLite 中实现os_unix.c,并且是SQLite 的逻辑锁系统和本地锁之间唯一的网关fcntl。
今天的阅读重点是sqlite3OsLock……
API sqlite3OsLock:安全地升级锁
函数签名如下:
int sqlite3OsLock(sqlite3_file *id, int locktype);
在哪里:
id表示数据库连接( sqlite3_file)locktype请求的 SQLite 锁
重要限制条件:
-
客户不能 PENDING直接提出请求。 -
锁具只能按以下顺序加固:
NOLOCK → SHARED → RESERVED → PENDING → EXCLUSIVE
降级操作通过以下方式单独处理sqlite3OsUnlock:
对等连接进行兼容性检查
如果同一进程中的另一个连接持有更强的锁:
-
SQLite 检查兼容性 -
目前尚未触及任何原生锁。
如果进程范围锁定为:
PENDING或更强 → 不兼容 → SQLITE_BUSYSHARED或者 RESERVED→ 可能允许有限的升级。
这时,nShared记账就显得至关重要了。
仅限内部使用的锁定转换
有些锁的更改不会影响操作系统。
例子:
-
进程已持有共享锁。 -
另一个连接请求共享
SQLite 简述:
-
增量 nShared -
更新连接状态 -
避免打电话 fcntl
这就是 SQLite 如何安全地在一个进程中支持多个读取器的方法。
锁升级:何时需要原生锁
真正的锁升级仅在以下情况下发生:
-
请求连接已经拥有最强的进程级锁。 -
需要提升到更高层次
例如:
-
共享 → 预留 -
已预留 → 独家
在这些情况下:
-
SQLite 可能会暂时获得一个待处理的锁。 -
等待共享锁耗尽 -
尝试获取本地写锁 -
如果仍然存在不兼容的锁,则会迅速失败。
如果升级失败,SQLite 会直接返回,SQLITE_BUSY而不会阻塞。
为什么这个算法很重要
这种分层算法保证了:
-
原生锁永远不会被意外覆盖。 -
线程级操作保持一致 -
文件描述符关闭不会中断事务 -
僵局仍然不可能出现 -
锁定状态始终是确定性的
SQLite 实现可序列化隔离不是靠复杂性,而是靠仔细的排序、严格的不变性和坚决拒绝阻塞。
关注「索引目录」公众号,获取更多干货。

