虽然平时已经很少使用MySQL了,但是数据库作为基本技能仍然不能忘,最近在学习数据库隔离级别,在此写下个人理解以备复习。
大家都知道数据库事务ACID(原子性、一致性、隔离性和持久性)的四个特征,也知道数据库存在三种并发问题(脏读、不可重复读、幻读),以及针对性的四种隔离级别(读未提交、读已提交、可重复读、序列化)。
解决与否 | 脏读 | 不可重复读 | 幻读 |
读未提交 | Yes | Yes | Yes |
读已提交 | No | Yes | Yes |
可重复读 | No | No | Yes |
串行化 | No | No | No |
特别提醒一句,隔离级别作用在连接(或会话)级别。客户端每次连接数据库的时候,都要根据自己对一致性的需求程度合理设置自己的事务隔离级别。
那么问题来了,MySQL底层(下文均指InnoDB引擎)是采用何种技术来实现这四种隔离级别的呢?
读未提交
MySQL全表的数据存储在以主键为排序值的B+树索引中,叶子节点存储了相应主键的整行记录。需要指出的是,叶子节点的数据都是最新的数据,可能是事务提交后的一致状态,也可能是事务执行中的中间值(可能被回滚)。
当隔离级别设置为RU时:
- 所有的读不加锁,读到的都是叶子节点上最新的值,性能最好。
- 所有的写(更新、插入、删除)加行级排斥锁,不存在脏写的问题,写完就释放锁。
读已提交
当隔离级别是RC和RR时,就要谈到大名鼎鼎的MVCC(多版本控制) 技术。通过在每行加入若干隐藏的字段,它实现了不加锁的读操作,性能较好。
- 先说RC级别的写操作,MySQL依然加行级排斥锁。事务开始时会往UNDO日志中写入当前的有效记录值,B+树叶子节点的隐藏列DATA_ROLL_PTR会存储指向该UNDO记录的指针。顺着行的DATA_ROLL_PTR的指针形成一个链表,记录该行数据的有效的历史记录。
- 再说不加锁的读操作,如果叶子节点正被其他事务锁定,那么MySQL顺着叶子节点的DATA_ROLL_PTR指针找到上一个有效的历史记录即可。
可重复读
在事务开始的时候,除了正常往UNDO日志中写回滚的数据外,会创建一个ReadView,记录了当前活跃的其他事务的ID,其中最小值为Tmin,最大值为Tmax。
当执行SELECT操作时,MySQL顺着行记录的DATA_ROLL_PTR指针查找符合条件的历史版本。这里就用到了另一个隐藏列DATA_TRX_ID,其中存储的是更新该记录的事务ID(事务ID是全局递增且唯一的)。如果DELETE_BIT为1,则代表ID为DATA_TRX_ID的事务对当前行执行了删除。
扫描历史版本串成的链表的过滤条件是:
- 如果当前记录的DATA_TRX_ID小于Tmin(之前存在的数据),那么由DELETE_BIT决定是否可见;否则,转2。
- 如果当前记录的DATA_TRX_ID小于Tmax,且不在活跃的事务ID集合中,那么由DELETE_BIT决定是否可见视为可见;否则,转3。
- 否则视为不可见,顺着DATA_ROLL_PTR进入上一个历史版本,或因为到头而结束回溯。
因为插入的数据版本号要么在活跃事务ID集合内、要么小于当前事务ID,所以MVCC机制同时解决了幻读问题。
需要指出的是,以上三个隔离级别中的读均为普通的SELECT。如果用的是SELECT ... LOCK IN SHARE MODE或SELCT ... FOR UPDATE,均属于当前读。即加读锁或写锁,读叶子节点最新值。如果更早的事务改了行值,依然会存在不可重复读的情况;如果前后两次读均为当前读,则不会如此(因为第一次读加锁了)。
在RR级别下,如果WHERE的条件列上有唯一索引,那么MySQL只加行级锁;如果是普通索引,会加间隙锁来防止幻读;如果没有索引,就会首先锁表的所有记录、再释放不符合条件的行的锁,因此会大大降低并发写的能力。
串行化
读写均加表级的读写锁即可,直接读主键索引B+树的叶子节点的最新数据。该级别下,数据一致性很强,但是并发写的能力非常差。