一、四个特性

  • 原子性:所有操作要么全部执行要么全部不执行,一条指令失败则数据进行回滚,回到所有指令执行前的状态。
  • 一致性:事务开始前和结束后,数据库的完整性约束没有被破坏。即数据从一个状态转换为另一个状态,但是对于整个数据的完整性保持稳定。比如A向B转账,不可能A扣了钱,B却没收到。
  • 隔离性:同一时间,只允许一个事务请求同一数据,不同的事务之间彼此没有任何干扰。比如A正在从一张银行卡中取钱,在A取钱的过程结束前,B不能向这张卡转账。
  • 持久性:事务完成后,事务对数据库的所有更新将被保存到数据库,不能回滚。

一致性是事务的最终追求的目标,隔离性、原子性、持久性是达成一致性目标的手段。

二、存在问题

  • 脏读:脏读是指在一个事务处理过程里读取了另一个未提交的事务中的数据。B事务更新了某个数据,A事务得到了这个新的数据之后B事务回滚,此时A持有的这个数据就是脏数据。
  • 不可重复读:一个事务两次读取同一个数据得到了不同的结果,即数据在中途被另一个事务修改了。
  • 幻读:一个事务两次读取,第二次结果集包含第一次中没有的数据或缺少了某些数据。即在中途另一个事务新增或删除了数据。

脏读读取的是其他事务未提交的数据,不可重复读读取的是其他事务提交了的数据

幻读和不可重复读都是读取了另一条已经提交的事务(这点就脏读不同),所不同的是不可重复读查询的都是同一个数据项,而幻读针对的是一批数据整体(比如数据的个数)

不可重复读侧重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表

三、隔离级别

事务的隔离级别有4种,由低到高分别为Read uncommitted 、Read committed 、Repeatable read 、Serializable 。

  • 读未提交:一个事务可以读取另一个事务未提交的数据
    • 最低的隔离级别,无法避免任何可能出现的并发问题
  • 读提交:一个事务要等另一个事务提交后才能读取数据,若有事务对数据进行更新操作时,读操作事务要等待这个更新操作事务提交后才能读取数据
    • 可以避免脏读现象的发生,但是可能会出现不可重复读现象
  • 可重复读:一个事务开始读取数据(事务开启)时,不再允许修改操作
    • 可重复读不允许的是修改操作,故可以避免不可重复读现象,但是无法避免幻读现象(新增和删除)
  • 序列化:事务串行化顺序执行
    • 最高的事务隔离级别,可以避免所有并发问题,但效率低下,一般不使用

读提交事务隔离级别是大多数流行数据库的默认事务隔离界别,比如 Oracle,但是不是 MySQL 的默认隔离级别。

MySQL的RR级别是可以防止幻读发生的。

在@Transactional 注解里是有一个 isolation 参数的用于设置事务隔离级别的,如 @Transactional(isolation=Isolation.DEFAULT) ,默认的就是 DEFAULT 值,即 MySQL 默认支持什么隔离级别就是什么隔离级别。

四、实现原理

  • 原子性:使用 undo log ,从而达到回滚
  • 持久性:使用 redo log,从而达到故障后恢复
  • 隔离性:使用锁以及MVCC,运用的优化思想有读写分离,读读并行,读写并行
  • 一致性:通过回滚,以及恢复,和在并发环境下的隔离做到一致性

0、SQL语句执行流程

想要了解MySQL事务的实现原理,我们首先就需要先明白在MySQL中语句的执行流程。

1)buffer pool

从字面上看,不难看出buffer pool其实就是缓存池的意思,它是MySQL中一个至关重要的主组件,MySQL中的增删改查操作基本都是在这里执行。之所以要引进缓存池这样一个主键,也不难想到无非就是为了提高性能,毕竟数据存储在磁盘中,如果每次操作都是直接操作磁盘,涉及到磁盘随机读写性能自然不高。所以MySQL会把数据取出,放入内存中,每次操作就直接在内存中执行即可,这样一个专门处理数据的区域就是buffer pool。

