【ⓂMySQL】你说熟悉MySQL事务,那来谈谈事务的实现原理吧!

事务的四大特性(ACID):原子性(Atomicity)、一致性(Consistency)、隔离型(Isolation)以及持久性(Durability)。

事务想要做到什么效果?无非是要做到可靠性以及并发处理:

  • 可靠性:数据库要保证当insert或update操作时抛异常或者数据库crash的时候需要保障数据的操作前后的一致,想要做到这个,我需要知道我修改之前和修改之后的状态,所以就有了undo logredo log
  • 并发处理:也就是说当多个并发请求过来,并且其中有一个请求是对数据修改操作的时候会有影响,为了避免读到脏数据,所以需要对事务之间的读写进行隔离,至于隔离到什么程度就得看业务系统的场景了,实现这个就得用MySQL的隔离级别。

redo log

redo log叫做重做日志,是用来实现事务的持久性。该日志文件由两部分组成:重做日志缓冲(redo log buffer)以及重做日志文件(redo log),前者是在内存中,后者在磁盘中。

当事务提交之后会把所有修改信息都会存到该日志中。

buffer pool

InnoDB 引擎存储数据的时候,是以页为单位的,每个数据页的大小默认是 16KB。

-- 查看页的大小 16384/1024=1616KB
show variables like '%innodb_page_size%';

计算机在存储数据的时候,最小存储单元是扇区,一个扇区的大小是 512 字节,而文件系统(例如 XFS/EXT4)最小单元是块,一个块的大小是 4KB,也就是四个块组成一个 InnoDB 中的页。我们在 MySQL 中针对数据库的增删改查操作,都是操作数据页,其实就是操作磁盘。

但是你想想,如果每一次操作都操作磁盘,那么就会产生海量的磁盘 IO 操作,如果是传统的机械硬盘,还会涉及到很多随机IO操作,效率非常低。这会严重影响MySQL的性能。

为了解决这一问题,MySQL 引入了 buffer pool,也就是我们常说的缓冲池。

buffer pool 的主要作用就是缓存索引和表数据,以避免每一次操作都要进行磁盘IO,通过buffer pool可以提高数据的访问速度。

-- 查看缓冲池的大小 134217728/1024/1024=128MB
show variables like '%buffer_pool_size%';

默认大小是 128MB。一般来说,如果一个服务器只是运行了一个MySQL服务,我们可以设置 buffer pool 的大小为服务器内存大小的 75%~80%。

change buffer

前面我们说的buffer pool虽然提高了访问速度,但是增删改的效率并没有因此提升,当涉及到增删改的时候,还是需要磁盘IO,那么效率一样非常低。

为了解决这个问题,MySQL 中引入了change buffer。change buffer 以前并不叫这个名字,以前叫 insert buffer,即只针对 insert 操作有效,现在改名叫 change buffer 了,不仅仅针对 insert 有效,对 delete 和 update 操作也是有效的,change buffer 主要是对非唯一的索引有效,如果字段是唯一性索引,那么更新的时候要去检查唯一性,依然无法避免磁盘 IO。

change buffer就是说,当我们需要更改数据库中的数据的时候,我们把更改记录到内存中,等到将来数据被读取的时候,再将内存中的数据merge到buffer pool然后返回,此时buffer pool中的数据和磁盘中的数据就会有差异,有差异的数据我们称之为脏页,在满足条件的时候(redo log写满了、内存写满了、其他空闲时候),InnoDB 会把脏页刷新回磁盘。这种方式可以有效降低写操作的磁盘IO,提升数据库的性能。

-- innodb_change_buffer_max_size:表示 change buffer 的大小占整个缓冲池的比例,默认值是 25%,最大值是 50%
-- innodb_change_buffering:表示哪些写操作会用到 change buffer,默认的 all 表示所有写操作
show variables like '%change_buffer%';
  • innodb_change_buffer_max_size:这个配置表示 change buffer 的大小占整个缓冲池的比例,默认值是 25%,最大值是 50%。
  • innodb_change_buffering:这个操作表示哪些写操作会用到 change buffer,默认的 all 表示所有写操作,我们也可以自己设置为 none/inserts/deletes/changes/purges 等。

