MySQL事务ACID

基本特性-ACID

  1. 原子性(Atomicity):事务是一个原子操作单元,其对数据的修改,要么全都执行,要么全都不执行。
  2. 一致性(Consistent):在事务开始和完成时,数据都必须保持一致状态。这意味着所有相关的数据规则都必须应用于事务的修改,以保持数据的完整性;事务结束时,所有的内部数据结构(如B树索引或双向链表)也都必须是正确的。
  3. 隔离性(Isolation):数据库系统提供一定的隔离机制,保证事务在不受外部并发操作影响的“独立”环境执行。这意味着事务处理过程中的中间状态对外部是不可见的,反之亦然。
  4. 持久性(Durable):事务完成之后,它对于数据的修改是永久性的,即使出现系统故障也能够保持。

事务并发带来的问题

  1. 丢失更新:P字段初始值为0,A事务开始后将p字段+1,B事务未等待A事务提交也将p字段值更新并提交,A再事务提交,p最后的值是1,B的丢失更新,按照正常逻辑应该是A未提交时B先等待,A提交后B再开始事务并修改提交,此时的p应该为2。
  2. 脏读:一个事务正在对一条记录做修改,在这个事务完成并提交前,这条记录的数据就处于不一致状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些“脏”数据,并据此做进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象地叫做“脏读”.,因为未提交的事务可能会回滚。
  3. 不可重复读:一个事务在读取某些数据后的某个时间,再次读取以前读过的数据,却发现其读出的数据已经发生了改变、或某些记录已经被删除了!这种现象就叫做“不可重复读”。其针对的是update
  4. 幻读:一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为“幻读”。其针对的是insert

事务隔离级别

在上面讲到的并发事务处理带来的问题中,“更新丢失”应该是要完全避免的,执行事务时必须要锁定对应的数据。
“脏读”、“不可重复读”和“幻读”,其实都是数据库读一致性问题,必须由数据库提供一定的事务隔离机制来解决。数据库实现事务隔离的方式,基本上可分为以下两种。

  1. 一种是在读取数据前,对其加锁,阻止其他事务对数据进行修改。
  2. 另一种是不用加任何锁,通过一定机制生成一个数据请求时间点的一致性数据快照(Snapshot),这种技术叫做数据多版本并发控制(MultiVersion Concurrency Control,简称MVCC或MCC),也经常称为多版本数据库
  • 当前读

像select lock in share mode(共享锁), select for update ; update, insert ,delete(排他锁)这些操作都是一种当前读,为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。

  • 快照读

像不加锁的select操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC,可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本(此种情况下可避免幻读,但如果当前事务中存在加锁操作比如update操作,将仍然会出现幻读)

隔离级别 脏读 不可重复读 幻读
未提交读(Read uncommitted) Y Y Y
已提交读(Read committed) N Y Y
可重复读(Repeatable read)默认 N N Y
可串行化(Serializable ) N N N

隔离级别及锁相关内容参考MySQL InnoDB锁原理

ACID实现原理

原子性Atomicity

实现原子性的关键是当事务回滚时能够撤销所有已经成功执行的SQL语句

当事务对数据库进行修改时,InnoDB会生成对应的 undo log;如果事务执行失败或调用了 rollback,导致事务需要回滚,便可以利用 undo log 中的信息将数据回滚到修改之前的样子。

undo log 属于逻辑日志,它记录的是sql执行相关的信息。当发生回滚时,InnoDB 会根据 undo log 的内容做与之前相反的工作:

  • 对于每个 insert,回滚时会执行 delete;
  • 对于每个 delete,回滚时会执行insert;
  • 对于每个 update,回滚时会执行一个相反的 update,把数据改回去。

以update操作为例:当事务执行update时,其生成的undo log中会包含被修改行的主键(以便知道修改了哪些行)、修改了哪些列、这些列在修改前后的值等信息,回滚时便可以使用这些信息将数据还原到update之前的状态。

持久性Durable

持久性靠的是 redo log

MySQL 里经常说到的 WAL 技术,WAL 的全称是 Write-Ahead Logging,它的关键点就是先写日志,再写磁盘。

当有一条记录要更新时,InnoDB 引擎就会先把记录写到 redo log(并更新内存),这个时候更新就算完成了。在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做。
redo log 有两个特点

  • 大小固定,循环写
  • crash-safe

对于redo log 是有两阶段的:commit 和 prepare
如果不使用“两阶段提交”,数据库的状态就有可能和用它的日志恢复出来的库的状态不一致。

Buffer Pool:InnoDB还提供了缓存,Buffer Pool 中包含了磁盘中部分数据页的映射,作为访问数据库的缓冲:

  • 当读取数据时,会先从Buffer Pool中读取,如果Buffer Pool中没有,则从磁盘读取后放入Buffer Pool;
  • 当向数据库写入数据时,会首先写入Buffer Pool,Buffer Pool中修改的数据会定期刷新到磁盘中。

