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

三种异常:脏读(读到没提交的)、不可重复读(同事务两次读不一样)、幻读(凭空多行)。 实测快照隔离:同一事务里两次读都是 10,第三次(提交后)才变 888;行数 2/2/3。 read_uncommitted 在这套环境上没验证出脏读——照实说「测不出来」,不硬编结论。

📝 本章和前面几章的关系

15.1 说「隔离性」,这一节把「隔离到什么程度」摊开讲——四个级别、三种异常、一堆取舍。 教材 15.4 分三小节:四种隔离级别、脏读/不可重复读/幻读、隔离级别的设置与代价。 沙箱只有两档:默认的 SERIALIZABLE(WAL 下是快照隔离)和可选的 read_uncommitted。 中间那两档(READ COMMITTED、REPEATABLE READ)没有, 所以演示测的是「默认档有多严」和「最松档能不能真松」——后者没成功,如实写在正文里。

一、四种隔离级别,各挡哪种异常

隔离级别 脏读 不可重复读 幻读
READ UNCOMMITTED 可能 可能 可能
READ COMMITTED(SQL Server 默认) 挡住 可能 可能
REPEATABLE READ 挡住 挡住 可能
SERIALIZABLE 挡住 挡住 挡住

越往下越安全、并发越差。 三种异常一句话说清:脏读=读到别人没提交的东西; 不可重复读=同一事务里两次读,值不一样;幻读=同一事务里两次查,凭空多(或少)了行。 「不可重复读」和「幻读」经常被混。 区别在于对象不同: 前者是「同一行的值变了」,后者是「行数变了」。 挡前者要锁住那些行,挡后者要把「范围」也锁住——代价更大,所以 SERIALIZABLE 最慢。 SQL Server 默认是 READ COMMITTED,也就是「脏读挡住、另外两个可能」。 绝大多数业务用这一档就够——再往上调,锁的范围一大,并发掉得厉害。

二、脏读:未提交的修改,别人能读到吗

a 把 v 改成 999、没提交,b 去读:读到的是 10,不是 999。 脏读被挡住了。 这在 SQL Server 的默认档(READ COMMITTED)上也一样—— 「读」要等「写」提交,等不到就先看旧值(或者干脆等锁)。 再试一次最松档: 给 b 打开 PRAGMA read_uncommitted = 1,再读——还是 10。 这个结果要照实说:这一次没验证出脏读。 原因是这一版 SQLite 的 read_uncommitted 只在「共享缓存模式」下才真生效, 普通连接上它是个空开关,设了不报错、但也不起作用。 所以结论只能写成「在这套环境下没测出脏读」,不能写成「脏读不存在」。 换 SQL Server 加上 NOLOCK 提示,脏读是能真出现的—— 这也是生产上 WITH (NOLOCK) 被滥用的根源:快是快,但你可能读到「从没存在过的数据」。

三、不可重复读:同一事务里读两次,值会变吗

什么时候读 读到的 v
a 的事务里,第一次 10
别人改完并提交后,a 还是同一事务 10
a 提交之后,再读 888

前两次一样,第三次才变。 这就是快照隔离: 事务一开始看到的是那一刻的数据,整个事务里都看这一份快照,别人后来改的看不见。 所以 SQLite 在这一档上比 SQL Server 默认档更严。 SQL Server 的 READ COMMITTED 下,同一事务里第二次读是能看到别人已提交的改动的—— 那种才叫「不可重复读」,而这里没发生。 快照隔离不是「加锁」,是「给每个事务一份历史版本」。 好处是读不加锁、写不挡读;代价是旧版本要留着—— SQL Server 打开 READ_COMMITTED_SNAPSHOT 之后 tempdb 会明显变大,就是这个原因。

四、幻读:范围里凭空多出一行

什么时候数 行数
a 的事务里,第一次 2
别人插了一行并提交后,a 还是同一事务 2
a 提交之后,再数 3

中间那次没变,所以幻读也被挡住了。 快照隔离一并解决了这个问题—— 因为整个事务只认一开始那份快照,新插入的行不在快照里。 在普通的锁实现里,幻读是最难挡的: 你得锁住「v > 0 这个范围」, 而不是锁住「已经查到的那两行」——因为新行不属于任何一行,锁行锁不到它。 这也是 SERIALIZABLE 慢的原因: 它要加范围锁(SQL Server 里叫键范围锁), 别人在这个范围内连插都插不进去,并发度自然掉下来。

五、写写冲突:同一时刻只有一个能写

a 拿着写锁不提交,b 也去写——b 等 427 毫秒后报 database is locked。 读可以很多人一起读,写只能一个。 这是单机库的规矩, SQL Server 上表现为「锁等待」,等久了还能检测出死锁、牺牲掉一方。 所以「隔离级别」不只是「读到什么」的问题,还包括「谁能写、要等多久」。 生产上大量「偶发超时」其实是锁等待超时,不是网络问题。 排查这类问题的方向: 找出长时间不提交的事务(SQL Server 有 sys.dm_tran_active_transactions), 而不是简单地把超时时间调大——那只是把问题推后。