2)执行流程

由于引入了缓存池,内存中的数据是使用异步线程随机时间刷盘,那么就会存在一定的问题,如果内存中的数据还未来得及更新到磁盘中,系统就宕机了,那么此时缓存中的数据便丢失了。

为了解决这样的问题,我们需要引入日志来将更改的操作记录下来。InnoDB中引入了undo log和redo log,在buffer pool内存数据修改之前会先保存旧数据到undolog,在修改之后会将在某个页上面做了什么修改写入redo log,那么即使MySQL中途挂了,也可以根据undolog进行回滚(事务未完成提交时)或redo log对数据进行恢复(事务完成提交后)

可能有人会说写日志不也是磁盘操作吗,将直接在磁盘中修改数据换成了在磁盘中写日志有区别吗?

这里其实redo log是顺序写的(磁盘顺序写的效率接近内存写,远超过磁盘随机写),记录的是物理修改,文件的体积很小,恢复速度也很快(因为不管对什么表的修改都是记录到一个日志文件中,所以可以采用顺序写,而数据保存在ibd文件,每个表对应一个文件,且会有删除操作,对数据的修改显然无法使用顺序写)

所以引入 redo log 机制是十分必要的。因为写 redo log 时,我们将 redo log 日志追加到文件末尾,虽然也是一次磁盘 IO,但是这是顺序写操作(不需要移动磁头);而对于直接将数据更新到磁盘,涉及到的操作是将 buffer pool 中缓存页写入到磁盘上的数据页上,由于涉及到寻找数据页在磁盘的哪个地方,这个操作发生的是随机写操作(需要移动磁头),相比于顺序写操作,磁盘的随机写操作性能消耗更大,花费的时间更长,因此 redo log 机制更优,能提升 MySQL 的性能。

从另一方面来讲,通常一次更新操作,我们往往只会涉及到修改几个字节的数据,而如果因为仅仅修改几个字节的数据,就将整个数据页写入到磁盘(无论是磁盘还是 buffer pool,他们管理数据的单位都是以页为单位),这个代价未免也太了(每个数据页默认是 16KB),而一条 redo log 日志的大小可能就只有几个字节,因此每次磁盘 IO 写入的数据量更小,那么耗时也会更短。 综合来看,redo log 机制的引入,在提高 MySQL 性能的同时,也保证了数据的可靠性。

具体执行流程图:

img

1、日志

正如前面所说,当我们查询数据的时候,会先去Buffer Pool中查询。如果Buffer Pool中不存在,存储引擎会先将数据从磁盘加载到Buffer Pool中,然后将数据返回给客户端;同理,当我们更新某个数据的时候,如果这个数据不存在于Buffer Pool,同样会先数据加载进来,然后修改修改内存的数据,被修改过的数据会在之后统一刷入磁盘。但是这个过程存在一定问题,如果还未来得及刷库就宕机了那么数据就永久丢失了。所以MySQL使用日志来保障系统崩溃时的恢复。

MySQL日志主要包括错误日志、查询日志、慢查询日志、事务日志、二进制日志几大类。其中比较重要的就是二进制日志binlog(归档日志)、事务日志redo log(重做日志)和undo log(回滚日志)。 这里面binlog是server层的日志,而redo log和undo log都是引擎层(innodb)的日志,要换其他数据引擎那么就未必有redo log和undo log了。

1)binlog

MySQL的二进制日志binlog可以说是MySQL最重要的日志,它记录了所有的DDL和DML语句(除了数据查询语句select),以事件形式记录,还包含语句所执行的消耗的时间。

使用场景

  1. mysql主从复制:master端开启binlog,master把它的二进制日志传递给slaves来达到master-slave数据一致的目的
  2. 数据恢复

