MySQL事务原理详解
事务介绍
过多的就不废话了,数据库事务具有ACID四大特性。ACID是以下4个词的缩写
ACID四大特性
-
原子性
-
事务最小工作单元,要么全成功,要么全失败
-
-
一致性
-
事务开始和结束后,数据库的完整性不会被破坏
-
-
隔离性
-
不同事务之间互不影响,四种隔离级别为RU(读未提交)、RC(读已提交)、RR(可重复读)、SERIALIZABLE (串行化)
-
-
持久性
-
事务提交后,对数据的修改是永久性的,即使系统故障也不会丢失
-
隔离级别
在理解下面之前先要明白几个术语的意义
-
脏读:一个事务读取到另一个事务未提交的数据
读未提交(READ UNCOMMITTED/RU )
-
如果一个事务读到了另一个未提交事务修改过的数据,那么这种隔离级别就称之为读未提交
-
暴露问题:(脏读)
读已提交(READ COMMITTED/RC )
-
如果一个事务只能读到另一个已经提交的事务修改过的数据,
-
并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值,
-
那么这种隔离级别就称之为读已提交
-
暴露问题:(不可重复读)
-
一个事务因读取到另一个事务已提交的update。导致对同一条记录读取两次以上的结果不一致
-
可重复读(REPEATABLE READ/RR )
-
一个事务只能读到另一个已经提交的事务修改过的数据
-
但是第一次读过某条记录后,即使其他事务修改了该记录的值并且提交,
-
该事务之后再读该条记录时,读到的仍是第一次读到的值,
-
而不是每次都读到不同的数据。那么这种隔离级别就称之为可重复读
-
暴露问题:(幻读)
-
一个事务因读取到另一个事务已提交的insert数据或者delete数据。导致对同一张表读取两次以上的结果不一致
-
串行化(SERIALIZABLE )
-
以上3种隔离级别都允许对同一条记录进行 读-读 、 读-写 、 写-读 的并发操作
-
如果我们不允许 读-写 、写-读 的并发操作,可以使用串行化隔离级别
相关数据库命令
-
查看当前事务级别
-
select @@tx_isolation;
-
-
设置事务级别
-
set session transaction isolation level [read uncommitted / read committed / repeatable read / serializable ];
-
事务和MVCC底层原理详解
丢失更新思考
两个事务针对同一数据都发生修改操作时,会存在丢失更新问题
张三和李四在银行各有存款1000
时间线 银行家 张三 、 李四 20:01 查看银行总存款数(开启事务) 20:02 读取到张三有1000余额 20:03 张三向李四转钱 500 20:04 转款完成:张三:500 --- 李四:1500 20:05 读取到李四有1500余额 20:06 统计得到银行总存款数为 :2500
解决方案1:LBCC
-
LBCC:基于锁的并发控制
-
银行家读取存款总额时,会对读取的行--》上锁
-
张三向李四转账,发现自己账户被锁,操作被阻塞
-
银行家读取存款总额操作完成--》释放锁
-
张三向李四转账的操作开始执行
-
-
这种方案比较简单粗暴,就是一个事务去读取一条数据的时候,就上锁,不允许其他事务来操作
-
MySQL加锁之后就是当前读
-
假如当前事务只是加共享锁,那么其他事务就不能有排他锁,也就是不能修改数据
-
假如当前事务需要加排他锁,那么其他事务就不能持有任何锁
-
总而言之,能加锁成功,就确保了除了当前事务之外,其他事务不会对当前数据产生影响
-
解决方案2:MVCC
-
MVCC:多版本的并发控制
-
银行家准备读取存款总额(事务开启)
-
读取张三的余额为1000
-
张三向李四转账500,转账成功
-
读取李四的余额为1000(读取的是事务开启时,所有数据的副本,所以读取的不是最新的1500)
-
-
该方案对于select 读不会对数据加锁,提高了数据并发处理性能
-
借助该方案,数据库可以实现 [读已提交,可重复读]等隔离级别
InnoDB的MVCC实现
-
来自官方翻译:
-
MVCC是用于数据库提供并发访问控制的并发控制技术。与MVCC相对的,是基于锁的并发控制
-
MVCC最大的好处,相信也是耳熟能详:读不加锁,读写不冲突
-
在读多写少的应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能
-
多版本并发控制仅仅是一种技术概念,并没有统一的实现标准
-
其核心理念就是数据快照,不同的事务访问不同版本的数据快照,从而实现不同的事务隔离级别
-
虽然字面上是说具有多个版本的数据快照,但这并不意味保存多份数据文件,这样会浪费大量的存储空间
-
InnoDB通过事务的undo日志巧妙地实现了多版本的数据快照
-
InnoDB的MVCC
-
InnoDB的MVCC是通过在每行记录后面保存两个隐藏的列来实现的
-
这两个列,一个保存了行的事务ID,一个保存了行的回滚指针
-
每开始一个新的事务,都会自动递增产生一个新的事务id
-
事务开始时会把事务id放到当前事务影响的行事务id中,当查询时需要用当前事务id和每行记录的事务id进行比较
-
下面我们我们一起来看看在可重复读的隔离级别下,MVCC的操作
MVCC只在可重复读和读已提交两个隔离级别下工作
因为读未提交总是读取最新的数据行,而不是符合当前事务版本的数据行
而串行化则会对所有读取的行都加锁
-
select
-
InnoDB 会根据以下两个条件检查每行记录
-
InnoDB只查找版本早于当前事务版本的数据行
-
确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的
-
-
行的删除版本要么未定义,要么大于当前事务版本号
-
确保事务读取到的行,在事务开始之前未被删除
-
-
只有符合上述两个条件的记录,才能返回作为查询结果
-
-
insert
-
InnoDB为新插入的每一行保存当前事务编号作为行版本号
-
-
delete
-
InnoDB为删除的每一行保存当前事务编号作为行删除标识
-
-
update
-
InnoDB为插入一行新记录,保存当前事务编号作为行版本号,同时保存当前事务编号到原来的行作为行删除标识
-
保存这两个额外事务编号,使大多数读操作都可以不用加锁
-
不足之处是每行记录都需要额外的存储空间,需要做更多的行检查工作,以及一些额外的维护工作
-
undo log
-
根据行为的不同,undo log 分为两种
-
insert undo log
-
是在 insert 操作中产生的 undo log
-
因为 insert 操作的记录只对事务本身可见,对于其它事务此记录是不可见的,
-
所以 insert undo log可以在事务提交后直接删除而不需要进行 purge 操作
-
-
-
update undo log
-
update undo log是指在delete和update操作中产生的undo log
-
因为会对已经存在的记录产生影响 ,该undo log会被后续用于MVCC当中,因此不能提交的时候删除
-
提交后会放入undo log的history list链表,等待purge线程进行最后的删除
-
-
如果我们再次开启修改该数据,如下图所示
-
-
为了保证事务并发操作时,在写各自的undo log时不产生冲突,InnoDB采用回滚段的方式来维护undolog的并发写入和持久化。回滚段实际上是一种 Undo 文件组织方式
-
-
Read View
-
设计InnoDB的人提出了一个ReadView的概念。
-
这个概念主要包含当前系统中还有那些活跃的读写事务
-
把他们的食事务ID放到一个列表中,我们把这个列表命名为:m_ids
-
这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本(版本链中的版本)是否可见
-
如果被访问版本的trx_id属性值小于m_ids列表中最小的事务ID:
-
表明生成该版本的事务在生成ReadView 前已经提交,所以该版本可以被当前事务访问
-
-
如果被访问版本的 trx_id 属性值大于 m_ids 列表中最大的事务ID:
-
表明生成该版本的事务在生成ReadView 后才生成,所以该版本不可以被当前事务访问
-
-
如果被访问版本的 trx_id 属性值在 m_ids 列表中最大的事务id和最小事务id之间:
-
那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,
-
如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;
-
如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问
-
-
-
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本,如果最后一个版本也不可见的话,那么就意味着该条记录对该事务不可见,查询结果就不包含该记录
读已提交和可重复度生成ReadView的时机
-
在MySQL中,两者非常大的区别就是他们生成ReadView的时机不同
读已提交生成ReadView
-
每次读取数据前都生成一个ReadView
-
比方说现在系统里有两个 id 分别为 100 、 200 的事务在执行
事务ID:100 | 事务ID:200 |
---|---|
start | start |
update t set c = '关羽' where id = 1; | 更新了一些别的表的数据 |
update t set c = '张飞' where id = 1; | |
没有提交 | 没有提交 |
-
事务执行过程中,只有在第一次真正修改记录时(比如使用INSERT、DELETE、UPDATE语句),才会被分配一个单独的事务id,这个事务id是递增的
-
此时表c中id为1的记录得到的版本链为:
-
此时我们发起一个使用读已提交隔离级别的查询语句:select * from c where id = 1
-
得到的值会是刘备的那条数据
-
首先在执行select时,就会生成一个ReadView,ReadView的m_ids列表的数据为:【100、200】
-
然后从版本链中依次挑选对比得到可见的记录:
-
首先张飞这条数据的trx_id为100,在m_ids列表内,不符合可见性要求,然后更具roll_pointer跳到下一个版本
-
来到了关于这条数据,发现trx_id在在m_ids列表内,也不符合可见性要求,继续跳
-
来到了刘备这一条数据,发现trx_id小于m_ida中最小的实物ID100,于是这个版本可见,便返回
-
-
-
如果我们现在把事务ID为100的提交了,然后再去事务ID为200中更新一下t表
事务ID:100 | 事务ID:200 |
---|---|
start | |
update t set c = '关羽' where id = 1; | |
update t set c = '张飞' where id = 1; | |
commit | |
start | |
update t set c = '诸葛匹夫' where id = 1; | |
update t set c = '黄忠小儿' where id = 1; | |
未提交 |
此时 t 表的记录版本链长这样
-
此时我们再发起一个使用读已提交隔离级别的查询语句:select * from c where id = 1
-
得到的值会是张飞的那条数据
-
首先在执行select时,就会生成一个ReadView,ReadView的m_ids列表的数据为:【200】
-
100那个事务已经提交了,所以生成快照时没有它了
-
-
然后从版本链中依次挑选对比得到可见的记录:
-
黄忠小儿这条数据,由于trx_id在m_ids中,不符合可见性要求,往下跳
-
诸葛匹夫这条数据同理,也不符合可见性要求,继续往下跳
-
张飞这条数据的trx_id为100,比m_ids中最小的实物ID200要小、
-
所以返回了张飞这条数据
-
-
以此类推,如果200事务ID也提交了,在读已提交的隔离级别事务中查询t表id为1的数据就该是:黄忠小儿了
使用READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView
可重复读生成ReadView
-
在事务开始后第一次读取数据时生成一个ReadView
-
比方说现在系统里有两个 id 分别为 100 、 200 的事务在执行
事务ID:100 | 事务ID:200 |
---|---|
start | start |
update t set c = '关羽' where id = 1; | 更新了一些别的表的数据 |
update t set c = '张飞' where id = 1; | |
没有提交 | 没有提交 |
-
此时表c中id为1的记录得到的版本链为:
-
此时我们发起一个使用可重复读隔离级别的查询语句:select * from c where id = 1
-
得到的值会是刘备
-
首先在执行select时,就会生成一个ReadView,ReadView的m_ids列表的数据为:【100、200】
-
然后从版本链中依次挑选对比得到可见的记录:
-
首先张飞这条数据的trx_id为100,在m_ids列表内,不符合可见性要求,然后更具roll_pointer跳到下一个版本
-
来到了关于这条数据,发现trx_id在在m_ids列表内,也不符合可见性要求,继续跳
-
来到了刘备这一条数据,发现trx_id小于m_ida中最小的实物ID100,于是这个版本可见,便返回
-
-
-
如果我们现在把事务ID为100的提交了,然后再去事务ID为200中更新一下t表
事务ID:100 | 事务ID:200 |
---|---|
start | |
update t set c = '关羽' where id = 1; | |
update t set c = '张飞' where id = 1; | |
commit | |
start | |
update t set c = '诸葛匹夫' where id = 1; | |
update t set c = '黄忠小儿' where id = 1; | |
未提交 |
此时 t 表的记录版本链长这样
-
此时我们再发起一个使用可重复读隔离级别的查询语句:select * from c where id = 1
-
得到的值就会是刘备的那条数据,而不是和上面一样的张飞
-
首先在执行select时,因为之前已经生成过RaadView了,直接复用:m_ids仍然为:【100、200】
-
100那个事务虽然已经提交了,但是我们使用的是旧版的ReadView,所以仍然有它
-
-
然后从版本链中依次挑选对比得到可见的记录:
-
黄忠小儿这条数据,由于trx_id在m_ids中,不符合可见性要求,往下跳
-
诸葛匹夫这条数据同理,也不符合可见性要求,继续往下跳
-
张飞这条数据同理,也不符合可见性要求,继续往下跳
-
关羽的这条数据同理,也不符合可见性要求,继续往下跳
-
刘备这条数据符合可见性要求,rtx_id小于 m_ids中最小的实物ID 100
-
所以返回了刘备这条数据
-
-
就算我们再把事务id为 200 的记录也提交了
之后再使用可重复读隔离级别的事务中继续查找这个id为 1 的记录,得到的结果还是 '刘备'
MVCC总结
-
从上边的笔记中我们可以看出来,所谓的MVCC(Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用 READ COMMITTD 、 REPEATABLE READ 这两种隔离级别的事务在执行普通的SEELCT 操作时访问记录的版本链的过程,这样子可以使不同事务的 读-写 、 写-读 操作并发执行,从而提升系统性能
-
READ COMMITTD 、 REPEATABLE READ 这两个隔离级别的一个很大不同就是生成 ReadView 的时机不 同, READ COMMITTD 在每一次进行普通 select 操作前都会生成一个 ReadView ,而 REPEATABLEREAD More Actions只在第一次进行普通 select操作前生成一个,之后的查询操作都重复这个ReadView
MVCC下的读操作
-
在MVCC并发控制中,读操作可以分成两类:快照读 (snapshot read)与当前读 (current read)
-
快照读:读取的是记录的可见版本 (有可能是历史版本),不用加锁
-
当前读:读取的是记录的最新版本,并且当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录
-
-
在一个支持MVCC并发控制的系统(读已提交、可重复读)中,哪些读操作是快照读?哪些操作又是当前读呢 ?
-
当然储存引擎命中为:InnoDB
-
当前读
特殊的读操作,插入/更新/删除操作,属于当前读,需要加锁。 加行写锁 读当前版本
简单的select操作,属于快照读,不加锁。(当然,也有例外,下面会分析) 不加读锁 读历史版本
select * from table where ? lock in share mode; select * from table where ? for update; insert into table values (…); update table set ? where ?; delete from table where ?;
-
以上的sql都属于当前读,读取记录的最新版本,并且读取之后还保证其他并发事务不能修改当前的记录对读取加锁
-
第一条sql,对读取记录加了S锁(共享锁)
-
下面其他的sql,都对读取几率加了X锁(排他锁)
-
-
一个update table set ... where ... 的流程是什么样的呢
-
当update语句被发给MySQL之后
-
MySQL会根据where条件,读取第一条满足条件的数据,然后InnoDB会将其返回并加锁
-
MySQL接收到InnoDB返回的记录后,会再发起一个update请求,更新这条记录
-
循环往复,直到该表中没有符合where条件的数据记录
-
所以update内部就包含了一个当前读,delete同理
-
insert操作会稍有不同,可能会触发Uniqu Key冲突检查,也会进行一个当前读
-
针对一条当前读的sql语句,InnoDB和MySQL的交互都是一条一条进行的,加锁也是一个一个的加锁
-
快照读
快照读也就是一致性非锁定读,是指InnoDB存储引擎通过多版本控制(MVCC)读取当前数据库中行数据的方式
如果读取的行正在执行delete或update操作,这时读取操作不会因此去等待行上锁的释放。相反地,InnoDB会去读取行的一个最新可见快照
-
都是上面这一套演示,在不同的事务隔离级别情况下查询到的数据是不一样的
事务回滚和数据恢复
-
事务的隔离性由多版本控制机制和锁实现,而原子性,持久性和一致性主要是通过redo log、undo log和Force Log at Commit机制机制来完成的
-
redo log用于在崩溃时恢复数据
-
undo log用于对事务的影响进行撤销,也可以用于多版本控制
-
Force Log at Commit机制保证事务提交后redo log日志都已经持久化
-
-
开启一个事务后,用户可以使用commit来提交,也可以用rollback来回滚。其中commit或者rollback执行成功之后,数据一定是会被全部保存或者全部回滚到最初状态的,这也体现了事务的原子性。但是也会有很多的异常情况,比如说事务执行中途连接断开,或者是执行commit或者rollback时发生错误,Server Crash等,此时数据库会自动进行回滚或者重启之后进行恢复
-
我们先来看一下redo log的原理,redo log顾名思义,就是重做日志,每次数据库的SQL操作导致的数据变化它都会记录一下,具体来说,redo log是物理日志,记录的是数据库页的物理修改操作。如果数据发生了丢失,数据库可以根据redo log进行数据恢复。
-
InnoDB通过Force Log at Commit机制实现事务的持久性,即当事务commit时,必须先将该事务的所有日志都写入到redo log文件进行持久化之后,commit操作才算完成
-
当事务的各种SQL操作执行时,即会在缓冲区中修改数据,也会将对应的redo log写入它所属的缓存。当事务执行commit时,与该事务相关的redo log缓冲必须都全部刷新到磁盘中之后commit才算执行成功
-
数据库日志和数据落盘机制之前我们已经详细说明,这里就不再二次累字数了
-
redo log写入磁盘时,必须进行一次操作系统的fsync操作,防止redo log只是写入了操作系统的磁盘缓存中。参数innodb_flush_log_at_trx_commit可以控制redo log日志刷新到磁盘的策略
-
数据库崩溃重启后需要从redo log中把未落盘的脏页数据恢复出来,重新写入磁盘,保证用户的数据不丢失。当然,在崩溃恢复中还需要回滚没有提交的事务。由于回滚操作需要undo日志的支持,undo日志的完整性和可靠性需要redo日志来保证,所以崩溃恢复先做redo恢复数据,然后做undo回滚
-
在事务执行的过程中,除了记录redo log,还会记录一定量的undo log。undo log记录了数据在每个操作前的状态,如果事务执行过程中需要回滚,就可以根据undo log进行回滚操作
-
undo log的存储不同于redo log,它存放在数据库内部的一个特殊的段(segment)中,这个段称为回滚段。回滚段位于共享表空间中。undo段中的以undo page为更小的组织单位。undo page和存储数据库数据和索引的页类似。因为redo log是物理日志,记录的是数据库页的物理修改操作。所以undolog(也看成数据库数据)的写入也会产生redo log,也就是undo log的产生会伴随着redo log的产生 ,这是因为undo log也需要持久性的保护。如上图所示,表空间中有回滚段和叶节点段和非叶节点段,而三者都有对应的页结构
-
我们再来总结一下数据库事务的整个流程,如下图所示
-
-
大致事务流程:
-
事务进行过程中,每次DML sql语句执行,都会记录undo log和redo log,
-
然后更新数据形成页,然后redo log按照时间或者空间等条件进行落盘,
-
undo log和脏页按照checkpoint进行落盘,落盘后相应的redo log就可以删除了。
-
此时,事务还未COMMIT,如果发生崩溃,则首先检查checkpoint记录,使用相应的redo log进行数据和undo log的恢复,
-
然后查看undo log的状态发现事务尚未提交,然后就使用undo log进行事务回滚。
-
事务执行commit操作时,会将本事务相关的所有redo log都进行落盘,只有所有redo log落盘成功,才算commit成功。
-
然后内存中的数据脏页继续按照checkpoint进行落盘。如果此时发生了崩溃,则只使用redo log恢复数据
-
-