我一直在用MySQL,但直到今天才真正看清“读”这个操作背后的两副面孔

事情源于我的一次好奇:如果我开始修改一条数据但不提交,别人还能读到它吗?

我打开了两个MySQL客户端窗口,准备亲手试试。

实验一:普通的读

在第一个窗口(事务A),我执行:

-- 事务 A
START TRANSACTION;
UPDATE users SET balance = 900 WHERE id = 1;
-- 暂停于此,不提交

我知道,此刻我事务A已经对id=1这条记录持有一个排他锁(X锁)

然后我切换到第二个窗口(事务B):

-- 事务 B
START TRANSACTION;
SELECT balance FROM users WHERE id = 1; -- 最普通的SELECT

结果瞬间返回了,显示的是更新前的旧值1000。 它完全没有被事务A卡住。

这个现象让我第一次真切地感受到MVCC的存在。这种不加锁的读,就是快照读。它没有去碰最新数据,而是从Undo Log里翻出了一个历史版本给我。正因为不涉及锁,所以它不会阻塞,也不会被阻塞。

实验二:带锁的读

接着,我在事务B里换了一种方式读:

-- 还是在事务 B
SELECT balance FROM users WHERE id = 1 FOR UPDATE;

这一次,查询挂起了,一直在等待。

我愣住了。同样是“读”,为什么一个畅通无阻,一个却寸步难行?

我意识到FOR UPDATE根本不是普通的读。它是一种当前读。它的目的不是查看历史,而是要基于最新数据进行操作。因此,它的第一原则是:必须先加锁

这条FOR UPDATE语句试图也获取一个排他锁(X锁),但这条记录已经被事务A的X锁占据了。X锁是排他的,不兼容任何其他锁,所以我的查询只能原地等待事务A释放锁。

为了验证,我回到事务A执行了COMMIT。锁一释放,事务B的SELECT ... FOR UPDATE立刻执行成功,并返回了最新值900。

实验三:另一种锁的读

我重启了事务,再次让事务A先UPDATE并不提交。

这次在事务B,我尝试另一种加锁读:

SELECT balance FROM users WHERE id = 1 LOCK IN SHARE MODE;

这个查询也同样被挂起了。

这打破了我一个错误的认知。我原以为共享锁(S锁)和排他锁(X锁)是兼容的。但实际上,X锁排斥一切其他锁,包括S锁。允许其他事务对一条即将被修改的数据加S锁是危险且无意义的,所以数据库直接禁止了这种行为。

我的结论

通过这几个简单的实验,我终于理清了思路:

快照读 (Snapshot Read) 当前读 (Current Read)
代表语句 SELECT SELECT ... FOR UPDATE, SELECT ... LOCK IN SHARE MODE, UPDATE, DELETE, INSERT
核心逻辑 读取历史版本(来自Undo Log) 读取最新版本
锁机制 绝不加锁 必须先加锁 (FOR UPDATE加X锁,LOCK IN SHARE MODE加S锁)
行为 旁观者,不参与冲突 参与者,会卷入锁竞争
实现 依赖MVCC (ReadView机制) 依赖悲观锁

所以,数据库并发控制的智慧就在于它的“双轨制”:

  1. 对于普通的SELECT,走快照读通道。通过MVCC机制提供一个无损性能的一致性视图,完美解决读写冲突
  2. 对于要改变数据的操作,走当前读通道。通过严格的加锁机制来保证数据在修改过程中的绝对安全,彻底解决写写冲突

以前我对“隔离级别”、“并发控制”的理解都停留在概念上。这次亲手实验让我真正看懂了数据库的设计思路。以后在写代码时,我会清楚地知道:当我写下SELECT ... FOR UPDATE时,我并不是在简单地“查询”,而是在申请一把锁,并加入了锁的竞争队列。这种理解,对写出更稳健的并发程序至关重要。