三种异常:脏读(读到没提交的)、不可重复读(同事务两次读不一样)、幻读(凭空多行)。
实测快照隔离:同一事务里两次读都是 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 秒实验
- 把 ② 的
PRAGMA read_uncommitted = 1挪到BEGIN之前,重跑。 大概还是读不到 999——因为问题不在「什么时候设」,而在「这个开关在这套模式下不生效」。 - 把
PRAGMA journal_mode = WAL删掉,重跑 ③。 别人的UPDATE会被 a 的读事务挡住(报database is locked或等待), 这就是「读挡写」的老式实现——WAL 存在的意义就是解决它。 - 想一想:一个报表要「先算总额、再算笔数、最后算平均」,中间被别人改数据会怎样? 三个数会互相矛盾。 这种活就该放在一个事务里(或者干脆用快照读)—— 这正是隔离级别存在的意义。
常见误区
- 「隔离级别越高越安全,无脑设 SERIALIZABLE」。 安全是真安全,并发也真掉。 范围锁会让别人连插都插不进去, 高峰期直接变成串行排队。 默认档往往就够。
- 「
NOLOCK只是快,没什么副作用」。NOLOCK就是「允许脏读」, 你可能读到「从未存在过」的中间状态, 还可能重复读到同一行、或者跳过某些行。 用在报表上都该谨慎。 - 「关掉
synchronous就等于换隔离级别」。 两件不同的事: 隔离级别管「事务之间看不看得见」,synchronous管「提交之后会不会丢」(持久性)。
自测三问(点开对答案)
1. 脏读、不可重复读、幻读分别是什么? —— **脏读=读到别人没提交的东西;不可重复读=同一事务里两次读、值变了;** **幻读=同一事务里两次查、行数变了。** 2. SQLite 默认档在这次实测里挡住了几种异常? —— **三种都挡住了**(快照隔离)。 **但「最松档」没验证出脏读**—— **那是 `read_uncommitted` 在这套模式下不生效导致的,不能当作「脏读不存在」。** 3. `READ_COMMITTED_SNAPSHOT` 打开之后,代价是什么? —— **读不再等写**(靠版本链),**但旧版本要留在 tempdb 里**,**所以 tempdb 会明显变大**, 写多的库还要多做版本清理。下一篇 15.5 讲分布式事务:两阶段提交到底怎么走、为什么它会「卡在中间」, 以及三种失败结局分别长什么样——用一个能跑的最小模型演一遍。
- ☐ (本篇完,下一篇 15.5 分布式事务)