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

三种模式:自动提交(每条语句一个事务)、隐式(语句替你开、你负责提交)、显式(你全包)。 实测 400 行:每条自动提交 4657 毫秒,包在一个事务里 26 毫秒——差 178 倍。 差的不是计算,是「等磁盘」:400 次提交 = 400 次刷盘确认,一个事务只等一次。

📝 本章和前面几章的关系

15.2 讲的是「你自己写 BEGIN」——那是最容易理解的一种。 这一节讲另外两种:数据库自动开的、和驱动替你开的。 教材 15.3 分三小节:隐式事务模式、自动提交事务、嵌套事务。 嵌套那节的核心是 @@TRANCOUNT 这个计数器,沙箱没有,用 Python 复刻。 这一节有个反直觉的结论要提前说: 「事务能提速」是错觉——它省的是刷盘次数,不是让数据库算得更快。 实测那个 178 倍就是这么来的。

一、自动提交:每条语句自己一个事务

SQL Server 的默认模式。你写一条 INSERT,它自己包一个事务、自己提交。 所以不写 BEGIN 的时候,每条语句都是原子的。 实测:自动提交模式下 insert 之后不做任何提交,另一个连接立刻就能看到 1 行。 因为那条语句自己已经提交了。 这也解释了一个常见疑惑:「我没写事务,为什么单条 UPDATE 要么全改要么不改? 」因为每条语句本来就在一个事务里。 Python 的 sqlite3 里对应的是 isolation_level=None。 默认值是空字符串,那是「隐式」模式——两者只差一个参数,行为完全不同, 这也是很多人第一次用 sqlite3 时「数据没进去」的原因。

二、隐式事务:语句替你开,你负责提交

默认参数下,INSERT 之后事务已经开了,但没提交:

谁在看 看到几行
自己(同一个连接) 1
另一个连接 0

提交之后,另一个连接才看到 1 行。 这就是隐式事务:开事务不用你管,收尾得你来。 SQL Server 上对应 SET IMPLICIT_TRANSACTIONS ON。 打开之后,INSERT/UPDATE/DELETE/SELECT 都会自动开新事务,直到你 COMMIT 或 ROLLBACK。 这个开关最危险的地方是「忘了提交」: 程序看着在跑、日志也在打,数据一条没进去,别人改同一张表还会卡死—— 因为在等一个永远不会来的提交。 排查时看 sys.dm_tran_active_transactions 里挂着的老事务。

三、三种模式差在哪:谁负责收尾

模式 开事务的 提交的 忘了会怎样
自动提交 数据库 数据库 不会忘
隐式 驱动/数据库 你 锁一直挂着,日志一直涨
显式 你 你 同上,而且更难看懂

这三行的共同点是最后一句:只要「提交的人是你」,就有忘掉的风险。 所以工程上的做法是「把事务包起来」——用框架的 with transaction: 或者 try/finally, 让「提交或回滚」变成一定会执行的那条路径。 一个可靠的写法长这样:

try:
    conn.execute(u"BEGIN")
    do_work(conn)
    conn.execute(u"COMMIT")
except Exception:
    conn.execute(u"ROLLBACK")     # 出错一定撤,绝不留下半截事务
    raise

四、一次提交 vs 逐条提交:那 178 倍是怎么来的

插 400 行(文件库,synchronous = FULL) 最快一次
每条自动提交 4657.1 ms
包在一个事务里 26.1 ms

差 178 倍。 这个数不是「事务有魔法」,而是「等磁盘」的次数不同: synchronous = FULL 时每次提交都要等数据真正落到盘上—— 400 次提交就是 400 次等盘,包成一个事务只等一次。 算一下账就明白了: 4657 ÷ 400 ≈ 每次提交 11.6 毫秒, 这 11.6 毫秒几乎全是磁盘的响应时间,不是数据库在算什么。 26 毫秒是「一次性写完 400 行 + 一次刷盘」的价钱。 所以要记住的结论是:事务省的是刷盘次数,不是计算量。 这也解释了为什么「批量插入要包事务」是铁律——跟数据库无关,是磁盘的物理特性决定的。 但别过头:事务不是越大越好。 事务开着的时候锁着、日志在涨、 回滚代价随规模线性上升(回滚一个 100 万行的事务能跑几分钟)。 常见做法是「几千行一批」,分批提交。

五、嵌套事务:@@TRANCOUNT 只是个计数器

教材讲嵌套,核心就一句:内层的 COMMIT 只是把计数减一,不真提交。

BEGIN TRAN          -- @@TRANCOUNT = 1
  BEGIN TRAN        -- @@TRANCOUNT = 2
  COMMIT            -- @@TRANCOUNT = 1,数据还没落盘