Buffer Pool 的使用大大提高了读写数据的效率,但是也带了新的问题:如果MySQL宕机,而此时 Buffer Pool 中修改的数据还没有刷新到磁盘,就会导致数据的丢失,事务的持久性无法保证。所以加入了 redo log
当数据修改时,除了修改Buffer Pool中的数据,还会在redo log记录这次操作;当事务提交时,会调用fsync接口对redo log进行刷盘。

如果MySQL宕机,重启时可以读取redo log中的数据,对数据库进行恢复。

redo log采用的是WAL(Write-ahead logging,预写式日志),所有修改先写入日志,再更新到Buffer Pool,保证了数据不会因MySQL宕机而丢失,从而满足了持久性要求。
而且这样做还有两个优点:

  • 刷脏页是随机 IO,redo log 顺序 IO
  • 刷脏页以Page为单位,一个Page上的修改整页都要写;而redo log 只包含真正需要写入的,无效 IO 减少。

binlog和redolog

  • 层次:redo log 是 innoDB 引擎特有的,server 层的叫 binlog(归档日志)
  • 内容:redolog 是物理日志,记录“在某个数据页上做了什么修改”;binlog 是逻辑日志,是语句的原始逻辑,如“给 ID=2 这一行的 c 字段加 1 ”
  • 写入:redolog 循环写且写入时机较多,binlog 追加且在事务提交时写入

对于语句 update T set c=c+1 where ID=2;

  1. 执行器先找引擎取 ID=2 这一行。ID 是主键,直接用树搜索找到。如果 ID = 2 这一行所在数据页就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,再返回。
  2. 执行器拿到引擎给的行数据,把这个值加上 1,N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
  3. 引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。
  4. 执行器生成这个操作的 binlog,并把 binlog 写入磁盘。
  5. 执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成

MySQL的update语句的执行过程:

  • 连接器:建立和客户端的连接,检查用户权限,清除查询缓存(MySQL 8.0之前)。
  • 分析器:对SQL语句进行词法分析和语法分析,提取关键字,判断SQL语句是否正确。
  • 优化器:选择最优的执行方案,比如是否使用索引等。
  • 执行器:调用引擎接口,执行更新操作,并生成两种日志:redo log和binlog。
  • redo log:是InnoDB引擎特有的物理日志,记录数据页的修改,用于恢复异常重启后的数据,采用循环写和两阶段提交的方式,保证crash-safe能力。
  • binlog:是MySQL Server层的逻辑日志,记录SQL语句的原始逻辑,用于归档和主从复制,采用追加写和事务ID的方式,保证完整性。

它的执行过程如下:

  • 连接器:检查你是否有权限更新表t,如果有权限,清除表t的查询缓存(如果有)。
  • 分析器:识别出这是一条update语句,要更新表t中id为2的记录的c字段。
  • 优化器:确定使用id作为主键索引来定位记录。
  • 执行器:打开表t,调用InnoDB引擎接口取出id为2的记录。如果记录不存在,则返回错误;如果记录存在,则修改b字段,并调用InnoDB引擎接口将新数据写入。
  • InnoDB引擎:将新数据更新到内存中,并将这个更新操作记录到redo log里面,此时redo log处于prepare状态。然后告诉执行器,我已经完成了,你可以提交事务了。
  • 执行器:生成这个操作的binlog,并把binlog写入磁盘。
  • 执行器:调用InnoDB引擎的提交事务接口,InnoDB引擎把刚刚写入的redo log改成commit状态,更新完成。

MySQL先写redo log,而不是直接写数据文件,是因为这样可以提高性能和效率。有以下几个原因:

  • redo log是顺序写的,而数据文件是随机写的。顺序写比随机写要快很多,因为不需要频繁地寻道和旋转。

  • redo log是先写到内存中的日志缓冲区,然后再批量写到磁盘上的日志文件。这样可以减少磁盘I/O的次数和开销。

  • redo log是固定大小的,而数据文件是不断增长的。固定大小的文件可以避免碎片和空间浪费。

  • redo log可以利用WAL技术(Write-Ahead Logging),即先写日志再写磁盘。这样可以降低对数据文件刷盘的要求,只需要在某个时间点将数据文件和redo log保持一致即可。

    先 redo 后 bin : binlog 丢失,少了一次更新,恢复后仍是0。

    先 bin 后 redo : 多了一次事务,恢复后是1。

什么是两阶段提交?

提交事务的时候,有三个步骤:

  • 写入redo log,并将其标记为prepare状态。
  • 写入binlog,并将其落盘。
  • 修改redo log状态为commit状态。

由于redo log的提交分为prepare和commit两个阶段,所以称之为两阶段提交。