记录格式

  1. ROW:基于变更的数据行进行记录,如果一个update语句修改一百行数据,那么这种模式下就会记录100行对应的记录日志
  2. STATEMENT:基于SQL语句级别的记录日志,相对于ROW模式,STATEMENT模式下只会记录这个update的语句。所以此模式下会非常节省日志空间,也避免着大量的IO操作
  3. MIXED:混合模式,此模式是ROW模式和STATEMENT模式的混合体,一般的语句修改使用statment格式保存binlog,如一些函数,statement无法完成主从复制的操作,则采用row格式保存binlog

这三种模式需要注意的是:使用 row 格式的 binlog 时,在进行数据同步或恢复的时候不一致的问题更容易被发现,因为它是基于数据行记录的。而使用 mixed 或者 statement 格式的 binlog 时,很多事务操作都是基于SQL逻辑记录,我们都知道一个SQL在不同的时间点执行它们产生的数据变化和影响是不一样的,所以这种情况下,数据同步或恢复的时候就容易出现不一致的情况

写入策略

在进行事务的过程中,首先会把binlog 写入到binlog cache中(因为写入到cache中会比较快,一个事务通常会有多个操作,避免每个操作都直接写磁盘导致性能降低),事务最终提交的时候再吧binlog 写入到磁盘中。当然事务在最终commit的时候binlog是否马上写入到磁盘中是由参数 sync_binlog 配置来决定的。

  1. sync_binlog=0:表示每次提交事务binlog不会马上写入到磁盘,而是先写到page cache,相对于磁盘写入来说写page cache要快得多,不过在Mysql 崩溃的时候会有丢失日志的风险
  2. sync_binlog=1:表示每次提交事务都会执行 fsync 写入到磁盘
  3. ync_binlog>1:表示每次提交事务都 先写到page cache,只有等到积累了N个事务之后才fsync写入到磁盘,同样在此设置下MySQL崩溃的时候会有丢失N个事务日志的风险

很显然三种模式下,sync_binlog=1是强一致的选择,选择0或者N的情况下在极端情况下就会有丢失日志的风险,具体选择什么模式还是得看系统对于一致性的要求。

一个事务的binlog是有完整格式的,statement格式(记录sql语句),最后会有个COMMIT;row格式(记录行的内容,记两条,更新前和更新后都有),最后会有个XID event

redo log 是属于引擎层(InnoDB)的日志,它的设计目标是支持InnoDB的事务特性中的持久性。

MySQL为了提升性能不会把每次的修改都实时同步到磁盘,而是会先存到Buffer Pool缓冲池里,把这个当作缓存来用。然后使用后台线程去做缓冲池和磁盘之间的同步,这就存在了系统故障时数据未同步的情况。而redo log能保证对于已经COMMIT的事务产生的数据变更,即使是系统宕机崩溃也可以通过它来进行数据重做。即一旦事务成功提交后,只要修改的数据都会进行持久化,不会因为异常、宕机而造成数据错误或丢失,所以解决了异常、宕机而可能造成数据错误或丢失问题,这就是redo log的核心职责。

写入策略

redo log占用的空间是一定的,并不会无限增大(可以通过参数设置),写入的时候是进顺序写的,所以写入的性能比较高。当redo log空间满了之后又会从头开始以循环的方式进行覆盖式的写入。在写入redo log的时候也有一个redo log buffer,日志什么时候会刷到磁盘(指的是redo log buffer刷到redo log file)是通过innodb_flush_log_at_trx_commit 参数决定。

  1. innodb_flush_log_at_trx_commit=0:表示每次事务提交时都只是把 redo log 留在 redo log buffer 中
  2. innodb_flush_log_at_trx_commit=1:表示每次事务提交时都将 redo log 直接持久化到磁盘
  3. innodb_flush_log_at_trx_commit=2:表示每次事务提交时都只是把 redo log 写到 page

除了上面几种机制外,还有其它两种情况会把redo log buffer中的日志刷到磁盘。

  1. 定时处理:有线程会定时(每隔 1 秒)把redo log buffer中的数据刷盘。
  2. 根据空间处理:redo log buffer 占用到了一定程度(innodb_log_buffer_size设置的值一半)占,这个时候也会把redo log buffer中的数据刷盘。

