MySQL 幻读的详解、实例及解决办法
你知道的越多,不知道的就越多,业余的像一棵小草!
你来,我们一起精进!你不来,我和你的竞争对手一起精进!
编辑:业余草
segmentfault.com/a/1190000016566788
推荐:https://www.xttblog.com/?p=5319
昨天的文章,有一个很有意思的评论,今天我们在这里讨论一下。
那么问题来了,MySQL 的 RR 隔离级别下,到底有没有彻底解决幻读问题?
❝脏读/不可重复读的概念都比较容易理解和掌握,这里不在讨论。
❞
事务隔离级别(tx_isolation)
mysql 有四级事务隔离级别 每个级别都有字符或数字编号
级别 | symbol | 值 | 描述 |
---|---|---|---|
读未提交 | READ-UNCOMMITTED | 0 | 存在脏读、不可重复读、幻读的问题 |
读已提交 | READ-COMMITTED | 1 | 解决脏读的问题,存在不可重复读、幻读的问题 |
可重复读 | REPEATABLE-READ | 2 | mysql 默认级别,解决脏读、不可重复读的问题,存在幻读的问题。使用 MMVC机制 实现可重复读 |
序列化 | SERIALIZABLE | 3 | 解决脏读、不可重复读、幻读,可保证事务安全,但完全串行执行,性能最低 |
我们可以通过以下命令 查看/设置
全局/会话
的事务隔离级别
mysql> SELECT @@global.tx_isolation, @@tx_isolation;
+-----------------------+------------------+
| @@global.tx_isolation | @@tx_isolation |
+-----------------------+------------------+
| REPEATABLE-READ | READ-UNCOMMITTED |
+-----------------------+------------------+
1 row in set (0.00 sec)
# 设定全局的隔离级别 设定会话 global 替换为 session 即可 把set语法温习一下
# SET [GLOABL] config_name = 'foobar';
# SET @@[session.|global.]config_name = 'foobar';
# SELECT @@[global.]config_name;
SET @@gloabl.tx_isolation = 0;
SET @@gloabl.tx_isolation = 'READ-UNCOMMITTED';
SET @@gloabl.tx_isolation = 1;
SET @@gloabl.tx_isolation = 'READ-COMMITTED';
SET @@gloabl.tx_isolation = 2;
SET @@gloabl.tx_isolation = 'REPEATABLE-READ';
SET @@gloabl.tx_isolation = 3;
SET @@gloabl.tx_isolation = 'SERIALIZABLE';
幻读
首先我们要搞明白何谓幻读,目前网上的众多解释幻读的博文个人感觉仔细设想一下就能找出推翻的例子,就像博文把 非阻塞IO 等同为 异步IO,然后好多文章都纷纷借用,其实这俩货是完全不同,非阻塞IO 是 同步IO 中的一种模式,并非 异步IO。错误的观点都被大众认同的 "正确化" 了,扯远了,回归主题。
幻读会在 RU / RC / RR
级别下出现,SERIALIZABLE
则杜绝了 幻读
,但 RU / RC
下还会存在脏读、不可重复读
,故我们就以 RR
级别来研究 幻读
,排除其他干扰。
注意:RR
级别下存在幻读的可能,但也是可以使用对记录手动加 X锁
的方法消除幻读
。SERIALIZABLE
正是对所有事务都加 X锁
才杜绝了 幻读
,但很多场景下我们的业务 sql
并不会存在 幻读
的风险。SERIALIZABLE
的一刀切虽然事务绝对安全,但性能会有很多不必要的损失。故可以在 RR
下根据业务需求决定是否加锁,存在幻读风险我们加锁,不存在就不加锁,事务安全与性能兼备,这也是 RR
作为 mysql
默认隔是个事务离级别的原因,所以需要正确的理解 幻读
。
❝幻读错误的理解:说幻读是 事务A 执行两次 select 操作得到不同的数据集,即 select 1 得到 10 条记录,select 2 得到 11 条记录。这其实并不是幻读,这是不可重复读的一种,只会在 R-U R-C 级别下出现,而在 mysql 默认的 RR 隔离级别是不会出现的。
❞
这里给出我对幻读的比较白话的理解:
❝幻读,并不是说两次读取获取的结果集不同,幻读侧重的方面是某一次的 select 操作得到的结果所表征的数据状态无法支撑后续的业务操作。更为具体一些:select 某记录是否存在,不存在,准备插入此记录,但执行 insert 时发现此记录已存在,无法插入,此时就发生了幻读。
❞
这里给出 mysql 幻读的比较形象的场景(借用我在知乎上的回答):
table users: id primary key
事务T1
事务T2
❝step1 T1:
❞SELECT * FROM users WHERE id = 1;
step2 T2:INSERT INTO users VALUES (1, 'big cat');
step3 T1:INSERT INTO users VALUES (1, 'big cat');
step4 T1:SELECT * FROM users WHERE id = 1;
T1 :主事务,检测表中是否有 id 为 1 的记录,没有则插入,这是我们期望的正常业务逻辑。
T2 :干扰事务,目的在于扰乱 T1 的正常的事务执行。
在 RR 隔离级别下,step1、step2 是会正常执行的,step3 则会报错主键冲突,对于 T1 的业务来说是执行失败的,这里 T1 就是发生了「幻读」,因为 T1 在 step1 中读取的数据状态并不能支撑后续的业务操作,T1:“见鬼了,我刚才读到的结果应该可以支持我这样操作才对啊,为什么现在不可以”。T1 不敢相信的又执行了 step4,发现和 setp1 读取的结果是一样的(RR下的 MMVC机制)。此时,幻读无疑已经发生,T1 无论读取多少次,都查不到 id = 1 的记录,但它的确无法插入这条他通过读取来认定不存在的记录(此数据已被T2插入),对于 T1 来说,它幻读了。
其实 RR
也是可以避免幻读的,通过对 select
操作手动加 行X锁
(SELECT ... FOR UPDATE
这也正是 SERIALIZABLE
隔离级别下会隐式为你做的事情),同时还需要知道,即便当前记录不存在,比如 id=1
是不存在的,当前事务也会获得一把记录锁(因为InnoDB的行锁锁定的是索引,故记录实体存在与否没关系,存在就加 行X锁
,不存在就加 next-key lock间隙X锁
),其他事务则无法插入此索引的记录,故杜绝了幻读。
在 SERIALIZABLE
隔离级别下,step1 执行时是会隐式的添加 行(X)锁 / gap(X)锁的,从而 step2 会被阻塞,step3 会正常执行,待 T1 提交后,T2 才能继续执行(主键冲突执行失败),对于 T1 来说业务是正确的,成功的阻塞扼杀了扰乱业务的T2,对于T1来说他前期读取的结果是可以支撑其后续业务的。
所以 mysql 的幻读并非什么读取两次返回结果集不同,而是事务在插入事先检测不存在的记录时,惊奇的发现这些数据已经存在了,之前的检测读获取到的数据如同鬼影一般。
这里要灵活的理解读取的意思,第一次select是读取,第二次的 insert 其实也属于隐式的读取,只不过是在 mysql 的机制中读取的,插入数据也是要先读取一下有没有主键冲突才能决定是否执行插入。
不可重复读侧重表达 读-读,幻读则是说 读-写,用写来证实读的是鬼影。
RR级别下防止幻读
RR级别下只要对 SELECT 操作也手动加行(X)锁即可类似 SERIALIZABLE 级别(它会对 SELECT 隐式加锁),即大家熟知的:
# 这里需要用 X锁, 用 LOCK IN SHARE MODE 拿到 S锁 后我们没办法做 写操作
SELECT `id` FROM `users` WHERE `id` = 1 FOR UPDATE;
如果 id = 1 的记录存在则会被加行(X)锁,如果不存在,则会加 next-lock key / gap 锁(范围行锁),即记录存在与否,mysql 都会对记录应该对应的索引加锁,其他事务是无法再获得做操作的。
这里我们就展示下 id = 1 的记录不存在的场景,FOR UPDATE 也会对此 “记录” 加锁,要明白,InnoDB 的行锁(gap锁是范围行锁,一样的)锁定的是记录所对应的索引,且聚簇索引同记录是直接关系在一起的。
❝id = 1 的记录不存在,开始执行事务:
❞
step1: T1 查询 id = 1 的记录并对其加 X锁
step2: T2 插入 id = 1 的记录,被阻塞
step3: T1 插入 id = 1 的记录,成功执行(T2 依然被阻塞中),T1 提交(T2 唤醒但主键冲突执行错误)
❝T1事务符合业务需求成功执行,T2干扰T1失败。
❞
SERIALIZABLE级别杜绝幻读
在此级别下,我们便不需要对 SELECT 操作显式加锁,InnoDB会自动加锁,事务安全,但性能很低
❝step1: T1 查询 id = 2 的记录,InnoDB 会隐式的对齐加 X锁
❞
step2: T2 插入 id = 2 的记录,被阻塞
step3: T1 插入 id = 2 的记录,成功执行(T2 依然被阻塞中)
step4: T1 成功提交(T2 此时唤醒但主键冲突执行错误)
❝T1事务符合业务需求成功执行,T2干扰T1失败。
❞
总结
RR
级别作为 mysql
事务默认隔离级别,是事务安全与性能的折中,可能也符合二八定律(20%的事务存在幻读的可能,80%的事务没有幻读的风险),我们在正确认识幻读后,便可以根据场景灵活的防止幻读的发生。
SERIALIZABLE
级别则是悲观的认为幻读时刻都会发生,故会自动的隐式的对事务所需资源加排它锁,其他事务访问此资源会被阻塞等待,故事务是安全的,但需要认真考虑性能。
InnoDB
的行锁锁定的是索引,而不是记录本身,这一点也需要有清晰的认识,故某索引相同的记录都会被加锁,会造成索引竞争,这就需要我们严格设计业务 sql
,尽可能的使用主键或唯一索引对记录加锁。索引映射的记录如果存在,加行锁,如果不存在,则会加 next-key lock / gap 锁 / 间隙锁
,故 InnoDB
可以实现事务对某记录的预先占用,如果记录存在,它就是本事务的,如果记录不存在,那它也将是本是无的,只要本是无还在,其他事务就别想占有它。