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

事务把「一批语句」当成一件事:要么全成,要么全败。四个特性合称 ACID。 实测转账:扣了甲 200、第二步故意崩掉,回滚之后甲一分没少——这就是原子性。 默认模式是自动提交:每条语句自己一个事务。所以平时感觉不到事务的存在。

📝 本章和前面几章的关系

第 14 章讲索引和完整性——那些约束管的是「单条语句」级别的事。 这一章补上另一层:多条语句之间的一致性。 教材 15.1 分三小节:事务的四个特性、事务的四种模式、事务与批处理的关系。 四个特性最值得花时间——后面 15.2 到 15.5 全是在补它的细节。 事务是标准 SQL 行为,SQLite 能真跑,所以这一章的演示大部分是实测数字, 只有 @@TRANCOUNT 和分布式协调器是复刻的,涉及处会说明。 多连接并发必须用文件库——:memory: 的各连接互相看不见。

一、什么是事务:一组语句,当成一件事

最典型的例子是转账。甲给乙转 200,要做两件事:

UPDATE acc SET balance = balance - 200 WHERE id = 1;  -- 甲扣 200
UPDATE acc SET balance = balance + 200 WHERE id = 2;  -- 乙加 200

两件事必须一起成、一起败。 只做了第一件会怎样?甲少了 200,乙没收到,钱凭空消失。 事务就是把这两条包起来,让数据库把它们当成一件事。

时刻 甲的余额 乙的余额
转账前 1000 500
扣款后、入账前(事务里) 800 500
第二步崩了,ROLLBACK 之后 1000 500

跑出来就是这样:事务里能看到甲变成 800,一回滚又变回 1000。 中间那个 800 只在这一批语句内部可见——第 ③ 段会验证「别人看不到」。

二、原子性(Atomicity):全成或全败

原子性说的就是上面这件事:事务里的语句要么全部生效,要么全部不生效。 没有中间状态——不会出现「扣了款但没入账」。 实现靠的是「日志 + 回滚」。 数据库改数据之前先写日志,事务失败时按日志把改过的页恢复原样。 这也是为什么大事务回滚很慢——它得一条一条往回撤。 要注意一个常被误解的点: 原子性保证的是「这一批」, 不是「整个程序」。程序里两个事务之间崩了,前一个照样是提交了的。 所以事务的边界要自己划对——该在一个事务里的,别拆成两个。

三、一致性(Consistency):约束在事务里一样管

从甲的账户扣 5000(余额只有 1000),结果被 CHECK 挡住:

消息 102,级别 15,状态 1
CHECK constraint failed: balance >= 0

余额没变,还是 1000。 约束不会因为「在事务里」就放松—— 这正是「一致性」的意思:事务开始前数据满足所有规则,结束后也必须满足。 注意区分「一致性」和「原子性」: 原子性管「做不做」,一致性管「做完合不合规」。 扣 5000 那条语句本身就是原子的(要么扣要么不扣),但它破坏了规则—— 所以被拒的不是「原子性」,是「一致性」。 换句话说:一致性靠的是约束(主键、CHECK、外键,14.4 讲的那五类), 事务只负责「一旦违规就整体撤掉」。 没有约束,事务保证不了任何一致性。

四、隔离性(Isolation):别人看不到你没提交的东西

在事务里把甲的余额改成 100、还没提交,另开一个连接去读:

谁在看 看到甲的余额
自己(事务里) 100
另一个连接 1000

另一个连接看到的还是 1000。 未提交的修改在别人眼里根本不存在—— 这就是隔离性,也是「脏读」被挡住的原因。 如果两个连接能同时改同一行,谁赢? 数据库的答案是排队: 写锁是互斥的,后到的等前面提交或回滚。 15.4 会实测这个等待。 隔离不是免费的: 挡得越严,并发越差。 四种隔离级别就是在「准不准」和「快不快」之间选一个位置(15.4 详讲)。

五、持久性(Durability):提交了就跑不掉

把甲的余额改成 777 并提交,再开一个全新的连接来读:777 在。 「提交」这个动作的含义就是「这件事定了」——之后哪怕断电、进程被杀、 数据库重启,这个 777 都必须在。 怎么做到的?先写日志,再改数据页。 提交时把「我要改什么」写进日志并刷到磁盘, 改数据页可以慢慢来;断电重启后按日志重放,结果一样。 代价也在这里:每次提交都要等磁盘确认。 实测 400 行逐条提交 4657 毫秒、包成一个事务 26 毫秒(15.3 会详细算这笔账)—— 差的 178 倍全是「等磁盘」的钱。

六、四种事务模式:自动提交是默认的

模式 谁开的 什么时候结束 SQL Server 里的样子
显式事务 你写 BEGIN TRAN 你写 COMMIT 或 ROLLBACK 最常见,也最好懂
隐式事务 上一条语句自己开 到下一条语句或你提交 SET IMPLICIT_TRANSACTIONS ON
自动提交 每条语句自己一个事务 语句结束就提交 默认模式
批范围事务 一个批里的一组语句 批结束(GO) 用得非常少