六、隔离是要付代价的

越严的隔离,并发越低。 挡住脏读靠的是锁或快照,锁住的时间越长,别人等得越久。 SQL Server 上这件事有两个旋钮:一是 SET TRANSACTION ISOLATION LEVEL(会话级), 二是库级的 READ_COMMITTED_SNAPSHOT——打开之后「读」不再等「写」,靠版本链实现快照隔离, 代价是 tempdb 会变大。 这就是工程上的取舍:隔离级别不是越高越好,是「够用就好」。 绝大多数业务用默认档就够,只有对账、统计、报表这类「一个事务里要读好几次、 而且要求前后一致」的活,才值得往上调。 还有一个常被忽略的选项:把「一致性要求」挪到业务层。 比如对账不平就重算一次、库存不足就补偿—— 用「最终一致」换并发,很多时候比死守最高隔离级别划算。

七、动手 Demo:脏读、不可重复读、幻读,一次看全

这一段开三个连接(a 当事务、b 当读者、c 当捣乱的写者),依次验三种异常挡没挡住, 再测一次写写冲突的等待时间。 库开成 WAL 模式——这才是 SQLite 快照隔离的常规形态; 不开 WAL 时读会被写挡,连实验都做不下去。

import sqlite3, time

c = sqlite3.connect(u"tx4.db", isolation_level=None)
c.execute(u"PRAGMA journal_mode = WAL")     # 快照隔离,读不挡写
c.execute(u"INSERT INTO t (v) VALUES (10), (20)")

a = sqlite3.connect(u"tx4.db", isolation_level=None)
b = sqlite3.connect(u"tx4.db", isolation_level=None)

# ② 脏读:a 改了不提交,b 读
a.execute(u"BEGIN IMMEDIATE")
a.execute(u"UPDATE t SET v = 999 WHERE id = 1")
print(b.execute(u"SELECT v FROM t WHERE id = 1").fetchone())   # 10
b.execute(u"PRAGMA read_uncommitted = 1")   # 最松档,看能不能读到 999
print(b.execute(u"SELECT v FROM t WHERE id = 1").fetchone())   # 还是 10
a.execute(u"ROLLBACK")

# ③ 不可重复读:同一事务里读两次,中间别人改了并提交
a.execute(u"BEGIN")
v1 = a.execute(u"SELECT v FROM t WHERE id = 1").fetchone()[0]
c.execute(u"BEGIN IMMEDIATE")
c.execute(u"UPDATE t SET v = 888 WHERE id = 1")
c.execute(u"COMMIT")
v2 = a.execute(u"SELECT v FROM t WHERE id = 1").fetchone()[0]
a.execute(u"COMMIT")
v3 = a.execute(u"SELECT v FROM t WHERE id = 1").fetchone()[0]

实跑输出:

--------------------------------------------------------------------
隔离级别:脏读、不可重复读、幻读
--------------------------------------------------------------------


  ① 四种隔离级别,各挡哪种异常
  --------------------------------------------------------------------

| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED(SQL Server 默认) | 挡住 | 可能 | 可能 |
| REPEATABLE READ | 挡住 | 挡住 | 可能 |
| SERIALIZABLE | 挡住 | 挡住 | 挡住 |

  越往下越安全、并发越差。 三种异常的定义一句话说清:
  脏读=读到别人没提交的东西;不可重复读=同一事务里两次读,值不一样;
  幻读=同一事务里两次查,凭空多(或少)了行。
  SQLite 只有两档:默认的 SERIALIZABLE(WAL 模式下是快照隔离),和可选的 read_uncommitted。
  中间那两档没有——所以下面测的主要是默认档的表现,最松档只能在 ② 里试一把。

  ② 脏读:未提交的修改,别人能读到吗
  --------------------------------------------------------------------

  a 改了 v=999,但没提交。b 去读:
    b 读到 v = 10
  b 读到的是 10,不是 999 —— 脏读被挡住了。
  b 打开 read_uncommitted 之后,再读:
    b 读到 v = 10
  还是 10。 这一版的 SQLite 上,read_uncommitted 只在「共享缓存模式」下才真生效,
  普通连接上它是个空开关——设了也不报错,但读到的仍是旧值。这就是「测不出来」的那类结论,
  要照实说:这里没验证出脏读,不是「脏读不存在」。

  ③ 不可重复读:同一事务里读两次,值会变吗
  --------------------------------------------------------------------

