满嘴都是糖果

事务

说到数据库离不开事务这个概念,什么是事务?事物是一组操作,这些操作要么全部执行,要不全部不执行。例如汇款就是一个事务,里面包含了汇款人余额的减少和收款人余额的增加,这两件事必须保证全部都执行。

事务的特性

说到事务,必须了解其特性,这也是老生常谈的东西了

  1. 原子性:事务是最小执行单位,事务里的动作要么全部执行,要么全不执行。
  2. 一致性:执行事务的前后,数据保持一致。
  3. 隔离性:并发时,各个并发事务之间互不干扰。
  4. 持久性:事务对数据库的改变是永久的。

下面看看Mysql为了保证事务的这些特性做了哪些努力吧。

MySQL日志

Mysql内有三大日志系统:二进制日志binlog(归档日志)、事务日志redo log和回滚日志undo log。

redo log

redo log是InnoDB存储引擎所独有的,它让MySQL拥有了崩溃恢复能力。

MySQL 中数据是以页为单位,你查询一条记录,会从硬盘把一页的数据加载出来,加载出来的数据叫数据页,会放入到 Buffer Pool 中。后续的查询都是先从 Buffer Pool 中找,没有命中再去硬盘加载,减少硬盘 IO 开销,提升性能。

更新表数据的时候,也是如此,发现 Buffer Pool 里存在要更新的数据,就直接在 Buffer Pool 里更新。然后会把“在某个数据页上做了什么修改”记录到重做日志缓存(redo log buffer)里,接着刷盘到 redo log 文件里。

刷盘时机

理想情况,事务一提交就会进行刷盘操作,但实际上,刷盘的时机是根据策略来进行的。

InnoDB 存储引擎为 redo log 的刷盘策略提供了 innodb_flush_log_at_trx_commit 参数,它支持三种策略:

innodb_flush_log_at_trx_commit 参数默认为 1 ,也就是说当事务提交时会调用 fsync 对 redo log 进行刷盘。另外,InnoDB 存储引擎有一个后台线程,每隔1 秒,就会把 redo log buffer 中的内容写到文件系统缓存(page cache),然后调用 fsync 刷盘。

除了后台线程每秒1次的轮询操作,还有一种情况,当 redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动刷盘。

日志数据损失情况

  1. innodb_flush_log_at_trx_commit=0时,如果MySQL挂了或宕机可能会有1秒数据的丢失。
  2. innodb_flush_log_at_trx_commit=1,只要事务提交成功,redo log记录就一定在硬盘里,不会有任何数据丢失。如果事务执行期间MySQL挂了或宕机,这部分日志丢了,但是事务并没有提交,所以日志丢了也不会有损失。
  3. innodb_flush_log_at_trx_commit=2时, 只要事务提交成功,redo log buffer中的内容只写入文件系统缓存(page cache)。如果仅仅只是MySQL挂了不会有任何数据丢失,但是宕机可能会有1秒数据的丢失。
日志文件组

硬盘上存储的 redo log 日志文件不只一个,而是以一个日志文件组的形式出现的,每个的redo日志文件大小都是一样的。比如可以配置为一组4个文件,每个文件的大小是 1GB,整个 redo log 日志文件组可以记录4G的内容。它采用的是环形数组形式,从头开始写,写到末尾又回到头循环写。

在个日志文件组中还有两个重要的属性,分别是 write pos、checkpoint

  1. write pos 是当前记录的位置,一边写一边后移
  2. checkpoint 是当前要擦除的位置,也是往后推移

write pos 和 checkpoint 之间的还空着的部分可以用来写入新的 redo log 记录。因此如果 write pos 追上 checkpoint ,表示日志文件组满了,这时候不能再写入新的 redo log 记录,MySQL 得停下来,清空一些记录,把 checkpoint 推进一下。

redo log优势