只有将innodb_flush_log_at_trx_commit设置为1才能够严格的保证数据不丢失,不然仍然存在宕机时redo log buffer未刷盘而造成数据无法重做的可能。

img

以上是redo log buffer在内存中的结构,可以看到这里有两个指针,write_pos表示当前写的位置,只要有记录更新了,write_pos就会往后移动。而check_point表示检查点,只要InnoDB将check_point指向的修改记录更新到了磁盘中,check_point将会往后移动。redo log buffer采用的是循环写,check point顺时针到write pos之间的数据是待刷盘数据,如果不刷盘数据则会被覆盖,write pos顺时针到check point之间则是可用的空间。

如果事务提交成功之后buffer pool中的数据还没有刷盘,这时MySQL宕机了,那么在重启的时通可以从redo log file中恢复数据,redo log file根据innodb_flush_log_at_trx_commit参数配置,通常最多丢失一秒的数据。

重做日志被设计成可循环使用,当日志文件写满时,重做日志中对应数据已经被刷新到磁盘的那部分不再需要的日志可以被覆盖重用。而判断哪部分日志可以覆盖重用就需要有一个标记。事实上,当数据库宕机时,数据库不需要重做所有的日志,只需要执行上次刷入点之后的日志。这个点就叫做Checkpoint,它解决了以下的问题:

  • 缩短数据库恢复时间
  • 缓冲池不够用时,将脏页刷新到磁盘
  • 重做日志不可用时,刷新脏页

InnoDB引擎通过LSN(Log Sequence Number)来标记版本,LSN是日志空间中每条日志的结束点,用字节偏移量来表示。每个page有LSN,redo log也有LSN,Checkpoint也有LSN。可以通过命令show engine innodb status来观察

  • Log sequence number(LSN1):当前系统LSN最大值,新的事务日志LSN将在此基础上生成(LSN1+新日志的大小)
  • Log flushed up to(LSN2):当前已经写入日志文件的LSN
  • Pages flushed up to(LSN3):当前最旧的脏页数据对应的LSN,写Checkpoint的时候直接将此LSN写入到日志文件
  • Last checkpoint at(LSN4):当前已经写入Checkpoint的LSN;

InnoDB实现了Fuzzy Checkpoint的机制,每次取到最老的脏页,然后确保此脏页对应的LSN之前的LSN都已经写入日志文件,再将此脏页的LSN作为Checkpoint点记录到日志文件,意思就是“此LSN之前的LSN对应的日志和数据都已经写入磁盘文件”。恢复数据文件的时候,InnoDB扫描日志文件,当发现LSN小于Checkpoint对应的LSN,就认为恢复已经完成

  1. 创建阶段:事务创建一条日志;
  2. 日志刷盘:日志写入到磁盘上的日志文件;
  3. 数据刷盘:日志对应的脏页数据写入到磁盘上的数据文件;
  4. 写CKP:日志被当作Checkpoint写入日志文件;

Checkpoint写入的位置在日志文件开头固定的偏移量处,即每次写Checkpoint都覆盖之前的Checkpoint信息。

参考文章:InnoDB Redo Flush及脏页刷新机制深入分析

3)对比

  • binlog只能用于归档,没有crash-safe能力,而redo log具有该能力
  • redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用
  • redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”
  • redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的(指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志),即 redo log 只会记录未刷盘的日志,已经刷入磁盘的数据都会从 redo log 这个有限大小的日志文件里删除。binlog 是追加日志,保存的是全量的日志。

虽然 binlog 拥有全量的日志,但没有一个标志让 innoDB 判断哪些数据已经刷盘,哪些数据还没有。而 redo log 每次刷盘会更新日志文件中的Check Point根据对应的LSN来判断该条操作是否已经落盘。所以redo log具有crash-safe能力

