事务与并发锁
本教程共 50 篇 · 第 36 篇 · 更新于 2026-07-31
36. 事务与并发锁
本节目标:学完本章你能用事务把多个操作打包成”要么全成要么全败”的整体,并理解 SQLite 为什么同一时刻只能有一个写者、并发读又是怎么做到的。
SQLite 是 serverless / 零配置 的:没有独立数据库服务进程帮你看门,整个数据库就是磁盘上的一个文件。这意味着”谁来保证多线程/多进程同时访问不出错”这件事,得靠 SQLite 自己在文件上加锁来解决。理解这一点,是理解本章并发模型的前提。
一、事务是什么:ACID
事务(Transaction) 是把一组操作(插入、更新、删除)打包成一个”工作单元”,要么全部成功提交,要么全部失败回滚。它满足数据库经典的 ACID 四性:
- 原子性(Atomicity):事务里的操作是一个整体,不能拆开。要么全做,要么全不做。
- 一致性(Consistency):事务把数据库从一个合法状态变到另一个合法状态(比如不破坏约束)。
- 隔离性(Isolation):一个未提交事务的改动,对其他连接默认不可见。
- 持久性(Durability):一旦 COMMIT,改动就永久落盘,哪怕随后断电也不丢。
二、默认自动提交,以及手动事务
SQLite 默认处于自动提交(autocommit)模式:你每发一条 INSERT/UPDATE/DELETE,它都自动开一个小事务、马上提交。想自己控制边界,就显式用 BEGIN 开头、COMMIT 收尾,中间出错用 ROLLBACK 撤销。
sqlite> BEGIN;
sqlite> UPDATE users SET age = age + 1 WHERE id = 1;
sqlite> UPDATE orders SET amount = amount * 1.1 WHERE user_id = 1;
sqlite> COMMIT;
原子性的价值在”转账”这类场景最明显:从 A 扣钱、给 B 加钱必须同时成功。如果中途出错(比如 B 的账户不合法),ROLLBACK 会把 A 的扣款也一并撤销,绝不会出现”钱扣了却没到账”的中间状态。
sqlite> BEGIN;
sqlite> UPDATE accounts SET balance = balance - 100 WHERE name = 'A';
sqlite> UPDATE accounts SET balance = balance + 100 WHERE name = 'B';
sqlite> ROLLBACK; -- 出任何问题就整体回退
Note重点提示:
BEGIN之后、未COMMIT之前的改动,只对你当前这个连接可见;别的连接读不到。这就是隔离性的体现。COMMIT 之后,改动才对其他连接公开。
BEGIN 的几种启动模式
BEGIN 后面其实可以跟一个修饰词,决定它一上来就抢什么锁,直接影响并发行为:
- DEFERRED(默认):事务开始时不立即拿任何锁,直到真正执行第一条读或写时才去申请。最宽松,适合读写混合、希望尽量晚加锁的场景。
- IMMEDIATE:事务一开始就直接申请 RESERVED 锁(写锁的预备态),保证后续一定能写、且不会被别人抢先。如果你确信这个事务要写、又不想中途被
SQLITE_BUSY打断,用它能提前”占坑”。 - EXCLUSIVE:一开始就申请 EXCLUSIVE 锁,彻底独占数据库,期间任何别的连接都不能读也不能写。只在你需要做独占维护、且能接受别人全被挡住时使用。
sqlite> BEGIN IMMEDIATE;
sqlite> UPDATE users SET age = age + 1 WHERE id = 1;
sqlite> COMMIT;
Tip实用技巧:写多读少、且事务较长时,用
BEGIN IMMEDIATE开头能在一开始就锁定写权限,避免跑到一半才发现自己抢不到写锁、整段白做。只读事务则建议用BEGIN DEFERRED或直接依赖自动提交,别一上来就抢写锁。
三、锁与并发写
关键事实:SQLite 默认对整个数据库文件加锁,而不是只锁某一行。在传统的回滚日志(rollback journal)模式下,写者会依次经历 RESERVED、PENDING、EXCLUSIVE 等锁级别,最终独占文件;此时其他连接想写就会拿到 SQLITE_BUSY(数据库忙)错误,必须等当前写者提交。
换句话说:同一时刻只能有一个写者在写,但多个读者可以并发读(读用共享锁,不与读冲突)。这正是 SQLite 适合”读多写少、单机/嵌入式”场景的原因,也是它不适合”高并发写”的根源。
SQLite 在回滚日志模式下,连接对数据库文件的锁是分层的,从松到紧依次为:UNLOCKED(无锁)→ SHARED(共享读锁)→ RESERVED(预备写锁)→ PENDING(待升级锁)→ EXCLUSIVE(独占写锁)。读者只拿 SHARED,彼此不冲突,所以多个读者能同时读;第一个想写的连接要先把 SHARED 升级到 RESERVED,再到 PENDING,最终到 EXCLUSIVE 独占文件。PENDING 是个”过渡态”:它允许已有的读者继续把当前查询读完,但不批准新的读者进场,目的就是让写者尽快能升到 EXCLUSIVE。理解这条锁阶梯,你就明白为什么”读多写少”很顺畅、而”多个连接同时写”却要排队——写者必须一路爬到顶端才能动笔。
当写者还握着 EXCLUSIVE 锁时,别的连接想写就会收到 SQLITE_BUSY(也就是应用里常见的 “database is locked” 报错)。应对办法有三:① 设 PRAGMA busy_timeout = 毫秒,让 SQLite 在正式报错前先自动重试一小段时间(最推荐);② 把写入尽量合并进单个事务、缩短持锁时间;③ 开启 WAL 模式让读写并行。硬抗不重试、一遇忙就直接退出,是最常见的并发 Bug 来源。
想要读写更并发,可以开启 WAL 模式(Write-Ahead Logging):写者把改动先写进单独的 WAL 文件,读者继续读旧版本,从而允许”一个写者 + 多个读者”真正并行。开启方式:
sqlite> PRAGMA journal_mode = WAL;
遇到 SQLITE_BUSY 时,应用层通常设置忙等待超时,让 SQLite 在报错前先重试一小段时间:
sqlite> PRAGMA busy_timeout = 3000; -- 最多等 3 秒
Tip实用技巧:把多个写操作合并进一个事务再一次性 COMMIT,能大幅减少锁竞争和磁盘同步次数,比一条一条自动提交快得多。写冲突频繁时,记得设
busy_timeout,别让程序一遇忙就直接报错退出。
Warning常见坑:① 别以为 SQLite 能像 MySQL 那样”锁行”做高并发写——默认是文件级锁,并发写靠排队。② 高并发写场景本就不是 SQLite 的强项,应评估是否换客户端/服务器型数据库。③ 如果你依赖外键约束,记住外键在 SQLite 中默认关闭,事务里也要先
PRAGMA foreign_keys = ON(且每次连接都需重设),否则约束不生效。④ DDL(建表/删表)在 SQLite 里是自动提交的,无法塞进你手动开的事务里回滚。
四、细粒度回滚:SAVEPOINT 嵌套事务
有时你不想为整个事务回滚,只想撤销其中一段。SQLite 支持 SAVEPOINT(保存点),它相当于事务里的”书签”:
sqlite> BEGIN;
sqlite> INSERT INTO users(name, age, email) VALUES ('A', 20, 'a@x.com');
sqlite> SAVEPOINT sp1;
sqlite> INSERT INTO users(name, age, email) VALUES ('B', 30, 'b@x.com');
sqlite> ROLLBACK TO sp1; -- 只撤销 sp1 之后的操作,前面的保留
sqlite> COMMIT;
执行后,‘A’ 被保留,‘B’ 被撤销。这比”一错全回”灵活,适合一段复杂流程里局部容错。与之相对,整个事务的 ROLLBACK 是回到 BEGIN 之前。
事务与性能:批量写入要包进一个事务
事务和性能关系密切:批量插入时,如果每行都走自动提交,等于每行都单独刷一次盘、单独抢一次锁,速度极慢;把它们包进一个 BEGIN ... COMMIT,SQLite 只刷一次盘,速度能快上几十倍。所以导入大批量数据(比如从 CSV 灌几万行)时,务必先 BEGIN,插完再 COMMIT:
sqlite> BEGIN;
sqlite> INSERT INTO users(name, age, email) VALUES ('A', 20, 'a@x.com');
sqlite> INSERT INTO users(name, age, email) VALUES ('B', 30, 'b@x.com');
sqlite> -- ... 更多行 ...
sqlite> COMMIT;
反过来,一个事务包得太大(比如一次性插上百万行不提交)也会撑爆内存、拖慢提交。经验法则是:分批提交,每批几千到几万行一个事务,既快又稳。
Tip实用技巧:导入数据前临时关掉一些负担(如先不建多余索引、或导入完再建索引),配合单事务提交,是 SQLite 批量写入最经典的提速组合拳。导入完成后再
CREATE INDEX比边插边维护索引快得多。
五、两种日志模式一句话对比
- 回滚日志(rollback journal,默认):写者改之前先把原页备份到
-journal文件,提交后删掉。读者和写者互斥较强,并发写要靠排队。 - WAL(write-ahead log):改动先追加进
-wal文件,读者读主库旧页、写者写 WAL,读写可并行。适合读多写多、且多连接并发的场景,但会多出一两个附属文件。
Tip实用技巧:单写多读的应用(比如手机 App 本地库)强烈建议开 WAL;它是
PRAGMA journal_mode = WAL,设置一次后会记录在数据库文件里,后续连接自动沿用。
类比小结:SQLite 的数据库文件像一间只有一支笔的阅览室。多人可以同时进来看书(并发读),但同一时间只能有一个人拿笔改写(单写者);想大家都能边读边写,就开启 WAL 模式,让改写先记在旁边的草稿本上。事务则是那支笔的”一次完整涂改”——要么整页改完交差,要么橡皮擦全擦掉重来;SAVEPOINT 则是在涂改过程中做的”中途标记”,擦错一小段时不必整页重来。