由于MySQL 中数据是以页为单位,大小是16KB,每次事务结束后,往往一页中只有几个Byte的数据有改变,因此频繁刷盘会导致性能的下降。其次修改的数据页的位置可能在硬盘的随机位置,因此性能很差。

如果是写 redo log,一行记录可能就占几十 Byte,只包含表空间号、数据页号、磁盘文件偏移 量、更新值,再加上是顺序写,所以刷盘速度很快,大大提高了数据库的并发能力。

binlog

binlog 是逻辑日志,记录内容是语句的原始逻辑,类似于“给 ID=2 这一行的 c 字段加 1”。MySQL数据库的数据备份、主备、主主、主从都离不开binlog,需要依靠binlog来同步数据,保证数据一致性。

binlog 日志有三种格式,可以通过binlog_format参数指定。

  1. statement:记录SQL原文
  2. row:记录的内容不再是简单的SQL语句了,还包含操作的具体数据(也就是SQL中会变化的部分),但是这种格式需要更大的容量来记录,比较占空间
  3. mixed:前两者的混合,MySQL会判断这条SQL语句是否可能引起数据不一致,如果是,就用row格式,否则就用statement格式。

binlog的写入时机也非常简单,事务执行过程中,先把日志写到binlog cache,事务提交的时候,再把binlog cache写到binlog文件中。

两阶段提交

redo log(重做日志)让InnoDB存储引擎拥有了崩溃恢复能力。binlog(归档日志)保证了MySQL集群架构的数据一致性。虽然它们都属于持久化的保证,但是侧重点不同。

在执行更新语句过程,会记录redo logbinlog两块日志,以基本的事务为单位,redo log在事务执行过程中可以不断写入,而binlog只有在提交事务时才写入,所以redo logbinlog的写入时机不一样。

undo log

我们知道如果想要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚,在 MySQL 中,恢复机制是通过 回滚日志(undo log) 实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后再执行相关的操作。如果执行过程中遇到异常的话,我们直接利用 回滚日志 中的信息将数据回滚到修改之前的样子即可!并且,回滚日志会先于数据持久化到磁盘上。这样就保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚将之前未完成的事务。

此外,undo log也是MVCC功能实现的基础。

MySQL InnoDB 引擎使用 redo log(重做日志) 保证事务的持久性,使用 undo log(回滚日志) 来保证事务的原子性MySQL数据库的数据备份、主备、主主、主从都离不开binlog,需要依靠binlog来同步数据,保证数据一致性。

MVCC

下面轮到MVCC了,MVCC是Multi version concurrent control的缩写,目的是为了实现在并发过程中,读-写操作可以并发进行。

说到数据库的并发读写,可能会出现四个问题

  1. 脏读(Dirty read): 当一个事务正在访问数据并且对数据进行了修改,而这种修改还没有提交到数据库中,这时另外一个事务也访问了这个数据,然后使用了这个数据。因为这个数据是还没有提交的数据,那么另外一个事务读到的这个数据是“脏数据”,依据“脏数据”所做的操作可能是不正确的。
  2. 丢失修改(Lost to modify): 指在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。 例如:事务1读取某表中的数据A=20,事务2也读取A=20,事务1修改A=A-1,事务2也修改A=A-1,最终结果A=19,事务1的修改被丢失。
  3. 不可重复读(Unrepeatableread): 指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。
  4. 幻读(Phantom read): 幻读与不可重复读类似。它发生在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。

不可重复度和幻读区别:不可重复读的重点是修改,幻读的重点在于新增或者删除。

事务的隔离级别

为了解决上述全部或者部分问题,数据库设置了一些隔离级别

read-uncommited(读取未提交):允许读取尚未提交的数据,可能会导致脏读、不可重复读和幻读

read-commited(读取已提交):允许读取已经提交的数据,避免脏读,可能会导致不可重复读和幻读

repeatalbe-read(可重复读):对同一字段的多次读取结果是一致的,可以阻止脏读和不可重复读,但幻读仍有可能发生

