一.什么是事务
首先简单说下什么是事务,事务就是具有原子性,一致性,隔离性,持久性的一组数据库操作。
如下图的sql语句即为事务。mysql默认自动提交事务,所以一条语句就是一个事务。也可以手动写begin和commit,在这之间的sql语句同属于一个事务。
二.原子性的实现
原子性比较容易理解,同一个事务的所有操作,要么都成功执行,要么都不执行,如下图。
要实现原子性,只需要能在事务执行失败的时候能够回滚到事务执行之前的状态即可。如下图,每次事务执行时,会把要修改的数据记录保存副本到undo log。每条数据记录会保存一个undo指针,指向undo log中的数据记录副本。如果事务执行失败,就可以使用undo指针指向的副本来恢复数据。
三.隔离性的实现
如上图,当多个事务同时读写同一个数据页的时候,会有并发问题。这时候需要隔离性对并发操作进行一定的控制,最强的并发控制是串行化,完全避免了并发问题,但是这样性能也会受到很大影响。所以设定了四种隔离级别,由使用者根据性能和并发问题的容忍度进行权衡。下表为不同的隔离级别会有的一致性问题,隔离级别越高,性能越差,但是一致性越好。
| 脏读 | 不可重复读 | 幻读 | 丢失更新 |
读未提交 | √ | √ | √ | √ |
读提交 |
| √ | √ | √ |
可重复读 |
|
| ? | √ |
串行化 |
|
|
| √ |
在介绍各种隔离级别如何实现之前,先介绍一下innodb的三种锁定方式。第一种是非锁定读,写的时候会默认加写锁,但是读的时候不会默认加读锁。非锁定读没有申请任何锁,也就不会和其他锁冲突,也就是说非锁定读不会被写锁阻塞。如下面这两个事务,事务1是非锁定读,所以不会阻塞事务2,事务2可以直接执行。
事务1 | 事务2 |
Select * from user where id=1 |
|
| Update user set name=xxx where id=1 (直接执行) |
Select * from user where id=1 |
|
剩下两种就是常见的读锁和写锁,读锁和写锁是互斥的。读操作在sql语句之后加上lock in share model可以申请读锁,加上for update可以申请写锁,而写操作会默认申请写锁。读锁和写锁互斥,所以下面事务2会被事务1阻塞。
事务1 | 事务2 |
Select * from user where id=1 lock in share model |
|
| Update user set name=xxx where id=1 (等待事务1释放锁) |
Select * from user where id=1 |
|
下面开始介绍4种隔离级别在innodb如何实现。
1.读未提交
如上图,读未提交隔离级别没有对并发操作进行任何控制。因为非锁定读和写锁不互斥,读写操作对同一个数据记录进行操作,这时候事务134可能会读到脏数据。
2.读提交
读提交使用mvcc(多版本并发控制)避免了脏读问题 ,mvcc指的是保存数据记录的历史版本,如果数据记录被写锁锁定,就读取数据记录的最新的历史版本,这样就不会读到正在被修改的脏数据了。前面实现原子性的时候有说过会保存数据记录的副本到undo log用来进行回滚,mvcc使用的历史版本也就是undo log中保存的副本。
因为读提交每次读的都是最新的历史版本,但是随着其他事务的提交,最新版本会改变,这样就导致同一个事务的两次读操作出现不同结果,这就是不可重复读问题。如下面事务2两次读取的结果不一致。
事务1 | 事务2 |
| Select cash from account where id=‘小明’ (事务1修改前的数据) |
Update account set cash=cash+1 where id=‘小明’ |
|
commit |
|
| Select cash from account where id=‘小明’ (事务1修改后的数据) |
3.可重复读
可重复读就是为了解决不可重复读问题,可重复读和读提交类似,都是使用mvcc,但是可重复读只会读取数据记录在事务开始时的历史版本,也就是说在上图中,开始时事务134读取数据记录的120版本。即使在事务133提交后,最新版本变成133,事务134读取的还是开始时的120版本,这样两次读取的数据就一样了。
另外,innodb与其他存储引擎不同的是,它在可重复读级别就解决了幻读问题。那什么是幻读问题呢?如下面两个事务,事务2开始时查询id=小明的所有记录,并且加了读锁。如果在读提交级别,使用的是记录锁,也就是说会锁定查到的所有记录,不允许修改。但是事务1是插入一条新记录,这条记录原来没有,也就不会被锁定,此时事务1不会被事务2阻塞。导致最后事务2第二次查询会多出一条记录,这就是幻读问题。
事务1 | 事务2 |
| Select * from record where id=‘小明’ lock in share model |
Insert into record(id,value) values(‘小明’,1) |
|
commit |
|
| Select * from record where id=‘小明’ lock in share model (多出一条记录) |
可重复读级别使用的是间隙锁,锁定的是一个范围,在上面事务2中锁定的就是id=小明这个范围,不论当前数据库中是否存在这些记录都会被锁住,此时事务1就会被阻塞。
4.串行化
上面可重复读解决幻读问题需要开发人员手动给读操作申请读锁( lock in share model)才会生效,串行化就是针对这个做了简单的优化,给每个读操作默认加上读锁,不需要手动申请。
四.持久性的实现
如上图,数据库的读写操作其实都是在内存中的数据页中进行的,为了提升性能,事务提交之后也不会马上就把内存中修改过的数据页同步到硬盘,而是等待一个时机批量同步。所以硬盘中的数据可能不是最新的,需要保证宕机后仍然能恢复内存中未同步到磁盘的数据。
innodb采用Write Ahead Log策略,事务提交前先将事务对数据页的物理操作保存到磁盘中redo log,如果事务修改在宕机时没有同步到磁盘,可以使用redo log对事务操作进行重放。那么为什么要写redo log,而不是直接同步数据页到磁盘中呢?我觉得有两个原因,一个是写redo log是顺序写的,而同步数据页是随机写,每个数据页写入都需要重新寻址;另一个原因是事务一般只修改数据页的一小部分,redo log只需要写入这一小部分,而同步数据页却需要写入一整页。
参考资料:《MySQL技术内幕:InnoDB存储引擎》