大家好,欢迎来到本期“数据库调解室”。今天我们要聊的话题,是并发事务的隔离级别。如果你觉得这个词太技术,我们可以换个说法:当一群人(事务)冲进数据库这个房间同时抢着读啊写啊的时候,我们怎么保证最后的数据不乱套?
数据库开发者发明了一个叫做“事务隔离”的东西,号称能让并发操作看起来像是排着队一个一个来的,这就是所谓的可串行化(Serializable)。但现实是,这种最强隔离级别太消耗性能了,大家都不爱用。于是就有了各种“弱隔离级别”——它们能解决一部分问题,但也留下了不少坑。
读已提交(Read Committed)
这是很多数据库的默认隔离级别,它给了你两个承诺:
- 不会读到未提交的数据(无脏读 No Dirty Reads)
- 不会覆盖未提交的数据(无脏写 No Dirty Writes)
翻译成人话:你不能看到别人写到一半还没存盘的东西;你也不能在别人刚写完一半时冲上去把它盖掉。听起来挺靠谱对吧?但这只是入门级。
接下来,请出我们的倒霉蛋:财务小张。小张正在做一个“部门预算汇总”的查询,与此同时,同事小李在调整两个部门的预算。下面这张图就是他们俩同时操作时,读已提交隔离级别下发生的惨案:
sequenceDiagram
participant 事务A as 小李(事务A)
participant 数据库 as 数据库
participant 事务B as 小张(事务B)
事务A->>数据库: BEGIN
事务B->>数据库: BEGIN
事务A->>数据库: UPDATE 部门A SET 预算=600 WHERE 部门='A' (原500)
数据库-->>事务A: 写入成功(未提交)
事务B->>数据库: SELECT 预算 FROM 部门A
数据库-->>事务B: 返回500 (旧值)
事务A->>数据库: UPDATE 部门B SET 预算=400 WHERE 部门='B' (原500)
数据库-->>事务A: 写入成功
事务A->>数据库: COMMIT
事务B->>数据库: SELECT 预算 FROM 部门B
数据库-->>事务B: 返回400 (新值)
事务B->>数据库: 汇总 A(500) + B(400) = 900
数据库-->>事务B: 结果900 (实际应为1000)
事务B->>数据库: COMMIT
小张从部门A读到的是小李修改前的500,从部门B读到的是小李修改后的400,加起来900。而实际上两个部门的预算应该是修改后的600+400=1000,或者修改前的500+500=1000。这100块就这么凭空消失了。这就是读倾斜(Read Skew),也叫不可重复读(Nonrepeatable Read)。读已提交并不能阻止它。
快照隔离(Snapshot Isolation)与多版本并发控制(MVCC)
为了解决这种“一边改一边读”的问题,数据库搞了个大招:快照隔离(Snapshot Isolation)。简单说,就是每个事务开始的时候,给数据库拍个照。整个事务期间,你看到的都是这张照片里的数据,别人怎么改都影响不到你。这就避免了读倾斜。
这招是怎么实现的?靠的是多版本并发控制(MVCC)。数据库不会直接覆盖掉老数据,而是给每条数据存多个版本,分别标记是谁写的。你的事务只能看到在你开始之前就提交了的版本。这就像是给每个事务配了一副眼镜,只看到“合法”的版本。
下面这张图展示了MVCC的工作原理:事务42和事务43同时启动,事务42修改了数据,但事务43看到的还是老版本。
sequenceDiagram
participant 事务42 as 事务42 (txid=42)
participant 数据库 as 数据库(多版本)
participant 事务43 as 事务43 (txid=43)
事务42->>数据库: BEGIN
事务43->>数据库: BEGIN
事务42->>数据库: UPDATE 账户 SET 余额=400 WHERE id=2 (原500)
数据库-->>事务42: 写入成功(未提交,版本标记 txid=42)
事务43->>数据库: SELECT 余额 FROM 账户 WHERE id=2
Note over 数据库: 根据快照规则,事务43只能看到小于43的已提交事务<br/>事务42未提交,因此返回旧版本(500)
数据库-->>事务43: 返回500
事务42->>数据库: COMMIT
Note over 数据库: 事务42的版本(400)变为已提交
事务43->>数据库: 继续其他查询 (仍使用启动时的快照)
数据库-->>事务43: 始终看到500
事务43->>数据库: COMMIT
事务43在整个生命周期里,看到的都是自己启动那一瞬间的数据库快照,即使事务42已经提交,它依然看到旧值。这保证了它不会受到后续写入的干扰。
这招很妙,读的永远不会阻塞写的,写的也永远不会阻塞读的,大家各玩各的。但要注意,不同数据库对这个级别的叫法很混乱,比如Oracle叫它“可串行化”,PostgreSQL叫它“可重复读”,但其实它们都不是真正的可串行化。你只能靠查文档才能知道自己到底在用啥。
丢失更新(Lost Updates)
快照隔离虽然治好了读倾斜,但对付不了另一种病:丢失更新(Lost Updates)。
想象你和同事同时打开一个文档,各自加了一段文字然后保存。你们都是先读后写,结果是谁后保存谁就覆盖了对方的内容,先保存的那段话就丢了。在数据库里,这就是经典的“读-修改-写”循环导致的丢失更新。
怎么治?
- 原子写操作(Atomic Write Operations):数据库自己帮你算,比如直接用
UPDATE table SET count = count + 1,别人插不进手。 - 显式锁(Explicit Locking):你手动锁住那几行数据,告诉别人“这我占了,等我改完”。
- 自动检测:有些数据库(比如PostgreSQL)会自动检测到丢失更新,然后把你的事务给中止了,逼你重试。
- 条件写(Compare-and-Set):只有在你读完之后数据没被别人改过的情况下,才允许你更新。
但在分布式数据库里,这些招就不好使了。数据在多个节点上飞来飞去,没法保证只有一个最新版本,锁也锁不住别人。那咋办?有些数据库会生成多个冲突版本,然后让你自己合并;或者用一些可交换的操作(比如计数器+1)来避免冲突。
写倾斜(Write Skew)与幻读(Phantoms)
你以为这就完了?天真。还有一个更隐蔽的bug,叫写倾斜(Write Skew)。
它是啥意思呢?简单说,就是两个事务分别读了同一组数据,然后根据读到的结果各自做了不同的更新,结果合起来一看,整个系统的约束被打破了。
比如你和你的搭档是当晚唯一两个值班的保安,你俩都想请假。你查了一下,发现除了你还有他在,就放心请假了;他也查了一下,发现除了他还有你,也放心请假了。结果你们都走了,当晚没人值班。这就是写倾斜——你们读的是同一份数据,写的却是不同的记录,但合起来就出大事了。
下面这张图生动地还原了案发现场:
sequenceDiagram
participant 你 as 你(事务A)
participant 数据库 as 数据库(值班表)
participant 搭档 as 搭档(事务B)
你->>数据库: BEGIN
搭档->>数据库: BEGIN
你->>数据库: SELECT count(*) FROM 值班 WHERE 班次='今晚'
数据库-->>你: 返回2 (包括你和搭档)
搭档->>数据库: SELECT count(*) FROM 值班 WHERE 班次='今晚'
数据库-->>搭档: 返回2 (包括你和搭档)
你->>数据库: UPDATE 值班 SET 状态='离岗' WHERE 姓名='你'
数据库-->>你: 更新成功
搭档->>数据库: UPDATE 值班 SET 状态='离岗' WHERE 姓名='搭档'
数据库-->>搭档: 更新成功
你->>数据库: COMMIT
搭档->>数据库: COMMIT
Note over 数据库: 最终:无人值班
这种情况在快照隔离下是防不住的,因为你们读的时候看到的都是对方还在,完全没毛病。
还有一个更棘手的变种叫幻读(Phantoms):你的查询条件是“有没有人和我冲突”,如果结果是“没有”,你就插入一条新数据。但如果两个人都查出来“没有”,然后都插入了,那冲突就发生了。问题是,你查的时候没有数据,也就没法加锁,锁空气是锁不住的。
怎么破?一种方法是物化冲突(Materializing Conflicts),就是人为造一个“锁对象”出来,比如会议室预订系统里,你可以先建一个时间槽表,每个槽代表一个会议室在某个时间段。你想预订的时候,先把对应槽锁住,再检查是否冲突。这招虽丑但管用。
当然,最彻底的解决办法是用可串行化隔离(Serializable Isolation),但那是性能的代价。
好了,今天的数据库调解就到这里。总结一下:
- 读已提交:能防脏读脏写,但防不了读倾斜
- 快照隔离:能防读倾斜,但防不了写倾斜和丢失更新
- 可串行化:能防一切,但性能代价大
如果你在写一个涉及多人协作的系统,别天真地以为默认隔离级别就够用。搞清楚你到底需要什么级别的保护,选对工具,才能在数据安全和高性能之间找到平衡。否则,你就等着半夜被叫起来修bug吧。