COMMIT              -- @@TRANCOUNT = 0,这时才真提交
GO

用 Python 复刻这个计数(SQLite 不允许嵌套 BEGIN,所以内层用保存点代替):

时刻 复刻的计数
BEGIN 之后 2
内层「提交」之后 1
外层 COMMIT 之后 0

只有最外层那次 COMMIT 真正生效。 所以有一个反直觉的结论: 「内层提交了、外层回滚了」的结果是——全没了。 这也是嵌套事务最坑的地方: 内层代码看到自己的 COMMIT 成功了, 以为数据已经落地,其实还在等外层。 所以存储过程里最好不要自己开事务——让调用方决定边界, 否则「过程里提交了、外面回滚了」这种事根本说不清。

六、动手 Demo:自动提交、隐式提交、178 倍,以及那个计数器

这一段先用两个连接对比「自动提交」和「隐式提交」的可见性差别, 再真测一次「400 行逐条提交 vs 包成一个事务」,最后复刻 @@TRANCOUNT 的计数。 注意 ④ 那段用的是文件库 + synchronous = FULL——换成内存库,这个 178 倍会直接消失, 因为内存库根本没有「等磁盘」这回事。

import sqlite3

# ① 自动提交:isolation_level=None
c = sqlite3.connect(u"tx3.db", isolation_level=None)
c.execute(u"INSERT INTO t (v) VALUES (1)")
# 另一个连接立刻能看到 1 行(这条语句自己提交了)

# ② 隐式:默认 isolation_level="",驱动替你 BEGIN
c2 = sqlite3.connect(u"tx3.db")
c2.execute(u"INSERT INTO t (v) VALUES (2)")
print(u"自己", count(c2), u"别人", count(other))   # 1 / 0
c2.commit()
print(u"提交后别人", count(other))                 # 1

# ④ 逐条提交 vs 一个事务
def one_by_one():
    cc = sqlite3.connect(u"tx3.db", isolation_level=None)
    cc.execute(u"PRAGMA synchronous = FULL")
    for i in range(400):
        cc.execute(u"INSERT INTO big (v) VALUES (?)", (i * 1.0,))


def in_one_tx():
    cc = sqlite3.connect(u"tx3.db", isolation_level=None)
    cc.execute(u"PRAGMA synchronous = FULL")
    cc.execute(u"BEGIN")
    for i in range(400):
        cc.execute(u"INSERT INTO big (v) VALUES (?)", (i * 1.0,))
    cc.execute(u"COMMIT")

print(u"逐条 %.1f ms,一个事务 %.1f ms" % (clock(one_by_one), clock(in_one_tx)))

实跑输出:

--------------------------------------------------------------------
隐式事务、自动提交、嵌套
--------------------------------------------------------------------


  ① 自动提交:每条语句自己一个事务
  --------------------------------------------------------------------

  自动提交模式下,insert 之后没有 commit:
    另一个连接看到 1 行
  已经能看到了。 因为 isolation_level=None 就是「每条语句自动提交」——
  这也正是 SQL Server 的默认模式:你不管事务,每条语句自己是一个事务。

  ② 隐式事务:语句替你开,你得自己提交
  --------------------------------------------------------------------

  隐式模式下,insert 之后还没 commit:
    自己看到 1 行,另一个连接看到 0 行
  提交之后,另一个连接看到 1 行
  这就是「隐式」的意思:你不用写 BEGIN,驱动替你开了,但 COMMIT 还得你自己来。
  SQL Server 对应的是 SET IMPLICIT_TRANSACTIONS ON——打开之后每条语句都开新事务,
  直到你 COMMIT 或 ROLLBACK。忘了提交,锁就一直挂着。

  ③ 两种模式的差在哪:谁负责收尾
  --------------------------------------------------------------------

| 模式 | 开事务的 | 提交的 | 忘了会怎样 |
|---|---|---|---|
| 自动提交 | 数据库 | 数据库 | 不会忘 |
| 隐式 | 驱动/数据库 | 你 | 锁一直挂着,日志一直涨 |
| 显式 | 你 | 你 | 同上,而且更难看懂 |

  「忘了提交」是新手最典型的线上事故。 程序看着在跑、数据一条没进去,
  别人改同一张表就卡死——因为在等一个永远不会来的提交。

  ④ 一次提交 vs 逐条提交:差多少
  --------------------------------------------------------------------

