🛢️ 第16章 事务
📄 一句话概括

四种隔离级别挡的是三件坏事:脏读、不可重复读、幻读。 SQLite 只有一档——默认就是最严的可串行化,没有 SET TRANSACTION 这个语法。 PRAGMA read_uncommitted 名字很唬人,实测打开也读不到未提交的数据。

📝 本章和前面几章的关系

教材 16.3 两个小节:隔离级别与并发问题、设置隔离级别。这一篇把三件坏事挨个造一遍。 前两篇是「事务怎么做」「锁怎么挡」,这一篇是「挡到什么程度」——隔离级别就是那个刻度盘。 这一篇是第 16 章的收尾,后面 16.4 小结和 16.5 实践与练习并进本篇末尾。

一、四种隔离级别与三种并发问题

先把三件「坏事」说清楚:

问题 一句话描述
脏读 读到了别人还没提交的数据
不可重复读 同一个事务里读两次,值不一样了
幻读 同一个事务里数两次,行数不一样了

对应四个隔离级别,从左到右越来越严、并发越来越差:

隔离级别 脏读 不可重复读 幻读
READ UNCOMMITTED 可能 可能 可能
READ COMMITTED 不会 可能 可能
REPEATABLE READ 不会 不会 可能
SERIALIZABLE 不会 不会 不会

这张表是 MySQL 的语境:InnoDB 默认 REPEATABLE READ,靠 MVCC 做快照读。 SQLite 只有一档:默认就是最严的那一档(可串行化)。 「可串行化」这个名要解释一下:它不是说「事务排队执行」,而是说「结果等价于排队执行」。 实现上可以靠阻塞(回滚日志模式)、也可以靠快照(WAL 模式),效果一样。 这一条是理解后两节实测的关键——两种模式结论相同、原因不同。

二、脏读:能不能读到别人没提交的数据

先让 a 开事务改一行但不提交,再让 b 去读。 b 还专门打开了 PRAGMA read_uncommitted = 1,等于「我允许自己读脏数据」。

时刻 b 打开 read_uncommitted 后读到
开关取值 1(已打开)
a 改了没提交 读到 1000
a 回滚之后 读到 1000

第二行读到的还是 1000,不是 555——开关打开了也读不到未提交的数据。 原因是 read_uncommitted 只在「共享缓存模式」下才起作用,而那个模式早就废弃了。 所以这一条要按实测说:SQLite 挡脏读, 而且挡得比 MySQL 更彻底——它连「想读脏数据」的机会都不给。 MySQL 那边是可以真造出来的:设成 READ UNCOMMITTED,另一个会话就能读到别的事务没提交的值。 这也是为什么这一档在实际项目里几乎没人用——它省下的那点锁开销,换不来「读到假数据」的风险。

三、不可重复读与幻读:两种模式,两种原因

这一节要两个连接真的并发,所以用落盘的库、WAL 模式。 a 开事务读一次,b 在中间改一行并提交,a 再读一次。

时刻 a 看到甲的余额 a 看到行数
第一次读 1000 3
b 改一行并提交 1000 3
b 插一行并提交 — 3
a 提交之后再数 — 4

b 两次都提交成功了,可 a 三次读到的完全一样:1000、3 行、3 行。 这就是快照读:事务一开始看到的那一版数据,整个事务里都不会变。 所以不可重复读、幻读在 WAL 下都不会发生——a 提交之后才看到 4 行。 换成传统的回滚日志模式,结论一样,但原因不同:b 连写都写不进去,直接被挡住。 所以「不可重复读」不是被快照挡住的,是「根本没机会发生」——a 在读,别人就写不了。 两种模式都达到了可串行化,代价不一样:一个靠阻塞,一个靠快照。 靠阻塞的那个,读多的时候写全卡住; 靠快照的那个,写可以继续,但写的人一多就会撞在「同一时刻只有一个写者」这条线上。 这三件坏事里,幻读最容易和不可重复读混。 区别在「读的是什么」:读同一行、值变了,是不可重复读; 读的是「符合条件的行有几条」、条数变了,是幻读。 前者靠锁住那一行能解决,后者得锁住「这个范围」才行——这也是为什么幻读最难挡。

四、设置隔离级别:两边各怎么设

MySQL 用 SET TRANSACTION ISOLATION LEVEL,可以设下一个事务、当前会话、或全局。

-- 看当前级别
SELECT @@transaction_isolation;

-- 只影响下一个事务
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 影响整个会话
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 影响全局(新连接的默认值)
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;