serializable(可串行化):所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读

MVCC实现方法

针对read-commited和repeatalbe-read两种不同的隔离级别,MVCC选择的Read View生成的时机不同。首先介绍一下MVCC 基础的实现:隐藏字段,Read View和undo log。

隐藏字段

在内部,InnoDB 存储引擎为每行数据添加了三个隐藏字段 :

这里着重注意db_trx_id和db_roll_ptr这两个字段。

ReadView

ReadView主要是用来做可见性判断,里面保存了 “当前对本事务不可见的其他活跃事务”

主要有以下字段:

undo log

InnoDB 存储引擎中 undo log 分为两种: insert undo log 和 update undo log。

以下是例子:

insert 数据

第一次修改数据

第二次修改数据

不同事务或者相同事务的对同一记录行的修改,会使该记录行的 undo log 成为一条链表,链首就是最新的记录,链尾就是最早的旧记录。

InnoDB 存储引擎中,创建一个新事务后,执行每个 select 语句前,都会创建一个快照(Read View),快照中保存了当前数据库系统中正处于活跃(没有 commit)的事务的 ID 号

下面是判断是否满足可见性条件的伪代码

def fun(DB_TRX_ID, ReadView):
    if DB_TRX_ID < ReadView.m_up_limit_id:
        那么表明最新修改该行的事务DB_TRX_ID在当前事务创建快照之前就提交了所以该记录行的值对当前事务是可见的
        return;
    else if DB_TRX_ID >= ReadView.m_low_limit_id:
        那么表明最新修改该行的事务DB_TRX_ID在当前事务创建快照之后才修改该行所以该记录行的值对当前事务不可见
    else:
        if DB_TRX_ID in 活跃事务列表 ReadView.m_ids:
            该记录行的值被事务 ID  DB_TRX_ID 的事务修改了
            这个记录行的值对当前事务都是不可见的
        else:
            记录行对当前事务可见
            return
    //记录对当前事务不可见通过链表在undo log中找到上一次更新时的Read View
	return fun(DB_TRX_ID, DB_ROLL_PTR.ReadView)

RC和RR隔离级别下MVCC的差异

在事务隔离级别 RCRR (InnoDB 存储引擎的默认事务隔离级别)下,InnoDB 存储引擎使用 MVCC(非锁定一致性读),但它们生成 Read View 的时机却不同

MVCC有没有实现在RR隔离级别中解决幻读的问题呢?

答案是仅仅只是解决了一部分

使用MVCC的目的是增加更多的并发支持,希望读与写不会阻塞,并不会加锁。由于使用了快照技术,即使事务2在事务1执行过程中更新了数据,事务1的读操作都只是会读取快照中允许看见的数据,读取是undo log中的历史数据,事务2做出的更新操作并不会被事务1读取到,由此实现了快照读与更新操作之间的非阻塞

当前读存在于更新操作中,在做出更新之前,读取到最新的数据,根据读取的最新的数据做出更新。因为只有读取到最新的数据,此更新操作才能正确。那么事务1在事务2做出更新操作之后,执行更新操作,那么它的当前读锁读取的数据并不是快照中的历史数据,而是事务2执行后最新的数据,这就会造成幻读的错误。

因此在使用MVCC时,如果事务中都是select操作,也就是都是快照读,那么MVCC会保证读出的数据时正确的;但是如果事务中存在更新操作,更新操作中隐含了当前读,就有可能出现幻读的情况。

解决这一问题的措施是上锁,在事务的第一个select操作中加上for update/lock in share mode锁(X/S锁),那么其他事务进行更新操作时就会阻塞,直至第一个事务释放锁。InnoDB 使用 Next-key Lock来防止这种情况。next-key lock 是 record lock 和 gap lock 的结合,锁定的是一个范围,如果查询数据为索引记录行,则只会锁定当前行,也就是说降级为 record lock。