Mysql事务及其原理

什么是事务

说到事务,首先想到 ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性)

MySQL 事务提供三个基本的事务指令:

  • 可以使用 start transaction 或者 begin 开启事务。
  • 使用 commit 提交当前事务,此时的数据才会真正进入持久化的流程
  • 使用 rollback 回滚当前事务,事务的修改会被取消
  • SET autocommit 可以启用或者禁用自动提交事务模式

默认,autocommit 是被开启的,这就保证了单个语句具有原子性,当然了,这也意味着每个语句执行成功就不能使用 rollback 进行回滚了。但是语句如果执行出错,还是会被自动回滚的。如果这个时候使用开启事务的命令,autocommit 就会被禁用,一直到提交或者回滚事务。

原子性

原子性概念很简单,即事务中的所有操作要么一起都完成,要么一起都不完成,不可分割。

做一个尝试,见 1.原子性

一致性

一致性是指在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设约束、触发器、级联回滚等。

隔离性

一致性是指数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括未提交读(Read uncommitted)、提交读(read committed)、可重复读(repeatable read)和串行化(Serializable)。

验证代码 2.隔离性

持久性

持久性是指事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

四个隔离级别

在理解隔离界别前,我们首先要了解数据库事务所面临的问题:脏读、幻读、不可重复读

什么是脏读、幻读、不可重复读呢?

  • 脏读:脏读是指一个事务中访问到了另外一个事务未提交的数据。理解:两个事务完全没有隔离,彼此操作都完全看得见,这就导致了并发情况下数据不一致
  • 幻读:一个事务读取 2 次,得到的记录条数不一致。理解:两个事务隔离了对于新增、删除数据的访问。也就是说就算其他事务新增、删除了数据,我查询一个范围的时候查到的还是我事务开始时的那些数据,不会因为其他事务的增删而导致结果出错
  • 不可重复读:一个事务读取同一条记录 2 次,得到的结果不一致。理解:两个事务隔离了同一条记录修改,也就是说就算其他事务修改了,我看到的还是之前的,这样就能保证我针对这一条数据的逻辑一定准确

脏读、幻读、不可重复读产生了什么问题呢?

要回答这个问题,首先应该了解并发的概念:如果我们的所有业务请求都是原子性的,那么我们完全不需要担心数据的操作会超出我们的预期,例如一个业务设置某个数为 2,不论十个请求还是一亿个请求进来都无所谓,反正先来后到,就算你一起来都行,最终都是一步到位。但是事与愿违,几乎不可能有这么简单的业务,起码也是 先查一查数据是不是为2,如果是2就设置为4,否则设置为8 这种级别,这时候就不能不先 getData(),然后 setData()了,正常情况下是这样,但是如果两个请求一起进来呢?理想情况下我们觉得因该是是 a 请求先查询是 2 然后修改成 4,然后 b 请求查询到 4 修改成 8,或者反过来 b 请求先查询是 2 然后修改成 4,然后 a 请求查询到 4,修改成 8。然而,事与愿违的是有可能 a 请求查了查是 2,此时 b 请求也查了查,嗯,是 2,然后 b 请求率先把 2 改成了 4。此时 a 请求才慢悠悠的过来改数据(因为并发情况下,没有人能预测谁会先走到哪一步),因为之前 a 已经看到了是 2,所以就信誓旦旦的直接设置成 4。这时,问题就来了,请问,这段逻辑是正确的吗

你可能会说,其实没什么问题,影响不大,他们单个的逻辑其实是正确的,也无所谓,修改个数字而已,大不了再来一次。确实,很多情况下,就是这样,没毛病,就像电脑系统出了问题大不了重启,这种情况下,虽然逻辑可能是错误的,但是最终结果其实无关紧要,这也就是可容忍的

但是如果把这个数字换成自己的账户,那就不可容忍

从上面的问题,可以看出,我们看问题时一定要看到这个逻辑是不是可容忍的错误,然后根据开发难度(你可能是个完全不会并发的小白)、执行效率(ab 之间都互相不管对方做了啥都坚持自我其实效率是最高的)、安全性(如果是我的钱包缩水那绝对不能容忍了)等方面综合评估。

MySQL 无法预估开发者的容忍度,而要一步到位完全解决这三个问题,那带来的效率损失不可估量,所以它为你提供了一步一步降低错误出错的方案,帮助你去做到你最想要的权衡,这也就是事务隔离性:

MySQL 提供了四个隔离级别:

  • Read Uncommitted:读未提交
  • Read Committed: 读已提交
  • Repeatable Read: 可重复读
  • Serializable: 串行化

