zl程序教程

您现在的位置是:首页 >  数据库

当前栏目

MySQL事务

2023-06-13 09:11:27 时间

引言

通俗来讲事务就是多步操作要么全部成功要么全部失败,保证最终状态一致。为了简化应用程序,使其可以忽略一些潜在错误和并发问题,数据库层对事务的ACID特性做了统一支持。

事务的基本特性

事务有四大特性:原子性(Atomicity) 一致性(Consistency) 隔离性(Isolation) 持久性(Durability),俗称:ACID

  • 原子性:多个操作要么全部执行成功,只要有一个执行失败,就回滚所有操作,回到最初的状态。
  • 一致性:保证数据从一个有效状态,转变到另一个有效状态,只要这些转变状态的数据满足一开始设立的规则就可以了。
  • 隔离性:一个事物在最终提交之前,数据的改变对其他事务是不可见的。
  • 持久性:事务的最终操作结果能被持久保存。

MySQL事务

接下来我们介绍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 buffer
  • 把数据顺序写入共享表空间的文件(第一次写,因为是顺序写所以效率很高)
  • 然后把数据分别写入各个表空间(第二次写,随机写效率低)

如果在写各表的表空间时发生异常,可以依赖共享表空间的数据进行数据恢复。(Doublewrite会带来一些性能损耗,可以根据自己实际的业务场景选择是否关闭) crash recover主要是依赖MySQL的binlog和InnoDB的redo log,在实例异常崩溃重启之后,进行数据恢复。具体过程这里就不描述了,感兴趣的同学可以参照(http://mysql.taobao.org/monthly/2018/07/05/)。

隔离性:

InnoDB支持四种隔离级别:

  • 读未提交(READ-UNCOMMITTED):事务在commit之前,它的变更就能被别的事务读到
  • 读已提交(READ-COMMITTED):事务在commit之后,他的变更才能被看到
  • 可重复读(REPEATABLE-READ):在一个事务内部读到的数据总是一致的
  • 序列化(SERIALIZABLE):出现读写锁冲突时,事务要依次顺序执行

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的写入分为两步:

  • 把日志写入binlog cache(内存)
  • 把binlog cache刷入磁盘

事务提交时,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(内存)
  • redo log buffer中的数据刷入磁盘

不过两者又有所区别,redo log buffer通过innodb_flush_log_at_trx_commit参数决定落盘的策略。

  • 值为0时:每次事务提交都只把redo log留在redo log buffer
  • 值为1时:每次事务提交把redo log直接fsync到磁盘
  • 值为2时:每次事务提交把redo log 写到文件系统的缓存

InnoDB会定时检测事务的日志,按照上面的配置策略进行落盘。(画外音:为了提升写磁盘的效率,MySQL采用了“组提交”机制,减少刷盘的次数。) MySQL为了保证最终落盘的数据是准确的,采用了两阶段提交的策略:

  • 先写redo log,进入prepare阶段
  • 再写binlog,等binlog完成后,commit,提交事务

这样当实例异常崩溃重启之后,如果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