弱隔离级别:一场关于“同时干架”的混乱调解指南

1 阅读6分钟

大家好,欢迎来到本期“数据库调解室”。今天我们要聊的话题,是并发事务的隔离级别。如果你觉得这个词太技术,我们可以换个说法:当一群人(事务)冲进数据库这个房间同时抢着读啊写啊的时候,我们怎么保证最后的数据不乱套?

数据库开发者发明了一个叫做“事务隔离”的东西,号称能让并发操作看起来像是排着队一个一个来的,这就是所谓的可串行化(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吧。