这些在沙箱里一条都过不去:

MySQL 的写法 沙箱的反应
SET TRANSACTION ISOLATION LEVEL ... near "SET": syntax error
SET SESSION TRANSACTION ... near "SET": syntax error
SET GLOBAL TRANSACTION ... near "SET": syntax error
SELECT @@transaction_isolation unrecognized token: "@"

三条 SET 开头的报语法错,两条 @@ 的报「这个记号我压根不认」——SQLite 没有会话变量这一层。 前三条是语法不存在,后一条是记号不存在,两类要分开看。 SQLite 能调的只有三样东西,而且都不是隔离级别:

能调什么 作用
journal_mode delete(默认)/wal,换的是并发模型
busy_timeout 锁等待多久才报错,换的是「快速失败还是排队」
read_uncommitted 只在废弃的共享缓存下有效,实测等于没用

第三行值得单独说一句:这个开关设了也没有效果,但它的名字很唬人。 看到它的人容易以为「打开就能读未提交」,实测读到的还是旧值。 这类「名字比功能大」的开关,一律以实测为准——文档里那句「只在共享缓存下有效」很容易被跳过。

五、本章小结与实践与练习

第 16 章三篇,真正要带走的是五条: 一、事务是「几条语句当一个用」,它保证的不是「不出错」,而是「出错之后不留半成品」。 二、没提交的改动只有自己看得见——这一条推出后面所有结论。 三、SQLite 只有一把写锁,锁的是整个库;写被挡住的时候,读照常。 四、SQLite 构造不出死锁,因为它只有一个写者;它只有锁等待超时,没有死锁检测。 五、SQLite 只有一档隔离级别(可串行化),WAL 用快照实现、回滚日志用阻塞实现。 这一章最该记住的一句是:事务解决的问题是「一致」,锁和隔离级别付的代价是「并发」。 这三样是一根绳上的:要更严的一致性,就得更少的并发,没有第三条路。 MySQL 让你在四档里选,SQLite 直接替你选了最严的那一档—— 省心,但也意味着没法为吞吐去松一松。 15.6 之后这一章的练习可以挑这三道: 把转账写成函数,加一个「余额不足就抛异常」的判断,验证异常时两条 UPDATE 都不生效; 开两个连接做「同时改同一行」,把 busy_timeout 分别设成 0 和 2000, 记录两次的等待时间和结果; 用 WAL 模式跑一遍「同一个事务里读两次、中间另一个连接改并提交」,把两次读到的值记下来。 三道都动手做过,这一章就算真读完了。

六、动手 Demo:脏读、快照、切级别,三件事各验一遍

这一段先摆四级隔离级别和三件坏事的对照表,再挨个实测:打开 read_uncommitted 能不能读到脏数据; WAL 下「改一行」「插一行」能不能被同一个事务看见;回滚日志模式下同样的场景为什么直接报锁; 最后把 MySQL 的四种设级别写法挨个试一遍。

import sqlite3

p = u"demo71.db"
c = sqlite3.connect(p, isolation_level=None)
c.execute(u"PRAGMA journal_mode = WAL")
c.execute(u"CREATE TABLE tb_acct (id INTEGER PRIMARY KEY, "
          u"name TEXT, money REAL)")
c.executemany(u"INSERT INTO tb_acct VALUES (?,?,?)",
               [(1, u"甲", 1000), (2, u"乙", 1000), (3, u"丙", 1000)])

# ① 脏读:打开 read_uncommitted 也读不到未提交数据
a = sqlite3.connect(p, isolation_level=None)
b = sqlite3.connect(p, isolation_level=None)
b.execute(u"PRAGMA read_uncommitted = 1")
a.execute(u"BEGIN IMMEDIATE")
a.execute(u"UPDATE tb_acct SET money = 555 WHERE id = 1")
print(u"b 读到", b.execute(u"SELECT money FROM tb_acct WHERE id=1").fetchone())
a.execute(u"ROLLBACK")

# ② WAL 快照:b 改、b 插都能提交,a 三次读完全一样
a.execute(u"BEGIN")
print(u"第一次", a.execute(u"SELECT count(*) FROM tb_acct").fetchone())
b.execute(u"BEGIN")
b.execute(u"UPDATE tb_acct SET money = 777 WHERE id = 1")
b.execute(u"INSERT INTO tb_acct VALUES (4,'丁',1000)")
b.execute(u"COMMIT")
print(u"第二次", a.execute(u"SELECT count(*) FROM tb_acct").fetchone())
a.execute(u"COMMIT")
print(u"提交后", a.execute(u"SELECT count(*) FROM tb_acct").fetchone())

