前置知识
脏读、幻读、不可重复读是什么?
脏读(Dirty Read) 事务A读取了事务B尚未提交(未Commit)的数据。如果事务B后来回滚(Rollback),事务A读到的就是“垃圾”数据。
不可重复读(Non-Repeatable Read) 在同一个事务A内,两次读取同一条记录,因为中间被其他已提交的事务修改了,导致两次读出的字段值不同。
幻读(Phantom Read) 定义:在同一个事务A内,两次执行相同的范围查询,因为中间被其他已提交的事务插入(Insert)或删除了符合条件的新数据,导致第二次查询返回的行数变多了(或变少了),像出现了幻觉。
MySQL四种级别按严格程度从低到高排列如下:
| 隔离级别 | 英文名称 | 脏读 (Dirty Read) | 不可重复读 (Non-Repeatable Read) | 幻读 (Phantom Read) |
|---|---|---|---|---|
| 读未提交 | READ UNCOMMITTED |
可能发生 | 可能发生 | 可能发生 |
| 读已提交 | READ COMMITTED |
不会发生 | 可能发生 | 可能发生 |
| 可重复读 | REPEATABLE READ |
不会发生 | 不会发生 | 可能发生(InnoDB可部分避免) |
| 串行化 | SERIALIZABLE |
不会发生 | 不会发生 | 不会发生 |
需要注意:REPEATABLE READ(可重复读)是 MySQL InnoDB 的默认隔离级别
MySQL为什么要搞个MVCC出来?
MVCC(多版本并发控制)是InnoDB在READ COMMITTED(读已提交)和REPEATABLE READ(可重复读)这两个级别下,实现“读不阻塞写、写不阻塞读”并发能力,并解决脏读和不可重复读问题的核心技术。
专业术语叫:一致性非锁定读
数据库的并发操作,无非就是三种:
- 读和读,不冲突
- 写和写。必须加锁
- 读和写?
最让人头疼的就是这个读写冲突了:就是你这边在不断地读数据,而同一时间其他人又在开启事务执行写操作 那这时候读操作到底要不要等写操作执行完呢?
- 如果你说等,加锁排队,那数据库的并发性能直接跌入谷底;
- 那如果你说不等,直接读,那你可能读到一个还没提交的半吊子数据,也就是脏读,数据就全乱套了。
既要保证性能不排队,又要保证正确不读错,怎么办呢?这时候MVCC就出场了。
- 它的基础思想就一句话:写操作不阻塞读操作,读操作也不阻塞写操作。
- 它是怎么做到的:你写你的当前版本,我读我的历史版本,大家互不干扰。
实现机制
隐藏字段、Undo Log和Read View
认识隐藏字段
用下面的方法来直观的看一下隐藏字段长什么样
- 我们在MySQL里开启一个事务,对一行数据执行一次update,注意先别提交。
- 然后我们查一下内置的information_schema.INNODB_TRX这个表,示例如下
mysql> begin;
Query OK, 0 rows affected (0.01 sec)
mysql> update `users` set `name`='小明' where id = 100;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> SELECT trx_id, trx_state, trx_query FROM information_schema.innodb_trx;
+--------+-----------+------------------------------------------------------------------------+
| trx_id | trx_state | trx_query |
+--------+-----------+------------------------------------------------------------------------+
| 14272 | RUNNING | SELECT trx_id, trx_state, trx_query FROM information_schema.innodb_trx |
+--------+-----------+------------------------------------------------------------------------+
1 row in set (0.02 sec)
mysql>
解读
- 这里清楚地记录着当前正在运行的 trx_id 是 14272,表示这条数据最后是由 14272 这个事务写进去的,而且这个事务正在运行。
- 执行update那一瞬间,这个 14272 就已经被MySQL悄悄写进了这行数据的隐藏字段 DB_TRX_ID 里了。
- 除了 trx_id,还有个隐藏字段叫做回滚指针(DB_ROLL_PTR),它连着 undo log,指向 undo log 当中的一条日志链。
- 日志链中串联着多个相关联的历史数据版本。
我们直接执行SHOW ENGINE INNODB STATUS来查看引擎的底层状态,关注其中的TRANSACTIONS事务部分。
mysql> show engine innodb status;
# 只看 TRANSACTIONS 部分
------------
TRANSACTIONS
------------
Trx id counter 14273
Purge done for trx's n:o < 14272 undo n:o < 0 state: running but idle
History list length 0
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 422125210957200, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 422125210956288, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 422125210955376, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 422125210954464, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 14272, ACTIVE 372 sec
2 lock struct(s), heap size 1136, 1 row lock(s), undo log entries 1
MySQL thread id 30866, OS thread handle 140649913382656, query id 53967 36.170.45.91 root starting
show engine innodb status
解读:
- 14272 之前的事务全部完成了,14272事务正在活跃,现在trx_id已经记到了 14273。
- 看这行
2 lock struct(s), heap size 1136, 1 row lock(s), undo log entries 2MySQL不仅改了原数据,还顺着隐藏的回滚指针在undo log里生成了一条旧数据的历史版本记录。 - 如果你再改一次,它就再生成一次,像链表一样串起来,这就叫版本链。
Read View 的判断逻辑
当执行事务查询时,MySQL会记录一个快照,记录一下当前这一瞬间数据库里有哪些事务正在活跃(也就是还没提交)。 拿到这个快照后,我们就开始拿着版本链里的数据一行行去套规则,逻辑非常的严密:
- 如果数据的事务ID比快照里最小的活跃ID还小,说明改这个数据的事务早就提交了,这个数据绝对靠谱,可见。
- 如果数据的事务ID比当前系统即将分配的最大ID还大,说明这个数据是未来的事务改的,不可见。
- 如果夹在中间,那就去快照的活跃集合里面查一下:
- 如果在这个集合里,说明还没有提交,不可见;
- 如果不在,说明已经提交了,可见。
如果当前版本不可见怎么办?那就顺着刚才咱们验证过的版本链往下找。直到找到可见的为止,就是指查询最后一个事务完成后的数据。 注意:事务自己修改过的记录,在后续查询中必须对自己可见 MySQL有两种常用的隔离级别为:
- RC(读已提交)
- RR(可重复读)
它们底层都用了MVCC。区别在哪呢? 其实它们在应用层可能会有各种花里胡哨的表现,但是在MVCC这一层,它们的区别仅仅在于生成Read View的时机不同。
- 在RC级别下,事务里的每一次SELECT都会重新拍一张快照,所以如果别的事务中途提交了,数据被修改了,你下一次查询就能看到了,但这就导致了不可重复读的问题。
- 而在RR级别下,事务只在第一次SELECT的时候会拍一张快照,事后所有的查询都用同一张快照,不管外面怎么改,你看到的世界始终如一,这就实现了可重复读。
MVCC的局限性
- 它只解决了读写冲突。如果是写写冲突,比如两个事务同时要update同一行,还是得老老实实加行锁去排队。
- 幻读问题。MVCC通过快照读确实缓解了幻读,但在RR级别下,如果你做了一次普通查询,然后又做了一次update(当前读),依然会遇到幻读。要彻底解决,还得靠Next-Key Lock(间隙锁)来配合。
- 空间开销。我们刚才看到了Undo Log当中的版本链,如果你写了一个超级长的大事务,迟迟不提交,底层的Undo Log版本链就会越拉越长,最终可能会撑爆表空间。所以在日常开发中,千万别写大事务。