默认是自动提交。 你写一条 UPDATE,数据库自己给它包一个事务, 语句结束就提交——所以平时根本感觉不到事务的存在。 要「多条一起成、一起败」,才需要显式写。 这就是这一章的重点:不是「怎么用事务」,而是「什么时候值得用」。 隐式事务(SET IMPLICIT_TRANSACTIONS ON)是另一回事: 它让每条语句自动开一个新事务,然后等你提交。 忘了提交就锁一直挂着——15.3 会实测这个坑。

七、事务和批处理:两件不同的事

GO 是客户端的分隔符,把脚本切成一个个「批」。但 GO 不是事务边界。 一个事务可以跨好几个批,一个批里也可以开好几个事务。

概念 管什么 边界在哪
事务 数据改动的「全成或全败」 BEGIN 到 COMMIT/ROLLBACK
批 一次发到服务端的语句包 GO(客户端切)

最常踩的坑: BEGIN TRAN 写在一个批里,COMMIT 写在下一个批里——语法上没问题, 但中途报错就很难说清哪个事务还开着,而 @@TRANCOUNT 又没打印出来,只能靠猜。 所以规矩是:一个事务尽量写在一个批里,BEGIN 和 COMMIT 隔得越近越好。 这不只是风格——是「出问题时能一眼看懂」的运维要求。

八、动手 Demo:ACID 一条一条验:转账、越界、并发、重启

这一段把四个特性各验一次:先演一次「扣了款、入账失败」的转账, 再撞一次 CHECK 约束,然后开第二个连接看「看不到未提交」,最后提交完再读一次。 用了三个连接——一个当事务、一个当「别人」、一个当「重启后的新会话」。 库必须是文件,:memory: 的各连接互相看不见。

import sqlite3

# 文件库:多连接才看得见彼此
c = sqlite3.connect(u"tx.db", isolation_level=None)
d = sqlite3.connect(u"tx.db", isolation_level=None)   # 另一个连接

# ① 原子性:扣款后故意抛错
try:
    c.execute(u"BEGIN")
    c.execute(u"UPDATE acc SET balance = balance - 200 WHERE id = 1")
    raise RuntimeError(u"模拟程序在第 2 步崩了")
except RuntimeError:
    c.execute(u"ROLLBACK")

# ③ 隔离性:改完不提交,另一个连接读
c.execute(u"BEGIN")
c.execute(u"UPDATE acc SET balance = 100 WHERE id = 1")
print(u"自己", bal(c, 1), u"别人", bal(d, 1))   # 100 / 1000
c.execute(u"ROLLBACK")

# ④ 持久性:提交后新连接读
c.execute(u"BEGIN")
c.execute(u"UPDATE acc SET balance = 777 WHERE id = 1")
c.execute(u"COMMIT")
print(bal(sqlite3.connect(u"tx.db"), 1))       # 777

实跑输出:

--------------------------------------------------------------------
事务的四个特性:ACID 一条一条验
--------------------------------------------------------------------


  ① 原子性:扣了款,入账失败了
  --------------------------------------------------------------------
  甲给乙转 200:先扣甲,再给乙加。中间那一步故意抛个错,模拟程序崩了。

  转账前:甲 1000,乙 500
  扣款后(事务里):甲 800
  抛出来的错:模拟程序在第 2 步崩了
  回滚后:甲 1000,乙 500
  甲的钱一分没少。 这就是原子性:整批当成一件事,失败就全撤。
  不写事务会怎样? 扣款那条一执行就生效了——甲少了 200、乙没收到,钱凭空消失。

  ② 一致性:约束在事务里也一样管用
  --------------------------------------------------------------------

  1> UPDATE acc SET balance = balance - 5000 WHERE id = 1
  消息 102,级别 15,状态 1
  CHECK constraint failed: balance >= 0
  余额:甲 1000
  约束不会因为「在事务里」就放松。 一致性说的是「事务前后数据都满足所有规则」——
  这里规则是余额不许为负,扣 5000 会变 -4000,所以整笔被拒、原样退回。

  ③ 隔离性:别人看不到你没提交的东西
  --------------------------------------------------------------------

  事务里改完、还没提交:

| 谁在看 | 看到甲的余额 |
|---|---:|
| 自己(事务里) | 100 |
| 另一个连接 | 1000 |

  另一个连接看到的还是 1000。 未提交的修改在别人眼里根本不存在——
  这就是隔离性,也是「脏读」被挡住的原因(15.4 专门测)。

  ④ 持久性:提交了就跑不掉
  --------------------------------------------------------------------

  提交之后,再开一个全新的连接来读:甲 777
  777 在。 新连接看到的是提交之后的状态——这就是持久性。
  真数据库靠写日志保证这件事:先写日志、再改数据页,断电也能重放。

  ⑤ 事务的四种模式
  --------------------------------------------------------------------

