🪟 第15章 事务
📄 一句话概括

三个动作: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 秒实验

  1. 把 ② 段的 ROLLBACK 换成 COMMIT,重跑。 表里会变成 6 条——回滚和提交只差一个词,结果完全不同。
  2. 在 ③ 段里 ROLLBACK TO s1 之后再插一条,然后 COMMIT。 最终是 7 条——说明保存点之后事务还能继续写。
  3. 把 ⑤ 段的 timeout=0.3 改成 timeout=5,重跑。b 会等更久才报错; 如果 a 在这期间提交了,b 就成功了——这就是「等一下也许就过去了」。

常见误区

  1. 「保存点等于嵌套事务」。保存点不结束事务、也不能独立提交,它只是「退回到这里」的标记。 SQLite 压根不允许嵌套 BEGIN。
  2. 「回滚会把自增号也退回去」。 不会。 回滚撤的是数据,号已经发出去了。 所以主键有空洞是正常的,别拿号连不连续判断数据有没有丢。
  3. 「事务不提交也没关系,连接关了自动回滚」。多数驱动确实会回滚, 但连接池复用连接时,事务可能一直开着——锁和日志都挂着。该 COMMIT 的地方就要写。
自测三问(点开对答案) 1. `COMMIT` 和 `ROLLBACK` 有什么共同点? —— **都是「结束事务」**——写完其中一个,这个事务就结束了,锁也会放开。 **区别只在于改动的数据是保留还是撤销。** 2. 保存点适合什么场景? —— **批量操作里「一条出错不想整批重来」。** 每条循环打一个保存点, **出错的退回来、记日志、继续下一条**,跑完统一提交。 3. `BEGIN IMMEDIATE` 相比默认的 `BEGIN` 好在哪? —— **把写锁的获取提前到事务开头。** 两个连接都「先读后写」时, **`DEFERRED` 会让它们读到一半才发现要互相等,容易死锁**;**`IMMEDIATE` 让后到的直接排队。**

下一篇 15.3 讲隐式事务与嵌套: 自动提交、隐式提交、@@TRANCOUNT 三层计数,以及「400 行逐条提交 4657 毫秒、 包成一个事务 26 毫秒」这笔账到底怎么算的。

相关笔记