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

ACID 四条:原子性(全成或全不成)、一致性(约束始终满足)、 隔离性(并发事务互不可见中间状态)、持久性(提交了就一定在)。 原子性和持久性靠日志,一致性靠约束,隔离性靠锁和 MVCC。 隔离级别有四种,MySQL 默认 repeatable read; 本沙箱只有一种行为,大致相当于 serializable。 越长的事务危害越大。

📝 本章和前面几章的关系

上一篇讲「怎么用事务」,这一篇讲「它凭什么敢承诺」。 ACID 这四个字母是数据库的立身之本,理解了它们,很多「奇怪的行为」就不奇怪了: 为什么长事务看到的是旧数据、为什么并发写会报死锁、为什么批量插入要包成一个事务。

一、A:原子性,要么全成要么全不成

Demo 里转账转到一半,第二条故意把表名写错(acctt), 报 no such table,回滚之后两边都是原值。 第一条明明执行成功了,但因为它没被提交,回滚就一起没了。 这就是原子性:事务是一个不可分割的单位。 它不是「尽量做到」,是「数据库保证的」—— 哪怕中途断电,重启之后也是「要么都在、要么都不在」。 靠的是日志:先写日志再改数据,重启时按日志决定重做还是撤销。

二、C:一致性,约束在事务里也守得住

给 balance 加一条 check (balance >= 0), 第二条扣 200 就被拦下,整个事务回滚,两边都没动。 一致性说的是「事务开始前数据满足约束,结束后也满足」。 中间那一瞬间可以违反,但对外永远看不到那一瞬间。 因为别人看到的是「提交后的样子」,不是中间态。 这一条其实最微妙: 数据库能保证的是「约束不被破坏」,不能保证「业务上对」—— 转账金额写错成 600 也是「一致」的,因为约束没被破坏。 业务正确性还得靠应用层校验和测试。

三、I:隔离性,别人看不到你还没提交的东西

Demo 里两个连接同时查同一张表: A 写了没提交,A 看到 40、B 看到 100。 A 一提交,B 立刻看到 40。 这就是隔离性:并发的事务之间互相看不见对方的中间状态。 没有它的话,B 可能基于一个「后来被回滚的数据」做出决策,那就是脏读。 隔离是靠锁和 MVCC 实现的: 读的时候拿快照、写的时候拿锁。 两套机制的代价就是「隔离级别」这个旋钮—— 锁得越紧越安全,也越慢。

四、写锁是库级的,MySQL 是行级的

Demo 里 B 想写另一行,直接报 database is locked。 本沙箱的写锁是「整个库一把」—— A 在写,B 连另一行都改不了。 MySQL 的 InnoDB 有行锁:改第 1 行不挡第 2 行, 所以并发写入的能力强很多。 但代价是锁管理更复杂—— 死锁、锁等待、间隙锁这些问题都是行锁带来的。 所以「换个数据库就快了」这件事要看场景:读写都少的嵌入式场景,库级锁够用而且简单; 高并发写入的场景,行级锁是必需的。 选型时这一条比「语法好不好看」重要得多。

五、D:持久性,提交了就一定在

Demo 里 commit 之后关连接、重开,999 还在。 这就是持久性。 它靠「先写日志、再改数据」实现: commit 时先把「我要改什么」写进日志并刷盘,然后才慢慢改数据文件。 即使改到一半断电,重启时按日志重做一遍就行。 代价是每次 commit 都要等一次磁盘。 这就是为什么「批量插入一万行」时,把一万行包进一个事务会快几十倍—— 不包的话就是一万次刷盘。 这条在导入数据时特别实用。

六、隔离级别:四种,越严越慢