# ③ 设级别:四条 MySQL 写法全过不去
for q in (u"SET TRANSACTION ISOLATION LEVEL READ COMMITTED",
          u"SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ",
          u"SELECT @@transaction_isolation"):
    try:
        c.execute(q)
    except sqlite3.Error as e:
        print(q, u"->", e)

# ④ 能调的只有这三样
for q in (u"PRAGMA journal_mode", u"PRAGMA busy_timeout",
          u"PRAGMA read_uncommitted"):
    print(q, c.execute(q).fetchone())

实跑输出:

--------------------------------------------------------------------
隔离级别:三种并发问题,两边各挡得住哪些
--------------------------------------------------------------------


  四种隔离级别与三种并发问题
  --------------------------------------------------------------------
**教材 16.3 的小节名是「事务的隔离级别与并发问题」。先把三件「坏事」说清楚:**

  问题        一句话描述
  --------------------------------------------
  脏读        读到了别人还没提交的数据
  不可重复读  同一个事务里读两次,值不一样了
  幻读        同一个事务里数两次,行数不一样了

**对应四个隔离级别,从左到右越来越严、并发越来越差:**

  隔离级别          脏读  不可重复读  幻读
  ----------------------------------------
  READ UNCOMMITTED  可能  可能        可能
  READ COMMITTED    不会  可能        可能
  REPEATABLE READ   不会  不会        可能
  SERIALIZABLE      不会  不会        不会

**这张表是 MySQL 的语境:InnoDB 默认 `REPEATABLE READ`,靠 MVCC 做快照读。**
**SQLite 只有一档:默认就是最严的那一档(可串行化)。**
**它没有 `SET TRANSACTION ISOLATION LEVEL` 这个语法,报的是 `near "SET": syntax error`。**

  脏读:能不能读到别人没提交的数据
  --------------------------------------------------------------------
**先让 a 开事务改一行但不提交,再让 b 去读。**
**b 还专门打开了 `PRAGMA read_uncommitted = 1`,等于「我允许自己读脏数据」。**

  时刻          b 打开 read_uncommitted 后读到
  --------------------------------------------
  开关取值      1(已打开)
  a 改了没提交  读到 1000
  a 回滚之后    读到 1000

**第二行读到的还是 1000,不是 555——开关打开了也读不到未提交的数据。**
**原因是 `read_uncommitted` 只在「共享缓存模式」下才起作用,而那个模式早就废弃了。**
**所以这一条要按实测说:SQLite 挡脏读,而且挡得比 MySQL 更彻底——它连「想读脏数据」的机会都不给。**

**MySQL 那边是可以真造出来的。**
**设成 `READ UNCOMMITTED`,另一个会话就能读到别的事务没提交的值。**
**这也是为什么这一档在实际项目里几乎没人用。**

  不可重复读与幻读:WAL 下被快照挡住
  --------------------------------------------------------------------
**这一节要两个连接真的并发,所以用落盘的库,WAL 模式。**
**a 开事务读一次,b 在中间改一行并提交,a 再读一次——看两次一样不一样。**

  时刻                    a 看到甲的余额  a 看到行数
  --------------------------------------------------
  第一次读                1000            3
  b 改一行并提交(成功)  1000            3
  b 插一行并提交(成功)  —               3
  a 提交之后再数          —               4

**b 两次都提交成功了,可 a 三次读到的完全一样:1000、3 行、3 行。**
**这就是快照读:事务一开始看到的那一版数据,整个事务里都不会变。**
**所以不可重复读、幻读在 WAL 下都不会发生——a 提交之后才看到 4 行。**

**换成传统的回滚日志模式,结论一样,但原因不同:**
  a 持读事务时,b 想改并提交 -> database is locked

**这里 b 连写都写不进去,直接被挡住。**
**所以「不可重复读」不是被快照挡住的,是「根本没机会发生」——a 在读,别人就写不了。**
**两种模式都达到了可串行化,代价不一样:一个靠阻塞,一个靠快照。**

  设置隔离级别:两边各怎么设
  --------------------------------------------------------------------
