美文网首页
MySQL事务分析

MySQL事务分析

作者: Doooook | 来源:发表于2020-12-06 22:34 被阅读0次

事务是数据库最为重要的机制之一,凡是使用过数据库的人,都了解数据库的事务机制,也对ACID四个基本特性如数家珍。但是聊起事务或者ACID的底层实现原理,往往言之不详,不明所以。在MySQL中的事务是由存储引擎实现的,而且支持事务的存储引擎不多,我们主要讲解InnoDB存储引擎中的事务。所以,今天我们就一起来分析和探讨InnoDB的事务机制,希望能建立起对事务底层实现原理的具体了解。

事务四大特性

数据库事务具有ACID四大特性。ACID是以下4个词的缩写:

  • 原子性(atomicity) :事务最小工作单元,要么全成功,要么全失败 。
  • 一致性(consistency): 事务开始和结束后,数据库的完整性不会被破坏 。
  • 隔离性(isolation) :不同事务之间互不影响,四种隔离级别为RU(读未提交)、RC(读已提交)、RR(可重复读)、SERIALIZABLE (串行化)。
  • 持久性(durability) :事务提交后,对数据的修改是永久性的,即使系统故障也不会丢失。

下面,我们就以一个具体实例来介绍数据库事务的原理,并介绍InnoDB是如何实现ACID四大特性的。

实例介绍

我们首先来看一下具体的示例。大家可以自己亲自试验一下,这样理解和记忆都会更加深刻。首先,使用如下的SQL语句创建两张表,分别是goods和trade,代表货物和交易。并向goods表中插入一条记录,id为1的货物数量为10。

CREATE TABLE goods (id INT, num INT, PRIMARY KEY(id));
CREATE TABLE trade (id INT, goods_id INT, user_id INT, PRIMARY KEY(id)); INSERT INTO goods VALUES(1, 10);

建立两个会话,两个会话中执行的具体语句和先后顺序如下图所示:

image.png

这个示例可以体现数据库事务的很多特性,我们一一来介绍。
首先会话一的操作2更新了id为1的货物的数量,但是会话二的操作5读出来的数量仍然是10,这体现了事务的隔离性,使用InnoDB的多版本控制机制实现。会话二的操作7也要更新同种货物的数量,此时因为会话一的操作2已经更新了该货物的数量,InnoDB已经锁住了该记录的行锁,所以操作7会被阻塞,直到会话一COMMIT。但是会话一的操作4和会话二的操作6都是向trade表中插入记录,后者却不会因为前者而阻塞,因为二者插入的不是同一行记录。锁机制是一种常见的并发控制机制,它和多版本控制机制一起实现了InnoDB事务的隔离性,关于InnoDB锁相关的具体内容可以参考InnoDB锁的类型和状态查询InnoDB行锁算法
会话一事务最终使用COMMIT提交了事务而会话二事务则使用ROLLBACK回滚了整个事务,这体现了事务的原子性。即事务的一系列操作要么全部执行(COMMIT),要么就全部不执行(ROLLBACK),不存在只执行一部分的情况。InnoDB使用事务日志系统来实现事务的原子性。这里有的同学就会问了,如果中途连接断开或者Server Crash会怎么样。能怎么样,直接自动回滚呗。
一旦会话一使用COMMIT操作提交事务成功后,那么数据一定会被写入到数据库中并持久的存储起来,这体现了事务的持久性。InnoDB使用redo log机制来实现事务的持久性。
而事务的一致性比较难以理解,简单的讲在事务开始时,此时数据库有一种状态,这个状态是所有的MySQL对象处于一致的状态,例如数据库完整性约束正确,日志状态一致等。当事务提交后,这时数据库又有了一个新的状态,不同的数据,不同的索引,不同的日志等。但此时,约束,数据,索引,日志等MySQL各种状态还是要保持一致性。也就是说数据库从一个一致性的状态,变到另一个一致性的状态。事务执行后,并没有破坏数据库的完整性约束。
下面我们就来详细讲解一下上述示例涉及的事务的ACID特性的具体实现原理。总结来说,事务的隔离性由多版本控制机制和锁实现,而原子性、一致性和持久性通过InnoDB的redo log、undo log和Force Log at Commit机制来实现

原子性、持久性和一致性

原子性、持久性和一致性主要是通过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 Buffer