从松到严:read uncommitted 能读到别人没提交的;read committed 只读已提交的; repeatable read 同一事务里反复读结果一样(MySQL 默认);serializable 完全串行。 严的级别解决三种「读得不对」:脏读(读到别人没提交、后来又被回滚的数据)、 不可重复读(同一事务里两次读同一行,值不一样)、幻读(同一事务里两次查同一个条件, 行数不一样)。 本沙箱只有一种行为,大致相当于 serializable: 读的时候看不到别人未提交的改动,写的时候整库加锁。 所以这里没法演示「脏读」—— 想看脏读得去 MySQL 上把级别调到 read uncommitted。 MySQL 的默认是 repeatable read,靠 MVCC 实现: 每个事务开始的时候拍一张快照,之后一直读那张快照。 所以「同一事务里读两次结果一样」是天然的,不用加锁。 这一条也解释了「为什么长事务看到的数据是旧的」——它一直在读自己那张老快照。

七、长事务:最该避免的用法

一是锁占用时间长。 你拿着锁不放,别人就等着—— 一个开了十分钟的事务,能让整条业务线卡十分钟。 二是旧版本数据堆着删不掉。 MVCC 要给老事务保留它能看见的旧版本, 长事务一直不结束,这些旧版本就一直留着——MySQL 里叫「undo log 膨胀」,磁盘会涨得很快。 三是回滚代价大。 改了十万行才发现要回滚,撤销本身也要时间,期间还占着锁。 四是容易撞上事务超时。 MySQL 的 innodb_lock_wait_timeout 默认 50 秒,等不到就报错; 应用那边如果没处理这个错,就是一次失败。 所以原则是「事务要短」:只把「必须一起成功」的语句包进去, 查数据、算逻辑、调接口都放到事务外面。 一个事务里最好别超过十几条语句。 这一条比任何调优参数都管用。

八、动手 Demo:ACID 四条各验一次,再看两个连接怎么互相看不见

Demo 六段:原子性(写到一半报错)、一致性(check 拦下)、隔离性(两个连接看到不同的值)、 库级写锁、持久性(重开连接还在)、隔离级别与长事务的说明。 第三段那个「A 看 40、B 看 100」是全篇最直观的一处——同一个时刻、同一张表、两个不同的答案。

import os
import sqlite3
import tempfile

db = os.path.join(tempfile.gettempdir(), "acid.db")
c = sqlite3.connect(db)
c.execute("CREATE TABLE acct (id INTEGER PRIMARY KEY, name TEXT, "
          "balance INTEGER CHECK (balance >= 0))")
c.executemany("INSERT INTO acct VALUES (?,?,?)",
               [(1, '账户1', 100), (2, '账户2', 50)])
c.commit()

# C:约束守得住——第二条被 check 拦下,整个事务回滚
c.execute("BEGIN")
c.execute("UPDATE acct SET balance = balance - 60 WHERE id = 1")
try:
    c.execute("UPDATE acct SET balance = balance - 200 WHERE id = 2")
except sqlite3.Error as e:
    print("第二条被拦:", e)
c.rollback()

# I:两个连接,一个写了没提交,另一个看不到
a = sqlite3.connect(db)
b = sqlite3.connect(db)
a.execute("BEGIN IMMEDIATE")
a.execute("UPDATE acct SET balance = balance - 60 WHERE id = 1")
print("A 看到", a.execute("SELECT balance FROM acct").fetchall())
print("B 看到", b.execute("SELECT balance FROM acct").fetchall())

# 写锁是库级的:B 想写直接报 locked
try:
    b.execute("UPDATE acct SET balance = balance + 1 WHERE id = 2")
except sqlite3.Error as e:
    print("B 想写:", e)

a.commit()                     # 提交后 B 立刻看到
print("提交后 B 看到", b.execute("SELECT balance FROM acct").fetchall())

# D:commit 之后重开连接,数据还在
c.execute("UPDATE acct SET balance = 999 WHERE id = 1")
c.commit()
c.close()
c = sqlite3.connect(db)
Q.show(c, "SELECT id, balance FROM acct WHERE id = 1")

实跑输出:

--------------------------------------------------------------------
第七十四课:事务的属性——ACID 四条,逐条验一遍
--------------------------------------------------------------------

  A:原子性,要么全成要么全不成
  **转账转到一半,第二条写错了**(故意把表名写错):
  SQL> BEGIN
  完成
  SQL> UPDATE acct SET balance = balance - 60 WHERE id = 1
  影响 1 行
  SQL> UPDATE acctt SET balance = balance + 60 WHERE id = 2
  报错:no such table: acctt
  SQL> ROLLBACK
  完成
  SQL> SELECT id, name, balance FROM acct
  id     name      balance
  -------------------------
  1      账户1     100
  2      账户2     50
  (2 行)
  **两边都是原值。** **第一条明明执行成功了,但因为它没被提交,回滚就一起没了。**
  **这就是原子性:事务是一个不可分割的单位。** **它不是「尽量做到」,是「数据库保证的」**——
  **哪怕断电,重启之后也是「要么都在、要么都不在」。**
  **靠的是日志:先写日志再改数据,重启时按日志决定重做还是撤销。**

  C:一致性,约束在事务里也守得住
  **`acct` 表没加约束,所以「余额扣成负数」这句话本身不会报错。**
  **要守住「余额不能为负」,得靠一条 `check` 约束**:
  SQL> BEGIN
  完成
  SQL> UPDATE acct2 SET balance = balance - 60 WHERE id = 1
  影响 1 行
  SQL> UPDATE acct2 SET balance = balance - 200 WHERE id = 2
  报错:CHECK constraint failed: balance >= 0
  SQL> ROLLBACK
  完成
  SQL> SELECT id, name, balance FROM acct2
  id     name      balance
  -------------------------
  1      账户1     100
  2      账户2     50
  (2 行)
  **第二条被 `check` 拦下,整个事务回滚,两边都没动。**
  **一致性说的是「事务开始前数据满足约束,结束后也满足」**——
  **中间那一瞬间可以违反,但对外永远看不到那一瞬间。**

  I:隔离性,别人看不到你还没提交的东西
  SQL> SELECT id, balance FROM acct
  id     balance
  ---------------
  1      40
  2      50
  (2 行)
  SQL> SELECT id, balance FROM acct
  id     balance
  ---------------
  1      100
  2      50
  (2 行)
  **同一个时刻,A 看到 40,B 看到 100。** **B 看到的是「A 开始之前的样子」。**
  **这就是隔离性:并发的事务之间互相看不见对方的中间状态。**
  SQL> SELECT id, balance FROM acct
  id     balance
  ---------------
  1      40
  2      50
  (2 行)
  **A 一提交,B 立刻看到 40。** **注意 B 这句是「重新查的」,所以看到了新值**——
  **如果 B 是在一个事务里连查两次、中间 A 提交了,结果就不一定了**——
  **那正是「不可重复读」,后面讲隔离级别时会说。**

  写锁是库级的:B 想写会直接报 locked
  SQL> UPDATE acct SET balance = balance + 1 WHERE id = 2
  报错:database is locked
  **报 `database is locked`。** **本沙箱的写锁是「整个库一把」**——**A 在写,B 连另一行都改不了。**
  **MySQL 的锁粒度细得多**:**InnoDB 有行锁**(改第 1 行不挡第 2 行),
  **所以并发写入的能力强很多。** **但代价是「锁管理更复杂」**——
  **死锁、锁等待、间隙锁这些问题都是行锁带来的。**

  D:持久性,提交了就一定在
  SQL> BEGIN
  完成
  SQL> UPDATE acct SET balance = 999 WHERE id = 1
  影响 1 行
  SQL> COMMIT
  完成
  SQL> SELECT id, balance FROM acct WHERE id = 1
  id     balance
  ---------------
  1      999
  (1 行)
  **连接关掉、重新打开,999 还在。**
  **这就是持久性:`commit` 返回成功的那一刻,数据就已经落到磁盘上了。**
  **这一条是靠「先写日志、再改数据」实现的**:
  **`commit` 时先把「我要改什么」写进日志并刷盘,然后才慢慢改数据文件。**
  **所以即使改到一半断电,重启时按日志重做一遍就行。** **代价是每次 `commit` 都要等一次磁盘**——
  **这就是为什么「批量插入一万行」时,把一万行包进一个事务会快几十倍。**

  隔离级别:四种,越严越慢
  MySQL 原码(本沙箱不执行):
  | SELECT @@transaction_isolation;   -- 8.0 之前叫 tx_isolation
  | SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
  **四种级别,从松到严**:**`read uncommitted`** 能读到别人没提交的;
  **`read committed`** 只读已提交的;
  **`repeatable read`** 同一事务里反复读结果一样(MySQL 默认);**`serializable`** 完全串行。
  **严的级别解决三种「读得不对」**:**脏读**(读到别人没提交、后来又被回滚的数据)、
  **不可重复读**(同一事务里两次读同一行,值不一样)、**幻读**(同一事务里两次查同一个条件,
  行数不一样)。
  **本沙箱只有一种行为,大致相当于 `serializable`**:
  **读的时候看不到别人未提交的改动,写的时候整库加锁。** **所以这里没法演示「脏读」——**
  **想看脏读得去 MySQL 上把级别调到 `read uncommitted`。**
  **MySQL 的默认是 `repeatable read`,靠 MVCC 实现**:
  **每个事务开始的时候拍一张快照,之后一直读那张快照。**
  **所以「同一事务里读两次结果一样」是天然的,不用加锁。**
  **这一条也解释了「为什么长事务看到的数据是旧的」**——**它一直在读自己那张老快照。**

  长事务:最该避免的用法
  **事务开得越久,危害越大**,四个理由:
  **一是锁占用时间长。** **你拿着锁不放,别人就等着**——
  **一个开了十分钟的事务,能让整条业务线卡十分钟。**
  **二是旧版本数据堆着删不掉。** **MVCC 要给老事务保留它能看见的旧版本**,
  **长事务一直不结束,这些旧版本就一直留着**——**MySQL 里叫「undo log 膨胀」,磁盘会涨得很快。**
  **三是回滚代价大。** **改了十万行才发现要回滚,撤销本身也要时间**,**期间还占着锁。**
  **四是容易撞上「事务超时」。** **MySQL 的 `innodb_lock_wait_timeout` 默认 50 秒**,
  **等不到就报错**;**应用那边如果没处理这个错,就是一次失败。**
  **所以原则是「事务要短」**:**只把「必须一起成功」的语句包进去**,
  **查数据、算逻辑、调接口都放到事务外面。** **一个事务里最好别超过十几条语句。**