为了保证两份日志的逻辑一致性(redo log 和 binlog 都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致)MySQL采用了两阶段提交,即将redo log日志的写入拆分为两个步骤:preparecommit

img

如果不采用两阶段提交,那么有可能在写入redo log后准备写入binlog时发生crash,那么就会导致两个日志的数据不一致。而采用两阶段提交,即使在写入binlog系统崩溃,那么在进行数据恢复时,发现redo log处于prepare阶段,并且没有对应的binlog日志,就会进行事务的回滚,从而保证了一致性。即redo log 在进行数据重做时,只有读到了 commit 标识,才会认为这条 redo log 日志是完整的,才会进行数据重做,否则会认为这个 redo log 日志不完整,不会进行数据重做。而 commit 标识是在 binlog 日志写完之后才标志上的,所以保证了两个日志的一致性。

4)undolog

redo log保证的是持久性,成功修改后一定持久化到数据库中,故日志中主要记录的是事务执行过程中的修改情况。而想要保证事务的原子性,就需要在发生异常时,对已经执行的操作进行回滚,所以它主要记录的是修改前的值。即每次修改之前,都会将修改前的数据作为历史保存一份到undo日志中,数据里面还会记录操作该数据的事务ID,之后可以使用该事务ID进行回滚。

对于如下数据表(其中DB_TRX_ID和DB_ROLL_PTR是两个隐式字段,分别表示最近进行修改的事务id,而回滚指针):

img

如执行update user_info set name = “李四” where id=1时,就会将原本的数据先保存到undo log,随后将表中的数据值进行修改,并更改最近修改的事务id为当前事务id,以及让回滚指针指向刚保存到undo log的记录

img

undo log没有buffer,不然系统崩溃时还未将buffer刷到文件中则无法进行回滚

2、MVCC原理

MySQL实现最高事务隔离级别串行化时,使用的就是锁技术。在MySQL中使用的是读写锁,即在读时加共享锁,写时加互斥锁。允许读读并行,读写以及写写都不能并行。

MVCC(Multi-Version Concurrency Control)多版本并发控制是一种并发控制机制,指维护一个数据的多个版本,使得读写操作没有冲突。具体实现就是采用快照读,快照读为 MySQL 实现 MVCC 提供了一个非阻塞读功能。MVCC 的具体实现,还需要依赖于数据库记录中的隐式字段、undo log和read view

当前读和快照读

当前读:读取的数据总是最新的,实现的原理是对正在读的记录加锁,使得读写互斥,保证每次读的都是数据库中最新的数据

快照读:每次读取到的数据不一定是最新的数据,而是这条数据的快照版本,这样可以保证读写不互斥(读写分离),能够并发执行

1)隐式字段

在MySQL中有两个隐藏字段:

  • 最近修改的事务的id:记录着上一次修改该条记录的日志id
  • 回滚指针:指向上一个版本的记录的地址

2)undo log版本链

undo log 版本链是基于 undo log 实现的。undo log 中主要保存了数据的基本信息,比如说日志开始的位置、结束的位置,主键的长度、表id,日志编号、日志类型,记录了每个事务对数据行的修改的情况。

在这里插入图片描述

此外,undo log 还包含两个隐藏字段 trx_id 和 roll_pointer。trx_id 表示当前这个事务的 id,MySQL 会为每个事务分配一个 id,这个 id 是递增的。roll_pointer 是一个指针,指向这个事务之前的 undo log。

在这里插入图片描述

当事务并发执行修改某条记录的时候,不同的事务对该数据的修改会产生不同的版本,每次修改都会记录下这条记录之前的数据,并且在隐式字段中设置上本次操作的事务的id,并让回滚指针指向上一个版本,这样就会形成一条链表,即所谓的版本链。

3)ReadView

ReadView其实就是一个保存事务id的list列表,记录的是本事务执行时有哪些事务在执行,且还没有提交(即当前系统还有哪些活跃的读写事务),用于判断事务是否可以读取某个数据行的版本。

