前言
我们来详细地聊一聊 mysql innodb 中的“幻读”(phantom read)问题。这是一个在数据库事务隔离中非常核心且有时令人困惑的概念。
我会从定义、例子、原因以及解决方案几个方面来彻底讲清楚。
1. 什么是幻读?
官方定义:幻读指的是在一个事务内,相同的查询在不同时间执行,返回了不同数量的行。
这听起来和“不可重复读”很像,但它们有关键区别:
不可重复读 (non-repeatable read):侧重于同一行的数据内容被修改或删除。(例如:你第二次查询时,某行的薪水从10000变成了12000)。
幻读 (phantom read):侧重于新的行被插入(或删除),导致结果集的行数发生了变化。(针对结果集的数量变化,例如:你第一次查询有10条记录,第二次查询却冒出了11条)。
简单比喻:
不可重复读:你碗里的一块红烧肉被别人咬了一口(数据内容变了)。
幻读:你正准备夹碗里最后一块红烧肉时,别人突然又往碗里加了一块新的肉(数据行数变了)。
2. 幻读发生的场景与例子
幻读发生的根本原因是:在“可重复读(repeatable read)”及以下隔离级别中,普通的一致性读(快照读)无法阻止其他事务插入新的、满足当前查询条件的数据。
我们来看一个经典的例子。
表结构:
create table `employee` ( `id` int(11) not null auto_increment, `name` varchar(50) not null, `salary` int(11) default null, primary key (`id`), key `idx_salary` (`salary`) ) engine=innodb; insert into `employee` (`name`, `salary`) values ('alice', 8000), ('bob', 9000), ('charlie', 10000);
时间线:
时间 | 事务a (隔离级别:rr) | 事务b |
---|---|---|
t1 | start transaction; | |
t2 | select * from employee where salary > 8000; 结果: bob, 9000 charlie, 10000 (2 rows) | start transaction; |
t3 | insert into employee (name, salary) values ('david', 9500); commit; <!-- 事务b提交 --> | |
t4 | select * from employee where salary > 8000; 结果: bob, 9000 charlie, 10000 (仍然是2 rows!) (这里没有幻读,因为rr级别通过mvcc提供了快照) | |
t5 | update employee set salary = 8888 where salary > 8000; (注意:这个更新操作是当前读,会看到事务b已提交的修改) | |
t6 | select * from employee where salary > 8000; 结果: bob, 8888 charlie, 8888 david, 8888 (3 rows! 幻读出现了!) |
例子分析:
t2时刻:事务a第一次查询,得到2条记录。
t4时刻:事务a第二次普通查询(快照读)。由于innodb的mvcc(多版本并发控制)机制,它会读取事务开始时的数据快照,所以看不到事务b新插入的
david(9500)
。此时还没有幻读。t5时刻:关键点来了!事务a执行了一个update操作。update/delete/insert 这类写操作会使用“当前读”(current read),它会读取数据库中最新的、已提交的数据。因此,它看到了事务b插入的
david(9500)
这条记录,并将其薪水也更新为8888。t6时刻:事务a再次查询。因为之前的update操作属于当前事务的修改,所以mvcc规则允许它看到自己的修改。于是,它神奇地看到了三条记录!幻读就在这一刻发生了。
这个例子展示了innodb中幻读最典型的特征:即使是在默认的rr隔离级别下,先快照读,再当前读进行写操作,可能会意外地影响新插入的行,从而导致数据不一致。
3. 解决方案:next-key lock 锁机制
innodb引擎为了解决幻读问题,在“可重复读(repeatable read)”隔离级别下就引入了一种叫做 next-key lock 的锁机制。它实际上是 记录锁(record lock) 和 间隙锁(gap lock) 的结合。
记录锁 (record lock):锁住索引上的某一条具体记录。
间隙锁 (gap lock):锁住索引记录之间的“间隙”,防止在这个间隙内插入新的数据。它是一个左开右开的区间
(a, b)
。临键锁 (next-key lock):是记录锁 + 间隙锁的结合。它锁住一条记录和它前面的间隙。它是一个左开右闭的区间
(a, b]
。
如何解决幻读?
在上面的例子中,如果事务a在第一次查询时,就对 salary > 8000
这个条件加上了锁,那么事务b的插入操作就会被阻塞,从而杜绝幻读。
让我们重演时间线,但这次事务a加锁查询:
时间 | 事务a (加锁查询) | 事务b |
---|---|---|
t1 | start transaction; | |
t2 | select * from employee where salary > 8000 for update; (for update 会给查询结果加next-key lock) 结果:2 rows | start transaction; |
t3 | insert into employee (name, salary) values ('david', 9500); (这条语句会被阻塞,一直等待事务a释放锁!) | |
t4 | select ... for update; (再次查询,结果一致) | ...(阻塞中)... |
t5 | commit; (提交事务,释放锁) | ...(阻塞结束)... |
t6 | (此时事务b才能成功插入) |
发生了什么?
当事务a执行 select ... for update
时,innodb会为其加next-key lock。假设 salary
上有二级索引 idx_salary
,它可能会锁住以下区间:
锁住
(8000, 9000]
这个next-key lock(锁住9000这条记录和它前面的间隙)。锁住
(9000, 10000]
这个next-key lock(锁住10000这条记录和它前面的间隙)。锁住
(10000, +∞]
这个next-key lock(锁住正无穷的上界)。
事务b试图插入 salary = 9500
的记录,这个值落在被事务a锁住的 (9000, 10000]
间隙内,因此插入操作会被阻塞,直到事务a提交释放锁。这样就彻底防止了幻读的发生。
总结与最佳实践
特性 | 说明 |
---|---|
幻读本质 | 同一事务内,两次查询结果集行数不一致, due to 其他事务的插入或删除操作。 |
innodb的默认防御 | 在repeatable read隔离级别下,innodb通过 next-key lock 机制来防止幻读。 |
何时会发生幻读 | 即使是在rr级别下,如果你只是进行普通的快照读(select),然后基于此进行当前读的写操作(update/insert/delete),仍然可能遇到幻读。快照读不加锁是根源。 |
彻底解决方法 | 在需要绝对保证数据一致性的关键操作中,使用 加锁读: 1. select ... for update; (加写锁,阻塞其他事务的写和加锁读)2. select ... lock in share mode; (加读锁,阻塞其他事务的写)这些语句会在符合条件的索引上加next-key lock,从而阻止其他事务在锁定区间内插入新数据。 |
终极方案 | 将事务隔离级别提升至 serializable。在这个级别下,所有的读操作都会默认加上类似 lock in share mode 的锁,幻读自然不会发生,但这是以牺牲并发性能为代价的,一般不建议使用。 |
核心要点:mysql innodb 在 rr 级别下已经通过 next-key lock 很大程度上解决了幻读问题。但你需要清楚地知道,只有在你的查询语句确实需要加锁(例如使用了 for update)或者涉及写操作时,next-key lock 才会生效。单纯的快照读是无法完全避免幻读的潜在影响的。
到此这篇关于mysql数据库幻读问题的文章就介绍到这了,更多相关mysql幻读详解内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论