原子性:第二条报 no such table: acctt,回滚后两边都是原值。 一致性:第二条报 CHECK constraint failed: balance >= 0,回滚后 100/50 没动。 隔离性:A 看到 40(自己事务里的新值),B 看到 100(旧值)。 A 一提交,B 立刻看到 40。 写锁:B 想改另一行报 database is locked。 持久性:commit 后关连接重开,999 还在。

30 秒实验

  1. 把 check (balance >= 0) 去掉再做一致性那段,会发生什么?
  2. 两个连接都是「读」,会不会互相锁住?为什么不?
  3. 为什么「一万行包一个事务」比「一万行各自提交」快几十倍?

常见误区

  1. 以为一致性等于业务正确。 数据库只保证约束不被破坏,金额算错它不管。
  2. 开一个长事务跑一整天。 锁、undo log、超时三重代价,事务要短。
  3. 在事务里调外部接口。 接口一慢,锁就握着不放,要放到事务外面。
自测三问(点开对答案) 1. ACID 四条分别靠什么实现? —— 原子性和持久性靠日志(先写日志再改数据),一致性靠约束和触发器,隔离性靠锁和 MVCC。 2. MySQL 默认的隔离级别是什么?它解决了哪个问题? —— `repeatable read`,靠 MVCC 拍快照,解决了不可重复读;同一事务里反复读结果一致。 3. 为什么长事务危害大? —— 锁占用久(挡住别人)、undo log 不能清理(磁盘涨)、回滚代价大、还容易撞上锁等待超时。

第 15 章完了: 事务的用法和它凭什么敢承诺,都过了。 下一篇进第 16 章——安全管理,讲账号和权限。

相关笔记