根据验证 2.隔离性所得出的结果来看,四个隔离级别对应的结果:

  • 读未提交:事务 1 可以读取到事务 2 修改过但未提交的数据(产生脏读,幻读,不可重复度)
  • 读已提交:事务 1 只能在事务 2 修改过并且已提交后才能读取到事务 2 修改的数据(产生幻读,不可重复度)
  • 可重复读:事务 1 只能在事务 2 修改过数据并提交后,自己也提交事务后,才能读取到事务 2 修改的数据(产生幻读)
  • 串行化:事务 1 在执行过程中完全看不到事务 2 对数据库所做的更新。当两个事务同时操作数据库中相同数据时,如果事务 1 已经在访问该数据,事务 2 只能停下来等待,必须等到事务 1 结束后才能恢复运行

但是在这里我建议记忆的顺序应该是脏读、不可重复读、幻读,每个隔离级别依次递增的解决每一个问题,最后通过串行化,完美解决三个问题。

事务的实现原理

实现原理其实不用到处找,官方文档就有,这里我是稍微整理了一下,具体查看地址 https://dev.mysql.com/doc/refman/5.7/en/mysql-acid.html

事实上ACID每个性质的实现并不能单独提取出来说明原理,因为互相交融杂错,是共同的结果,但是为了更好的理解透彻每一个要点,我这里还是选择单独提出来进行说明,其中难免会互相混杂,请保持思路。

原子性原理

原子性其实代表的就是 commit 和 rollback 两个操作。对于此,MySQL 提供一个 undo log(回滚日志)的日志来实现原子性,undo log 是 MySQL innodb 存储引擎所携带的日志

当事务对数据库进行修改时,InnoDB 会生成对应的 undo log,然后如果需要回滚,就会调用利用 undolog 进行反向操作从而达到回滚的目的。

例如当 update 时,会记录被修改行的主键(以便知道修改了哪些行)、修改了哪些列、这些列在修改前后的值等信息,回滚时便可以使用这些信息将数据还原到 update 之前的状态。

知道可以回滚了,接下来就自然想到一种情况,如果事务 a 修改了列,但未提交,事务 b 再次修改此列,会发生什么情况呢?该怎么回滚呢?

根据这种思路,我做了一个尝试,重现代码见 3.修改同列

可以看到,当在两个事务中尝试修改同一行数据时,第二次修改等待锁,而尝试了各种隔离级别后,均会如此,也就是说,事务 b 将在事务 a 执行过程中,为了保证一致性,不会允许事务 b 修改事务 a 中修改的行,即使是最低的隔离级别。

由此可见,实现原子性的第二个关键就是锁。它保证事务 B 中的操作不会干扰事务 a,保证操作整体的原子性。事实上,在事务中,写入数据总是会加排他锁,直到事务结束时才释放。

一致性原理

要处理一致性,其实也就是指任何时刻无论宕机甚至断电,数据都不会受到影响,MySQL如何做到这一点呢?

redo log

这部分可参见下文持久性原理

Double Write

MySQL读写数据是以(page)为单位,默认为16k。而文件系统IO的最小单位是4K(也有1K的),磁盘IO的最小单位是512字节。

因此,存在IO写入导致page损坏的风险:

当MySQL要写入数据时,可能写入一部分就断电了,那么磁盘数据库这个数据页就是不完整的,是一个坏掉的数据页。redo log只能加上旧、校检完整的数据页恢复一个脏块,不能修复坏掉的数据页,所以这个数据就丢失了,可能会造成数据不一致。redo log记录的是对页的物理修改,如果页本身已经损坏,重做日志也无能为力。所以需要double write。

doublewrite由两部分组成,一部分为内存中的doublewrite buffer,其大小为2MB,另一部分是磁盘上共享表空间(ibdata x)中连续的128个页,即2个区(extent),大小也是2M。

  1. 当一系列机制触发数据缓冲池中的脏页刷新时,并不直接写入磁盘数据文件中,而是先拷贝至内存中的doublewrite buffer中;
  2. 接着从两次写缓冲区分两次写入磁盘共享表空间中(连续存储,顺序写,性能很高),每次写1MB;
  3. 待第二步完成后,再将doublewrite buffer中的脏页数据写入实际的各个表空间文件(离散写);(脏页数据固化后,即进行标记对应doublewrite数据可覆盖)

如果操作系统在将页写入磁盘的过程中发生崩溃,在恢复过程中,innodb存储引擎可以从共享表空间的doublewrite中找到该页的一个最近的副本,将其复制到表空间文件,再应用redo log,就完成了恢复过程。

因为有副本所以也不担心表空间中数据页是否损坏。

Q:为什么log write不需要doublewrite的支持?

A: 因为redolog写入的单位就是512字节,也就是磁盘IO的最小单位,所以无所谓数据损坏。