| 什么时候读 | 读到的 v |
|---|---:|
| a 的事务里,第一次 | 10 |
| 别人改完并提交后,a 还是同一事务 | 10 |
| a 提交之后,再读 | 888 |

  前两次一样,第三次才变。 这就是快照隔离:事务一开始看到的是那一刻的数据,
  整个事务里都看这一份快照,别人后来改的看不见。
  所以 SQLite 在这一档上比 SQL Server 的默认(READ COMMITTED)更严——
  后者在同一事务里第二次读是能看到别人已提交的改动的,那种才叫「不可重复读」。

  ④ 幻读:范围里凭空多出一行
  --------------------------------------------------------------------

| 什么时候数 | 行数 |
|---|---:|
| a 的事务里,第一次 | 2 |
| 别人插了一行并提交后,a 还是同一事务 | 2 |
| a 提交之后,再数 | 3 |

  中间那次没变,所以幻读也被挡住了。 快照隔离一并解决了这个问题——
  因为整个事务只认一开始那份快照,新插入的行不在快照里。

  ⑤ 写写冲突:同一时刻只有一个能写
  --------------------------------------------------------------------

  b 等 411 毫秒后:database is locked
  读可以很多人一起读,写只能一个。 这是 SQLite 的规矩(也是所有单机库的规矩)。
  所以「隔离级别」不只是「读到什么」的问题,还包括「谁能写、要等多久」。

  ⑥ 隔离是要付代价的
  --------------------------------------------------------------------
  越严的隔离,并发越低。 挡住脏读靠的是锁或快照,锁住的时间越长,别人等得越久。
  SQL Server 上这件事有专门的开关:SET TRANSACTION ISOLATION LEVEL,
  还有 READ_COMMITTED_SNAPSHOT 这个库级选项——打开之后「读」不再等「写」,
  靠版本链实现快照隔离,代价是 tempdb 会变大。
  这就是工程上的取舍:隔离级别不是越高越好,是「够用就好」。
  绝大多数业务用默认档就够,只有对账、统计这类「多次读要一致」的活才需要往上调。

② 那两个「还是 10」是这一节最诚实的一段: 打开最松档也没读到脏数据,所以这一条结论只能写「没测出来」。 这比「脏读是不好的」这种套话有用得多——它告诉你「隔离的实现方式比级别名字更关键」。 ③ 的 10/10/888 三个数说明快照隔离真在起作用。 注意第三个 888 是「同一个连接、COMMIT 之后」——事务一结束,它就看到最新值了。 ④ 幻读那段用的是「数行数」:2/2/3。 中间那次没变, 说明新插入的行不在 a 的快照里——这是快照隔离比 REPEATABLE READ 更强的地方。 ⑤ 那个 427 毫秒的等待是 timeout 决定的(设的 0.3 秒)。 生产上这个参数叫「锁等待超时」,调大了只是等更久,根子还在事务太长。

30 秒实验

  1. 把 ② 的 PRAGMA read_uncommitted = 1 挪到 BEGIN 之前,重跑。 大概还是读不到 999——因为问题不在「什么时候设」,而在「这个开关在这套模式下不生效」。
  2. 把 PRAGMA journal_mode = WAL 删掉,重跑 ③。 别人的 UPDATE 会被 a 的读事务挡住(报 database is locked 或等待), 这就是「读挡写」的老式实现——WAL 存在的意义就是解决它。
  3. 想一想:一个报表要「先算总额、再算笔数、最后算平均」,中间被别人改数据会怎样? 三个数会互相矛盾。 这种活就该放在一个事务里(或者干脆用快照读)—— 这正是隔离级别存在的意义。

常见误区

  1. 「隔离级别越高越安全,无脑设 SERIALIZABLE」。 安全是真安全,并发也真掉。 范围锁会让别人连插都插不进去, 高峰期直接变成串行排队。 默认档往往就够。
  2. 「NOLOCK 只是快,没什么副作用」。NOLOCK 就是「允许脏读」, 你可能读到「从未存在过」的中间状态, 还可能重复读到同一行、或者跳过某些行。 用在报表上都该谨慎。
  3. 「关掉 synchronous 就等于换隔离级别」。 两件不同的事: 隔离级别管「事务之间看不看得见」, synchronous 管「提交之后会不会丢」(持久性)。
自测三问(点开对答案) 1. 脏读、不可重复读、幻读分别是什么? —— **脏读=读到别人没提交的东西;不可重复读=同一事务里两次读、值变了;** **幻读=同一事务里两次查、行数变了。** 2. SQLite 默认档在这次实测里挡住了几种异常? —— **三种都挡住了**(快照隔离)。 **但「最松档」没验证出脏读**—— **那是 `read_uncommitted` 在这套模式下不生效导致的,不能当作「脏读不存在」。** 3. `READ_COMMITTED_SNAPSHOT` 打开之后,代价是什么? —— **读不再等写**(靠版本链),**但旧版本要留在 tempdb 里**,**所以 tempdb 会明显变大**, 写多的库还要多做版本清理。

下一篇 15.5 讲分布式事务:两阶段提交到底怎么走、为什么它会「卡在中间」, 以及三种失败结局分别长什么样——用一个能跑的最小模型演一遍。

相关笔记