它主要包含:

  • m_ids:当前有哪些事务正在执行,且还没有提交,这些事务的 id 就会存在这里
  • min_trx_id:是指 m_ids 里最小的值
  • max_trx_id:是指下一个要生成的事务 id,下一个要生成的事务 id 肯定比现在所有事务的 id 都大
  • creator_trx_id:每开启一个事务都会生成一个 ReadView,而 creator_trx_id 就是这个开启的事务的 id

在访问某条记录时,只需要按照下面的步骤判断该记录在版本链中某个版本是否可见:

  1. 版本id小于最小事务id:表明生成该版本的事务在生成ReadView前已经提交,所以该版本可以被当前事务访问
  2. 版本id大于最大事务id:表名生成该版本的事务在生成ReadView之后才提交,所以该版本不可以被当前事务访问
  3. 版本id大于最小事务id,小于最大事务id:
    1. 包含在m_ids中:表明ReadView创建的时候该事务还是活跃的,该版本不可被访问
    2. 不包含在m_ids中:表明ReadView创建的时候该事务已经提交,该版本可以被访问

即当事务id在m_ids中,或者大于m_ids中最大的事务id的时候,这个版本就不能被访问

如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本,如果最后一个版本也不可见的话,那么就意味着该条记录对该事务不可见,查询结果就不包含该记录。查询到便使用undo log来恢复该数据行的历史版本。

4)RC和RR的区别

RC级别时,在一个事务中每一次查询都会生成一次读视图,使得每次读取都有了不同的ReadView,就可以读到已经提交了的数据,造成不可重复度的问题

RR级别时,在一个事务中只有**第一次查询(查询时才生成)**会生成一次视图,后面的查询都是用的这个读视图,保证了可重复读,因为数据对于当前事务的可见性和第一次是一样的,从unlog中读到的数据快照和第一次是一样的,中途即使有事务修改也读取不到。

MySQL的RR不止防止了不可重复读问题,同时也避免了幻读的发生,因为在事务执行过程中如果其他事务插入了一些新的数据,由于版本号的问题,当前事务仍然是读不到的。

5)存在问题

由于MVCC使用的是快照读(普通的select则就是快照读),所以再查询出数据后如果需要进行修改可能会存在脏写的情况,即别的事务已经将数据修改了,而当前事务并没有读取到新的数据就进行修改,则会出现写覆盖,即脏写的问题。

解决脏写有两种方式:

  1. 采用乐观锁:为记录增加一个版本号(某些情况下也可以使用查询时读取出的字段值作为where的查询条件),每次修改的时候都会为版本号+1。事务在最开始读取的数据的时候读出版本号,后面修改时比较记录版本号是否一致,若不一致则不允许修改(即CAS机制,后续可继续尝试)。
  2. 采用当前读:在mysql中insert、update、delete、select xxx for update都是当前读(且会加写锁),那么修改时可以采用update xxx set xxx = xxx - 1 where id = 1,此时就会读取到最新的xxx值去进行修改

3、可串行化原理

MySQL有以下两种锁

  • 读锁(共享锁、S锁):select … lock in share mode
    • 读锁是共享的,多个事务可以同时读取同一个数据,但不允许其他事务修改
  • 写锁(排它锁、X锁):select … for update
    • 写锁是排它的,会阻塞其他的写锁和读锁,update、insert和delete都会加写锁

而串行化的实现非常简单,其实就是对默认的select语句加上lock in share mode,即加上共享锁。那么由于修改语句都会加写锁,因此实现了阻塞

参考文章:

Mysql buffer pool详解 - 奕锋博客 - 博客园 (cnblogs.com)

redo log —— MySQL宕机时数据不丢失的原理

Mysql 核心日志(redolog、undolog、binlog)

MySql事务之两阶段提交与redo log、binlog

基于Redo Log和Undo Log的MySQL崩溃恢复流程

Mysql基础(十二):隔离/锁/MVCC/ReadView

Q.E.D.


   七岁几胆敢预言自己,操一艘战机