redo log写入磁盘时,必须进行一次操作系统的fsync操作,防止redo log只是写入了操作系统的磁盘缓存中。参数innodb_flush_log_at_trx_commit可以控制redo log日志刷新到磁盘的策略。
redo log全部写入磁盘后事务就算COMMIT成功了,但是此时事务修改的数据还在内存的缓冲区中,称其为脏页,这些数据会依据检查点(CheckPoint)机制择时刷新到磁盘中,然后删除相应的redo log,但是如果在这个过程中数据库Crash了,那么数据库重启时,会依据redo log file将那些还在内存中未更新到磁盘上的数据进行恢复
数据库为了提高性能,数据页在内存修改后并不是每次都会刷到磁盘上。而是引入checkpoint机制,择时将数据页落盘,checkpoint记录之前的数据页保证一定落盘了,这样相关的redo log就没有用了(由于InnoDB redo log file循环使用,这时这部分日志就可以被覆盖),checkpoint之后的数据页有可能落盘,也有可能没有落盘,所以checkpoint之后的redo log file在崩溃恢复的时候还是需要被使用的。InnoDB会依据脏页的刷新情况,定期推进checkpoint,从而减少数据库崩溃恢复的时间。检查点的信息在第一个日志文件的头部。
数据库崩溃重启后需要从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是物理日志,记录的是数据库页的物理修改操作。所以undo log(也看成数据库数据)的写入也会产生redo log,也就是undo log的产生会伴随着redo log的产生,这是因为undo log也需要持久性的保护。如上图所示,表空间中有回滚段和叶节点段和非叶节点段,而三者都有对应的页结构。
我们再来总结一下数据库事务的整个流程,如下图所示:

事务的相关流程

事务进行过程中,每次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恢复数据。

隔离性

事务并发问题

在事务的并发操作中可能会出现一些问题:

  • 丢失更新:两个事务针对同一数据都发生修改操作时,会存在丢失更新问题。
丢失更新
  • 脏读:一个事务读取到另一个事务未提交的数据。
  • 不可重复读:一个事务因读取到另一个事务已提交的update或者delete数据。导致对同一条记录读取两次以上的结果不一致。
  • 幻读:一个事务因读取到另一个事务已提交的insert数据。导致对同一张表读取两次以上的结果不一致。

事务隔离级别

  • 四种隔离级别(SQL92标准):
    现在来看看MySQL数据库为我们提供的四种隔离级别(由低到高):
    ① Read uncommitted (读未提交):最低级别,任何情况都无法保证。
    ② Read committed (RC,读已提交):可避免脏读的发生。
    ③ Repeatable read (RR,可重复读):可避免脏读、不可重复读的发生。
    注意事项:InnoDB的RR还可以解决幻读,主要原因是Next-Key锁,只有RR才能使用Next-Key锁
    ④ Serializable (串行化):可避免脏读、不可重复读、幻读的发生。
    由MVCC降级为Locking-Base CC

    事务隔离级别与脏读、不可重复读、幻读的关系
  • 默认隔离级别
    大多数数据库的默认隔离级别是Read Committed,比如Oracle、DB2等。MySQL数据库的默认隔离级别是可重复读(Repeatable Read)

  • 如何查看和设置隔离级别:
    在MySQL数据库中查看当前事务的隔离级别:

mysql> select @@tx_isolation;
+-----------------+
| @@tx_isolation  |
+-----------------+
| REPEATABLE-READ |
+-----------------+

在MySQL数据库中设置事务的隔离级别:

set [glogal | session] transaction isolation level 隔离级别名称;
set tx_isolation=’隔离级别名称’;

注意事项

隔离级别越高,越能保证数据的完整性和一致性,但是对并发性能的影响也越大。

对于多数应用程序,可以优先考虑把数据库系统的隔离级别设为Read Committed 。它能够避免脏读取,而且具有较好的并发性能。尽管它会导致不可重复读、幻读这些并发问题,在可能出现这类问题的个别场合,可以由应用程序采用悲观锁或乐观锁来控制
数据库的事务并发问题需要使用并发控制机制去解决,数据库的并发控制机制有很多,最为常见的就是锁机制。(Locking-Base CC: LBCC)锁机制一般会给竞争资源加锁,阻塞读或者写操作来解决事务之间的竞争条件,最终保证事务的可串行化。但是这种并发控制机制,性能很差,而且很容易发生死锁现象
MySQL则引入了另外一种并发控制--MVCC(Multi-Version Concurrency Control多版本并发控制),它让读写操作互不阻塞,每一个写操作都会创建一个新版本的数据,读操作会从有限多个版本的数据中挑选一个最合适的结果直接返回,由此解决了事务的竞争条件。
考虑一个现实场景
管理者要查询所有用户的存款总额,假设除了用户A和用户B之外,其他用户的存款总额都为0,A、B用户各有存款1000,所以所有用户的存款总额为2000。但是在查询过程中,用户A会向用户B进行转账操作。转账操作和查询总额操作的时序图如下图所示。