**MySQL 用 `SET TRANSACTION ISOLATION LEVEL`,可以设当前会话,也可以设下一个事务。**

  [教材原码 · 本沙箱不执行]
  | -- 看当前级别
  | SELECT @@transaction_isolation;
  | 
  | -- 只影响下一个事务
  | SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
  | 
  | -- 影响整个会话
  | SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
  | 
  | -- 影响全局(新连接默认值)
  | SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;


  MySQL 的写法                                               沙箱的反应
  -----------------------------------------------------------------------------------
  `SET TRANSACTION ISOLATION LEVEL READ COMMITTED`           near "SET": syntax error
  `SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ`  near "SET": syntax error
  `SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED`    near "SET": syntax error
  `SELECT @@transaction_isolation`                           unrecognized token: "@"
  `SELECT @@tx_isolation`                                    unrecognized token: "@"

**三条 `SET` 开头的报 `near "SET": syntax error`,两条 `@@` 的报 `unrecognized token: "@"`。**
**前三条是语法不存在,后两条是「这个记号我压根不认」——SQLite 没有会话变量这一层。**
**能调的只有三样东西,而且都不是隔离级别:**

  能调什么            作用
  ----------------------------------------------------------------
  `journal_mode`      `delete`(默认)/`wal`,换的是并发模型
  `busy_timeout`      锁等待多久才报错,换的是「快速失败还是排队」
  `read_uncommitted`  只在废弃的共享缓存下有效,实测等于没用

**第三行值得单独说一句:这个开关设了也没有效果,但它的名字很唬人。**
**看到它的人容易以为「打开就能读未提交」,实测读到的还是旧值。**
**这类「名字比功能大」的开关,一律以实测为准。**

**教材 16.3 讲的是 MySQL 的四档级别,这一篇能给出的是「SQLite 只有一档」这个对照。**
**结论不难记:MySQL 靠选级别,在「正确」和「并发」之间找平衡。**
**SQLite 直接把最严的那一档给你,用 WAL 把读的并发补回来,写的并发就认了。**

第一节那个 1000 是全篇最反直觉的一个数:开关打开了、读到的还是旧值。 这类「设了没用」的开关最坑人,因为它不报错、不提示,看着像生效了。 验证办法只有一个:真造一次并发,看读到的数变没变。 第二节三次读数一样,说明 a 那个事务拿到的是一个快照。 注意 b 的写是成功的——这和回滚日志模式完全不同,那里 b 会直接 database is locked。 同一个「可串行化」,两种实现,一个让你等,一个让你看到旧版本。 第三节注意两类报错:near "SET" 是语法不存在,unrecognized token: "@" 是记号不存在。 后者更彻底——它连「会话变量」这个概念都没有,@@xxx 这种写法在它眼里不是一个完整的东西。

30 秒实验

  1. 在 WAL 模式下,让 a 开事务读一行,b 改这一行并提交,a 再读一次——把两次的值都记下来, 和正文那张表对一下。
  2. 把 read_uncommitted 分别设成 0 和 1,各跑一次「a 改不提交、b 读」,对比两次读到的值, 验证它到底有没有用。
  3. 在回滚日志模式下重复「a 读、b 改并提交」,把报错记下来; 再把 busy_timeout 调大,看它是变成「等到了」还是「等更久才报错」。

常见误区

  1. 以为 SQLite 可以像 MySQL 那样切隔离级别。它没有 SET TRANSACTION 这个语法, 默认就是可串行化,没法松。
  2. 以为 PRAGMA read_uncommitted = 1 能读到脏数据。实测读到的还是旧值。 它只在已废弃的共享缓存模式下有效,平时等于没设。
  3. 把「可串行化」理解成「事务排队执行」。它说的是「结果等价于排队执行」,实现上可以靠阻塞, 也可以靠快照,WAL 走的是后者。
自测三问(点开对答案) 1. 不可重复读和幻读的区别在哪? —— 读的是同一个东西还是「一批」:读同一行、值变了,是不可重复读; 读「符合条件的行有几条」、条数变了,是幻读。 前者锁住那一行就够,后者得锁住整个范围,所以更难挡。 2. WAL 模式下为什么不会出现不可重复读? —— 因为事务一开始就拿到一个快照,整个事务里读的都是这一版数据,别人后续提交的改动看不见, 直到自己提交。这是用「快照」而不是「阻塞」实现的隔离。 3. `PRAGMA read_uncommitted` 打开了却读不到脏数据,说明什么? —— 说明它在这个配置下不起作用。它只在共享缓存模式下有效,而那个模式已经废弃。 这也提醒一件事:参数名写的意图,和它实际生效的条件,是两回事。

第 16 章到这里结束。下一篇进入第 17 章:事件——让数据库自己按时间表干活。

相关笔记