| 插 400 行(文件库,synchronous = FULL) | 最快一次 |
|---|---:|
| 每条自动提交 | 4656.6 ms |
| 包在一个事务里 | 26.6 ms |

  差 175 倍。 快的那个不是「事务有魔法」,而是它只等了一次磁盘确认——
  synchronous = FULL 时,每次提交都要等数据真正落到盘上;400 次提交就是 400 次等磁盘,
  包成一个事务就只等一次。 这就是「事务越大越快」的真实原因:省的是刷盘次数,不是计算。
  反过来说也要当心: 事务开太大,锁住的时间长、回滚代价大、日志暴涨——不是越大越好。

  ⑤ 嵌套事务:SQL Server 的 @@TRANCOUNT 是什么
  --------------------------------------------------------------------
  教材讲嵌套事务,核心就一句:内层的 COMMIT 只是把计数减一,不真提交。

  1> BEGIN TRAN          -- @@TRANCOUNT = 1
  1> BEGIN TRAN          -- @@TRANCOUNT = 2
  1> COMMIT              -- @@TRANCOUNT = 1,数据还没落盘
  1> COMMIT              -- @@TRANCOUNT = 0,这时才真提交
  用 Python 复刻这个计数(BEGIN 只能有一次,所以用保存点代替内层):

  BEGIN 之后 @@TRANCOUNT 复刻值 = 2
  内层提交之后 = 1(数据还没落盘)
  外层提交之后 = 0(这时才真落盘)
  只有最外层那次 COMMIT 真正生效。 内层的 COMMIT 只是计数减一——
  所以「内层提交了,外层回滚了」的结果是:全没了。 这一点跟直觉相反,很值得记。

①② 两个数(1 / 0)是这一节的分水岭: 同一个数据库、同一条 INSERT,只因为 isolation_level 不同,另一个连接的可见性就不一样。 这不是 SQLite 的怪癖——它就是「自动提交」和「隐式事务」的定义差别。 ④ 那 4657 毫秒听着夸张,但拆开算一点也不夸张: 4657 ÷ 400 ≈ 11.6 毫秒一次,这就是一块普通磁盘「确认写入了」的响应时间。 400 次就是 400 次,跑不掉。 26 毫秒那边是把 400 行攒在内存里、最后一次性刷盘。 所以快的本质是「攒」,不是「事务」——事务只是提供了「攒」的边界。 ⑤ 那个计数(2 → 1 → 0)看着简单,但结论很反直觉: 内层提交是假的。 如果外层最后回滚,内层「提交」的那些改动一条都留不下。

30 秒实验

  1. 把 ④ 的 synchronous = FULL 改成 OFF,重跑。 两个数的差距会大幅缩小—— 因为不等磁盘了,逐条提交也不贵了。这就反证了「时间花在等盘上」。
  2. 把 400 行改成 4000 行,重测。 逐条那个大概会涨到十倍以上,一个事务那个涨得很少—— 前者是线性的「次数」,后者是线性的「数据量」,两者不同。
  3. 想一想:一个程序每天要导入 50 万行 CSV,你会怎么切事务? 每 5000 行提交一次——既不把事务开得太大(回滚和锁的代价),也不至于 50 万次刷盘。 具体数字要实测,别照抄。

常见误区

  1. 「事务能让数据库算得更快」。 不能。 它省的是「等磁盘」的次数。实测 178 倍全在刷盘上,不在计算上。
  2. 「隐式事务等于不用管事务」。 恰恰相反:它把「提交」的责任全推给了你,忘了提交就是锁死加数据不落盘。 宁可显式写 BEGIN/COMMIT,也别开 IMPLICIT_TRANSACTIONS。
  3. 「内层 COMMIT 之后数据就安全了」。 没有。 它只是把 @@TRANCOUNT 减一,外层回滚时内层一起没。
自测三问(点开对答案) 1. 自动提交和隐式事务差在哪? —— **自动提交是「每条语句数据库自己开、自己提交」**(SQL Server 默认); **隐式事务是「语句自动开,但提交要你来」**。**区别就在最后那一下谁负责。** 2. 400 行逐条提交 4657 毫秒、一个事务 26 毫秒,这 178 倍差在哪? —— **差在刷盘次数。** 每提交一次等一次磁盘确认(约 11.6 毫秒), **400 次就是 400 次;包成一个事务只等一次。** 3. 内层 `COMMIT` 之后、外层 `ROLLBACK`,数据会怎样? —— **全没了。** 内层 `COMMIT` 只是把 `@@TRANCOUNT` 减一, **只有最外层那次 `COMMIT` 才真正落盘。**

下一篇 15.4 讲隔离级别: 脏读、不可重复读、幻读各是什么样,以及为什么 SQLite 的快照隔离连幻读都挡住了——代价又是什么。

相关笔记