不过change buffer和buffer pool都涉及到内存操作,数据不能持久化,那么,当存在脏页的时候,MySQL 如果突然挂了,就有可能造成数据丢失(因为内存中的数据还没写到磁盘上),但是我们在实际使用MySQL的时候,其实并不会有这个问题,那么问题是怎么解决的?那就得靠 redo log 了。

redo log

WAL 预写日志

WAL 全称是 Write-Ahead Logging,中文预写日志。就是说MySQL的写操作并不是立刻更新到磁盘上,而是先记录在日志上,然后在合适的时间再更新到磁盘上,这样的好处是错开高峰期的磁盘 IO,提高 MySQL 的性能。

配合上前面的buffer pool和change buffer,WAL 就是说在操作buffer pool和change buffer之前,会先把记录写到redo log日志中,然后再去更新buffer pool或者change buffer,这样,即使系统突然崩了,将来也可以通过 redo log 恢复数据。

当然,redo log 本身又分为:

  • 日志缓冲(redo log buffer),该部分日志是易失性的。
  • 重做日志(redo log file),这是磁盘上的日志文件,该部分日志是持久的。

有人会说写redo log不就是磁盘IO吗?而写数据到磁盘也是磁盘 IO,既然都是磁盘IO,那干嘛不把直接把数据写到磁盘呢?

其实,写redo log跟写数据有一个很大的差异,那就是redo log是顺序 IO,而写数据涉及到随机IO,写数据需要寻址,找到对应的位置,然后更新/添加/删除,而写redo log则是在一个固定的位置循环写入,是顺序IO,所以速度要高于写数据。

redo log buffer

我们说数据的变化是先写入redo log中,并不是上来就写磁盘,也是先写到内存中,即redo log buffer,在时机成熟时,再写入磁盘,也就是redo log file。

-- redo log buffer大小
-- 16777216/1024/1024 = 16MB
show variables like '%innodb_log_buffer_size%';

数据的变更都会首先记录在这块内存中。我们知道,MySQL的增删改,如果我们没有显式的开启事务,MySQL 内部也是有一个事务存在的,当内部这个事务 commit 的时候,redo log buffer 会持久化到磁盘中。

具体来说,有如下几个持久化时机:

1)innodb_flush_log_at_trx_commit

通过 innodb_flush_log_at_trx_commit 参数来控制持久化时机,该参数默认值为 1。

show variables like 'innodb_flush_log_at_trx_commit';

当然开发者可根据自己的实际需求修改该参数。该参数有三种取值,含义分别如下:

  • 0:每秒一次,将redo log buffer中的数据刷新到磁盘中。
  • 1:每次commit时,将 redo log buffer 中的数据刷新到磁盘中,即只要 commit 成功,磁盘上就有对应的 redo log 日志,这是最安全的情况,也是推荐使用的参数。
  • 2:每次commit时,将 redo log buffer 中的数据刷新到操作系统缓存中,操作系统缓存中的数据每秒刷新一次,会持久化到磁盘中。

2)当 redo log buffer 的使用量达到 innodb_log_buffer_size 的一半时,将其写入磁盘成为 redo log file

3)MySQL 关闭时,将 redo log buffer 写入磁盘成为 redo log file。

那如果redo log buffer中的数据还没有磁盘,MySQL 就挂了该怎么办?没写入磁盘,说明你还没 commit,既然没 commit,那就数据修改操作都还没有完成,那只能丢了就丢了,如果已经 commit 了,那么数据就会持久化到 redo log file 中,此时即使 MySQL 挂了,将来 MySQL 重启恢复了,数据也是可以被恢复的。

redo log落盘

还有一个需要大家注意的问题就是 redo log 落盘,落盘的数据从哪里来?是从 redo log 日志中来还是从 buffer pool 中来?

由于 redo log 并没有记录数据页的完整数据,所以正常的落盘其实用不到 redo log,数据落盘的时机到了时,直接拿着将脏页(buffer pool)持久化到磁盘中即可。

