SQLite 没有表级锁、也没有行级锁,它锁的是整个数据库文件。 一个连接写的时候,别人可以读,但不能写——这是「一把写锁」的直接后果。 SQLite 不会形成环形等待,所以它没有死锁检测,只有锁等待超时。
教材 16.2 四个小节:锁的基本知识、表级锁、InnoDB 行级锁、死锁。 这一篇要交代一个不太好接受的事实: SQLite 一个都不到,它锁的是库。 上一篇讲「没提交的别人看不见」,那是结果;这一篇讲「怎么做到的」,那是机制。 两者是一回事的两面:看不见,是因为锁把它挡住了。 这一篇的实测全部要两个连接,而且要把超时调小,否则会等很久。
一、锁的基本知识:读锁与写锁
事务要保证「别人看不到我的中间状态」,靠的就是锁。 最粗的分法是两把:读锁(共享锁,几个人能一起拿)和写锁(排他锁,只能一个人拿)。
| 锁 | 别名 | 能不能共存 |
|---|---|---|
| 读锁 | 共享锁 S | 多个读锁可以同时拿 |
| 写锁 | 排他锁 X | 只能一个,且排斥所有读锁 |
先看沙箱的默认配置:
| PRAGMA | 取值 | 含义 |
|---|---|---|
journal_mode |
delete |
传统回滚日志模式 |
locking_mode |
normal |
每个事务结束就放锁 |
busy_timeout |
200 毫秒 | 拿不到锁等多久才报错 |
journal_mode 是 delete,也就是传统的回滚日志模式。
这种模式下的锁粒度是「整个数据库文件」,不是表、更不是行。
所以讲「表级锁/行级锁」的时候,得先说清楚:SQLite 一个都不到,它锁的是库。
这不是「实现得糙」,而是它的定位:一个跑在手机里、单进程为主的小引擎。
把锁做到行级要维护锁表、要处理锁升级、要防死锁,代价比好处大得多。
知道这条边界,选型时就不会拿它去扛「高并发写入」的场景。
二、表级锁:一个连接写,另一个等着
a 用 BEGIN IMMEDIATE 开一个写事务,改一行但不提交;b 这时想改另一行。
| 时机 | 另一个连接想写 | 另一个连接想读 |
|---|---|---|
| a 持有写事务 | database is locked |
读到 3 行 |
| a 提交之后 | 写成功 | — |
第一行:写被挡,读照常。
这就是「写锁排他、读锁共享」最朴素的样子:一个人写的时候,别人可以读,但不能写。
第二行 a 交了事务,b 立刻就能写——锁是跟着事务走的,事务一结束就放。
BEGIN IMMEDIATE 这个写法要解释一下:它开事务的同时就把写锁拿了。
如果只写 BEGIN(默认是 DEFERRED),
锁要等到第一条写语句才拿——也就是说「先读后写」的事务有可能会在中间失败。
| 开头写法 | 什么时候拿写锁 | 另一连接能不能写 |
|---|---|---|
BEGIN/BEGIN DEFERRED |
第一条写语句时 | 能(还没拿锁) |
BEGIN IMMEDIATE |
开事务时立刻拿 | 不能,database is locked |
BEGIN EXCLUSIVE |
开事务时拿排他锁 | 不能,连读也不能 |
第三行值得单独看一眼:EXCLUSIVE 连读都挡住了。
这一档只在「要独占整个库做维护」时用,比如备份、重建索引。
三、行级锁:SQLite 没有,改不同行也挡
MySQL 的 InnoDB 有行级锁:两个人改两行不同的记录,互不影响。 SQLite 没有这一层。哪怕改的是完全不同的两行,也一样被挡。
| 配置 | b 改 id=2(a 在改 id=1) |
|---|---|
| 回滚日志模式 | database is locked |
| WAL 模式 | database is locked |
两种模式都被挡。
WAL 改善的是「读不被写阻塞」,不是「写能并行」——同一时刻仍然只允许一个写者。
所以「SQLite 并发差」这句流行说法要说得更准一点:
它差在「写不能并行」;读在 WAL 下是可以并行的。
这一条对设计的影响很具体:SQLite 适合「读多写少」的场景。
写入吞吐上不去,不是调参数能解决的——它只有一把写锁,这是引擎结构决定的。
教材里 InnoDB 的行级锁还有一个更要紧的细节:行锁是加在索引上的。
UPDATE ... WHERE 无索引的列 = ? 这种写法,InnoDB 会锁住扫过的所有行,效果接近表锁。
所以「用了 InnoDB 就有行级锁」这句话要打个折——得看 WHERE 走不走索引。
四、死锁:SQLite 不会环形等待,只会超时
死锁的经典画面是:甲锁了 A 等 B,乙锁了 B 等 A,谁也走不了。 SQLite 只有一把写锁,「各拿一半」这个局面根本构造不出来。
| 动作 | 结果 |
|---|---|
| a 拿到写锁 | 成功 |
| b 也想拿写锁 | database is locked |
b 不会等在那儿形成环,它直接报错退出。
所以 SQLite 上没有「死锁检测」,只有「锁等待超时」。
把超时从 0.1 秒调到 3 秒,b 会一直等到超时才报错——实测等了 3.4 秒。
这个等待时长就是 busy_timeout,它决定了「并发上来之后是快速失败还是慢慢排队」。
MySQL 那边不一样:InnoDB 会真的形成环形等待。
它会主动检测,回滚其中一个,报错误码 1213「Deadlock found」。
所以「怎么防死锁」这件事,两边要防的东西完全不同:
MySQL 要防环形等待(固定加锁顺序、短事务),SQLite 只要防「写事务拖太久」。
对 SQLite 来说,「长事务」是最要命的:它占着唯一那把写锁,别的连接全在等。
所以它的最佳实践很明确:事务里只放必要语句,别在事务中间去调网络、等用户输入。
这两件事都会把持锁时间从毫秒拉到秒级,而在秒级上,busy_timeout 根本不够用。
五、动手 Demo:一把写锁,谁被挡住、谁没被挡住
这一段全用落盘的库、两个连接:先看默认配置,再验「写被挡、读照常」, 然后验「改不同行也挡」(回滚日志和 WAL 两种模式各一次),最后看「拿不到锁」到底是怎么报错的、 等多久才报。
import sqlite3, time
p = u"demo70.db"
c = sqlite3.connect(p, isolation_level=None)
c.execute(u"CREATE TABLE tb_acct (id INTEGER PRIMARY KEY, "
u"name TEXT, money REAL)")
c.executemany(u"INSERT INTO tb_acct VALUES (?,?,?)",
[(1, u"甲", 1000), (2, u"乙", 1000), (3, u"丙", 1000)])
# ① 默认配置
for q in (u"PRAGMA journal_mode", u"PRAGMA locking_mode", u"PRAGMA busy_timeout"):
print(q, c.execute(q).fetchone())
# ② 写被挡、读照常
a = sqlite3.connect(p, isolation_level=None)
b = sqlite3.connect(p, isolation_level=None, timeout=0.1)
a.execute(u"BEGIN IMMEDIATE")
a.execute(u"UPDATE tb_acct SET money = 1 WHERE id = 1")
try:
b.execute(u"UPDATE tb_acct SET money = 2 WHERE id = 2")
except sqlite3.Error as e:
print(u"b 写 ->", e)
print(u"b 读 ->", b.execute(u"SELECT count(*) FROM tb_acct").fetchone())
a.execute(u"COMMIT")
# ③ 连读都不让:BEGIN EXCLUSIVE
a.execute(u"BEGIN EXCLUSIVE")
try:
b.execute(u"SELECT count(*) FROM tb_acct").fetchone()
except sqlite3.Error as e:
print(u"EXCLUSIVE 下 b 读 ->", e)
a.execute(u"ROLLBACK")
# ④ 死锁构造不出来:只有一个写者
a.execute(u"BEGIN IMMEDIATE")
try:
b.execute(u"BEGIN IMMEDIATE")
except sqlite3.Error as e:
print(u"b 想拿写锁 ->", e)
a.execute(u"COMMIT")
# ⑤ busy_timeout:等多久才放弃
a2 = sqlite3.connect(p, isolation_level=None, timeout=3.0)
b2 = sqlite3.connect(p, isolation_level=None, timeout=3.0)
a2.execute(u"BEGIN IMMEDIATE")
a2.execute(u"UPDATE tb_acct SET money = 1 WHERE id = 1")
t0 = time.time()
try:
b2.execute(u"UPDATE tb_acct SET money = 2 WHERE id = 2")
except sqlite3.Error as e:
print(u"等了 %.1f 秒 -> %s" % (time.time() - t0, e))
a2.execute(u"COMMIT")
实跑输出:
--------------------------------------------------------------------
锁机制:一个写者,别人全得等
--------------------------------------------------------------------
锁的基本知识:读锁与写锁
--------------------------------------------------------------------
**事务要保证「别人看不到我的中间状态」,靠的就是锁。**
**最粗的分法是两把:读锁(共享锁,几个人能一起拿)和写锁(排他锁,只能一个人拿)。**
**先看 SQLite 的默认配置:**
PRAGMA 取值
------------------------
`journal_mode` `delete`
`locking_mode` `normal`
`busy_timeout` 200 毫秒
**`journal_mode` 是 `delete`,也就是传统的回滚日志模式。**
**这种模式下的锁粒度是「整个数据库文件」,不是表、更不是行。**
**所以讲「表级锁/行级锁」的时候,得先说清楚:SQLite 一个都不到,它锁的是库。**
表级锁:一个连接写,另一个连接等着
--------------------------------------------------------------------
时机 另一个连接想写 另一个连接想读
------------------------------------------------
a 持有写事务 database is locked 读到 3 行
a 提交之后 成功 —
**第一行:写被挡(`database is locked`),读照常。**
**这就是「写锁排他、读锁共享」最朴素的样子:一个人写的时候,别人可以读,但不能写。**
**第二行 a 交了事务,b 立刻就能写——锁是跟着事务走的,事务一结束就放。**
**`BEGIN` 后面那三个词是有差别的,量一次就清楚:**
开头写法 另一个连接能写吗 另一个连接能读吗
-------------------------------------------------------
BEGIN 成功 成功
BEGIN IMMEDIATE database is locked 成功
BEGIN EXCLUSIVE database is locked database is locked
**第一行 `BEGIN` 什么都不挡:它还没碰数据,也就还没拿锁。**
**这种事务最危险的地方在「先读后写」:读的时候没人挡,写的时候可能就升级不上去了。**
**第三行 `BEGIN EXCLUSIVE` 连读都挡住——那是「我要独占整个库」的意思。**
行级锁:SQLite 没有,改不同行也挡
--------------------------------------------------------------------
**MySQL 的 InnoDB 有行级锁:两个人改两行不同的记录,互不影响。**
**SQLite 没有这一层。哪怕改的是完全不同的两行,也一样被挡。**
配置 b 改 id=2(a 在改 id=1)
--------------------------------------
回滚日志模式 database is locked
WAL 模式 database is locked
**两种模式都被挡。**
**WAL 改善的是「读不被写阻塞」,不是「写能并行」——同一时刻仍然只允许一个写者。**
**所以「SQLite 并发差」这句流行说法要说得更准一点:**
**它差在「写不能并行」;读在 WAL 下是可以并行的。**
**这一条对设计的影响很具体:SQLite 适合「读多写少」的场景。**
**写入吞吐上不去,不是调参数能解决的——它只有一把写锁,这是引擎结构决定的。**
死锁:SQLite 不会环形等待,只会超时
--------------------------------------------------------------------
**死锁的经典画面是:甲锁了 A 等 B,乙锁了 B 等 A,谁也走不了。**
**SQLite 只有一把写锁,「各拿一半」这个局面根本构造不出来。**
动作 结果
--------------------------------
a 拿到写锁 成功
b 也想拿写锁 database is locked
**b 不会等在那儿形成环,它直接报 `database is locked` 退出。**
**所以 SQLite 上没有「死锁检测」,只有「锁等待超时」。**
把超时从 0.1 秒调到 3 秒,b 会一直等到超时才报错:
b 等了 3.4 秒,最后报:database is locked
**等了 3.4 秒才放弃——这个等待时长就是 `busy_timeout`。**
**它决定了「并发上来之后是快速失败还是慢慢排队」。**
**MySQL 那边不一样:InnoDB 会真的形成环形等待。**
**它会主动检测,回滚其中一个,报错误码 1213「Deadlock found」。**
**所以「怎么防死锁」这件事,两边要防的东西完全不同:**
**MySQL 要防环形等待(固定加锁顺序、短事务),SQLite 只要防「写事务拖太久」。**
第二节和第三节放在一起看最有意思:写被挡住、读还能读,这就是「共享锁」的存在意义。
但把开头换成 EXCLUSIVE,读也被挡住了——同一把锁的两个档位,差别就在这儿。
第四节那一行是这一章最有价值的一条:SQLite 里构造不出死锁。
不是它「防得好」,而是「只有一个写者」这个设计从根上消灭了环形等待的可能。
代价就是吞吐——这两件事在引擎设计里基本是一对一的交换。
第五节那个 3.4 秒是量出来的,不是估的。
注意它比设的 3.0 秒还多一点:busy_timeout 是「等这么久再试一次」的粒度,
最后一轮之后还要再确认一次,多出来的那点就是重试的尾巴。
30 秒实验
- 把
BEGIN IMMEDIATE换成BEGIN(默认 DEFERRED),重复一遍「a 改一行、b 改另一行」, 看 b 这次能不能写进去——能的话,说明锁是等到第一条写语句才拿的。 - 用
BEGIN EXCLUSIVE开事务,让另一个连接读、写各试一次,把两个报错都记下来, 和BEGIN IMMEDIATE的结果对照。 - 把
busy_timeout分别设成 0、200、2000 毫秒,各跑一次抢锁,把等待时间和结果列成表。
常见误区
- 以为「用了 SQLite 就有表级锁」。它锁的是整个数据库文件。 表级锁是 MyISAM 的概念,行级锁是 InnoDB 的概念,都不属于 SQLite。
- 以为 WAL 能让「两个写并行」。WAL 只解决了「读不被写阻塞」,写仍然只有一个位置。 它是改善读的并发,不是改善写的。
- 以为「SQLite 不会死锁」是因为它防得好。是因为它只有一把写锁,环形等待的前提不成立。 这不是优点,是设计取舍的另一面。
自测三问(点开对答案)
1. 一个连接 `BEGIN IMMEDIATE` 改了行但没提交,另一个连接能做哪些事? —— 能读(读到的是旧值),不能写(报 `database is locked`)。 这正是共享锁与排他锁的分工:读锁可以和别人共存,写锁不行。 2. 为什么 SQLite 里构造不出死锁? —— 因为它同一时刻只允许一个写者。 死锁的前提是「两个人各拿一把、再互相等对方那把」,只有一把锁的时候这个局面不存在。 等不到就直接超时报错。 3. InnoDB 的行级锁在什么情况下会退化成「接近表锁」? —— 当 `WHERE` 条件用不上索引时。 行锁是加在索引项上的,扫全表就会把扫过的行全锁住,效果跟锁表差不多。 所以「有行级锁」不等于「一定只锁一行」。下一篇讲隔离级别:脏读、不可重复读、幻读这三件坏事,两边各挡得住哪些。
- ☐ (本篇完,下一篇 16.3 事务的隔离级别)