我一直在用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机制) | 依赖悲观锁 |
所以,数据库并发控制的智慧就在于它的“双轨制”:
- 对于普通的
SELECT
,走快照读通道。通过MVCC机制提供一个无损性能的一致性视图,完美解决读写冲突。 - 对于要改变数据的操作,走当前读通道。通过严格的加锁机制来保证数据在修改过程中的绝对安全,彻底解决写写冲突。
以前我对“隔离级别”、“并发控制”的理解都停留在概念上。这次亲手实验让我真正看懂了数据库的设计思路。以后在写代码时,我会清楚地知道:当我写下SELECT ... FOR UPDATE
时,我并不是在简单地“查询”,而是在申请一把锁,并加入了锁的竞争队列。这种理解,对写出更稳健的并发程序至关重要。