undo log

undo log 叫做回滚日志,用于记录数据被修改前的信息。它正好跟前面所说的重做日志所记录的相反,重做日志记录数据被修改后的信息。undo log主要记录的是数据的逻辑变化,为了在发生错误时回滚之前的操作,需要将之前的操作都记录下来,然后在发生错误时才可以回滚。

每次写入数据或者修改数据之前都会把修改前的信息记录到 undo log。

undo log 有什么作用?

undo log 记录事务修改之前版本的数据信息,因此假如由于系统错误或者rollback操作而回滚的话可以根据undo log的信息来进行回滚到没被修改前的状态。

undo log是用来回滚数据的用于保障未提交事务的原子性

MySQL锁技术

当有多个请求来读取表中的数据时可以不采取任何操作,但是多个请求里有读请求,又有修改请求时必须有一种措施来进行并发控制。不然很有可能会造成不一致。

解决上述问题很简单,只需用两种锁的组合来对读写请求进行控制即可,这两种锁被称为:

共享锁(shared lock),又叫做"读锁"

读锁是可以共享的,或者说多个读请求可以共享一把锁读数据,不会造成阻塞。

排他锁(exclusive lock),又叫做"写锁"

写锁会排斥其他所有获取锁的请求,一直阻塞,直到写入完成释放锁

MVCC基础

MVCC (MultiVersion Concurrency Control) 叫做多版本并发控制。

InnoDB的 MVCC ,是通过在每行记录的后面保存两个隐藏的列来实现的。这两个列, 一个保存了行的创建时间,一个保存了行的过期时间,当然存储的并不是实际的时间值,而是系统版本号。

MVCC在MySQL中的实现依赖的是undo log与read view:

  • undo log:undo log 中记录某行数据的多个版本的数据。
  • read view:用来判断当前版本数据的可见性

事务的实现

  • 事务的原子性是通过 undo log 来实现的
  • 事务的持久性是通过 redo log 来实现的
  • 事务的隔离性是通过 (读写锁+MVCC)来实现的
  • 事务的一致性是通过原子性,持久性,隔离性来实现的

原子性,持久性,隔离性的目的都是为了保障数据的一致性!

ACID只是个概念,事务最终目的是要保障数据的可靠性,一致性。

原子性的实现

一个事务中的所有操作要么全部成功提交,要么全部失败回滚,对于一个事务来说不可能只执行其中的部分操作,这就是事务的原子性。

概念大家都了解,那么数据库是怎么实现的呢?就是通过回滚操作。

所谓回滚操作就是当发生错误异常或者显式的执行rollback语句时,需要把数据还原到原先的模样,所以这时候就需要用到undo log来进行回滚。

  • 每条数据变更(insert/update/delete)操作都伴随一条undo log的生成,并且回滚日志必须先于数据持久化到磁盘上
  • 所谓的回滚就是根据回滚日志做逆向操作,比如delete的逆向操作为insert,insert的逆向操作为delete,update的逆向为update等。

为什么先写日志后写数据库?

持久性的实现

事务一旦提交,其所作做的修改会永久保存到数据库中,此时即使系统崩溃修改的数据也不会丢失。

MySQL的表数据是存放在磁盘上的,因此想要存取的时候都要经历磁盘IO,然而即使是使用SSD磁盘IO也是非常消耗性能的。

为此,为了提升性能InnoDB提供了缓冲池(Buffer Pool),Buffer Pool中包含了磁盘数据页的映射,可以当做缓存来使用:

  • 读数据:会首先从缓冲池中读取,如果缓冲池中没有,则从磁盘读取在放入缓冲池;
  • 写数据:会首先写入缓冲池,缓冲池中的数据会定期同步到磁盘中;

上面这种缓冲池的措施虽然在性能方面带来了质的飞跃,但是它也带来了新的问题,当MySQL系统宕机,断电的时候可能会丢数据!

因为我们的数据已经提交了,但此时是在缓冲池里头,还没来得及在磁盘持久化,所以我们急需一种机制需要存一下已提交事务的数据,为恢复数据使用。

