通俗来讲事务就是多步操作要么全部成功要么全部失败,保证最终状态一致。为了简化应用程序,使其可以忽略一些潜在错误和并发问题,数据库层对事务的ACID特性做了统一支持。
事务有四大特性:原子性(Atomicity) 一致性(Consistency) 隔离性(Isolation) 持久性(Durability),俗称:ACID
接下来我们介绍MySQL中的事务是如何保证ACID特性的。(以下不做特殊说明,都默认InnoDB引擎)
我们在MySQL中可以使用以下语句开启一个事务:
BEGIN / START TRANSACTION
(START TRANSACTION还可以指定只读事务和读写事务,不过使用的比较少)
提交事务:COMMIT
回滚事务:ROLLBACK
站在使用者的角度,我们开启一个事务,然后执行SQL语句,最终COMMIT成功则所有的SQL都会成功执行,如果失败执行ROLLBACK操作所有的语句都回到最初状态。MySQL对数据的变更操作总是会记录undo log, undo log中记录了一条数据被修改的链条,当需要执行回滚操作时,根据对应的事务ID,找到回滚点对数据执行恢复操作。如果执行commit的话,MySQL把最终结果刷到磁盘中。为了保证事务执行中,异常宕机导致事务仍可回滚,undo log的数据也是会被持久化到磁盘的。如果一些比较大的事务undo内存缓存失效时,回滚操作可能会伴随大量的磁盘IO。
MySQL中的一致性主要包括,数据在任何时候状态都是一致的。如何保证在实例异常崩溃情况下的一致性,MySQL主要依靠Doublewrite、crash recover来保证事务的一致性。为什么需要Doublewrite?
InnoDB是以页为单位存储数据的
Page Size默认16KB
EXT家族文件系统的IO最小单元通常设置为1KB 2KB 或者4KB(传送门)
磁盘的最小IO是一个扇区:512字节
因此再写一页数据时,有可能存在写了2KB电脑异常断电的情况,这个时候的页数据是损坏的。为了解决这个问题MySQL在脏数据写入磁盘时:
如果在写各表的表空间时发生异常,可以依赖共享表空间的数据进行数据恢复。(Doublewrite会带来一些性能损耗,可以根据自己实际的业务场景选择是否关闭) crash recover主要是依赖MySQL的binlog和InnoDB的redo log,在实例异常崩溃重启之后,进行数据恢复。具体过程这里就不描述了,感兴趣的同学可以参照(http://mysql.taobao.org/monthly/2018/07/05/)。
InnoDB支持四种隔离级别:
InnoDB引擎默认是可重复读隔离级别,也是业务场景中使用最多的隔离级别。下面我们主要介绍下,事务可重复读的实现方式。
在可重复读隔离级别下,事务启动时,会给数据创建一个“视图”,这里说视图并不是物理存在的,而是逻辑上的快照。前文已经提到过,当我们对数据进行变更时,会产生undo log记录,记录的变更操作包括INSERT/UPDATE/DELETE。INSERT的变更记录处理比较简单,在事务commit之后直接删除就可以了。UPDATE和DELETE的变更记录稍微复杂一些,需要维护多个版本信息。InnoDB中的每一个事务也会依照顺序生成递增的事务ID(trx_id)。下图就是一条记录在三个事务中存在三个版本的示意图。可以依据当前的trx_id在unlog的“链表”中,找到记录在当前事务的状态,这也是MVCC
的实现原理。
举一个具体的例子:我们先构造一张curriculum(课程表):
id | teacher |
---|---|
1 | 1 |
3 | 3 |
5 | 5 |
事务的执行顺序:
事务一 | 事务二 |
---|---|
START TRANSACTION WITH CONSISTENT SNAPSHOT | START TRANSACTION WITH CONSISTENT SNAPSHOT |
update curriculum set teacher=100 where id = 1; | |
commit; | |
select teacher from curriculum where id=1; | |
commit; |
此时事务一中查到的teacher值是1。这里很容易理解,因为我们开启了事务并创建了视图,id=1的记录虽然在事务二中被修改为100了,但是在事务一仍然可以根据trx_id和undo log中的“链条”找到自己当前trx_id对应的id=1记录的值。
我们再举一个例子:
事务一 | 事务二 |
---|---|
START TRANSACTION WITH CONSISTENT SNAPSHOT | START TRANSACTION WITH CONSISTENT SNAPSHOT |
update curriculum set teacher=100 where id = 1; | |
commit; | |
update curriculum set teacher=teacher+1 where id = 1; | |
select teacher from curriculum where id=1; | |
commit; |
和上面的例子相比我们在事务一的查询之前,增加了一条update语句,那么这时查询到teacher的值是多少呢?查询出来的值是101。为什么是101不是2呢?因为事务二已经对id=1的列进行了修改,此时如果事务一还是按照事务初始记录的状态更新数据会导致,事务二的更新操作丢失,而出现数据不一致,这个时候就需要读当前数据了。总结起来就是, 查询操作默认“快照读”,有了更新操作和更新操作后的查询需要获取“当前读”的数据。
进一步演化下这个例子:
事务一 | 事务二 |
---|---|
START TRANSACTION WITH CONSISTENT SNAPSHOT | START TRANSACTION WITH CONSISTENT SNAPSHOT |
update curriculum set teacher=100 where id = 1; | |
update curriculum set teacher=teacher+1 where id = 1; | |
select teacher from curriculum where id=1; | |
commit; | |
commit; |
把事务二的commit放到了事务一的更新语句之后。此时因为事务一和事务二同时修改id=1这一条记录,事务二首先获取到了此记录的写锁,事务一锁等待,等事务二commit完成之后,事务一中的update语句才能正常执行,执行结果和上一个例子一样。MySQL的锁比较复杂,我们单独有一篇文章介绍传送门
MySQL为了保证数据不丢失记录了多份日志。首先是binlog,binlog的写入分为两步:
事务提交时,MySQL的执行器会把binlog cache里的完整事务写入binlog中,并清空binlog cache。每个线程都有自己的binglog cache。binlog写文件的时候还涉及到两个操作一个是write一个是fsync,write只是把数据写入了文件系统的缓冲区,fsync会直接落盘。MySQL提供了一个参数sync_binlog。sync_binlog=0时每次提交事务只write不主动fsync,sync_binlog=n时表明n次commit之后,统一调用fsync落盘。redo log的写入和binlog是很像的也是有两个阶段
不过两者又有所区别,redo log buffer通过innodb_flush_log_at_trx_commit参数决定落盘的策略。
InnoDB会定时检测事务的日志,按照上面的配置策略进行落盘。(画外音:为了提升写磁盘的效率,MySQL采用了“组提交”机制,减少刷盘的次数。) MySQL为了保证最终落盘的数据是准确的,采用了两阶段提交的策略:
这样当实例异常崩溃重启之后,如果redo log中已经commit,则数据继续提交就好了;如果redo log只在prepare状态,则判断binlog是否存在完整事务日志,如果日志完整继续commit事务,如果不完整根据undo log中的数据版本进行事务回滚。
参考文献: http://mysql.taobao.org/monthly/2015/12/01/ http://mysql.taobao.org/monthly/2015/04/01/ http://mysql.taobao.org/monthly/2018/07/05/ https://dev.mysql.com/doc/refman/8.0/en/innodb-doublewrite-buffer.html