官方定义
在一次查询的结果集里出现了某一行数据,但是该数据并未出现在更早的查询结果集里。例如,在一次事务里进行了两次查询,在这两次之间另一个事务插入某一行或更新某一行数据后提交, 并且数据可以匹配查询的where条件
在不同的隔离级别中,串行化读可以避免幻读,而其他隔离级别都允许出现幻读.
演示
执行前user表
mysql> select * from user;
+----+------+------+
| id | name | age |
+----+------+------+
| 1 | 小李 | 10 |
| 2 | 小王 | 20 |
+----+------+------+
2 rows in set (0.00 sec)
sql执行顺序
事务A | 事务B | |
---|---|---|
0 | begin; | begin; |
1 | select * from user where age > 10 | |
2 | insert into user(name, age) value(‘小张’, 30); | |
3 | commit; | |
4 | select * from dept where age > 10 | |
5 | update dept set name = ‘改’ where age > 10; | |
6 | select * from dept where age > 10; | |
7 | commit; |
执行过程
- 事务A
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from user where age > 10;
+----+------+------+
| id | name | age |
+----+------+------+
| 2 | 小王 | 20 |
+----+------+------+
1 row in set (0.00 sec)
- 事务B
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into user(name, age) values('小张', 30);
Query OK, 1 row affected (0.00 sec)
mysql> commit;
Query OK, 0 rows affected (0.00 sec)
- 事务A
mysql> select * from user where age > 10;
+----+------+------+
| id | name | age |
+----+------+------+
| 2 | 小王 | 20 |
+----+------+------+
1 row in set (0.00 sec)
- 事务A
mysql> update user set name = '改' where age > 10;
Query OK, 2 rows affected (0.00 sec)
Rows matched: 2 Changed: 2 Warnings: 0
- 事务A
mysql> select * from user where age > 10;
+----+------+------+
| id | name | age |
+----+------+------+
| 2 | 改 | 20 |
| 3 | 改 | 30 |
+----+------+------+
2 rows in set (0.00 sec)
执行后user表
mysql> select * from user;
+----+------+------+
| id | name | age |
+----+------+------+
| 1 | 小李 | 10 |
| 2 | 改 | 20 |
| 3 | 改 | 30 |
+----+------+------+
3 rows in set (0.00 sec)
基础概念
快照读和当前读
- 快照读
当执行普通select操作时Innodb默认会执行快照读, 读的是数据库记录的快照版本 - 当前读
对于会对数据修改的操作(update、insert、delete)都是采用当前读, 读取的是最新版本, 并且对读取的记录加锁, 阻塞其他事务同时改动相同记录,避免出现安全问题
select可以加锁实现当前读
select * from user where age > 10 lock in share mode;
-- 或
select * from user where age > 10 for update;
锁类型
- 共享锁
又称读锁, S锁, 事务T1对数据A加上共享锁, 那么事务T2可以读数据A, 不能修改数据A, 事务T2可以对数据A再加共享锁, 不能加排它锁 - 排他锁
又称写锁, X锁, 事务T1对数据A加上排它锁, 那么事务T2不能读数据A, 不能修改数据A, 事务T2不能对数据A再加任何锁
锁级别
- 行锁
- 表锁
- 间隙锁
- next-key锁
redo 和 undo
- redo
redo log是重做日志,是记录物理数据变化的日志, 使用数据库DML对数据的修改操作,都会产生redo log,它可以保证事务的持久性 - undo
undo log是回滚日志,主要记录的是数据的逻辑变化,它是逻辑日志- 回滚
可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,当update一条记录时,它记录一条对应相反的update记录, 当执行rollback时,就可以从undo log中的逻辑记录读取到相应的内容并进行回滚。 - mvcc
当读取的某一行被其他事务锁定时,它可以从undo log中分析出该行记录以前的数据是什么,从而提供该行版本信息,让用户实现非锁定一致性读取
- 回滚
MVCC
版本链
对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列
- trx_id: 每次对某条记录进行改动时,都会把对应的事务id赋值给trx_id隐藏列
- roll_pointer: 每次对某条记录进行改动时,这个隐藏列会存一个指针,可以通过这个指针找到该记
录修改前的信息
ReadView
ReadView中主要包含当前MySQL中还有哪些活跃的读写事务,把它们的事务id放到一个列表中,我们把这个列表命名为为m_ids(一个数组), 以及下面几个
- m_ids,就是说此时有哪些事务在MySQL里执行还没提交的
- min_trx_id: m_ids中最小的值
- max_trx_id: MySQL下一个要生成的事务ID,就是最大事务ID
- creator_trx_id: 当前事务的ID
对比规则
- trx_id小于等于min_trx_id, 表示这个版本是已提交的事务或者是当前事务自己生成的,可见.
- trx_id大于等于max_trx_id, 表示这个版本是由将来启动的事务生成的,不可见.
- trx_id大于min_trx_id且小于max_trx_id
3.1. trx_id在m_ids中, 表示这个版本是由还没提交的事务生成的,不可见
tr_id等于creator_trx_id, 表示这个版本是当前事务自己生成的,可见
3.2. trx_id不在m_ids中, 表示这个版本是已经提交了的事务生成的,可见
如果某个版本的数据对当前事务不可见的话,那就顺着版本链继续去找下一个版本的数据记录,一直到版本链中的最后一个版本数据,如果最后一个版本的数据也不可见的话,那么也就意味着该条记录对该事务不可见,查询结果就不包含该记录
mvcc如何实现不同隔离级别
可重复读
事务A的事务id = 101, 事务B的事务id = 102, user表内数据由之前事务id = 100的事务插入, 已经提交
事务A条件查询
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from user where age > 10;
+----+------+------+
| id | name | age |
+----+------+------+
| 2 | 小王 | 20 |
+----+------+------+
1 row in set (0.00 sec)
- 版本链
- 执行流程
- select时会首先生成一个ReadView,此时m_ids数组列表就是[101]
- 根据条件age>10, 查到了id=2的记录, 现在该记录只有一个版本
- 最新版本链内的记录, trx_id=100, 小于min_trx_id, 该记录对事务A可见
事务B进行新增
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into user(name, age) values('小张', 30);
Query OK, 1 row affected (0.00 sec)
mysql> commit;
Query OK, 0 rows affected (0.00 sec)
- 事务B新增后的版本链
事务A再次条件查询
mysql> select * from user where age > 10;
+----+------+------+
| id | name | age |
+----+------+------+
| 2 | 小王 | 20 |
+----+------+------+
1 row in set (0.00 sec)
- 版本链
- 执行流程
- 因为隔离级别是可重复读, 所以第二次查询还是用的第一次的ReadView, m_ids是[101]
- 根据条件查到id = 2和id = 3两条记录, id = 2是可见的
- id = 3的trx_id = 102, 大于max_trx_id, 所以id = 3的对事务A不可见
事务A根据条件修改
mysql> update user set name = '改' where age > 10;
Query OK, 2 rows affected (0.00 sec)
Rows matched: 2 Changed: 2 Warnings: 0
- 版本链
- 执行流程
- update操作是当前读, 总会读到最新的数据, 和ReadView以及trx_id没关系
- 事务A进行update的时候, 会读到两条记录
- 事务A修改后, id = 2和id = 3的记录, 他们最新版本的trx_id都变成101了, 也就是事务A的id
事务A修改完再次条件查询
mysql> select * from user where age > 10;
+----+------+------+
| id | name | age |
+----+------+------+
| 2 | 改 | 20 |
| 3 | 改 | 30 |
+----+------+------+
2 rows in set (0.00 sec)
- 版本链
- 执行流程
- 可重复读, m_ids依然是[101]
- 根据条件查到id = 2和id = 3两条记录
- 判断是否可见, trx_id存在于m_ids中, 且trx_id等于creator_trx_id, 对事务A可见
读已提交
读已提交也是用mvcc实现的, 唯一不同的是, 读已提交每次select的时候, 都会重新生成一个ReadView, 相应的trx_max, min_trx_id等参数都会取最新的.
如何避免幻读
next-key锁
next-key锁 = 行锁+间隙锁
间隙锁
gap间隙锁只有在可从重复读和串行化的隔离级别中才会有, 锁定范围空间的数据
使用方式
select * from user where age > 10 lock in share mode
-- 或
select * from user where age > 10 for update
总结
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交 | 是 | 是 | 是 |
读已提交 | 否 | 是 | 是 |
可重复读 | 否 | 否 | 是 |
串行化 | 否 | 否 | 否 |
参考:
https://juejin.cn/post/6916500638457298958
https://juejin.cn/post/6844903799534911496