于是 redo log就派上用场了。redo log是什么时候产生的呢?

事务开始之后就产生redo log,redo log的落盘并不是随着事务的提交才写入的,而是在事务的执行过程中,便开始写入redo log文件中。

既然redo log也需要存储,也涉及磁盘IO为什么还用它?

  • redo log 的存储是顺序存储,而缓存同步是随机操作。
  • 缓存同步是以数据页为单位的,每次传输的数据大小大于redo log。

隔离性实现

隔离性是事务ACID特性里最复杂的一个。在SQL标准里定义了四种隔离级别,每一种级别都规定一个事务中的修改,哪些是事务之间可见的,哪些是不可见的。

级别越低的隔离级别可以执行越高的并发,但同时实现复杂度以及开销也越大。

Mysql 隔离级别有以下四种(级别由低到高):

  • READ UNCOMMITED (未提交读)
  • READ COMMITED (提交读)
  • REPEATABLE READ (可重复读)
  • SERIALIZABLE (可重复读)

前面说过原子性,隔离性,持久性的目的都是为了要做到一致性,但隔离性跟其他两个有所区别,原子性和持久性是为了要实现数据的可靠性保障,比如要做到宕机后的恢复,以及错误后的回滚。

那么隔离性是要做到什么呢?隔离性是要管理多个并发读写请求的访问顺序。这种顺序包括串行或者是并行。

总之,从隔离性的实现可以看出这是一场数据的可靠性与性能之间的权衡。

  • 可靠性高的,并发性能低(比如 Serializable)
  • 可靠性低的,并发性能高(比如 Read Uncommited)

READ UNCOMMITED (未提交读)

在READ UNCOMMITTED隔离级别下,事务中的修改即使还没提交,对其他事务是可见的。事务可以读取未提交的数据,造成脏读。

因为读不会加任何锁,所以写操作在读的过程中修改数据,所以会造成脏读。好处是可以提升并发处理性能,能做到读写并行。

  • 优点:读写并行,性能高
  • 缺点:造成脏读

READ COMMITED (提交读)

一个事务的修改在它提交之前的所有修改,对其他事务都是不可见的。其他事务能读到已提交的修改变化。在很多场景下这种逻辑是可以接受的。

InnoDB在 READ COMMITTED(读已提交),使用排它锁,读取数据不加锁而是使用了MVCC机制。或者换句话说它采用了读写分离机制。

但是该级别会产生不可重读以及幻读问题。

什么是不可重读?

在一个事务内多次读取的结果不一样。

为什么会产生不可重复读?

这跟 READ COMMITTED 级别下的MVCC机制有关系,在该隔离级别下每次 select的时候新生成一个版本号,所以每次select的时候读的不是同一个副本而是不同的副本。

在每次select之间有其他事务更新了我们读取的数据并提交了,那就出现了不可重复读。

REPEATABLE READ (可重复读,MySQL默认隔离级别)

在一个事务内的多次读取的结果是一样的。这种级别下可以避免脏读,不可重复读等查询问题。mysql 有两种机制可以达到这种隔离级别的效果,分别是采用读写锁以及MVCC。

采用读写锁实现

为什么能可重复读?只要没释放读锁,再次读的时候还是可以读到第一次读的数据。

  • 优点:实现起来简单
  • 缺点:无法做到读写并行

采用MVCC实现

为什么能可重复读?因为多次读取只生成一个版本,读到的自然是相同数据。

  • 优点:读写并行
  • 缺点:实现的复杂度高

但是在该隔离级别下仍会存在幻读的问题。

SERIALIZABLE (序列化)

该隔离级别理解起来最简单,实现也最简单。在隔离级别下除了不会造成数据不一致问题,没其他优点。

一致性的实现

数据库总是从一个一致性的状态转移到另一个一致性的状态。

实现事务采取了哪些技术以及思想?

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

 

参考:

 

posted @ 2023-03-19 18:22  残城碎梦  阅读(51)  评论(0编辑  收藏  举报