| 模式 | 谁开的 | 什么时候结束 | SQL Server 里的样子 |
|---|---|---|---|
| 显式事务 | 你写 BEGIN TRAN | 你写 COMMIT 或 ROLLBACK | 最常见,也最好懂 |
| 隐式事务 | 上一条语句自己开 | 到下一条语句或你提交 | SET IMPLICIT_TRANSACTIONS ON |
| 自动提交 | 每条语句自己一个事务 | 语句结束就提交 | 默认模式 |
| 批范围事务 | 一个批里的一组语句 | 批结束(GO) | 用得很很少 |

  默认是自动提交:写一条 UPDATE,它自己包在一个事务里,语句结束就提交——
  这就是平时感觉不到事务存在的原因。 要「多条一起成、一起败」,才需要显式写。

  ⑥ 事务和批处理:两件不同的事
  --------------------------------------------------------------------
  GO 是客户端的分隔符,把脚本切成一个个「批」。但 GO 不是事务边界——
  一个事务可以跨好几个批,一个批里也可以开好几个事务。两者管的是不同的东西:

| 概念 | 管什么 | 边界在哪 |
|---|---|---|
| 事务 | 数据改动的「全成或全败」 | BEGIN 到 COMMIT/ROLLBACK |
| 批 | 一次发到服务端的语句包 | GO(客户端切) |

  最常踩的坑: BEGIN TRAN 写在一个批里,COMMIT 写在下一个批里——
  语法上没问题,但中途报错就很难说清哪个事务还开着。
  所以规矩是:一个事务尽量写在一个批里,BEGIN 和 COMMIT 隔得越近越好。

第一段的关键是那个 raise。 它模拟「第一条语句成功了、 第二条没成功」——真实场景里可能是网络断了、程序抛异常、进程被 kill,效果一样。 ROLLBACK 之后甲还是 1000,钱没丢。 第二段的报错是 CHECK constraint failed,不是「事务失败」。 这条语句自己被拒了,事务里其他语句不受影响—— 想全撤得自己 ROLLBACK(演示里就是这么做的)。 第三段两个数(100 / 1000)是这一节最该记住的一对: 同一条记录,事务里看到 100、事务外看到 1000。 中间没有任何「延迟一秒」之类的玄机,就是隔离性。 第四段的 777 是持久性的证据。 注意这里新开了一个连接—— 不是「同一个连接自己看到」,是「另一个会话也看到」,这才叫持久化。

30 秒实验

  1. 把 try 里那个 raise 删掉、把 ROLLBACK 改成 COMMIT,重跑。 甲变成 800 且不再变回来——这就是「成功提交」的样子,跟失败回滚正好相反。
  2. 在 ② 那段里把 ROLLBACK 去掉、直接 COMMIT,看会怎样。 提交会失败并报约束错——因为 CHECK 在语句执行时就拦住了,事务里根本没脏数据可提交。
  3. 想一想:一个程序要做「下订单 + 扣库存 + 写日志」三件事,哪些必须在一个事务里? 订单和库存必须(不然会出现「有订单没扣库存」); 写日志可以不在——日志丢了不影响业务正确性,把它拉进事务只会让事务更长、锁更久。

常见误区

  1. 「事务能保证业务逻辑正确」。事务只保证「这一批要么全成要么全败」。 规则本身对不对,得靠约束和代码——CHECK 写错了,事务照样把错数据提交得干干净净。
  2. 「提交了就等于写进磁盘了」。要看 synchronous 设置。 关掉同步能快很多,但断电就真的可能丢——这是性能与可靠性的取舍,不是「数据库的 bug」。
  3. 「GO 就是事务结束」。GO 是客户端的批分隔符,跟事务一点关系都没有。 事务只认 BEGIN/COMMIT/ROLLBACK。
自测三问(点开对答案) 1. ACID 四个特性各管什么? —— **原子性管「全成或全败」,一致性管「前后都满足规则」,隔离性管「别人看不到未提交」,** **持久性管「提交了就丢不了」。** 2. 「别人看不到我没提交的修改」属于哪个特性? —— **隔离性。** 同一行数据,事务里看到 100、事务外看到 1000——**未提交的修改在别人眼里不存在。** 3. 为什么 400 行逐条提交比包成一个事务慢 178 倍? —— **因为每次提交都要等一次磁盘确认。** 400 次提交就是 400 次等盘, **包成一个事务只等一次**——省的是刷盘次数,不是计算量。

下一篇 15.2 讲显式事务的三个动作:BEGIN、COMMIT、ROLLBACK, 外加一个「只回滚一半」的保存点——批量导入时它比整批重来省太多。

相关笔记