三个动作:BEGIN TRANSACTION 开、COMMIT 提交、ROLLBACK 回滚。
保存点(SAVEPOINT)能「只回滚一半」:插了 8 条,退到保存点剩 6 条,再整笔提交。
实测写锁互斥:a 拿着写锁不提交,b 等 420 毫秒后报 database is locked。
15.1 讲「事务是什么、四个特性」,这一节讲「怎么写」——三个动作加一个保存点。
教材 15.2 分三小节:
BEGIN TRANSACTION、COMMIT 与 ROLLBACK、保存点 SAVE TRANSACTION。
保存点是这一节最有价值的部分,
也是最容易被跳过的一节——
很多人只会「整批回滚」,遇到「批量导入时某一条出错」就只能全部重来。
一、三步:开、提交、回滚
BEGIN TRANSACTION;
UPDATE acc SET balance = balance - 200 WHERE id = 1;
UPDATE acc SET balance = balance + 200 WHERE id = 2;
COMMIT TRANSACTION;
GO
BEGIN TRANSACTION 之后,所有语句都在同一个事务里,直到你 COMMIT 或 ROLLBACK。
中间的语句自己不会提交——这就是跟自动提交模式最大的区别。
实测插 3 条:事务里数到 3 条,提交之后还是 3 条。
数字一样,但含义完全不同——提交之前,那 3 条在别人眼里是不存在的(15.1 的 ③ 验过)。
三个动作的角色分工:
BEGIN 划边界、COMMIT 拍板、ROLLBACK 撤销。
COMMIT 和 ROLLBACK 是一对,写了一个这个事务就结束了,
再写第二个会报「没有对应的 BEGIN」。
二、回滚:插了 3 条,一条不留
承接上一段(表里已有 3 条),再插 3 条然后回滚:
| 时刻 | 表里几行 |
|---|---|
| 插入 3 条之后(事务里) | 6 |
ROLLBACK 之后 |
3 |
6 变回 3,那 3 条插了跟没插一样。 而且自增主键也不会退回去——
回滚只撤数据,不撤「已经发出去的号」,所以正式库里出现「主键空洞」很正常,
别拿自增号连不连续当数据完整性的判断标准。
ROLLBACK 还干一件重要的事:放锁。 事务不结束,锁就一直挂着;
回滚和提交一样,都是「结束事务」的方式,都会把锁放开。
三、保存点:只回滚一半
业务场景:批量导入 1000 条,第 837 条格式不对。 没有保存点,只能整批回滚重来(前 836 条白干); 有保存点,退回到「本次循环开始」那个点,记一条错误,继续下一条。
BEGIN TRANSACTION;
INSERT INTO log (msg) VALUES ('第 1 条');
SAVE TRANSACTION s1; -- 打一个点
INSERT INTO log (msg) VALUES ('第 2 条');
ROLLBACK TRANSACTION s1; -- 只退到这个点
COMMIT TRANSACTION; -- 第 1 条还在,第 2 条没了
GO
| 时刻 | 表里几行 |
|---|---|
| 插 3 条、打了保存点、又插 2 条 | 8 |
ROLLBACK TO s1 之后 |
6 |
整笔 COMMIT 之后 |
6 |
三步数字看得很清楚:8 → 6 → 6。 保存点之前那 3 条留下了,之后的 2 条撤了,最后整笔提交。
注意 ROLLBACK TO s1 不会结束事务——它只是「退到这个点」,事务还开着,
后面还能继续写、还能再打新的保存点。 这一点跟完整回滚不一样。
批量导入的标准写法就是它: 每条循环里打一个保存点,出错的退回来、记日志、继续;
全部跑完最后一次提交。 这样「一条坏数据」不会拖垮整批。
四、保存点不是嵌套事务
在事务里再写一个 BEGIN,直接报错:
消息 102,级别 15,状态 1
cannot start a transaction within a transaction
SQLite 不支持嵌套事务,所以「事务里再开事务」只能用保存点模拟。
这也是为什么很多框架的「嵌套事务」其实是保存点——它只退到某个点,不独立提交。
SQL Server 那边 BEGIN TRAN 是可以套的,
但它只是把计数器 @@TRANCOUNT 加一——内层的 COMMIT 并不真提交,
只有最外层那次 COMMIT 才落盘。这跟「真嵌套」的直觉差别很大,15.3 会实测这个计数。
所以两个库的「嵌套」语义并不一样: SQLite 是「不允许」,SQL Server 是「允许但只算一层」。
写跨库的代码时,别指望嵌套事务能按你想的方式工作。
五、BEGIN 的三种写法:锁什么时候拿
| 写法 | 什么时候拿锁 | 适合什么场景 |
|---|---|---|
BEGIN(DEFERRED) |
第一条读写语句时 | 默认,最省 |
BEGIN IMMEDIATE |
立刻拿写锁 | 确定要写,早点拿免得后面抢 |
BEGIN EXCLUSIVE |
立刻拿排他锁 | 要独占整库,比如批量重整 |
BEGIN IMMEDIATE 的意义是「把冲突提前」。
两个连接都先读后写时,DEFERRED 会让两边都读到、到写的时候才发现互相要等——
那是最容易死锁的形状;IMMEDIATE 让第二个连接在动手之前就排队。
实测一次写锁互斥: a 用 BEGIN IMMEDIATE 写一行不提交,
b 也去写,等 420 毫秒后报 database is locked。
等待时间是连接参数 timeout 决定的(这里设的 0.3 秒,实际等了 420 毫秒才放弃)。
这就是生产上大量「偶发写入失败」的真相:
不是代码有 bug,是某个事务开得太久、把写锁攥了太久。
排查方向应该是「谁的事务太长」,不是「重试次数不够」。
六、动手 Demo:提交、回滚、保存点,以及一次写锁互斥
这一段在文件库上依次演:正常提交(3 条)→ 整批回滚(6 变 3)→ 保存点(8 变 6), 最后两个连接抢同一张表的写锁,看等待和报错长什么样。 每一步都把「表里有几行」打出来——这类实验不看数字就等于没做。
import sqlite3, time
c = sqlite3.connect(u"tx2.db", isolation_level=None)
c.execute(u"CREATE TABLE log (id INTEGER PRIMARY KEY AUTOINCREMENT,"
u" msg TEXT NOT NULL)")
# ① 正常提交
c.execute(u"BEGIN")
for i in range(1, 4):
c.execute(u"INSERT INTO log (msg) VALUES (?)", (u"第 %d 条" % i,))
c.execute(u"COMMIT")
# ② 整批回滚
c.execute(u"BEGIN")
for i in range(4, 7):
c.execute(u"INSERT INTO log (msg) VALUES (?)", (u"第 %d 条" % i,))
c.execute(u"ROLLBACK")
# ③ 保存点:只退一半
c.execute(u"BEGIN")
for i in range(4, 7):
c.execute(u"INSERT INTO log (msg) VALUES (?)", (u"第 %d 条" % i,))
c.execute(u"SAVEPOINT s1")
for i in range(7, 9):
c.execute(u"INSERT INTO log (msg) VALUES (?)", (u"第 %d 条" % i,))
c.execute(u"ROLLBACK TO s1")
c.execute(u"COMMIT")
# ⑤ 写锁互斥
a = sqlite3.connect(u"tx2b.db", isolation_level=None)
a.execute(u"BEGIN IMMEDIATE")
a.execute(u"INSERT INTO t (v) VALUES (1)") # 拿着写锁不放
b = sqlite3.connect(u"tx2b.db", isolation_level=None, timeout=0.3)
b.execute(u"INSERT INTO t (v) VALUES (2)") # -> database is locked
实跑输出:
--------------------------------------------------------------------
显式事务:提交、回滚、保存点
--------------------------------------------------------------------
① 正常路径:BEGIN 到 COMMIT
--------------------------------------------------------------------
事务里刚插完 3 条,表里已经有 3 条
提交之后:3 条
事务里的写,自己能看到、别人看不到。 提交之后才真正落到库里。(上一段的 ③ 验过这一条)
② 回滚:插了 3 条,一条不留
--------------------------------------------------------------------
回滚前:6 条
回滚后:3 条
ROLLBACK 把事务里的改动全部撤掉、把锁放开。 这是「出错了怎么办」的标准答案。
③ 保存点:只回滚一半
--------------------------------------------------------------------
插 3 条 → 打一个保存点 → 再插 2 条 → 只回滚到保存点。
打保存点后插了 2 条:8 条
ROLLBACK TO s1 之后:6 条
整笔提交之后:6 条
保存点只撤到那个点,前面的改动还在。 典型场景是「先做主体、再试一步,失败就退回试的那步」——
比如批量导入:先插主表、打保存点、插明细,明细出错就退到保存点记一条错误继续下一条,
不用整批重来。
④ 保存点不是嵌套事务
--------------------------------------------------------------------
1> BEGIN
消息 102,级别 15,状态 1
cannot start a transaction within a transaction
SQLite 不支持嵌套 BEGIN,所以「事务里再开事务」只能用保存点模拟。
SQL Server 那边 BEGIN TRAN 是可以套的,但它只是把计数器 @@TRANCOUNT 加一——
内层的 COMMIT 并不真提交,只有最外层那次 COMMIT 才落盘。15.3 会实测这个计数。
⑤ BEGIN 的三种写法:锁什么时候拿
--------------------------------------------------------------------
| 写法 | 什么时候拿锁 | 适合什么场景 |
|---|---|---|
| BEGIN(DEFERRED) | 第一条读写语句时 | 默认,最省 |
| BEGIN IMMEDIATE | 立刻拿写锁 | 确定要写,早点拿免得后面抢 |
| BEGIN EXCLUSIVE | 立刻拿排他锁 | 要独占整库,比如批量重整 |
BEGIN IMMEDIATE 的意义是「把冲突提前」。 两个连接都先读后写,
用 DEFERRED 的话两边都能读、到写的时候才发现互相要等,容易死锁;
IMMEDIATE 让第二个连接在读之前就等着,代价是并发度低一点。
真测一次:a 用 BEGIN IMMEDIATE 写一行但不提交,b 想写同一条——
消息 102,级别 15,状态 1
database is locked
b 等了 415 毫秒后报 database is locked。
这就是「写锁互斥」:同一时刻只有一个连接能写。 b 的等待时间由 timeout 决定,
超时就报 database is locked——生产上大量「偶发写入失败」都是这个原因:
不是代码错,是事务开得太久、锁住太久。
①②③ 三段的行数变化是 3 → 6 → 3 → 8 → 6 → 6。 每一步都对得上:
提交的留下、回滚的消失、保存点之后的消失而之前的留下。
注意 ③ 里 ROLLBACK TO s1 之后还能 COMMIT——保存点不结束事务。
这条如果搞错,代码会写成「保存点之后忘记提交」,数据看起来正常、其实一直没落盘。
⑤ 那段报的是 database is locked,不是「死锁」。
单机库的写锁是「排队 + 超时」,等不到就报错,不会像 SQL Server 那样检测死锁并牺牲一方。
所以在 SQLite 上「重试」是常见手段,但根治办法永远是缩短事务。
BEGIN IMMEDIATE 那一步不能省。 用默认的 DEFERRED,a 要等到第一条写语句才拿锁,
demo 里看起来一样,但两个连接都「先读后写」时差别就出来了——那时候才会互相卡死。
30 秒实验
- 把 ② 段的
ROLLBACK换成COMMIT,重跑。 表里会变成 6 条——回滚和提交只差一个词,结果完全不同。 - 在 ③ 段里
ROLLBACK TO s1之后再插一条,然后COMMIT。 最终是 7 条——说明保存点之后事务还能继续写。 - 把 ⑤ 段的
timeout=0.3改成timeout=5,重跑。b 会等更久才报错; 如果 a 在这期间提交了,b 就成功了——这就是「等一下也许就过去了」。
常见误区
- 「保存点等于嵌套事务」。保存点不结束事务、也不能独立提交,它只是「退回到这里」的标记。
SQLite 压根不允许嵌套
BEGIN。 - 「回滚会把自增号也退回去」。 不会。 回滚撤的是数据,号已经发出去了。 所以主键有空洞是正常的,别拿号连不连续判断数据有没有丢。
- 「事务不提交也没关系,连接关了自动回滚」。多数驱动确实会回滚,
但连接池复用连接时,事务可能一直开着——锁和日志都挂着。该
COMMIT的地方就要写。
自测三问(点开对答案)
1. `COMMIT` 和 `ROLLBACK` 有什么共同点? —— **都是「结束事务」**——写完其中一个,这个事务就结束了,锁也会放开。 **区别只在于改动的数据是保留还是撤销。** 2. 保存点适合什么场景? —— **批量操作里「一条出错不想整批重来」。** 每条循环打一个保存点, **出错的退回来、记日志、继续下一条**,跑完统一提交。 3. `BEGIN IMMEDIATE` 相比默认的 `BEGIN` 好在哪? —— **把写锁的获取提前到事务开头。** 两个连接都「先读后写」时, **`DEFERRED` 会让它们读到一半才发现要互相等,容易死锁**;**`IMMEDIATE` 让后到的直接排队。**下一篇 15.3 讲隐式事务与嵌套:
自动提交、隐式提交、@@TRANCOUNT 三层计数,以及「400 行逐条提交 4657 毫秒、
包成一个事务 26 毫秒」这笔账到底怎么算的。
- ☐ (本篇完,下一篇 15.3 隐式事务)