五分钟详解MySQL并发控制及事务原理
在如今互联网业务中使用范围最广的数据库无疑还是关系型数据库MySQL,之所以用"还是"这个词,是因为最近几年国内数据库领域也取得了一些长足进步,例如以TIDB、OceanBase等为代表的分布式数据库,但它们暂时还没有形成绝对的覆盖面,所以现阶段还得继续学习MySQL数据库以应对工作中遇到的一些问题,以及面试过程中关于数据库部分的考察。
今天的内容就和大家聊一聊MySQL数据库中关于并发控制、事务以及存储引擎这几个最核心的问题。本内容涉及的知识图谱如下图所示:
并发控制
并发控制是一个内容庞大的话题,在计算机软件系统中只要在同一时刻存在多个请求同时修改数据的情况,就都会产生并发控制的问题,例如Java中的多线程安全问题等。在MySQL中的并发控制,主要是讨论数据库如何控制表数据的并发读写。
例如有一张表useraccount,其结构如下:
此时如果有如下两条SQL语句同一时刻向数据库发起请求:
SQL-A:
update useraccount t set t.account=t.account+100 where username='wudimanong';
SQL-B:
update useraccount t set t.account=t.account-100 where username='wudimanong'
当上述语句都执行完成,正确结果应该是account=100,但在并发情况下,却有可能发生这样的情况:
那么在MySQL中是如何进行并发控制的呢?实际上与大多数并发控制方式一样,在MySQL中也是利用锁机制来实现并发控制的。
1.MySQL锁类型
在MySQL中主要是通过"读写锁"来实现并发控制。
读锁(read lock):也叫共享锁(share lock),多个读请求可以同时共享一把锁来读取数据,而不会造成阻塞。
写锁(write lock):也叫排他锁(exclusive lock),写锁会排斥其他所有获取锁的请求,一直阻塞,直到完成写入并释放锁。
读写锁可以做到读读并行,但是无法做到写读、写写并行。后面会讲到的事务隔离性就是根据读写锁来实现的!
2.MySQL锁粒度
上面提及的读写锁是根据MySQL的锁类型来划分的,而读写锁能够施加的粒度在数据库中主要体现为表和行,也称为表锁(table lock)、行锁(row lock)。
表锁(table lock):是MySQL中最基本的锁策略,它会锁定整张表,这样维护锁的开销最小,但是会降低表的读写效率。如果一个用户通过表锁来实现对表的写操作(插入、删除、更新),那么先需要获得锁定该表的写锁,那么在这种情况下,其他用户对该表的读写都会被阻塞。一般情况下"alter table"之类的语句才会使用表锁。
行锁(row lock):行锁可以最大程度地支持并发读写,但数据库维护锁的开销会比较大。行锁是我们日常使用最多的锁策略,一般情况下MySQL中的行级锁由具体的存储引擎实现,而不是MySQL服务器层面去实现(表锁MySQL服务器层面会实现)。
3.多版本并发控制(MVCC)
MVCC(MultiVersion Concurrency Control),多版本并发控制。在MySQL的大多数事务引擎(如InnoDB)中,都不只是简单地实现了行级锁,否则会出现这样的情况:"数据A被某个用户更新期间(获取行级写锁),其他用户读取该条数据(获取读锁)都会被阻塞“。但现实情况显然不是这样,这是因为MySQL的存储引擎基于提升并发性能的考虑,通过MVCC数据多版本控制,做到了读写分离,从而实现不加锁读取数据进而做到了读写并行。
以InnoDB存储引擎的MVCC实现为例:
InnoDB的MVCC,是通过在每行记录后面保存两个隐藏的列来实现的。这两个列,一个保存了行的创建时间,一个保存了行的过期时间。当然它们存储的并不是实际的时间值,而是系统版本号。每开启一个新的事务,系统版本号都会自动递增;事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号进行比较。
MVCC在MySQL中实现所依赖的手段主要是:"undo log和read view"。
-
undo log :undo log 用于记录某行数据的多个版本的数据。
-
read view :用来判断当前版本数据的可见性
undo log在后面讲述事务还会介绍到。关于MVCC的读写原理示意图如下:
上图演示了MySQL InnoDB存储引擎,在REPEATABLE READ(可重复读)事务隔离级别下,通过额外保存两个系统版本号(行创建版本号、行删除版本号)实现MVCC,从而使得大多数读操作都可以不用再加读锁。这样的设计使得数据读取操作更加简单、性能更好。
那么在MVCC模式下数据读取操作是如何保证数据读取正确的呢?以InnoDB为例,Select时会根据以下两个条件检查每行记录:
-
只查找版本号小于或等于当前事务版本的数据行,这样可以确保事务读取的行要么是在事务开始前已经存在,要么是事务自身插入或者修过的。
-
行的删除版本号要么未定义,要么大于当前事务版本号。这样可以确保事务读取到的行,在事务开始之前未被删除。
只有符合上述两个条件的记录,才能返回作为查询的结果!以图中示范的逻辑为例,写请求将account变更为200的过程中,InnoDB会再插入一行新记录(account=200),并将当前系统版本号作为行创建版本号(createVersion=2),同时将当前系统版本号作为原来行的行删除版本号(deleteVersion=2),那么此时关于这条数据有两个版本的数据副本,具体如下:
假如现在写操作还未结束,事务对其他用户暂不可见,按照Select检查条件只有accout=100的记录才符合条件,因此查询结果会返回account=100的记录!
上述过程就是InnoDB存储引擎关于MVCC实现的基本原理,但是后面需要注意MVCC多版本并发控制的逻辑只能工作在“REPEATABLE READ(可重复读)和READ COMMITED(提交读)”两种事务隔离级别下。其他两个隔离级别都与MVCC不兼容,因为READ UNCOMMITED(未提交读)总是读取最新的数据行,而不是符合当前事务版本的数据行;而SERIALIZABLE则会对所有读取的行都加锁,也不符合MVCC的思想。
MySQL事务
前面在讲解了关于MySQL并发控制的过程中,也提到了事务相关的内容,接下来我们来更全面的梳理下关于事务的核心知识。
相信大家在日常的开发过程中,都使用过数据库事务,对事务的特点也都能张口就来——ACID。那么事务内部到底是怎么实现的呢?在接下来的内容中,就来和大家具体聊一聊这个问题!
1.事务概述
数据库事务本身所要达成的效果主要体现在:"可靠性"以及"并发处理"这两个方面。
-
可靠性:数据库要保证当insert或update操作抛出异常,或者数据库crash的时候要保障数据操作的前后一致。
-
并发处理:说的是当多个并发请求过来,并且其中有一个请求是对数据进行修改操作,为了避免其他请求读到脏数据,需要对事务之间的读写进行隔离。
实现MySQL数据库事务功能主要有三个技术,分别是日志文件(redo log和undo log)、锁技术及MVCC。
2.redo log与undo log
redo log与undo log是实现MySQL事务功能的核心技术。
1)、redo log
redo log叫做重做日志,是实现事务持久性的关键。redo log日志文件主要由2部分组成:重做日志缓冲(redo log buffer)、重做日志文件(redo log file)。
在MySql中为了提升数据库性能并不会把每次的修改都实时同步到磁盘,而是会先存到一个叫做“Boffer Pool”的缓冲池中,之后会再使用后台线程去实现缓冲池和磁盘之间的同步。
如果采取这样的模式,可能会出现这样的问题:如果在数据还没来得及同步的情况下出现宕机或断电,那么就可能会丢失部分已提交事务的修改信息!而这种情况对于数据库软件来说是不可以接受的。
所以redo log的主要作用就是用来记录已成功提交事务的修改信息,并且会在事务提交后实时将redo log持久化到磁盘,这样在系统重启之后就可以读取redo log来恢复最新的数据。
接下来我们以前面SQL-A所开启的事务为例来演示redo log的具体是如何运行的,如下图所示:
如上图所示,当修改一行记录的事务开启,MySQL存储引擎是把数据从磁盘读取到内存的缓冲池上进行修改,这个时候数据在内存中被修改后就与磁盘中的数据产生了差异,这种有差异的数据也被称之为“脏页”。
而一般存储引擎对于脏页的处理并不是每次生成脏页就即刻将脏页刷新回磁盘,而是通过后台线程“master thread”以大致每秒运行一次或每10秒运行一次的频率去刷新磁盘。在这种情况下,出现数据库宕机或断电等情况,那么尚未刷新回磁盘的数据就有可能丢失。
而redo log日志的作用就是为了调和内存与磁盘的速度差异。当事务被提交时,存储引擎会首先将要修改的数据写入redo log,然后再去修改缓冲池中真正的数据页,并实时刷新一次数据同步。如果在这个过程中,数据库挂了,由于redo log物理日志文件已经记录了事务修改,所以在数据库重启后就可以根据redo log日志进行事务数据恢复。
2)、undo log
上面我们聊了redo log日志,它的作用主要是用来恢复数据,保障已提交事务的持久化特性。在MySQL中还有另外一种非常重要的日志类型undo log,又叫回滚日志,它主要是用于记录数据被修改前的信息,这与记录数据被修改后信息的redo log日志正好相反。
undo log 主要记录事务修改之前版本的数据信息,假如由于系统错误或者rollback操作而回滚的话就可以根据undo log日志来将数据回滚到没被修改之前的状态。
每次写入数据或者修改数据之前存储引擎都会将修改前的信息记录到undo log。
3.事务的实现
前面我们讲到了锁、多版本并发控制(MVCC)、重做日志(redo log)以及回滚日志(undo log),这些内容就是MySQL实现数据库事务的基础。从事务的四大特性来说,其对应关系主要体现如下:
实际上事务原子性、持久性、隔离性的最终目的都是为了确保事务数据的一致性。而ACID只是个概念,事务的最终目的是要保障数据的可靠性和一致性。
接下来我们再具体分析下事务ACID特性的实现原理。
1)、原子性的实现
原子性,是指一个事务必须被视为不可分割的最小单位,一个事务中的所有操作要么全部执行成功、要么全部失败回滚,对一个事务来说不可能只执行其中的部分操作,这就是事务原子性的概念。
而MySQL数据库实现原子性的主要是通过回滚操作来实现的。所谓回滚操作就是当发生错误异常或者显示地执行rollback语句时需要把数据还原到原先的模样,而这个过程就需要借助undo log来进行。具体规则如下:
-
每条数据变更(insert/update/delete)操作都伴随着一条undo log的生成,并且回滚日志必须先于数据持久化到磁盘上;
-
所谓的回滚就是根据undo log日志做逆向操作,比如delete的逆向操作为insert,insert的逆向操作为delete,update的逆向操作为update等;
2)、持久性的实现
持久性,指的是事务一旦提交其所作的修改会永久地保存到数据库中,此时即使系统崩溃修改的数据也不会丢失。
事务的持久性主要是通过redo log日志来实现的。redo log日志之所以能够弥补缓存同步所造成的数据差异,主要其具备以下特点:
-
redo log的存储是顺序的,而缓存同步则是随机操作;
-
缓存同步是以数据页为单位,每次传输的数据大小大于redo log;
关于redo log实现事务持久性的逻辑可参考本文前面关于redo log部分的内容!
3)、隔离性的实现
隔离性是事务ACID特性中最复杂的一个。在SQL标准里定义了四种隔离级别,每一种隔离级别都规定一个事务中的修改,那些是事务之间可见的,那些是不可见的。
MySQL隔离级别有以下四种(级别由低到高):
-
READ UNCOMMITED (未提交读);
-
READ COMMITED (提交读)
-
REPEATABLE READ (可重复读)
-
SERIALIZABLE (可串行化)
隔离级别越低,则数据库可以执行的并发度越高,但是实现的复杂度和开销也越大。只要彻底理解了隔离级别以及它的实现原理,就相当于理解了ACID中的事务隔离性。
前面提到过,原子性、持久性、隔离性的目的最终都是为了实现数据的一致性,但隔离性与其它两个有所区别,原子性和持久性主要是为了保障数据的可靠性,比如做到宕机后的数据恢复,以及错误后的数据回滚。而隔离性的核心目标则是要管理多个并发读写请求的访问顺序,实现数据库数据的安全和高效访问,实质上就是一场数据的安全性与性能之间的权衡游戏。
可靠性高的隔离级别,并发性能低(例如SERIALIZABLE隔离级别,因为所有的读写都会加锁);而可靠性低的,并发性能高(例如READ UNCOMMITED,因为读写完全不加锁)。
接下来我们再分别分析下这四种隔离级别的特点:
READ UNCOMMITTED
在READ UNCOMMITTED隔离级别下,一个事务中的修改即使还没有提交,对其它事务也是可见,也就是说事务可以读取到未提交的数据。
因为读不会添加锁,所以写操作在读的过程中修改数据的话会造成"脏读"。未提交读隔离级别读写示意图如下:
如上图所示,写请求将account修改为200,此时事务未提交;但是读请求可以读取到未提交的事务数据account=200;随后写请求事务失败回滚account=100;那么此时读请求读取的account=200的数据就是脏数据。
这种隔离级别的优点是读写并行、性能高;但是缺点是容易造成脏读。所以在MySQL数据库中一般情况下并不会采取此种隔离级别!
READ COMMITED
这种事务隔离级别也叫"不可重复读或提交读"。它的特点是一个事务在它提交之前的所有修改,其它事务都是不可见的;其它事务只能读到已提交的修改变化。
这种隔离级别看起来很完美,也符合大部分逻辑场景,但该事务隔离级别会产生"不可重读"和"幻读"的问题。
不可重读:是指一个事务内多次读取的相同行的数据,结果却不一样。例如事务A读取a行数据,而事务B此时修改了a行的数据并提交了事务,那么事务A在下一次读取a行数据时,发现和第一次不一样了!
幻读:是指一个事务按照相同的查询条件检索数据,但是多次检索出的数据结果却不一样。例如事务A第一次以条件x=0检索数据获取了5条记录;此时事务B向表中插入了一条x=0的数据并提交了事务;那么事务A第二次再以条件x=0检索数据时,发现获取了6条记录!
那么在READ COMMITED隔离级别下为什么会产生不可重复读和幻读的问题呢?
实际上不可重复读事务隔离级别也采用了我们前面讲过的MVCC(多版本并发控制)机制。但在READ COMMITED隔离级别下的MVCC机制,会在每次select的时候都生成一个新的系统版本号,所以事务中每次select操作读到的不是一个副本而是不同的副本数据,所以在每次select之间,如果有其它事务更新并提交了我们读取的数据,那么就会产生不可重复读和幻读的现象。
不可重复读产生的原因示意图如下:
REPEATABLE READ
事务隔离级别REPEATABLE READ,也叫可重复读,它是MySQL数据库的默认事务隔离级别。在这种事务隔离级别下,一个事务内的多次读取结果是一致的,这种隔离级别可以避免脏读、不可重复读等查询问题。
这种事务隔离级别的实现手段主要是采用读写锁+MVCC机制。具体示意图如下:
如上图所示,在该事务隔离级别下的MVCC机制,并不会在事务内每次查询都产生一个新的系统版本号,所以一个事务内的多次查询,数据副本都是一个,因此不会产生不可重复读问题。关于此隔离级别下MVCC更多的细节可参考前面内容!
但是需要注意,此隔离级别解决了不可重复读的问题,但是并没有解决幻读的问题,所以如果事务A中存在条件查询,另外一个事务B在此期间新增或删除了该条件的数据并提交了事务,那么依然会造成事务A产生幻读。所以在使用MySQL时需要注意这个问题!
SERIALIZABLE
该隔离级别理解起来最简单,因为它读写请求都会加排他锁,所以不会造成任何数据不一致的问题,就是性能不高,所以采用此隔离级别的数据库很少!
4)、一致性的实现
一致性主要是指通过回滚、恢复以及在并发条件下的隔离性来实现数据库数据的一致!前面所讲述的原子性、持久性及隔离性最终就是为了实现一致性!
MySQL存储引擎
前面的内容我们分别讲述了MySQL并发控制和事务的内容,而实际上在并发控制和事务的具体细节都是依赖于MySql存储引擎来实现的。MySQL最重要、最与众不同的特性就是它的存储引擎架构,这种将数据处理和存储分离的架构设计使得用户在使用时可以根据性能、特性以及其它具体需求来选择相应的存储引擎。
虽然如此,但绝大部分情况下使用MySQL数据库时选择的还是InnoDB存储引擎,不过这并不妨碍我们适当地了解下其它存储引擎的特点。接下来给大家简单总结下,具体如下:
以上我们简单总结了MySQL各种存储引擎的大概特点及其大致适用的场景,但实际上除了InnoDB存储引擎外,在互联网业务中很少会看到其它存储引擎的身影。虽然MySQL内置了多种针对特定场景的存储引擎,但是它们大多都有相应的替代技术,例如日志类应用现在有Elasticsearch、而数仓类应用现在则有Hive、HBase等产品,至于内存数据库有MangoDB、Redis等NoSQL数据产品,所以能够给MySQL发挥的也只有InnoDB了!