double write带来的写负载

  1. double write是一个buffer, 但其实它是开在物理文件上的一个buffer, 其实也就是file, 所以它会导致系统有更多的fsync操作, 而硬盘的fsync性能是很慢的, 所以它会降低mysql的整体性能。
  2. 但是,doublewrite buffer写入磁盘共享表空间这个过程是连续存储,是顺序写,性能非常高,(约占写的%10),牺牲一点写性能来保证数据页的完整还是很有必要的。

DoubleWrite默认启用,如果需要禁用,可以设置innodb_doublewrite为0。

MySQL官方文档提到, Fusion-io 设备也会默认禁用,因为支持原子写,至于Fusion-io设备是啥,暂时还未了解。

关闭double write适合的场景

  1. 海量DML
  2. 不惧怕数据损坏和丢失
  3. 系统写负载成为主要负载

使用mysql> show global status like '%dblwr%';可监控doublewrite 的工作负载。

隔离性原理

隔离性在不同的隔离级别下层层附加实现,所以分开讲解。

隔离性原理-读未提交

根据MySQL的事务隔离级别,最低的是读未提交,按照字面意思,一个事务还没提交时,它做的变更就能被别的事务看到。也就是说要保证两个事务之间能够读同一条数据,但是不能写同一条数据,因为事务需要互相隔离,这可以参考 3.修改同列 的重现过程。

在这种情况下与无事务一样,读数据是不加锁(除非显式加锁),只有在写数据时会加排他锁,直到事务结束之后再释放。

隔离性原理-读已提交

在这种场景下,写数据加排他锁,但是读数据却需要处理了,需要的是读取其他事务已经提交的数据。

按照正常的思路,解决方案可以是加读写锁,但是需要知道的是,其他事务添加的排他锁会直到其他事务提交才会解锁,其他事务可能是一个长事务,这就会导致本事务一直在等待其他事务提交,性能上肯定会产生问题,如果可以把读写分开,把要读的数据记录下来,不论怎么更新,都只读这个记录,直到其他事务提交之后,再把真正修改的数据写入,就能够不使用锁实现读已提交了。

这里说不使用锁并不是说完全不使用,因为是多线程,在实际实现代码时肯定会需要锁来控制,但是粒度很小,所以性能损耗忽略不计,学习的时候还是要注意宏观。

MySQL给出了MVCC(多版本并发控制)的解决方案,它的核心逻辑与CopyOnWrite类似,在更改数据的时候,不直接在原数据上进行更改,而是预先“复制”一份,而读数据时,则直接读取原始版本。

但是MVCCCopyOnWrite实现区别很大,它被成为多版本并发控制,是因为它的实际操作是每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。这与CopyOnWrite直接复制完全不同,所以MVCC的“复制”需要加引号。

MVCC的回滚记录使用undo log(撤销日志)进行记录,当一个事务进行更新时,把值直接写入到行,同时在undo log中记录上一版本的值。也就是说如果想要读取上一次的值,则需要将当前值根据当前undo log的记录进行一次撤销即可。那么当需要读取数据时,直接读取最新的快照即可实现读已提交,因为最新的快照一定是最新的已提交事务产生的。

undo log记录的是主键索引,也就是说记录的是一行。若多个事务同时修改一行记录,同时只能有一个事务对此行进行修改,直到提交放弃锁,然后另外一个事务执行修改。

隔离性原理-可重复读

使用MVCC后,因为有了历史数据,那么可重复读就已经很容易实现了。

首先要知道,在MySQL中,每个事务都有自己的id,这个id在事务启动时就已经确定,这个id是自增的。并且,在事务启动的瞬间,MySQL会为当前事务构造一个数组,用于记录这一瞬间在“活跃”(启动了但还未提交)的所有的事务id。

这里还有几个术语:

  • 低水位:当前活跃事务数组中最小的事务id
  • 高水位:当前所有事务最大的事务id + 1

通过这个事务数组+高水位构成了一致性视图(read-view),数组是用来判断该事务有没有在生成这个当前事务的时候提交。

而表中的每行数据都有一个row trx_id属性,它记录的就是这一行是由哪个事务更新的。

这样,当事务启动时,我们需要读取的行的row rtx_id可能有三种情况:

  • row rtx_id 小于低水位,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的
  • row rtx_id 大于等于高水位,表示这个版本是之后的事务生成的,这个数据是不可见的
  • row rtx_id 大于等于低水位且小于高水位,此时还得分为两种情况:
    • row rtx_id 在事务数组中,表示这个版本是当前事务启动瞬间还没提交的其他事务生成的,这个数据是不可见的。
    • row rtx_id 不在事务数组中,表示这个版本是当前事务启动瞬间前已经提交的事务产生的,这个数据是可见的。事务启动时看到的一批活跃事务id 不一定都是连续的,比如99,100,102,104,105。而事务id又是严格递增的,这是因为 101,103 事务虽然晚于99事务启动,但先提交了。所以也应该可见。