如果redo log在prepare状态时未写入binlog时发生异常,重启后会发生什么操作?

  • InnoDB引擎会根据redo log进行崩溃恢复,将内存中的数据页恢复到prepare状态。

    内存中的数据页是已经被修改为新的状态,但是还没有被刷新到磁盘中。也就是说,内存中的数据页和磁盘中的数据页是不一致的,这种数据页称为脏页。

    当事务提交时,redo log会从prepare状态变为commit状态,表示事务已经完成。此时,内存中的脏页会在某个时机被刷新到磁盘中,使得内存和磁盘的数据一致。这个时机可以由参数innodb_flush_log_at_trx_commit来控制,一般有以下三种值:

    • 0:表示每秒刷新一次redo log到磁盘,不管事务是否提交。
    • 1:表示每次事务提交时都刷新redo log到磁盘,这是最安全的选项,也是默认值。
    • 2:表示每次事务提交时都将redo log写入操作系统缓存,由操作系统决定何时刷新到磁盘。
  • InnoDB引擎会扫描redo log,找出所有处于prepare状态的事务,并将它们的事务ID记录到一个内存表中。

  • MySQL Server层会扫描binlog,找出所有已经写入binlog但未提交的事务,并将它们的事务ID记录到另一个内存表中。

  • MySQL Server层会比较两个内存表,如果发现某个事务ID只存在于InnoDB引擎的内存表中,说明该事务在写入binlog之前发生了异常,那么就会回滚该事务。

  • MySQL Server层会比较两个内存表,如果发现某个事务ID同时存在于两个内存表中,说明该事务在写入binlog之后发生了异常,那么就会提交该事务。

通过这样的操作,MySQL可以保证在异常重启后,redo log和binlog之间的数据一致性,避免数据丢失或不一致的情况。

一致性Consistent

一致性与原子性有何区别?

事务的原子性和一致性是两个不同的概念,但是它们都是为了保证数据库的正确性和完整性。简单地说,

  • 原子性是指一个事务内的所有操作要么全部成功,要么全部失败,不会出现部分成功或部分失败的情况。
  • 一致性是指一个事务执行前后,数据库的状态都符合预期的业务规则,不会出现数据的矛盾或损坏。

例如,假设有一个转账的事务,需要从账户A扣除100元,然后给账户B增加100元。

  • 如果这个事务具有原子性,那么就不会出现只扣除了A的钱,但没有给B增加钱的情况,或者只给B增加了钱,但没有扣除A的钱的情况。
  • 如果这个事务具有一致性,那么就不会出现转账前后,A和B的总金额发生变化的情况,或者转账前后,A和B的金额不符合业务逻辑的情况。

原子性和一致性之间有一定的联系,但是它们并不等价。

  • 原子性是一种实现机制,通过日志、回滚等技术来保证事务内的操作可以全部成功或全部失败。
  • 一致性是一种目标或约束,通过业务规则、约束条件等来保证事务执行前后的数据状态是正确和合理的。
  • 原子性可以帮助实现一致性,但是并不能完全保证一致性。在并发或分布式的场景下,还需要考虑隔离性和持久性等其他特性来保证一致性。

分布式事务

XA协议是一种分布式事务处理规范,主要定义了事务管理器(Transaction Manager,TM)和资源管理器(Resource Manager,RM)之间的接口。XA协议基于两阶段提交(Two-Phase Commit,2PC)或三阶段提交(Three-Phase Commit,3PC)的机制,保证了分布式事务的一致性原子性

XA协议的持久性隔离性是由各个RM自己保证的。每个RM都有自己的数据文件和日志文件,当执行事务时,会将数据和日志写入磁盘,以保证持久性。同时,每个RM也有自己的锁机制和隔离级别,当执行事务时,会根据锁的粒度和隔离级别来控制并发访问,以保证隔离性。

XA协议的基本流程如下:

  1. TM向所有的RM发送prepare请求,要求它们准备执行事务。
  2. RM执行事务,并将结果(成功或失败)返回给TM。
  3. TM根据所有RM的结果,决定是否提交或回滚事务,并通知所有RM执行相应的操作。
  4. RM根据TM的指令,提交或回滚事务,并释放资源。

XA协议在MySQL中可以通过一些特殊的语句来实现,例如:

  1. XA START xid:开启一个XA事务,指定一个全局事务ID(xid)。
  2. XA END xid:结束一个XA事务,准备进入prepare阶段。
  3. XA PREPARE xid:准备提交一个XA事务,将结果返回给TM。
  4. XA COMMIT xid:提交一个XA事务,释放资源。
  5. XA ROLLBACK xid:回滚一个XA事务,释放资源。
  6. XA RECOVER:查看处于prepare阶段的所有XA事务。

