【第19天】每天一个MySQL知识点,百日打怪升级
脏读、不可重复读、幻读 —— 遇到了怎么定位,怎么修?
大家好,我是一名拥有10年以上经验的DBA老兵。
做这个系列,源于一个朴素的愿望:把踩过的坑、总结的经验系统化输出,希望能帮到刚入行或想进阶的兄弟们。
让我们开始今天的第19天内容。
昨天聊了隔离级别,也讲了三大问题的定义。今天换个角度——线上真的遇到这些问题了,你该怎么判断是哪一种?怎么修?
说白了,面试考你定义,但工作考你"遇到了能不能认出来"。
三个问题,三组"症状"
每个问题都有自己的"指纹"。你看到的异常现象不一样,对应的诊断方向也不一样。
症状一:你读到了本不该存在的数据
典型表现:一条SELECT返回了看起来"不可能"的数据。比如用户刚注册就有一条购买记录,但那个购买记录后来消失了。
诊断:看看那条"幽灵数据"是不是来自另一个尚未提交的事务。
大概率 → 脏读
怎么确认?查一下你数据库的隔离级别是不是被人改成了 READ UNCOMMITTED:
SELECT @@transaction_isolation;
如果是 READ-UNCOMMITTED,恭喜你找到原因了。
修复:改成 READ COMMITTED。
SET GLOBAL transaction_isolation = 'READ-COMMITTED';
-- 注意:已有连接要重连才生效
不过说实话,线上碰到脏读的概率已经很低了。因为RU基本没人用。我在生产环境干了十几年,只遇到过一次——某监控系统的开发图省事把全局隔离级别改成了RU,结果影响了所有连到这个实例的业务。改回来后,世界清净了。
症状二:同一事务里,同一行数据不一样
典型表现:你的代码逻辑是——先查A,用A的值做计算,再查A做核验——结果对不上。
# 伪代码:业务里可能写成这样
def process_order(order_id):
with transaction():
status = db.query("SELECT status FROM orders WHERE id = ?", order_id)
# status = 'pending'
if status == 'pending':
do_something() # 基于 status=pending 做了一些操作
# 此时另一个事务把 status 改成了 'paid' 并提交了
status_again = db.query("SELECT status FROM orders WHERE id = ?", order_id)
# status_again = 'paid' ← 同一个事务里,同一行数据变了!
大概率 → 不可重复读
怎么确认:
SELECT @@transaction_isolation;
如果结果是 READ-COMMITTED,那就对上了。
修复方案(按推荐优先级排序):
| 方案 | 操作 | 代价 |
|---|---|---|
| 方案A:改成RR | SET TRANSACTION ISOLATION LEVEL REPEATABLE READ |
快照读,看不到别人新提交的数据 |
| 方案B:加锁 | SELECT ... FOR UPDATE |
行锁,并发下降 |
| 方案C:一次读完 | 把两次查询合并成一次,或第一条SELECT用FOR UPDATE | 改代码,稍微麻烦 |
症状三:同一条件,两次COUNT不一样
典型表现:你的分页查询,第一页返回了10条,第二页却看到了第一页的数据。或者统计报表里,SUM/COUNT结果不符合预期。
# 伪代码
def generate_report():
with transaction():
total = db.query("SELECT COUNT(*) FROM orders WHERE date = ?", today)
# total = 100
# 此时另一个事务新插入了2条今天的订单并提交
total_again = db.query("SELECT COUNT(*) FROM orders WHERE date = ?", today)
# total_again = 102 ← 行数变了
大概率 → 幻读
不过要注意:MySQL的RR级别下,普通SELECT不会出现幻读(有MVCC快照保护)。所以如果你用的是RR还遇到这个问题,大概率是你代码里混用了当前读(FOR UPDATE)和快照读。
修复:如果业务可以接受,统一用RR + 快照读。如果有DML操作,记得在事务中对目标数据集先执行 SELECT ... FOR UPDATE 用Gap Lock占住间隙,再基于这个结果做后续DML,才能防止其他事务插入新行。注意 FOR UPDATE 必须在任何快照读之前执行,否则为时已晚——你看到的仍然是快照读的旧数据。
说到这个想起之前一个case。有个哥们跑来跟我说:"报告跑出来的数据和人工核对的不一致,差了3行。"查了半天,发现是他的报表事务里先 SELECT COUNT(*)(快照读)得到100,然后 SELECT ... FOR UPDATE(当前读)又查了一次——这次看到了别人刚提交的3行,变成了103。这不是Gap Lock的锅,而是快照读和当前读混用的结果。 第二次的FOR UPDATE确实加了Next-Key Lock,但它加锁的时候新行已经存在了,锁并不能把已存在的行变回去。这种问题真不好排查,因为你不容易想到"自己的读操作之间还在互相打架"。
故障诊断速查表
| 你看到的症状 | 排查方向 | 大概率是 |
|---|---|---|
| 读到不可能存在的数据 | 查隔离级别是不是RU | 脏读 |
| 同一行两次读不一样 | 查隔离级别是不是RC | 不可重复读 |
| 同一条件行数不对 | 查代码里有没有混用当前读和快照读 | 幻读 |
| DELETE删了比预期多的行 | 查事务内是否先用快照读估算行数,再直接DML(当前读)导致多删 | RR下的边缘情况 |
| 什么都不确定 | 先查 SELECT @@transaction_isolation |
— |
🤖 AI实战工具箱:让AI帮你造并发测试
隔离级别的问题最蛋疼的是什么?不好复现。
你在单机单连接上跑一万次都跑不出来,因为需要两个事务在毫秒级的窗口里交错执行。但AI可以替你生成一套"精准卡点"的并发脚本——开两个session,用sleep控制时序,把概率性问题变成确定性复现。
复现不可重复读
直接把下面这段粘给AI(比如Claude、GPT):
帮我写一组MySQL测试SQL,演示RC级别下的不可重复读。需要两个终端的完整操作步骤,事务A先SELECT余额,事务B UPDATE并提交,事务A再SELECT看到不同值。用sleep控制执行时序,每条SQL前用注释标注在哪端执行。包含验证步骤:改成RR级别后再跑一次,确认两次SELECT结果一致。
AI会输出类似这样的脚本:
-- 准备工作(任何一端执行一次)
CREATE TABLE IF NOT EXISTS account (
id INT PRIMARY KEY,
balance INT
);
INSERT INTO account VALUES (1, 100);
-- ===== 终端A:事务A =====
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM account WHERE id = 1;
-- 结果: 100
-- SLEEP 给时间切换到终端B执行
SELECT SLEEP(5);
-- 切换到终端B执行完再回来
SELECT balance FROM account WHERE id = 1;
-- 结果: 200(跟第一次不一样!不可重复读出现了)
COMMIT;
-- ===== 终端B:事务B(在终端A SLEEP 期间执行)=====
BEGIN;
UPDATE account SET balance = 200 WHERE id = 1;
COMMIT;
注意SLEEP的作用:终端A执行到SELECT SLEEP(5)后会等待5秒,这5秒内你切换到终端B执行UPDATE并提交,再切回终端A就能看到第二次SELECT的结果变了。没有SLEEP的话,两个终端的操作时序全靠手速,容易乱。
改成RR再跑一遍,两次结果就都是100了——修复验证完成。
复现脏读
写一组MySQL测试SQL,演示READ UNCOMMITTED级别下的脏读。一个事务修改数据但不提交,另一个事务读到未提交的数据,然后第一个事务回滚。用sleep确保时序清晰。
复现RR下的DML边缘情况
写一组MySQL SQL,演示RR级别下DELETE能看到快照读看不到的行。开两个终端,事务A SELECT查不到数据,事务B INSERT并提交,事务A DELETE居然能删掉。用sleep控制时序,加注释标注端。
没有AI的时候你要手写这些,开两个终端来回切,手忙脚乱。有了AI,30秒生成一套完整的测试用例,跑完就知道自己理解对了没有。
这才是AI在DBA工作里的正确用法——不是问它"什么是幻读",而是让它帮你把课本上的理论变成能亲手跑的通关文牒。
面试加餐
昨天已经讲了RC和RR怎么选。今天补充几个面试官爱追问的:
Q: 不可重复读和幻读,哪个更严重?
从业务影响看,幻读通常更严重。因为不可重复读影响的是"一条数据"的准确性,幻读影响的是"结果集"的完整性。想象一下:你给所有符合条件的人发优惠券,结果因为幻读漏发了三个人——这个比某个人的余额读错了更难修复。
Q: 如果RC下既要防不可重复读、又不想换RR,怎么办?
用
SELECT ... FOR UPDATE对关键行加锁,让其他事务的修改阻塞。代价是并发下降(行锁等待)。或者把需要一致性的多次读取合并成一次,比如用JOIN一次性把所需数据都查出来。
Q: 生产中你遇到过最离奇的隔离级别故障是什么?
遇到过一个连接池的问题。应用配置的是RC,但某个连接因为之前跑过一段脚本,会话级别被改成了SERIALIZABLE。这个连接被归还到连接池后,下一个请求拿到它,所有SELECT都被阻塞了——因为SERIALIZABLE下SELECT自动加共享锁,写操作被排队了。排查了很久才发现是"连接污染"。
顺便提醒一下
SET SESSION和SET TRANSACTION的区别:
SET SESSION TRANSACTION ISOLATION LEVEL ...影响当前会话的所有后续事务SET TRANSACTION ISOLATION LEVEL ...只影响当前会话的下一个事务,执行完一次就失效连接池回收时,如果只
SET TRANSACTION改了下个事务的隔离级别,跑完就恢复默认了,不会"污染"。但如果你用了SET SESSION改了一整个会话级别,归还后下一个请求拿到这个连接就会继承这个设置——这就是污染源头。现在我们会在中间件层做隔离级别兜底校验,每个连接归还前用SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ重置回默认。
总结
🎯 面试考点
- 三大问题的实战诊断方法:看症状、查隔离级别、定位根因
- 脏读:线上极少见,确认RU→改RC即可
- 不可重复读:最常见,RC下必出。方案:改RR / FOR UPDATE / 重构代码逻辑
- 幻读:MySQL RR下快照读不出现,但当前读要注意Gap Lock范围
- 连接池污染:会话级隔离级别设置会被复用,容易被忽略
- IRL原则:先确认现象 → 再查级别 → 最后定修复方案
今天就试一下:登录你的数据库,执行这条SQL:
SELECT @@global.transaction_isolation, @@session.transaction_isolation;
确认一下当前会话和全局的隔离级别是不是一致的。如果不一致——你现在的连接可能已经被"污染"了。
下期预告:锁机制:行锁 vs 表锁 —— 面试必问!
有问题欢迎评论区交流,明天见!