通过这样的规则,就可以确定此行数据是否对于当前事务可见,首先查看表中此行数据的row trx_id,确定表中数据是否可见,如果可见直接读此值,如果不可见,则需要配合undo log进行撤销回滚,若读到的row trx_id仍然不可见,则继续撤销回滚,直到确定数据可见,读取此值。

事实上这里还是有例外的,考虑这样一个问题,如果当前事务进行update C set value = value + 1这样的更新时,它的value是按照MVCC可见性原则读到的还是读最新的数据呢?

在MySQL的处理中,这种会直接读取最新值,不能为了隔离性而抛弃其他事务所做的更改,这种读被称为当前读(current read)。除了这种更新,还有select * from C where id=1 lock in share mode或者select * from C where id=1 for update都会使用当前读,而放弃可见性的原则。

隔离性原理-串行化

这个级别很简单,读加共享锁,写加排他锁,读写互斥。使用的悲观锁的理论,实现简单,数据更加安全,但是并发能力非常差。如果你的业务并发的特别少或者没有并发,同时又要求数据及时可靠的话,可以使用这种模式。

这里需要注意改变一个观念,不要看到select就说不会加锁了,在Serializable这个级别,还是会加锁的。

持久性原理

持久性是指事务一旦提交,它对数据库的改变就应该是永久性的。要实现这一点,就必须保证每一次写入都被持久化下来。

众所周知,数据库的主要瓶颈就是 io,MySQL 提高性能关键就在于减少 io。如果是为了持久性就让每次写入都放进磁盘,那么数据库效率将极低,为了能够提高效率,首先就会想到,把数据的写入放到内存中,然后定期一起写进磁盘。而 MySQL 由此提出的解决方案为 Buffer Pool,Buffer Pool 中包含了磁盘中部分数据页的映射, 当取数据时,先从 buffer pool 中读取,如果没有则去数据库读取然后放入到 buffer pool 中,请注意,这里一般取是直接取一页,也就是说可能不止你查询的数据,而是可能更多的数据取到了 buffer 中。当写入数据时,会先写入到 buffer pool 中。

其中,先写入到 buffer pool 这一部分我标记了一下,这里再展开说说详细过程,buffer pool 包含一部分特殊的 buffer,它叫 Change Buffer。change buffer 记录了那些对使用二级索引但数据没有在 Buffer Pool 的数据进行增、删、改的操作。这句话很绕,但是有几个关键点,必须分开来说,首先是记录增删改操作,这个很好理解,如果我们的操作每一次都直接落实到了磁盘,那么必然会导致 io 飙升,这显然是不行的,为此,才把增删改操作给放进 change buffer。其次是没有在 buffer pool 中的数据,这也很容易理解,因为如果数据就已经在内存中了,那么本身就不必写入磁盘,写完内存之后,再由 buffer pool 统一刷数据进磁盘就好。那就难在这句二级索引,为什么要是二级索引,不能是主键索引呢?首先根据前面说的,数据此时仅在磁盘中,并没有在 buffer pool 中,试想,如果要插入一个表中已经存在的主键的行,那么首先就会需要判断主键是否存在,此时就一定需要读取磁盘了,既然都已经读取了磁盘,也就可以直接放在 buffer pool 中了,那么直接使用 buffer pool 还更快,也就不需要这个 change buffer 了,然而对于二级索引通常是没有唯一需求的,并且插入二级索引通常是随机 io,而不像主键索引有顺序,我们都知道顺序 io 速度要远大于随机 io 的。所以,可以采用此方式来进行优化。

因为 buffer pool 的使用,使得数据库的效率大大提高了,但是却失去了持久性,因为部分数据并没有切实写进磁盘中,而是放到了内存中。当服务突然宕机,会导致还没来得及刷盘的写入操作失效。

为了解决这个问题,又引入了 redo log。当事务提交时,会优先写入 redo log,然后再写入到 buffer pool 中。当服务突然宕机后,会根据 redolog 来进行数据恢复。

但是这又引入了新的问题,redolog 也是磁盘 io 操作,它的性能问题又怎么办呢?

  • 首先,虽然 redolog 是 io 操作,但是它与 buffer pool 的不同在于,它是顺序 io,顺序 io 操作会明显快于随机 io。
  • buffer pool 并不是以每条数据为单位的,而是为了效率以数据页为单位,MySQL 默认数据也大小为 16kb。一个 page 上有很多数据,任何一个小修改都会需要刷入磁盘。而 redolog 是以一条条操作为单位,不会写入其他数据,写入量大大减小。

当然了,尽管如此,每次操作都会附带 io 仍然会降低性能。为之,就像套娃一样,MySQL 又为 redolog 提供了不同的写入时机:

  • 默认每次事务提交时写入磁盘
  • 每次事务提交时写到系统页面缓存,不保证写入磁盘
  • 每秒写入一次,也就是说 crash 仅最多丢失一秒的数据