【MySQL百日打怪升级第19天】脏读、不可重复读、幻读 —— 遇到了怎么定位,怎么修?

简介: 本系列第19天聚焦MySQL事务隔离问题实战诊断:详解脏读、不可重复读、幻读的典型症状、精准定位方法(查隔离级别+观察读现象)及修复方案(调级别/加锁/重构),附AI生成并发复现脚本技巧与面试高频考点,助你从理论走向线上排障。

【第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 表锁 —— 面试必问!


有问题欢迎评论区交流,明天见!

相关文章
|
4月前
|
人工智能 Linux 开发者
OpenCode AI编程Agent完整配置保姆级手册:阿里云通义千问、Ollama本地部署实战
OpenCode是一款终端优先、模型中立、本地优先开源AI编程Agent,区别于传统对话式AI代码工具。传统Chat类工具仅能完成一问一答,代码需要人工复制粘贴整合;OpenCode具备完整项目感知、任务自主规划、文件批量修改、终端命令执行、改动审查闭环能力,能够接收自然语言开发目标,全自动完成整套编码任务,真正实现“输入需求,AI写完代码”。产品开源生态数据显示,当前GitHub星标超17万,月度活跃开发者750万,支持75款以上大模型无缝切换,覆盖海外闭源模型、国内云大模型、本地离线模型三大类,适配国内开发者网络与合规需求。
1471 0
|
5月前
|
SQL 关系型数据库 MySQL
【MySQL百日打怪升级第20天】行锁 vs 表锁 —— InnoDB 什么时候锁行、什么时候锁表?
【第20天】MySQL锁机制精讲!深入解析InnoDB行锁与表锁的本质区别:行锁依赖索引,无索引则全表扫描→实际锁全表;详解Record/Gap/Next-Key三种行锁、意向锁作用及锁诊断实战(PS视图+INNODB STATUS)。避坑指南:UPDATE/DELETE务必走索引!
371 3
|
5月前
|
SQL 关系型数据库 MySQL
【MySQL百日打怪升级第14天】 LIMIT 分页的性能优化:深分页到底慢在哪?
本文深入剖析MySQL深分页(如`LIMIT 100000,20`)性能瓶颈:本质是OFFSET导致全量扫描与丢弃,页码越深,扫描行数线性增长。详解三种实战优化方案——游标分页(高效稳定,需有序唯一字段)、延迟关联(兼容OFFSET,索引覆盖减回表)、范围分页(极简但场景受限),并附EXPLAIN对比与避坑指南。(239字)
510 6
|
5月前
|
SQL 算法 关系型数据库
【MySQL百日打怪升级第10天】JOIN的底层原理与优化:NLJ、Hash Join 与 Merge Join
本文系统解析MySQL三大JOIN算法:NLJ(含Simple/Index/Block变体)、8.0.18引入的Hash Join(O(N+M)复杂度,专治无索引大表连接),以及面试常考但MySQL原生不支持的Sort-Merge Join,附实战EXPLAIN识别与优化指南。(239字)
451 5
|
4月前
|
人工智能 NoSQL 关系型数据库
【MySQL百日打怪升级第22天】事务死锁的成因与避免 —— 死锁检测、锁等待超时
【MySQL死锁精讲】第22天干货:剖析死锁成因(循环加锁)、检测机制(等待图算法)、四大场景(转账/外键/Gap Lock等)及实战避坑方案——统一加锁顺序、缩短事务、合理选隔离级别,附AI诊断工具箱。
369 0
|
5月前
|
SQL 缓存 关系型数据库
【MySQL百日打怪升级第18天】事务隔离级别详解:RC、RR、Serializable 到底怎么选?
本系列第18天详解MySQL事务隔离级别:RC(读已提交)、RR(可重复读,默认)、Serializable。厘清脏读、不可重复读、幻读三大问题,剖析InnoDB下RR如何借MVCC+Gap Lock基本防幻读,并给出RC与RR选型建议及真实避坑指南。
552 0
|
5月前
|
SQL 安全 关系型数据库
【MySQL百日打怪升级第16天】UPDATE 与 DELETE 的最佳实践:锁升级到底是怎么发生的?
本文详解MySQL中UPDATE/DELETE的锁机制陷阱:WHERE无索引→全表扫描→行锁累积→等效表锁,尤其在RR隔离级下触发Gap Lock加剧阻塞。分享游标分批、索引优化、pt-archiver等安全实践,助你避开线上卡顿雷区。
441 0
|
5月前
|
SQL 存储 数据采集
【MySQL百日打怪升级第15天】INSERT 批量插入的最佳实践:别再逐条 INSERT 了!
MySQL批量插入最佳实践:告别低效逐条INSERT!本文详解为何单次提交1万行比1万次提交快百倍,涵盖批量语法、合理批次(500–1000行)、LOAD DATA极速导入、冲突处理(IGNORE/ON DUPLICATE)及避坑要点,助你秒级完成大数据写入。(239字)
671 0
|
6月前
|
SQL 关系型数据库 MySQL
EXPLAIN 执行计划:一眼看穿你的SQL慢在哪
数据库小学妹带你轻松掌握SQL性能诊断!通过EXPLAIN查看执行计划,精准识别索引失效、全表扫描(ALL)、key为NULL等瓶颈。聚焦type、key、rows等6个关键字段,结合实战案例与避坑指南(如函数滥用、最左前缀破坏),让优化有的放矢。学完即用,告别盲目调优!
|
4月前
|
SQL 关系型数据库 MySQL
【第一阶段总结】MySQL基础20天 —— 知识地图与避坑复盘
本文是MySQL基础20天学习的系统复盘,涵盖架构、索引、SQL优化、事务、锁五大模块,提炼核心知识地图与高频避坑点(如无索引导致全表锁、RR下快照读与当前读差异等),并附面试考点与自测清单,助力夯实底层原理,平稳进阶。(239字)
301 1