三阶段提交:三阶段提交有以下三个阶段:

  • canCommit阶段:协调者向各个RM发送canCommit请求,询问它们是否可以执行事务。各个RM根据自己的情况,回复Yes或No。
  • preCommit阶段:如果所有RM都回复了Yes,协调者向各个RM发送preCommit请求,要求它们准备提交事务,并锁定资源。各个RM执行事务,并将结果(成功或失败)返回给协调者。
  • doCommit阶段:如果所有RM都回复了成功,协调者向各个RM发送doCommit请求,要求它们提交事务,并释放资源。各个RM根据协调者的指令,提交事务,并释放资源。如果有任何一个RM回复了失败,协调者向各个RM发送doRollback请求,要求它们回滚事务,并释放资源。各个RM根据协调者的指令,回滚事务,并释放资源。

两阶段提交和三阶段提交的差异主要有以下几点:

  • 两阶段提交只有prepare和commit两个阶段,而三阶段提交在prepare之前增加了一个canCommit阶段,用于询问各个RM是否可以执行事务。
  • 两阶段提交在prepare阶段就锁定了资源,导致资源的长时间占用,而三阶段提交在canCommit阶段不锁定资源,只在preCommit阶段才锁定资源,缩短了资源的占用时间。
  • 两阶段提交在协调者宕机后,可能导致各个RM处于不同的状态,造成数据不一致,而三阶段提交在协调者宕机后,可以通过preCommit阶段的结果来保证各个RM的状态一致。
  • 两阶段提交的性能比三阶段提交高,因为它只需要两次通信,而三阶段提交需要三次通信。但是两阶段提交的可靠性比三阶段提交低,因为它更容易出现数据不一致的情况。

分布式事务的实现

要实现XA协议,首先需要有一个TM和多个RM,以及一个全局事务ID。假设有两个RM,分别是RM1和RM2,它们都是MySQL数据库,分别有一个表t1和t2。现在要执行一个分布式事务,即在t1中插入一条记录,然后在t2中更新一条记录。可以按照以下步骤来实现XA协议:

  • TM生成一个全局事务ID,比如xid=‘123’。
  • TM向RM1发送XA START xid命令,开启一个XA事务。
  • TM向RM1发送INSERT INTO t1 VALUES (1, ‘a’)命令,插入一条记录。
  • TM向RM1发送XA END xid命令,结束XA事务。
  • TM向RM1发送XA PREPARE xid命令,准备提交XA事务。
  • RM1执行prepare操作,并将结果返回给TM。
  • TM向RM2发送XA START xid命令,开启一个XA事务。
  • TM向RM2发送UPDATE t2 SET name = ‘b’ WHERE id = 2命令,更新一条记录。
  • TM向RM2发送XA END xid命令,结束XA事务。
  • TM向RM2发送XA PREPARE xid命令,准备提交XA事务。
  • RM2执行prepare操作,并将结果返回给TM。
  • TM根据两个RM的结果,决定是否提交或回滚事务。假设都成功了,则TM向两个RM发送XA COMMIT xid命令,提交事务。否则,TM向两个RM发送XA ROLLBACK xid命令,回滚事务。
  • 两个RM根据TM的指令,执行commit或rollback操作,并释放资源。

这样就完成了一个分布式事务的执行过程。

这里的TM如何实现?

这个示例中的TM是一个假设的角色,它可以是任何能够与RM通信的组件,比如一个应用程序、一个中间件、一个框架等。TM的实现方式有很多,比如可以使用JTA(Java Transaction API)来管理XA事务,或者使用一些开源的分布式事务解决方案,如Seata、Narayana等。TM的主要职责是生成全局事务ID,协调各个RM的状态,以及发送commit或rollback指令。

如果TM或者某个RM宕机了怎么办?

如果TM或者某个RM宕机了,会导致分布式事务的中断或不一致。为了解决这个问题,XA协议引入了超时机制和恢复机制。

  • 超时机制:TM和RM之间会设置一个超时时间,如果在这个时间内没有收到对方的响应,就认为对方已经宕机或失效,从而采取相应的措施,比如回滚事务、重试事务、通知管理员等。
  • 恢复机制:TM和RM都会记录自己的事务状态和日志,当宕机后恢复时,会根据这些信息来恢复事务。比如TM可以通过XA RECOVER命令来查询各个RM处于prepare阶段的事务,然后根据自己的日志来判断是否提交或回滚这些事务。RM也可以通过TM的指令或者自己的日志来提交或回滚事务,并释放资源。

如果一个RM已经提交了,另一个RM宕机了,那么就会出现数据不一致的情况。这种情况下,TM需要等待宕机的RM恢复后,再向它发送commit指令,以保证事务的最终一致性。如果TM也宕机了,那么就需要人工介入,根据TM和RM的日志来判断事务的状态,并手动执行commit或rollback操作。

posted @ 2021-03-14 17:19  Abserver  阅读(92)  评论(0)    收藏  举报