浅析MySQL InnoDB的事务和并发控制

这部分内容应该算是存储引擎最重要并且最复杂的部分了, 不同的存储引擎区别很大, 实现机制也各不相同,这里仅尝试对InnoDB做一个简单的分析, 也算是最近一段时间收获的总结吧。
ACID_engineering.png

References:

首先从为什么需要事务说起吧。

数据库是一个多用户的共享资源,允许多个用户同时访问相同的数据。火车订票系统的数据库、银行系统的数据库等都是典型多用户共享的数据库。在这样的系统中,同一时刻同时运行的事务可达数百个。若对多用户的并发操作不加控制,就会造成数据存、取的错误,破坏数据的一致性和完整性,造成数据异常。在文件系统中,如果正在写文件,但是操作系统突然崩溃了,这个文件就很有可能被破坏。因此,事务(Transaction)是数据库区别于文件系统的重要特性之一。

最经典的莫过于银行转账的栗子了:

设有用户转账业务,A账户(假设目前有10000元)转账给B账户(假设目前有3000元)2000元钱,这个业务活动包含以下两个操作。
① A账户 - 2000
② B账户 + 2000
可以设想,假设第一个操作成功了,第二个操作由于某种原因没有成功(如程序崩溃、系统死机、突然停电等)。那么,在系统恢复运行后,A账户的金额是减2000之前的值,还是减2000之后的值呢?很明显B账户没有加钱,那么A账户是不能扣这2000的,否则账就对不上了。保证系统恢复正常后,A账户中的金额是减2000前的值,这就需要用到事务的概念。
事务可以保证在一个事务中的所有操作要么全部成功,要么全部失败。也就是说,当第二个操作没有成功时,系统自动将第一个操作撤销掉,使数据恢复到第一个操作未做之前的状态。这样,当系统恢复正常时,A账户和B账户中的数值就是正确的。

Snipaste_2021-05-16_21-16-04.png

事务具有4个特性,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。这4个特征也简称为事务的ACID特性。这些特性用于保证事务执行后数据库仍然是正确的状态。

  • 原子性(Atomicity)
    事务中的操作要么都成功,要么都不成功。
  • 一致性(Consistency)
    事务执行的结果必须是使数据库从一个一致性状态变到另一个一致性状态。如前边所述的转账事务,必须保证转账后A账户和B账户的总金额与转账前是一致的。因此,当事务成功提交时,数据库就从事务开始前的一致性状态转到了事务结束后的一致性状态。
  • 隔离性(Isolation)
    一个事务的执行不能被其他事务干扰,即一个事务内部的操作及使用的数据对其他事务是隔离的,并发执行的各个事务之间不能相互干扰。比如多个事务同时对一个账户的余额进行操作。
  • 持久性(Durability)
    事务一旦提交(COMMIT),则其对数据库中数据的改变就是永久的。