转账和查询的时序图

如果没有任何的并发控制机制,查询总额事务先读取了用户A的账户存款,然后转账事务改变了用户A和用户B的账户存款,最后查询总额事务继续读取了转账后的用户B的账号存款,导致最终统计的存款总额多了100元,发生错误。

-- 创建账户表并初始化数据
create table t_account(id int, a_name varchar(100), account int, primary key(id)); 
insert into t_account values(1,'a',1000);
insert into t_account values(2,'b',1000); 
-- 设置隔离级读未提交(read-uncommitted)
mysql> set session transaction isolation level read uncommitted;
-- session 1
mysql> start transaction;
mysql> select * from t_account where a_name='a';
+----+--------+---------+
| id | a_name | account |
+----+--------+---------+
|  1 | a      |    1000 |
+----+--------+---------+
-- session 2
mysql> start transaction;
update t_account set account=1100 where a_name='b';
-- session 1
mysql> select * from t_account where a_name='b';
+----+--------+---------+
| id | a_name | account |
+----+--------+---------+
|  2 | b      |    1100 |
+----+--------+---------+

使用锁机制可以解决上述的问题。查询总额事务会对读取的行加锁,等到操作结束后再释放所有行上的锁。因为用户A的存款被锁,导致转账操作被阻塞,直到查询总额事务提交并将所有锁都释放。 使用锁机制:

使用锁机制

但是这时可能会引入新的问题,当转账操作是从用户B向用户A进行转账时会导致死锁。转账事务会先锁住用户B的数据,等待用户A数据上的锁,但是查询总额的事务却先锁住了用户A数据,等待用户B的数据上的锁。

-- 设置隔离级别为串行化(serializable) 死锁演示
mysql> set session transaction isolation level serializable;
-- session 1 加索引
mysql> alter table t_account add index idx_name(a_name);
-- session 1
mysql> start transaction;
mysql> select * from t_account where a_name='a'; -- 查询总额的事务锁住了用户A数据
+----+--------+---------+
| id | a_name | account |
+----+--------+---------+
|  1 | a      |    1000 |
+----+--------+---------+
-- session 2
mysql> start transaction;
update t_account set account=900 where a_name='b'; -- 锁住用户B的数据
-- session 1
mysql> select * from t_account where a_name='b'; -- 阻塞,该行数据的锁在session 1那里
-- session 2
mysql> update t_account set account=1100 where a_name='a'; -- 死锁阻塞,该行数据的锁在session 1那里
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

使用MVCC机制也可以解决这个问题。查询总额事务先读取了用户A的账户存款,然后转账事务会修改用户A和用户B账户存款,查询总额事务读取用户B存款时不会读取转账事务修改后的数据,而是读取本事务开始时的数据副本(在REPEATABLE READ隔离等级下)。
使用MVCC机制(RR隔离级别下的演示情况):

使用MVVC机制

MVCC使得数据库读不会对数据加锁,普通的SELECT请求不会加锁,提高了数据库的并发处理能力。借助MVCC,数据库可以实现READ COMMITTED,REPEATABLE READ等隔离级别,用户可以查看当前数据的前一个或者前几个历史版本,保证了ACID中的I特性(隔离性)。