理论上说,事务有着极其严格的定义,它必须同时满足ACID四个特性。但是数据库厂商出于各种目的,并没有严格去满足所有的ACID特性。例如,对于MySQL的NDB Cluster引擎来说,虽然其支持事务,但是不满足D的要求,即持久性的要求。对于Oracle数据库来说,其默认的事务隔离级别为READ COMMITTED,不满足I的要求,即隔离性的要求。虽然在大多数的情况下,这并不会导致严重的结果,甚至可能还会带来性能的提升,但是首先需要知道严谨的事务标准,并在实际的生产应用中避免可能存在的潜在问题。对于InnoDB存储引擎而言,其默认的事务隔离级别为READ REPEATABLE,完全遵循和满足事务的ACID特性(ACID-compliant

事务是数据库并发控制和恢复的基本单位。保证事务的ACID特性是事务处理的重要任务。事务的ACID特性可能遭到破坏的因素有:
① 事务在运行过程中被强迫停止。
② 多个事务并行运行时,不同事务的操作有交叉情况。

那么这些事务问题该如何解决呢?欢迎收看本期的走进科学节目。

  • 为支持事务的A(原子性)和D(持久性),通常在对数据库进行更改前先写日志,后写数据库,这种方式称为预写式日志(Write-Ahead Logging, WAL)。在使用WAL的系统中,所有的修改在提交之前都要先写入log文件中。如果事务失败,WAL中的记录会被忽略,撤销修改;如果事务成功,它将在随后的某个时间被写回到数据库文件中,提交修改。
  • 为支持事务的I(隔离性),一方面要确保每个用户能以一致的方式读取和修改数据,另外一方面还要最大程度地利用数据库的并发访问。为此就有了锁(locking)的机制。一般这一点会和并发、性能等词语联系在一起。
    当实现了以上三点后,我们追求的目标:C(一致性)就基本达到了。

那么InnoDB是如何解决这些问题并满足ACID的要求呢?

  • 首先看InnoDB如何满足D,也就是InnoDB如何实现WAL机制。要实现WAL,日志必不可少,InnoDB在提交(COMMIT)事务之前会将操作写入redo log中,并且强制写入磁盘后(调用fsync())才会让事务完成, 这一步也被称为Force Log at Commit机制。
    1470992598-5c3c5e91c5251.png
  • 再看如何满足A。InnoDB通过undo log记录数据的变化,将之前的操作都记录下来,undo log中包含了如何撤销事务对聚簇索引记录(clustered index record)最新的修改,这样就可以在发生错误时回滚数据。
  • 最后是如何满足I,InnoDB使用了MVCC和锁,在满足事务隔离级别的情况下也极大的提高了并发能力,这些将在下文详说。

下面对InnoDB使用的这些机制做一个更具体的说明,看看这些机制是如何实现的。
Redo Log:
重做日志用来实现事务的持久性,即事务ACID中的D。其由两部分组成:一是内存中的重做日志缓冲(redo log buffer),其是易失的(volatile);二是重做日志文件(redo log file),其是持久的。

由于重做日志文件打开并没有使用O_DIRECT选项,因此redo log buffer会写入文件系统缓存,为了确保redo log写入磁盘,必须进行一次fsync()操作。由于fsync的效率取决于磁盘的性能,因此磁盘的性能决定了事务提交的性能,也就是数据库的性能。

InnoDB存储引擎允许用户手工设置非持久性的情况发生,以此提高数据库的性能。即当事务提交时,日志不写入重做日志文件,而是等待一个时间周期后再执行fsync操作。由于并非强制在事务提交时进行一次fsync操作,显然这可以显著提高数据库的性能。但是当数据库发生宕机时,由于部分日志未刷新到磁盘,因此会丢失最后一段时间的事务。

参数innodb_flush_log_at_trx_commit用来控制重做日志刷新到磁盘的策略。该参数的默认值为1,表示事务提交时必须调用fsync操作。还可以设置该参数的值为0和2。0表示事务提交时不写入重做日志,依靠InnoDB的主线程每秒执行一次的fsync操作。2表示事务提交时将重做日志写入重做日志文件,但仅写入文件系统的缓存中,不进行fsync操作。所以只有当设为1时才能获得可靠的持久性保障。

看一个例子,比较innodb_flush_log_at_trx_commit对事务的影响:

CREATE TABLE test_load ( a INT, b CHAR ( 80 ) ) ENGINE = INNODB;

DELIMITER //
CREATE PROCEDURE p_load ( count INT UNSIGNED )
BEGIN
    DECLARE s INT UNSIGNED DEFAULT 1;
    DECLARE c CHAR ( 80 ) DEFAULT REPEAT( 'a', 80 );
    WHILE s <= count DO
        INSERT INTO test_load SELECT NULL, c;
        COMMIT;
        SET s = s + 1;
    END WHILE;
END
//
DELIMITER ;

存储过程p_load的作用是将数据不断地插入表test_load中,并且每插入一条就进行一次显式的COMMIT操作。在默认的设置下,即参数innodb_flush_log_at_trx_commit为1的情况下,InnoDB存储引擎会将重做日志缓冲中的日志写入文件,并调用一次fsync操作。如果执行命令CALL p_load(500000),则会向表中插入50万行的记录,并执行50万次的fsync操作。先看在默认情况插入50万条记录所需的时间下:

mysql> CALL p_load(500000);
Query OK,0 rows affected(1 min 53.11 sec)

可以看到插入50万条记录差不多需要2分钟的时间。对于生产环境的用户来说,这个时间显然是不能接受的。而造成时间比较长的原因就在于fsync操作所需的时间。接着来看将参数innodb_flush_log_at_trx_commit设置为0的情况:

mysql> CALL p_load(500000);
Query OK,0 rows affected(13.90 sec)

可以看到将参数innodb_flush_log_at_trx_commit设置为0后,插入50万行记录的时间缩短为了13.90秒,差不多是之前的12%。而形成这个现象的主要原因是:后者大大减少了fsync的次数,从而提高了数据库执行的性能。

innodb_flush_log_at_trx_commit执行所用时间
013.90秒
11分53.11秒
223.37秒

虽然可以通过设置参数innodb_flush_log_at_trx_commit为0或2来提高事务提交的性能,但是需要牢记的是,这种设置方法丧失了事务的ACID特性。而针对上述存储过程,为了提高事务的提交性能,应该在将50万行记录插入表后进行一次的COMMIT操作,而不是在每插入一条记录后进行一次COMMIT操作。这样做的好处是避免过多的fsync操作同时还可以使事务方法在回滚时回滚到事务最开始的确定状态。仔细观察和刚刚的存储过程区别:

DELIMITER //
CREATE PROCEDURE p_load_single_tx ( count INT UNSIGNED )
BEGIN
    DECLARE s INT UNSIGNED DEFAULT 1;
    DECLARE c CHAR ( 80 ) DEFAULT REPEAT( 'a', 80 );
START TRANSACTION; /*开启事务*/
    WHILE s <= count DO
        INSERT INTO test_load SELECT NULL, c;
        SET s = s + 1;
    END WHILE;
COMMIT; /*提交*/
END
//
DELIMITER ;

如果单个文件过大,有些操作系统维护起来会很不方便。所以redo log分为多个文件存储依次循环(in circular manner)写入数据。感觉有点类似于disruptor的ring buffer?
Snipaste_2021-05-18_18-11-09.png

上图展示redo log各个部分的关系,虽然很多细节没有写出来,但是大概的架构应该是清晰的。一个事务中可能包含多个对数据页的改动,其中每个改动必须通过一个mini事务完成的(mini transaction, mtr),多个mtr并发写入redo log buffer,在mtr_commit()后数据在后台将buffer写入磁盘。redo log的大小是固定的(可以配置),事务一直写,日志也一直写,日志写了还要修改表的数据(刷脏页),后台线程会将写好的表数据刷到磁盘上,刷到哪个位置那儿就是checkpoint,如果崩了就从checkpoint之后的位置用日志恢复就可以了,因为checkpoint之前的表数据都已经刷到磁盘了,这样就大大节省了恢复的时间。如果写的太快,write pos写了一圈追上checkpoint了,就表示没有空间再写,这时只能阻塞住等待fsnyc写数据,要尽量避免这种情况。

为了下面说明的连贯性,这里插一个额外的内容:
Binary Log(binlog)
要了解binlog首先看下MySQL的架构图:
mysql-architecture.png

binlog是在MySQL 3.23.14中引入的。在MySQL Server层记录所有更新数据的语句(insert, update, delete), 但是不包括SELECT和SHOW这类操作,因为这类操作对数据本身并没有修改。语句以“events”的形式存储,用于描述修改。binlog还包含每个语句更新数据用了多长时间等信息。binlog events记录的操作可用于重现服务器上发生更改。

binlog有两个重要作用:

  • 复制(replication): 用于MySQL的主从架构,binlog的格式和许多处理的细节都是专门为了这个目的。master服务器将其包含events的binlog发送到slave服务器,slave服务器再执行这些events以保持和master服务器数据相同。slave服务器将接收到的events存储在其relay log中,直到可以执行为止。relay log的格式与binlog格式相同。
  • 恢复(recovery): 某些数据恢复操作需要使用二进制日志。例如,在一个数据库全备文件恢复后,用户可以通过二进制日志进行POINT-IN-TIME(PIT)的恢复。

从表面上看binlog和redo log非常相似,都是记录了对于数据库操作的日志。然而,从本质上来看,两者有着非常大的不同。
redo log是在InnoDB存储引擎层(Storage Engines)产生,而binlog是在MySQL数据库的上层产生的,并且二进制日志不仅仅针对于InnoDB存储引擎,MySQL数据库中的任何存储引擎对于数据库的更改都会产生二进制日志。而redo log只有InnoDB能用。这样就能理解为什么主从复制要用binlog了吧。

那如果不使用主从架构,单论恢复功能呢?binlog和redo log都能提供崩溃恢复的能力,为什么还要有两份日志呢?
有两份日志的历史原因:

  • MySQL一开始并没有InnoDB,采用的是MyISAM,但MyISAM没有crash-safe的能力,binlog日志只能用于归档
  • InnoDB是以插件的形式引入MySQL的,为了实现crash-safe,InnoDB采用了redolog的方案
    binlog一开始的设计就是不支持崩溃恢复(原库)的,如果不考虑搭建从库等操作,binlog是可以关闭的
区别binlogredo log
日志内容形式不同逻辑日志,其记录的是对应的SQL语句物理日志,记录的是对于每个数据页的修改
写入磁盘时间点不同只在事务提交完成后进行一次写入在事务进行中不断地被写入
持久化方式不同追加写,空间不受限制,有归档功能循环写,空间固定,没有归档功能
用途不同主要用于恢复成临时库(从库)主要用于crash-safe,原库恢复
恢复过程的差异崩溃恢复的过程不写binlog(可能需要读binlog)用binlog恢复实例(从库),需要写redolog

逻辑日志:可以给别的存储引擎用,是其他引擎也能能理解的逻辑(如记录SQL语句,或者记录修改之前和之后的数据等方式,binlog目前有三种格式
物理日志:只能内部使用,其他引擎无法共享内部的物理格式(如对页的更新:page(2,3),offset 32,value 1,2

二进制日志与重做日志的写入磁盘时间点不同,表现为日志并不是随事务提交的顺序进行写入的:
Snipaste_2021-05-18_00-47-35.png

binlog仅在事务提交时记录,并且对于每一个事务,仅包含对应事务的一个日志。而对于InnoDB存储引擎的redo log,由于其记录的是物理操作日志,因此每个事务对应多个日志条目,并且事务的redo log写入是并发的,并非在事务提交时写入,故其在文件中记录的顺序并非是事务开始的顺序。

和redo log的innodb_flush_log_at_trx_commit类似,binlog也可以配置刷盘策略,通过参数sync_binlog控制,这里就不再赘述。官网的建议是将两个值都设为1。

现在既有redo log可以恢复数据,又有binlog可以恢复数据,应该不会丢数据了,看起来很完美,然而如果两个日志不一致呢?不管是先对binlog做fsync还是对redo log做fsync,都有可能导致两个日志不一样,恢复的时候就会出现数据不一致,InnoDB采用了两阶段提交(2PC)解决这个问题。有没有分布式事务的感觉了?
Snipaste_2021-05-19_01-18-23.png

再看一个图感受下
groupcommit_2pc.png

两个log都记完事务了再依次提交,先对binlog做fsync,如果binlog写成功那么事务就算成功,可以提交事务,即使中断了也能通过binlog恢复,如果binlog没有记录事务(写失败)则不能确定redolog的执行情况,就将事务回滚,保证两个文件的事务一致。

那么一个事务两个日志做两次fsync,是不是有点太慢了?InnoDB对此也做了优化:Group Commit,不管是一个还是多个事务同时执行,日志一次fsync的操作都消耗相同的IOPS,一次尽可能多带点事务就可以节省很多IO,所以并发高的情况下Group Commit的效果会很好。此外两个日志都是顺序写入,比随机写要快很多。
group commit.gif

Undo log
前面说了redo log,而WAL系统除了提供持久性(D)还能提供原子性(A),redo log显然不能满足原子性的需求,所以需要新的日志。事务不全是成功的,失败的事务需要进行回滚操作,这时就需要undo。因此在对数据库进行修改时,InnoDB存储引擎不但会产生redo,还会产生一定量的undo。这样如果执行的事务或语句由于某种原因失败了,又或者客户端主动发送ROLLBACK语句请求回滚,就可以利用这些undo信息将数据回滚到修改之前的样子。并且undo log在相关的事务都完成后就不再需要,可以删除了。

redo存放在redo log文件中,与redo不同,undo存放在数据库内部的undo log segments,位于共享表空间内的rollback segments。
而且undo并 不是 将数据库物理地恢复到事务之前的样子,undo是逻辑日志,只是将数据库逻辑地恢复到原来的样子,但是数据结构和页本身在回滚之后可能大不相同。
例如,用户执行了一个INSERT 10W条记录的事务,这个事务会导致分配一个新的段,即表空间会增大。在用户执行ROLLBACK时,会将插入的事务进行回滚,但是表空间的大小并不会因此而收缩。因此,当InnoDB存储引擎回滚时,它实际上做的是与先前相反的工作。对于每个INSERT,InnoDB存储引擎会完成一个DELETE;对于每个DELETE,InnoDB存储引擎会执行一个INSERT;对于每个UPDATE,InnoDB存储引擎会执行一个相反的UPDATE,将修改前的行放回去。

除了回滚操作,undo的另一个作用是MVCC,即在InnoDB存储引擎中MVCC的实现是通过undo来完成。当用户读取一行记录时,若该记录已经被其他事务占用,当前事务可以通过undo读取之前的行版本信息,以此实现非锁定读取。这点会在下面MVCC部分再次说明。

undo log会产生redo log,也就是undo log的产生会伴随着redo log的产生,这是因为undo log也需要持久性的保护。
事务提交后并不能马上删除undo log及undo log所在的页。这是因为可能还有其他事务需要通过undo log来得到行记录之前的版本。故事务提交时将undo log放入一个链表中,是否可以最终删除undo log及undo log所在页由purge线程来判断。

在InnoDB存储引擎中,undo log分为:

  • insert undo log
    是指在insert操作中产生的undo log。因为insert操作的记录,只对事务本身可见,对其他事务不可见(事务隔离性),故该undo log可以在事务提交后直接删除。不需要进行purge操作。
  • update undo log
    记录的是对delete和update操作产生的undo log。该undo log可能需要提供MVCC机制,因此不能在事务提交时就进行删除。提交时放入undo log链表,等待purge线程进行最后的删除。

对于delete操作,仅是将记录的delete flag设置为1,记录并没有被删除,即记录还是存在于B+树中。而真正删除这行记录的操作其实被“延时”了。purge用于最终完成delete和update操作。这样设计是因为InnoDB存储引擎支持MVCC,所以记录不能在事务提交时立即进行处理。这时其他事务可能正在引用这行,故InnoDB存储引擎需要保存记录之前的版本。而是否可以删除该条记录通过purge来进行判断。若该行记录已不被任何其他事务引用,那么就可以进行真正的delete操作。可见,purge操作是清理之前的delete和update操作,将操作“最终”完成。

在执行purge的过程中,InnoDB存储引擎首先从history list中找到第一个需要被清理的记录,这里为trx1,清理之后InnoDB存储引擎会在trx1的undo log所在的页中继续寻找是否存在可以被清理的记录,这里会找到事务trx3,接着找到trx5,但是发现trx5被其他事务所引用而不能清理,故去再次去history list中查找,发现这时最尾端的记录为trx2,接着找到trx2所在的页,然后依次再把事务trx6、trx4的记录进行清理。由于undo page2中所有的页都被清理了,因此该undo page可以被重用。
Snipaste_2021-05-20_14-22-14.png

MVCC
好了,最后一部分就是InnoDB如何满足ACID中的I了。
要解释这部分,得先知道为什么需要隔离性,不妨先看看如果没有隔离性会怎么样?这里以文件系统为例,由于文件系统没有ACID的特性,所以在对文件的数据进行操作时,同一个文件被多个任务同时处理会相互影响,从而发生混乱和错误。如下面的代码:

public class DemoApplication {
    static CountDownLatch latch = new CountDownLatch(1);

    public static void main(String[] args) throws Exception {
        String str = "www.racecoder.com";

        new Thread(() -> write(str.getBytes())).start();
        new Thread(() -> write(str.getBytes())).start();

        latch.countDown();
    }

    private static void write(byte[] bytes) {
        try (FileOutputStream fos = new FileOutputStream("D:\\test\\test.txt", true)) {
            latch.await();
            for (byte b : bytes) {
                fos.write(b);
            }
            String separator = System.lineSeparator();
            fos.write(separator.getBytes()); // 换行
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

这段代码很简单,用两个线程同时向一个文件中写下本博客的域名。执行结果如何呢?如下:

wwwwww..rraacceeccooddeerr..ccoomm  // 第1次

wwwwww..rraacceeccooddeer.rc.ocmom  // 第2次

wwwwww..rraacceeccooddeerr.c.ocmom  // 第3次

www.racecoder.com
www.racecoder.com                   // 第4次
wwwwww..rraacceeccooddeerr..ccoomm  // 第5次

wwwwww..rraacceeccooddeerr..ccoomm  // 第6次

wwwwww..rraacceeccooddeerr..ccoomm  // 第7次

wwwwww..rraacceeccooddeerr..ccoomm  // 第8次

wwwww.wr.arcaecceocdoedre.rc.ocmom  // 第9次

wwwww.wr.arcaecceocdodeerr..ccoomm  // 第10次

wwwwww..rracaecceodceord.ecro.mcom  // 第11次

wwwwww..rarcaecceocdoedre.rc.ocmom  // 第12次

wwwwww..rraacceeccooddeerr..ccoomm  // 第13次

可以看到只有一次达到了预期效果,这放在数据库中肯定是不能接受的,因为数据库随时都有可能对一个数据有上百次的并发操作,尤其是对余额或状态等敏感字段的操作,错一次都可能是灾难。

在数据库中,事务的隔离性是指在并发环境下,并发的事务是相互隔离的,一个事务的执行不能被其他事务干扰。也就是说,不同的事务并发操纵相同的数据时,每个事务都有各自完整的数据空间,即一个事务内部的操作及使用的数据对其他并发事务是隔离的,并发执行的各个事务之间不能互相干扰。
在ISO的SQL:1992规范中,定义了4个事务隔离级别: READ UNCOMMITTED, READ COMMITTED, REPEATABLE READSERIALIZABLEInnoDB的默认隔离级别是REPEATABLE READ。关于这几个隔离级别的文章实在是太多了,这里就以参考书中一张图略说一下就过吧。
Snipaste_2021-05-23_23-02-48.png

上图中4个隔离级别的隔离性依次增强,分别解决不同的问题。

隔离级别脏读可重复读幻读
读未提交(READ UNCOMMITTED)存在不可以存在
读已提交(READ COMMITTED)不存在不可以存在
可重复读(REPEATABLE READ)不存在可以存在
串行化(SERIALIZABLE)不存在可以不存在

事务隔离级别越高,就越能保证数据的完整性和一致性,但同时对并发性能的影响也越大。为了保证隔离性,以串行方式顺序执行和提交的事务可以达到这个目的。但在多用户环境中,同时可能会有成百上千,甚至数万个事务,显然以串行方式执行事务是不切实际的。因此,数据库管理系统应对事务进行合理的调度,使在没有相互干扰的情况下可以并行地执行多个事务,以尽可能提高系统的并发性。最终要达到的效果:事务并发执行的效果,与事务串行执行的效果完全相同(serializability)。

通常可以优先考虑将数据库系统的隔离级别设置为READ COMMITTED(Oracle数据库默认隔离级别),这能够在避免脏读取的同时保证较好的并发性能。尽管这种事务隔离级别会导致不可重复读、虚读和第二类丢失更新等并发问题,但较为科学的做法是在可能出现这类问题的个别场合中,由应用程序主动采用悲观锁或乐观锁来进行事务控制。

为了方便描述并发控制如何保证隔离性,我们简化事务模型。事务是由一个或多个操作组成,所有的操作最终都可以拆分为一系列读和写。因此可能对数据有影响的情况包括:

  • 读-读操作:如果同时只存在多个读操作,对于数据自身则没有影响。即读和读操作互不影响数据的一致性,可以并发执行。
  • 读-写操作:如果读写操作都存在,因写在前读在后(如脏读现象)、读在前写在后(如不可重复读现象),或者读在前写在后然后又读(如幻象现象),就可能因数据被写而导致另外一个读操作的会话读到错误的数据。这个操作可以根据动作发生的先后顺序被细分为读–写操作、写–读操作。
  • 写-写操作:如果同时存在多个写操作,写–写操作直接改变了数据在同一时刻的语义,这就更不被允许,所以写–写操作通常不允许被并发执行。并且写–写并发操作也会带来“更新丢失”异象,两个操作先后写一个对象,后一个操作的结果决定了写入的最终结果,好像前面的更新操作丢失了一样。

基于以上,如果需要提高并发并发性,应主要关注读-写操作和写-写操作的冲突解决。不管是读异常还是写异常,并发控制技术都要规避这些异常,保证数据在不同隔离级别下一致性不被破坏。
Snipaste_2021-05-25_10-58-38.png

图上的并发控制方法分类可能并不完全正确,某些方法可能不止使用一种思想,将多种思想结合使用以获得更好的效果。此处仅帮助了解大体结构,感兴趣可自行了解具体方法的实现,而且上图的方法并不全,为了控制文章的篇幅和书写时间省略了很多细节。
并发控制技术,从实现的思路角度看,有两类,常说的乐观与悲观并发控制两类思路的差别在于,是事后检查还是提前预防。

  • 乐观(Optimistic Concurrency Control,OCC):不阻塞任何读写操作,但在事务提交的时刻,进行隔离性和完整性约束的检查,如果有违反则事务被中止。显然,如果并发冲突少的场景,乐观并发控制方法是适合的。
  • 悲观(Pessimistic Concurrency Control,PCC):阻塞可能违反隔离性和完整性约束的操作,阻塞操作通常和性能降低有关。
  • 半乐观(Semi-optimistic Concurrency Control):在可能违反隔离性和完整性约束的操作的部分情况下阻塞,可以理解为上述两种的折衷,总体上还是乐观的。

解决并发问题当然是加锁最简单了,任何事务只有在获得合适的锁之后才能读或写数据,InnoDB存储引擎实现了两种标准的行级锁:

  • 共享锁(shared lock, S Lock),允许持有该锁的事务读一行数据。
  • 排他锁(exclusive lock, X Lock),允许持有该锁的事务删除或更新一行数据。

InnoDB支持多粒度(granular)锁定,允许行级锁和表级锁同时存在,为此还支持两种意向锁(Intention Locks),意向锁是表级锁。

  • 意向共享锁(intention shared lock, IS): 事务想要获得一张表中某几行的共享锁
  • 意向排他锁(intention exclusive lock, IX): 事务想要获得一张表中某几行的排他锁

意向锁的协议如下:

  • 在事务获取行的S锁之前,必须获得表的IS或IX锁
    Before a transaction can acquire a shared lock on a row in a table, it must first acquire an IS lock or stronger on the table.
  • 在事务获取行的X锁之前,必须获取表的IX锁
    Before a transaction can acquire an exclusive lock on a row in a table, it must first acquire an IX lock on the table.

由于InnoDB存储引擎支持的是行级别的锁,意向锁不会和行级的X,S锁发生冲突。只会和表级的X,S发生冲突,因此意向锁其实不会阻塞除全表扫以外的任何请求。意向锁的主要目的是为了表示有事务正在或将要在表中的行上加锁,如果没有意向锁,要添加表级的X锁则需要遍历整个表每一行的锁以避免冲突。故表级意向锁与行级锁的兼容性如下所示

(✿◠‿◠)XIXSIS
XConflictConflictConflictConflict
IXConflictCompatibleConflictCompatible
SConflictConflictCompatibleCompatible
ISConflictCompatibleCompatibleCompatible

如果一个事务T1已经获得了行r的共享锁,那么事务T2可以立即获得行r的共享锁,因为读取并没有改变行r的数据,称这种情况为锁兼容(Lock Compatible)。但若有事务T3想获得行r的排他锁,则其必须等待事务T1、T2释放行r上的共享锁——这种情况称为锁不兼容。X锁与任何的锁都不兼容,而S锁仅和S锁兼容。这里的S和X锁都是指行锁,兼容是指对同一记录(row)锁的兼容性情况。
Snipaste_2021-05-25_14-18-51.png

若将上锁的对象看成一棵树,那么对最下层的对象上锁,也就是对最细粒度的对象进行上锁,那么首先需要对粗粒度的对象上锁。如果需要对页上的记录r进行上X锁,那么分别需要对数据库A、表、页上意向锁IX,最后对记录r上X锁。若其中任何一个部分导致冲突,那么该操作需要等待粗粒度锁的完成。举例来说,在对记录r加X锁之前,已经有事务对表1进行了S表锁,那么表1上已存在S锁,之后事务需要对记录r在表1上加上IX,由于不兼容,所以该事务需要等待表锁操作的完成。

在需要显式地对数据库读取操作进行加锁以保证数据逻辑的一致性时,即使是对于SELECT的只读操作。InnoDB存储引擎对于SELECT语句支持两种一致性的锁定读(locking read)操作:

  • SELECT…FOR UPDATE
  • SELECT…LOCK IN SHARE MODE

SELECT…FOR UPDATE对读取的行记录加一个X锁,其他事务不能对已锁定的行加上任何锁。SELECT…LOCK IN SHARE MODE对读取的行记录加一个S锁,其他事务可以向被锁定的行加S锁,但是如果加X锁,则会被阻塞。
关于InnoDB存储引擎行锁的算法,这里只介绍3种,更多的请参阅文档:

  • Record Locks:单个行记录上的锁,Record Lock总是会去锁住索引记录(index records),阻止其他事务对此记录的增删改操作。如:SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE;
  • Gap Locks:间隙锁是作用于索引记录(index records)间隙(gap)的锁,锁定一个范围,但不包含记录本身。如SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE;会阻止其他事务插入15,不论这个值是不是已经存在,因为这个值所属的间隙(gap)已经被锁了。
  • Next-Key Locks∶Gap Lock + Record Lock,并且锁定记录本身并且锁定记录之前的间隙,InnoDB对于行的查询都是采用这种锁定算法。

Next-Key Lock是结合了Gap Lock和Record Lock的一种锁定算法,例如一个索引有10,11,13和20这四个值,那么该索引可能被Next-Key Locking的区间为

(-∞, 10]
(10, 11]
(11, 13]
(13, 20]
(20, +∞)

设计Next-Key Locking的目的是为了解决Phantom Problem。Gap Lock的作用是为了阻止多个事务将记录插入到同一范围内,而这会导致Phantom Problem问题的产生。

事务1事务2
START TRANSACTION; 
 START TRANSACTION;
SELECT * FROM test WHERE c1 > 2 FOR UPDATE;
将c1>2的gap锁住
 
 INSERT INTO test(c1, c2) VALUES (4, "ddd");
在可重复读隔离级别下,事务1提交前此插入会被阻塞
COMMIT;
事务1提交,锁释放,事务二可以插入
即使事务2插入了,在提交之前对其他事务仍不可见
 COMMIT;
提交后才对之后其他的事务可见

Snipaste_2021-05-26_11-33-16.png

加锁可以解决许多问题,但即使已经对锁做了很多的优化,依然会导致性能的下降。例如一个事务读取的行正被另一个事务执行DELETE或UPDATE操作,这时读取操作就被阻塞等待等待行上锁的释放。为了解决读写冲突导致的性能问题,InnoDB通过多版本控制的方式解决这个问题,即读取不会等待锁的释放,这种方式又称为一致性非锁定读(consistent nonlocking read)。

怎么实现的呢?InnoDB存储引擎会去读取行的一个快照数据,快照数据是指该行的之前版本的数据,而undo用来在事务中回滚事务,包含每行数据的所有版本的数据,正好可以利用undo来完成。因此快照数据本身是没有额外的开销。此外,读取快照数据是不需要上锁的,因为没有事务需要对历史的数据进行修改操作。
Snipaste_2021-05-26_00-30-00.png

快照数据其实就是当前行数据之前的历史版本,每行记录可能有多个版本。一个行记录可能有不止一个快照数据,一般称这种技术为行多版本技术。由此带来的并发控制,称之为多版本并发控制(Multi Version Concurrency Control,MVCC)。

非锁定读机制极大地提高了数据库的并发性。这是InnoDB默认的读取方式,即读取不会占用和等待表上的锁。但是在不同事务隔离级别下,读取的方式不同,并不是在每个事务隔离级别下都是采用非锁定的一致性读。此外,即使都是使用非锁定的一致性读,但是对于快照数据的使用也各不相同。
在事务隔离级别READ COMMITTEDREPEATABLE READ下,InnoDB存储引擎使用非锁定的一致性读。然而,对于快照数据的读取却不相同。在READ COMMITTED事务隔离级别下,非一致性读总是读取被锁定行的最新一份快照数据。而在REPEATABLE READ事务隔离级别下,非一致性读总是读取事务开始时的行数据版本。
至于另外两种隔离级别都用不上MVCC,READ UNCOMMITTED可以直接读脏数据,读版本链最新的记录就可以了,不需要读旧版本信息。SERIALIZABLE需要对读加锁,不需要应用多版本信息。
mysql-mvcc-4.png

InnoDB使用锁和MVCC技术实现了并发事务的访问并发控制。其中,锁是并发控制的基础,在此基础上,实现了MVCC机制,用以提高基于锁的方式带来的低效问题,使得读–写、写–读两种操作互不阻塞,提高了单纯基于锁技术的并发效率。

文章的最后,ACID和InnoDB的实现就介绍的差不多,延申一下分布式事务吧,有兴趣可以搜索相关知识继续学习或者继续深入InnoDB的底层。
随着分布式计算的发展,事务在分布式计算领域中也得到了广泛的应用。在单机数据库中,我们很容易能够实现一套满足 ACID 特性的事务处理系统,但在分布式数据库中,数据分散在各台不同的机器上,如何对这些数据进行分布式的事务处理具有非常大的挑战。如果我们期望实现一套严格满足 ACID 特性的分布式事务,很可能出现的情况就是在系统的可用性和严格一致性之间出现冲突。
可以设想一个最典型的分布式事务场景:一个跨银行的转账操作涉及调用两个异地的银行服务,其中一个是本地银行提供的取款服务,另一个则是目标银行提供的存款服务,这两个服务本身是无状态并且是互相独立的,共同构成了一个完整的分布式事务。一个分布式事务可以看作是由多个分布式的操作序列组成的,同时也就具有了ACID事务特性。但由于在分布式事务中,各个子事务的执行是分布式的,因此要实现一种能够保证ACID特性的分布式事务处理系统就显得格外复杂。于是如何构建一个兼顾可用性和一致性的分布式系统成为了无数工程师探讨的难题,出现了诸如CAP和BASE这样的分布式系统经典理论。

此文并没有对技术做深入的分析,这部分内容如果真的展开说,我估计写一本厚厚的书是绝对没问题的。但限于能力和篇幅,只是将尽可能多的知识点串起来,将技术产生的思路写出来。我觉得这样会让很多问题清晰许多,知道哪有问题比解决问题本身更重要,剩下的就可以挑自己感兴趣的部分深入研究。
终于写完了,说实话,自己整理了一遍才感觉很多问题和技术才清晰了不少,但越觉得学海无涯,路漫漫其修远兮……

标签: none

仅有一条评论

  1. 66 66

    66

添加新评论

ali-01.gifali-58.gifali-09.gifali-23.gifali-04.gifali-46.gifali-57.gifali-22.gifali-38.gifali-13.gifali-10.gifali-34.gifali-06.gifali-37.gifali-42.gifali-35.gifali-12.gifali-30.gifali-16.gifali-54.gifali-55.gifali-59.gif

加载中……