-- 显示当前隔离级别为 REPEATABLE-READ MySQL默认隔离级别
mysql> select @@tx_isolation;
-- 如果不是则设置为可重复读级别
mysql> set session transaction isolation level repeatable read;
-- session 1 开启事务
mysql> start transaction;
mysql> select * from t_account where a_name='a';
+----+--------+---------+
| id | a_name | account |
+----+--------+---------+
|  1 | a      |    1000 |
+----+--------+---------+
-- session 2
-- 如果不是则设置为可重复读级别
mysql> set session transaction isolation level repeatable read;
mysql> start transaction;
update t_account set account=1100 where a_name='a';
-- session 1
mysql> select * from t_account where a_name='a';
+----+-------+--------+ 
| id | a_name | account |
+----+-------+--------+ 
| 1 | a | 1000 | 
+----+-------+--------+ 
-- session 2 提交事务
mysql> commit;
-- session 1 显示在session 1 事务开始时的数据
mysql> select * from t_account where a_name='a';
+----+--------+---------+
| id | a_name | account |
+----+--------+---------+
|  1 | a      |    1000 |
+----+--------+---------+
-- 设置事务隔离级别为 REPEATABLE-COMMITTED 读已提交
-- session 1
mysql> set session transaction isolation level read committed;
mysql> start transaction;
mysql> select * from t_account where a_name='a';
+----+--------+---------+
| id | a_name | account |
+----+--------+---------+
|  1 | a      |    1000 |
+----+--------+---------+
-- session 2
mysql> set session transaction isolation level read committed;
mysql> start transaction;
update t_account set account=1100 where a_name='a';
-- session 1
mysql> select * from t_account where a_name='a';
+----+--------+---------+
| id | a_name | account |
+----+--------+---------+
|  1 | a      |    1000 |
+----+--------+---------+
-- session 2 提交事务 
mysql> commit;
-- session 1 显示最新事务提交后的数据
mysql> select * from t_account where a_name='a';
+----+--------+---------+
| id | a_name | account |
+----+--------+---------+
|  1 | a      |    1100 |
+----+--------+---------+

InnoDB的MVCC实现

我们首先来看一下wiki上对MVCC的定义:

Multiversion concurrency control (MCC or MVCC), is a concurrency control method commonly used by database management systems to provide concurrent access to the database and in programming languages to implement transactional memory.

由定义可知,MVCC是用于数据库提供并发访问控制的并发控制技术。与MVCC相对的,是基于锁的并发控制 Lock-Based Concurrency Control。MVCC最大的好处,相信也是耳熟能详:不加锁,写依然加锁,读写不冲突。在读多写少的OLTP应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能,这也是为什么现阶段,几乎所有的RDBMS,都支持了MVCC。
多版本并发控制仅仅是一种技术概念,并没有统一的实现标准, 其核心理念就是数据快照,不同的事务访问不同版本的数据快照,从而实现不同的事务隔离级别。虽然字面上是说具有多个版本的数据快照,但这并不意味着数据库必
须拷贝数据,保存多份数据文件,这样会浪费大量的存储空间。InnoDB通过事务的undo log巧妙地实现了多版本的数据快照。MVCC 在 mysql 中的实现依赖的是undo logread view

Undo Log

数据库的事务有时需要进行回滚操作,这时就需要对之前的操作进行undo。因此,在对数据进行修改时,InnoDB会产生undo log。当事务需要进行回滚时,InnoDB可以利用这些undo log将数据回滚到修改之前的样子。
根据行为的不同,undo log分为两种:insert undo log和update undo log

  • insert undo log:是在 insert 操作中产生的 undo log。因为 insert 操作的记录只对事务本身可见,对于其它事务此记录是不可见的,所以 insert undo log 可以在事务提交后直接删除而不需要进行 purge 操作
  • update undo log: 是 update 或 delete 操作中产生的 undo log,因为会对已经存在的记录产生影响,为了提供 MVCC机制,因此 update undo log 不能在事务提交时就进行删除,而是将事务提交时放到入 history list上,等待 purge 线程进行最后的删除操作。

为了保证事务并发操作时,在写各自的undo log时不产生冲突,InnoDB采用回滚段的方式来维护undo log的并发写入和持久化。回滚段实际上是一种 Undo 文件组织方式。
InnoDB行记录有三个隐藏字段:分别对应该行的rowid、事务号db_trx_id和回滚指针db_roll_ptr,其中db_trx_id表示最近修改的事务的id,db_roll_ptr指向回滚段中的undo log
如下图所示(初始状态)

初始状态

当事务2使用UPDATE语句修改该行数据时,会首先使用排他锁锁定改行,将该行当前的值复制到undo log中,然后再真正地修改当前行的值,最后填写事务ID,使用回滚指针指向undo log中修改前的行。
如下图所示(第一次修改)

第一次修改

当事务3进行修改与事务2的处理过程类似,如下图所示(第二次修改)

第二次修改

相关文章

网友评论

      本文标题:MySQL事务分析

      本文链接:https://www.haomeiwen.com/subject/zokowktx.html