0%

问题解析

读写分离有哪些坑?在上一篇文章中,我和你介绍了一主多从的结构以及切换流程。

今天我们就继续聊聊一主多从架构的应用场景:读写分离,以及怎么处理主备延迟导致的读写分离问题。

我们在上一篇文章中提到的一主多从的结构,其实就是读写分离的基本结构了。

这里,我再把这张图贴过来,方便你理解。

图1 读写分离基本结构读写分离的主要目标就是分摊主库的压力。

图1中的结构是客户端(client)主动做负载均衡,这种模式下一般会把数据库的连接信息放在客户端的连接层。

也就是说,由客户端来选择后端数据库进行查询。

还有一种架构是,在MySQL和客户端之间有一个中间代理层proxy,客户端只连接proxy, 由proxy根据请求类型和上下文决定请求的分发路由。

图2 带proxy的读写分离架构接下来,我们就看一下客户端直连和带proxy的读写分离架构,各有哪些特点。

  1. 客户端直连方案,因为少了一层proxy转发,所以查询性能稍微好一点儿,并且整体架构简单,排查问题更方便。

但是这种方案,由于要了解后端部署细节,所以在出现主备切换、库迁移等操作的时候,客户端都会感知到,并且需要调整数据库连接信息。

你可能会觉得这样客户端也太麻烦了,信息大量冗余,架构很丑。

其实也未必,一般采用这样的架构,一定会伴随一个负责管理后端的组件,比如Zookeeper,尽量让业务端只专注于业务逻辑开发。

  1. 带proxy的架构,对客户端比较友好。

客户端不需要关注后端细节,连接维护、后端信息维护等工作,都是由proxy完成的。

但这样的话,对后端维护团队的要求会更高。

而且,proxy也需要有高可用架构。

因此,带proxy架构的整体就相对比较复杂。

理解了这两种方案的优劣,具体选择哪个方案就取决于数据库团队提供的能力了。

但目前看,趋势是往带proxy的架构方向发展的。

但是,不论使用哪种架构,你都会碰到我们今天要讨论的问题:由于主从可能存在延迟,客户端执行完一个更新事务后马上发起查询,如果查询选择的是从库的话,就有可能读到刚刚的事务更新之前的状态。

这种“在从库上会读到系统的一个过期状态”的现象,在这篇文章里,我们暂且称之为“过期读”。

前面我们说过了几种可能导致主备延迟的原因,以及对应的优化策略,但是主从延迟还是不能100%避免的。

不论哪种结构,客户端都希望查询从库的数据结果,跟查主库的数据结果是一样的。

接下来,我们就来讨论怎么处理过期读问题。

这里,我先把文章中涉及到的处理过期读的方案汇总在这里,以帮助你更好地理解和掌握全文的知识脉络。

这些方案包括:

强制走主库方案;

sleep方案;

判断主备无延迟方案;

配合semi-sync方案;

等主库位点方案;

等GTID方案。

强制走主库方案强制走主库方案其实就是,将查询请求做分类。

通常情况下,我们可以将查询请求分为这么两类:

  1. 对于必须要拿到最新结果的请求,强制将其发到主库上。

比如,在一个交易平台上,卖家发布商品以后,马上要返回主页面,看商品是否发布成功。

那么,这个请求需要拿到最新的结果,就必须走主库。

  1. 对于可以读到旧数据的请求,才将其发到从库上。

在这个交易平台上,买家来逛商铺页面,就算晚几秒看到最新发布的商品,也是可以接受的。

那么,这类请求就可以走从库。

你可能会说,这个方案是不是有点畏难和取巧的意思,但其实这个方案是用得最多的。

当然,这个方案最大的问题在于,有时候你会碰到“所有查询都不能是过期读”的需求,比如一些金融类的业务。

这样的话,你就要放弃读写分离,所有读写压力都在主库,等同于放弃了扩展性。

因此接下来,我们来讨论的话题是:可以支持读写分离的场景下,有哪些解决过期读的方案,并分析各个方案的优缺点。

Sleep 方案主库更新后,读从库之前先sleep一下。

具体的方案就是,类似于执行一条select sleep(1)命令。

这个方案的假设是,大多数情况下主备延迟在1秒之内,做一个sleep可以有很大概率拿到最新的数据。

这个方案给你的第一感觉,很可能是不靠谱儿,应该不会有人用吧?并且,你还可能会说,直接在发起查询时先执行一条sleep语句,用户体验很不友好啊。

但,这个思路确实可以在一定程度上解决问题。

为了看起来更靠谱儿,我们可以换一种方式。

以卖家发布商品为例,商品发布后,用Ajax(Asynchronous JavaScript + XML,异步JavaScript和XML)直接把客户端输入的内容作为“新的商品”显示在页面上,而不是真正地去数据库做查询。

这样,卖家就可以通过这个显示,来确认产品已经发布成功了。

等到卖家再刷新页面,去查看商品的时候,其实已经过了一段时间,也就达到了sleep的目的,进而也就解决了过期读的问题。

也就是说,这个sleep方案确实解决了类似场景下的过期读问题。

但,从严格意义上来说,这个方案存在的问题就是不精确。

这个不精确包含了两层意思:

  1. 如果这个查询请求本来0.5秒就可以在从库上拿到正确结果,也会等1秒;

  2. 如果延迟超过1秒,还是会出现过期读。

看到这里,你是不是有一种“你是不是在逗我”的感觉,这个改进方案虽然可以解决类似Ajax场景下的过期读问题,但还是怎么看都不靠谱儿。

别着急,接下来我就和你介绍一些更准确的方案。

判断主备无延迟方案要确保备库无延迟,通常有三种做法。

通过前面的第25篇文章,我们知道show slave status结果里的seconds_behind_master参数的值,可以用来衡量主备延迟时间的长短。

所以第一种确保主备无延迟的方法是,每次从库执行查询请求前,先判断seconds_behind_master是否已经等于0。

如果还不等于0 ,那就必须等到这个参数变为0才能执行查询请求。

seconds_behind_master的单位是秒,如果你觉得精度不够的话,还可以采用对比位点和GTID的方法来确保主备无延迟,也就是我们接下来要说的第二和第三种方法。

如图3所示,是一个show slave status结果的部分截图。

图3 show slave status结果现在,我们就通过这个结果,来看看具体如何通过对比位点和GTID来确保主备无延迟。

第二种方法,对比位点确保主备无延迟:

Master_Log_File和Read_Master_Log_Pos,表示的是读到的主库的最新位点;

Relay_Master_Log_File和Exec_Master_Log_Pos,表示的是备库执行的最新位点。

如果Master_Log_File和Relay_Master_Log_File、Read_Master_Log_Pos和Exec_Master_Log_Pos这两组值完全相同,就表示接收到的日志已经同步完成。

第三种方法,对比GTID集合确保主备无延迟:

Auto_Position=1 ,表示这对主备关系使用了GTID协议。

Retrieved_Gtid_Set,是备库收到的所有日志的GTID集合;

Executed_Gtid_Set,是备库所有已经执行完成的GTID集合。

如果这两个集合相同,也表示备库接收到的日志都已经同步完成。

可见,对比位点和对比GTID这两种方法,都要比判断seconds_behind_master是否为0更准确。

在执行查询请求之前,先判断从库是否同步完成的方法,相比于sleep方案,准确度确实提升了不少,但还是没有达到“精确”的程度。

为什么这么说呢?我们现在一起来回顾下,一个事务的binlog在主备库之间的状态:

  1. 主库执行完成,写入binlog,并反馈给客户端;

  2. binlog被从主库发送给备库,备库收到;

  3. 在备库执行binlog完成。

我们上面判断主备无延迟的逻辑,是“备库收到的日志都执行完成了”。

但是,从binlog在主备之间状态的分析中,不难看出还有一部分日志,处于客户端已经收到提交确认,而备库还没收到日志的状态。

如图4所示就是这样的一个状态。

图4 备库还没收到trx3这时,主库上执行完成了三个事务trx1、trx2和trx3,其中:

  1. trx1和trx2已经传到从库,并且已经执行完成了;

  2. trx3在主库执行完成,并且已经回复给客户端,但是还没有传到从库中。

如果这时候你在从库B上执行查询请求,按照我们上面的逻辑,从库认为已经没有同步延迟,但还是查不到trx3的。

严格地说,就是出现了过期读。

那么,这个问题有没有办法解决呢?配合semi-sync要解决这个问题,就要引入半同步复制,也就是semi-sync replication。

semi-sync做了这样的设计:

  1. 事务提交的时候,主库把binlog发给从库;

  2. 从库收到binlog以后,发回给主库一个ack,表示收到了;

  3. 主库收到这个ack以后,才能给客户端返回“事务完成”的确认。

也就是说,如果启用了semi-sync,就表示所有给客户端发送过确认的事务,都确保了备库已经收到了这个日志。

在第25篇文章的评论区,有同学问到:如果主库掉电的时候,有些binlog还来不及发给从库,会不会导致系统数据丢失?答案是,如果使用的是普通的异步复制模式,就可能会丢失,但semi-sync就可以解决这个问题。

这样,semi-sync配合前面关于位点的判断,就能够确定在从库上执行的查询请求,可以避免过期读。

但是,semi-sync+位点判断的方案,只对一主一备的场景是成立的。

在一主多从场景中,主库只要等到一个从库的ack,就开始给客户端返回确认。

这时,在从库上执行查询请求,就有两种情况:

  1. 如果查询是落在这个响应了ack的从库上,是能够确保读到最新数据;

  2. 但如果是查询落到其他从库上,它们可能还没有收到最新的日志,就会产生过期读的问题。

其实,判断同步位点的方案还有另外一个潜在的问题,即:如果在业务更新的高峰期,主库的位点或者GTID集合更新很快,那么上面的两个位点等值判断就会一直不成立,很可能出现从库上迟迟无法响应查询请求的情况。

实际上,回到我们最初的业务逻辑里,当发起一个查询请求以后,我们要得到准确的结果,其实并不需要等到“主备完全同步”。

为什么这么说呢?我们来看一下这个时序图。

图5 主备持续延迟一个事务图5所示,就是等待位点方案的一个bad case。

图中备库B下的虚线框,分别表示relaylog和binlog中的事务。

可以看到,图5中从状态1 到状态4,一直处于延迟一个事务的状态。

备库B一直到状态4都和主库A存在延迟,如果用上面必须等到无延迟才能查询的方案,select语句直到状态4都不能被执行。

但是,其实客户端是在发完trx1更新后发起的select语句,我们只需要确保trx1已经执行完成就可以执行select语句了。

也就是说,如果在状态3执行查询请求,得到的就是预期结果了。

到这里,我们小结一下,semi-sync配合判断主备无延迟的方案,存在两个问题:

  1. 一主多从的时候,在某些从库执行查询请求会存在过期读的现象;

  2. 在持续延迟的情况下,可能出现过度等待的问题。

接下来,我要和你介绍的等主库位点方案,就可以解决这两个问题。

等主库位点方案要理解等主库位点方案,我需要先和你介绍一条命令:

这条命令的逻辑如下:

  1. 它是在从库执行的;

  2. 参数file和pos指的是主库上的文件名和位置;

  3. timeout可选,设置为正整数N表示这个函数最多等待N秒。

这个命令正常返回的结果是一个正整数M,表示从命令开始执行,到应用完file和pos表示的binlog位置,执行了多少事务。

当然,除了正常返回一个正整数M外,这条命令还会返回一些其他结果,包括:

  1. 如果执行期间,备库同步线程发生异常,则返回NULL;

  2. 如果等待超过N秒,就返回-1;

  3. 如果刚开始执行的时候,就发现已经执行过这个位置了,则返回0。

对于图5中先执行trx1,再执行一个查询请求的逻辑,要保证能够查到正确的数据,我们可以使用这个逻辑:

  1. trx1事务更新完成后,马上执行show master status得到当前主库执行到的File和Position;

  2. 选定一个从库执行查询语句;

  3. 在从库上执行select master_pos_wait(File, Position, 1);

  4. 如果返回值是>=0的正整数,则在这个从库执行查询语句;

  5. 否则,到主库执行查询语句。

我把上面这个流程画出来。

select master_pos_wait(file, pos[, timeout]);图6 master_pos_wait方案这里我们假设,这条select查询最多在从库上等待1秒。

那么,如果1秒内master_pos_wait返回一个大于等于0的整数,就确保了从库上执行的这个查询结果一定包含了trx1的数据。

步骤5到主库执行查询语句,是这类方案常用的退化机制。

因为从库的延迟时间不可控,不能无限等待,所以如果等待超时,就应该放弃,然后到主库去查。

你可能会说,如果所有的从库都延迟超过1秒了,那查询压力不就都跑到主库上了吗?确实是这样。

但是,按照我们设定不允许过期读的要求,就只有两种选择,一种是超时放弃,一种是转到主库查询。

具体怎么选择,就需要业务开发同学做好限流策略了。

GTID方案如果你的数据库开启了GTID模式,对应的也有等待GTID的方案。

MySQL中同样提供了一个类似的命令:

select wait_for_executed_gtid_set(gtid_set, 1);这条命令的逻辑是:

  1. 等待,直到这个库执行的事务中包含传入的gtid_set,返回0;

  2. 超时返回1。

在前面等位点的方案中,我们执行完事务后,还要主动去主库执行show master status。

而MySQL 5.7.6版本开始,允许在执行完更新类事务后,把这个事务的GTID返回给客户端,这样等GTID的方案就可以减少一次查询。

这时,等GTID的执行流程就变成了:

  1. trx1事务更新完成后,从返回包直接获取这个事务的GTID,记为gtid1;

  2. 选定一个从库执行查询语句;

  3. 在从库上执行 select wait_for_executed_gtid_set(gtid1, 1);

  4. 如果返回值是0,则在这个从库执行查询语句;

  5. 否则,到主库执行查询语句。

跟等主库位点的方案一样,等待超时后是否直接到主库查询,需要业务开发同学来做限流考虑。

我把这个流程图画出来。

图7 wait_for_executed_gtid_set方案在上面的第一步中,trx1事务更新完成后,从返回包直接获取这个事务的GTID。

问题是,怎么能够让MySQL在执行事务后,返回包中带上GTID呢?你只需要将参数session_track_gtids设置为OWN_GTID,然后通过API接口mysql_session_track_get_first从返回包解析出GTID的值即可。

在专栏的第一篇文章中,我介绍mysql_reset_connection的时候,评论区有同学留言问这类接口应该怎么使用。

这里我再回答一下。

其实,MySQL并没有提供这类接口的SQL用法,是提供给程序的API(https://dev.mysql.com/doc/refman/5.7/en/c-api-functions.html)。

比如,为了让客户端在事务提交后,返回的GITD能够在客户端显示出来,我对MySQL客户端代码做了点修改,如下所示:

图8 显示更新事务的GTID–代码这样,就可以看到语句执行完成,显示出GITD的值。

图9 显示更新事务的GTID–效果当然了,这只是一个例子。

你要使用这个方案的时候,还是应该在你的客户端代码中调用mysql_session_track_get_first这个函数。

小结在今天这篇文章中,我跟你介绍了一主多从做读写分离时,可能碰到过期读的原因,以及几种应对的方案。

这几种方案中,有的方案看上去是做了妥协,有的方案看上去不那么靠谱儿,但都是有实际应用场景的,你需要根据业务需求选择。

即使是最后等待位点和等待GTID这两个方案,虽然看上去比较靠谱儿,但仍然存在需要权衡的情况。

如果所有的从库都延迟,那么请求就会全部落到主库上,这时候会不会由于压力突然增大,把主库打挂了呢?其实,在实际应用中,这几个方案是可以混合使用的。

比如,先在客户端对请求做分类,区分哪些请求可以接受过期读,而哪些请求完全不能接受过期读;然后,对于不能接受过期读的语句,再使用等GTID或等位点的方案。

但话说回来,过期读在本质上是由一写多读导致的。

在实际应用中,可能会有别的不需要等待就可以水平扩展的数据库方案,但这往往是用牺牲写性能换来的,也就是需要在读性能和写性能中取权衡。

最后 ,我给你留下一个问题吧。

假设你的系统采用了我们文中介绍的最后一个方案,也就是等GTID的方案,现在你要对主库的一张大表做DDL,可能会出现什么情况呢?为了避免这种情况,你会怎么做呢?你可以把你的分析和方案设计写在评论区,我会在下一篇文章跟你讨论这个问题。

感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间上期给你留的问题是,在GTID模式下,如果一个新的从库接上主库,但是需要的binlog已经没了,要怎么做?@某、人同学给了很详细的分析,我把他的回答略做修改贴过来。

  1. 如果业务允许主从不一致的情况,那么可以在主库上先执行show global variables like‘gtid_purged’,得到主库已经删除的GTID集合,假设是gtid_purged1;然后先在从库上执行reset master,再执行set global gtid_purged =‘gtid_purged1’;最后执行start slave,就会从主库现存的binlog开始同步。

binlog缺失的那一部分,数据在从库上就可能会有丢失,造成主从不一致。

  1. 如果需要主从数据一致的话,最好还是通过重新搭建从库来做。

  2. 如果有其他的从库保留有全量的binlog的话,可以把新的从库先接到这个保留了全量binlog的从库,追上日志以后,如果有需要,再接回主库。

  3. 如果binlog有备份的情况,可以先在从库上应用缺失的binlog,然后再执行start slave。

问题解析

主库出问题了,从库怎么办?在前面的第24、25和26篇文章中,我和你介绍了MySQL主备复制的基础结构,但这些都是一主一备的结构。

大多数的互联网应用场景都是读多写少,因此你负责的业务,在发展过程中很可能先会遇到读性能的问题。

而在数据库层解决读性能问题,就要涉及到接下来两篇文章要讨论的架构:一主多从。

今天这篇文章,我们就先聊聊一主多从的切换正确性。

然后,我们在下一篇文章中再聊聊解决一主多从的查询逻辑正确性的方法。

如图1所示,就是一个基本的一主多从结构。

图1 一主多从基本结构图中,虚线箭头表示的是主备关系,也就是A和A’互为主备, 从库B、C、D指向的是主库A。

一主多从的设置,一般用于读写分离,主库负责所有的写入和一部分读,其他的读请求则由从库分担。

今天我们要讨论的就是,在一主多从架构下,主库故障后的主备切换问题。

如图2所示,就是主库发生故障,主备切换后的结果。

图2 一主多从基本结构–主备切换相比于一主一备的切换流程,一主多从结构在切换完成后,A’会成为新的主库,从库B、C、D也要改接到A’。

正是由于多了从库B、C、D重新指向的这个过程,所以主备切换的复杂性也相应增加了。

接下来,我们再一起看看一个切换系统会怎么完成一主多从的主备切换过程。

基于位点的主备切换这里,我们需要先来回顾一个知识点。

当我们把节点B设置成节点A’的从库的时候,需要执行一条change master命令:

CHANGE MASTER TO MASTER_HOST=$host_name MASTER_PORT=$port MASTER_USER=$user_name MASTER_PASSWORD=$password MASTER_LOG_FILE=$master_log_name MASTER_LOG_POS=$master_log_pos 这条命令有这么6个参数:

MASTER_HOST、MASTER_PORT、MASTER_USER和MASTER_PASSWORD四个参数,分别代表了主库A’的IP、端口、用户名和密码。

最后两个参数MASTER_LOG_FILE和MASTER_LOG_POS表示,要从主库的master_log_name文件的master_log_pos这个位置的日志继续同步。

而这个位置就是我们所说的同步位点,也就是主库对应的文件名和日志偏移量。

那么,这里就有一个问题了,节点B要设置成A’的从库,就要执行change master命令,就不可避免地要设置位点的这两个参数,但是这两个参数到底应该怎么设置呢?原来节点B是A的从库,本地记录的也是A的位点。

但是相同的日志,A的位点和A’的位点是不同的。

因此,从库B要切换的时候,就需要先经过“找同步位点”这个逻辑。

这个位点很难精确取到,只能取一个大概位置。

为什么这么说呢?我来和你分析一下看看这个位点一般是怎么获取到的,你就清楚其中不精确的原因了。

考虑到切换过程中不能丢数据,所以我们找位点的时候,总是要找一个“稍微往前”的,然后再通过判断跳过那些在从库B上已经执行过的事务。

一种取同步位点的方法是这样的:

  1. 等待新主库A’把中转日志(relay log)全部同步完成;

  2. 在A’上执行show master status命令,得到当前A’上最新的File 和 Position;

  3. 取原主库A故障的时刻T;

  4. 用mysqlbinlog工具解析A’的File,得到T时刻的位点。

图3 mysqlbinlog 部分输出结果图中,end_log_pos后面的值“123”,表示的就是A’这个实例,在T时刻写入新的binlog的位置。

然后,我们就可以把123这个值作为$master_log_pos ,用在节点B的change master命令里。

mysqlbinlog File –stop-datetime=T –start-datetime=T当然这个值并不精确。

为什么呢?你可以设想有这么一种情况,假设在T这个时刻,主库A已经执行完成了一个insert 语句插入了一行数据R,并且已经将binlog传给了A’和B,然后在传完的瞬间主库A的主机就掉电了。

那么,这时候系统的状态是这样的:

  1. 在从库B上,由于同步了binlog, R这一行已经存在;

  2. 在新主库A’上, R这一行也已经存在,日志是写在123这个位置之后的;

  3. 我们在从库B上执行change master命令,指向A’的File文件的123位置,就会把插入R这一行数据的binlog又同步到从库B去执行。

这时候,从库B的同步线程就会报告 Duplicate entry ‘id_of_R’ for key ‘PRIMARY’ 错误,提示出现了主键冲突,然后停止同步。

所以,通常情况下,我们在切换任务的时候,要先主动跳过这些错误,有两种常用的方法。

一种做法是,主动跳过一个事务。

跳过命令的写法是:

因为切换过程中,可能会不止重复执行一个事务,所以我们需要在从库B刚开始接到新主库A’时,持续观察,每次碰到这些错误就停下来,执行一次跳过命令,直到不再出现停下来的情况,以此来跳过可能涉及的所有事务。

另外一种方式是,通过设置slave_skip_errors参数,直接设置跳过指定的错误。

在执行主备切换时,有这么两类错误,是经常会遇到的:

1062错误是插入数据时唯一键冲突;

1032错误是删除数据时找不到行。

因此,我们可以把slave_skip_errors 设置为 “1032,1062”,这样中间碰到这两个错误时就直接跳过。

这里需要注意的是,这种直接跳过指定错误的方法,针对的是主备切换时,由于找不到精确的同步位点,所以只能采用这种方法来创建从库和新主库的主备关系。

这个背景是,我们很清楚在主备切换过程中,直接跳过1032和1062这两类错误是无损的,所以set global sql_slave_skip_counter=1;start slave;才可以这么设置slave_skip_errors参数。

等到主备间的同步关系建立完成,并稳定执行一段时间之后,我们还需要把这个参数设置为空,以免之后真的出现了主从数据不一致,也跳过了。

GTID通过sql_slave_skip_counter跳过事务和通过slave_skip_errors忽略错误的方法,虽然都最终可以建立从库B和新主库A’的主备关系,但这两种操作都很复杂,而且容易出错。

所以,MySQL 5.6版本引入了GTID,彻底解决了这个困难。

那么,GTID到底是什么意思,又是如何解决找同步位点这个问题呢?现在,我就和你简单介绍一下。

GTID的全称是Global Transaction Identifier,也就是全局事务ID,是一个事务在提交的时候生成的,是这个事务的唯一标识。

它由两部分组成,格式是:

其中:

server_uuid是一个实例第一次启动时自动生成的,是一个全局唯一的值;

gno是一个整数,初始值是1,每次提交事务的时候分配给这个事务,并加1。

这里我需要和你说明一下,在MySQL的官方文档里,GTID格式是这么定义的:

这里的source_id就是server_uuid;而后面的这个transaction_id,我觉得容易造成误导,所以我改成了gno。

为什么说使用transaction_id容易造成误解呢?因为,在MySQL里面我们说transaction_id就是指事务id,事务id是在事务执行过程中分配的,如果这个事务回滚了,事务id也会递增,而gno是在事务提交的时候才会分配。

从效果上看,GTID往往是连续的,因此我们用gno来表示更容易理解。

GTID模式的启动也很简单,我们只需要在启动一个MySQL实例的时候,加上参数gtid_mode=on和enforce_gtid_consistency=on就可以了。

在GTID模式下,每个事务都会跟一个GTID一一对应。

这个GTID有两种生成方式,而使用哪种方式取决于session变量gtid_next的值。

  1. 如果gtid_next=automatic,代表使用默认值。

这时,MySQL就会把server_uuid:gno分配给GTID=server_uuid:gnoGTID=source_id:transaction_id这个事务。

a. 记录binlog的时候,先记录一行 SET @@SESSION.GTID_NEXT=‘server_uuid:gno’;b. 把这个GTID加入本实例的GTID集合。

  1. 如果gtid_next是一个指定的GTID的值,比如通过set gtid_next=’current_gtid’指定为current_gtid,那么就有两种可能:

a. 如果current_gtid已经存在于实例的GTID集合中,接下来执行的这个事务会直接被系统忽略;

b. 如果current_gtid没有存在于实例的GTID集合中,就将这个current_gtid分配给接下来要执行的事务,也就是说系统不需要给这个事务生成新的GTID,因此gno也不用加1。

注意,一个current_gtid只能给一个事务使用。

这个事务提交后,如果要执行下一个事务,就要执行set 命令,把gtid_next设置成另外一个gtid或者automatic。

这样,每个MySQL实例都维护了一个GTID集合,用来对应“这个实例执行过的所有事务”。

这样看上去不太容易理解,接下来我就用一个简单的例子,来和你说明GTID的基本用法。

我们在实例X中创建一个表t。

图4 初始化数据的binlog可以看到,事务的BEGIN之前有一条SET @@SESSION.GTID_NEXT命令。

这时,如果实例X有从库,那么将CREATE TABLE和insert语句的binlog同步过去执行的话,执行事务之前就会先执行这两个SET命令, 这样被加入从库的GTID集合的,就是图中的这两个GTID。

CREATE TABLE t̀ ̀( ìd ̀int(11) NOT NULL, `c ̀int(11) DEFAULT NULL, PRIMARY KEY (̀ id )̀) ENGINE=InnoDB;insert into t values(1,1);假设,现在这个实例X是另外一个实例Y的从库,并且此时在实例Y上执行了下面这条插入语句:

并且,这条语句在实例Y上的GTID是 “aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10”。

那么,实例X作为Y的从库,就要同步这个事务过来执行,显然会出现主键冲突,导致实例X的同步线程停止。

这时,我们应该怎么处理呢?处理方法就是,你可以执行下面的这个语句序列:

其中,前三条语句的作用,是通过提交一个空事务,把这个GTID加到实例X的GTID集合中。

如图5所示,就是执行完这个空事务之后的show master status的结果。

图5 show master status结果可以看到实例X的Executed_Gtid_set里面,已经加入了这个GTID。

这样,我再执行start slave命令让同步线程执行起来的时候,虽然实例X上还是会继续执行实例Y传过来的事务,但是由于“aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10”已经存在于实例X的GTID集合中了,所以实例X就会直接跳过这个事务,也就不会再出现主键冲突的错误。

在上面的这个语句序列中,start slave命令之前还有一句set gtid_next=automatic。

这句话的作用是“恢复GTID的默认分配行为”,也就是说如果之后有新的事务再执行,就还是按照原来的分配方式,继续分配gno=3。

insert into t values(1,1);set gtid_next=’aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10’;begin;commit;set gtid_next=automatic;start slave;基于GTID的主备切换现在,我们已经理解GTID的概念,再一起来看看基于GTID的主备复制的用法。

在GTID模式下,备库B要设置为新主库A’的从库的语法如下:

其中,master_auto_position=1就表示这个主备关系使用的是GTID协议。

可以看到,前面让我们头疼不已的MASTER_LOG_FILE和MASTER_LOG_POS参数,已经不需要指定了。

我们把现在这个时刻,实例A’的GTID集合记为set_a,实例B的GTID集合记为set_b。

接下来,我们就看看现在的主备切换逻辑。

我们在实例B上执行start slave命令,取binlog的逻辑是这样的:

  1. 实例B指定主库A’,基于主备协议建立连接。

  2. 实例B把set_b发给主库A’。

  3. 实例A’算出set_a与set_b的差集,也就是所有存在于set_a,但是不存在于set_b的GITD的集合,判断A’本地是否包含了这个差集需要的所有binlog事务。

a. 如果不包含,表示A’已经把实例B需要的binlog给删掉了,直接返回错误;

b. 如果确认全部包含,A’从自己的binlog文件里面,找出第一个不在set_b的事务,发给B;

  1. 之后就从这个事务开始,往后读文件,按顺序取binlog发给B去执行。

其实,这个逻辑里面包含了一个设计思想:在基于GTID的主备关系里,系统认为只要建立主备关系,就必须保证主库发给备库的日志是完整的。

因此,如果实例B需要的日志已经不存在,A’就拒绝把日志发给B。

这跟基于位点的主备协议不同。

基于位点的协议,是由备库决定的,备库指定哪个位点,主库就发哪个位点,不做日志的完整性判断。

基于上面的介绍,我们再来看看引入GTID后,一主多从的切换场景下,主备切换是如何实现的。

CHANGE MASTER TO MASTER_HOST=$host_name MASTER_PORT=$port MASTER_USER=$user_name MASTER_PASSWORD=$password master_auto_position=1 由于不需要找位点了,所以从库B、C、D只需要分别执行change master命令指向实例A’即可。

其实,严谨地说,主备切换不是不需要找位点了,而是找位点这个工作,在实例A’内部就已经自动完成了。

但由于这个工作是自动的,所以对HA系统的开发人员来说,非常友好。

之后这个系统就由新主库A’写入,主库A’的自己生成的binlog中的GTID集合格式是:

server_uuid_of_A’:1-M。

如果之前从库B的GTID集合格式是 server_uuid_of_A:1-N, 那么切换之后GTID集合的格式就变成了server_uuid_of_A:1-N, server_uuid_of_A’:1-M。

当然,主库A’之前也是A的备库,因此主库A’和从库B的GTID集合是一样的。

这就达到了我们预期。

GTID和在线DDL接下来,我再举个例子帮你理解GTID。

之前在第22篇文章《MySQL有哪些“饮鸩止渴”提高性能的方法?》中,我和你提到业务高峰期的慢查询性能问题时,分析到如果是由于索引缺失引起的性能问题,我们可以通过在线加索引来解决。

但是,考虑到要避免新增索引对主库性能造成的影响,我们可以先在备库加索引,然后再切换。

当时我说,在双M结构下,备库执行的DDL语句也会传给主库,为了避免传回后对主库造成影响,要通过set sql_log_bin=off关掉binlog。

评论区有位同学提出了一个问题:这样操作的话,数据库里面是加了索引,但是binlog并没有记录下这一个更新,是不是会导致数据和日志不一致?这个问题提得非常好。

当时,我在留言的回复中就引用了GTID来说明。

今天,我再和你展开说明一下。

假设,这两个互为主备关系的库还是实例X和实例Y,且当前主库是X,并且都打开了GTID模式。

这时的主备切换流程可以变成下面这样:

在实例X上执行stop slave。

在实例Y上执行DDL语句。

注意,这里并不需要关闭binlog。

执行完成后,查出这个DDL语句对应的GTID,并记为 server_uuid_of_Y:gno。

到实例X上执行以下语句序列:

这样做的目的在于,既可以让实例Y的更新有binlog记录,同时也可以确保不会在实例X上执行这条更新。

接下来,执行完主备切换,然后照着上述流程再执行一遍即可。

小结在今天这篇文章中,我先和你介绍了一主多从的主备切换流程。

在这个过程中,从库找新主库的位点是一个痛点。

由此,我们引出了MySQL 5.6版本引入的GTID模式,介绍了GTID的基本概念和用法。

可以看到,在GTID模式下,一主多从切换就非常方便了。

因此,如果你使用的MySQL版本支持GTID的话,我都建议你尽量使用GTID模式来做一主多从的切换。

在下一篇文章中,我们还能看到GTID模式在读写分离场景的应用。

最后,又到了我们的思考题时间。

你在GTID模式下设置主从关系的时候,从库执行start slave命令后,主库发现需要的binlog已经被删除掉了,导致主备创建不成功。

这种情况下,你觉得可以怎么处理呢?你可以把你的方法写在留言区,我会在下一篇文章的末尾和你讨论这个问题。

感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间上一篇文章最后,我给你留的问题是,如果主库都是单线程压力模式,在从库追主库的过程中,binlog-transaction-dependency-tracking 应该选用什么参数?这个问题的答案是,应该将这个参数设置为WRITESET。

由于主库是单线程压力模式,所以每个事务的commit_id都不同,那么设置为COMMIT_ORDER模式的话,从库也只能单线程执行。

同样地,由于WRITESET_SESSION模式要求在备库应用日志的时候,同一个线程的日志必须set GTID_NEXT=”server_uuid_of_Y:gno”;begin;commit;set gtid_next=automatic;start slave;与主库上执行的先后顺序相同,也会导致主库单线程压力模式下退化成单线程复制。

所以,应该将binlog-transaction-dependency-tracking 设置为WRITESET。

问题解析

备库为什么会延迟好几个小时?在上一篇文章中,我和你介绍了几种可能导致备库延迟的原因。

你会发现,这些场景里,不论是偶发性的查询压力,还是备份,对备库延迟的影响一般是分钟级的,而且在备库恢复正常以后都能够追上来。

但是,如果备库执行日志的速度持续低于主库生成日志的速度,那这个延迟就有可能成了小时级别。

而且对于一个压力持续比较高的主库来说,备库很可能永远都追不上主库的节奏。

这就涉及到今天我要给你介绍的话题:备库并行复制能力。

为了便于你理解,我们再一起看一下第24篇文章《MySQL是怎么保证主备一致的?》的主备流程图。

图1 主备流程图谈到主备的并行复制能力,我们要关注的是图中黑色的两个箭头。

一个箭头代表了客户端写入主库,另一箭头代表的是备库上sql_thread执行中转日志(relay log)。

如果用箭头的粗细来代表并行度的话,那么真实情况就如图1所示,第一个箭头要明显粗于第二个箭头。

在主库上,影响并发度的原因就是各种锁了。

由于InnoDB引擎支持行锁,除了所有并发事务都在更新同一行(热点行)这种极端场景外,它对业务并发度的支持还是很友好的。

所以,你在性能测试的时候会发现,并发压测线程32就比单线程时,总体吞吐量高。

而日志在备库上的执行,就是图中备库上sql_thread更新数据(DATA)的逻辑。

如果是用单线程的话,就会导致备库应用日志不够快,造成主备延迟。

在官方的5.6版本之前,MySQL只支持单线程复制,由此在主库并发高、TPS高时就会出现严重的主备延迟问题。

从单线程复制到最新版本的多线程复制,中间的演化经历了好几个版本。

接下来,我就跟你说说MySQL多线程复制的演进过程。

其实说到底,所有的多线程复制机制,都是要把图1中只有一个线程的sql_thread,拆成多个线程,也就是都符合下面的这个模型:

图2 多线程模型图2中,coordinator就是原来的sql_thread, 不过现在它不再直接更新数据了,只负责读取中转日志和分发事务。

真正更新日志的,变成了worker线程。

而work线程的个数,就是由参数slave_parallel_workers决定的。

根据我的经验,把这个值设置为8~16之间最好(32核物理机的情况),毕竟备库还有可能要提供读查询,不能把CPU都吃光了。

接下来,你需要先思考一个问题:事务能不能按照轮询的方式分发给各个worker,也就是第一个事务分给worker_1,第二个事务发给worker_2呢?其实是不行的。

因为,事务被分发给worker以后,不同的worker就独立执行了。

但是,由于CPU的调度策略,很可能第二个事务最终比第一个事务先执行。

而如果这时候刚好这两个事务更新的是同一行,也就意味着,同一行上的两个事务,在主库和备库上的执行顺序相反,会导致主备不一致的问题。

接下来,请你再设想一下另外一个问题:同一个事务的多个更新语句,能不能分给不同的worker来执行呢?答案是,也不行。

举个例子,一个事务更新了表t1和表t2中的各一行,如果这两条更新语句被分到不同worker的话,虽然最终的结果是主备一致的,但如果表t1执行完成的瞬间,备库上有一个查询,就会看到这个事务“更新了一半的结果”,破坏了事务逻辑的隔离性。

所以,coordinator在分发的时候,需要满足以下这两个基本要求:

  1. 不能造成更新覆盖。

这就要求更新同一行的两个事务,必须被分发到同一个worker中。

  1. 同一个事务不能被拆开,必须放到同一个worker中。

各个版本的多线程复制,都遵循了这两条基本原则。

接下来,我们就看看各个版本的并行复制策略。

MySQL 5.5版本的并行复制策略官方MySQL 5.5版本是不支持并行复制的。

但是,在2012年的时候,我自己服务的业务出现了严重的主备延迟,原因就是备库只有单线程复制。

然后,我就先后写了两个版本的并行策略。

这里,我给你介绍一下这两个版本的并行策略,即按表分发策略和按行分发策略,以帮助你理解MySQL官方版本并行复制策略的迭代。

按表分发策略按表分发事务的基本思路是,如果两个事务更新不同的表,它们就可以并行。

因为数据是存储在表里的,所以按表分发,可以保证两个worker不会更新同一行。

当然,如果有跨表的事务,还是要把两张表放在一起考虑的。

如图3所示,就是按表分发的规则。

图3 按表并行复制程模型可以看到,每个worker线程对应一个hash表,用于保存当前正在这个worker的“执行队列”里的事务所涉及的表。

hash表的key是“库名.表名”,value是一个数字,表示队列中有多少个事务修改这个表。

在有事务分配给worker时,事务里面涉及的表会被加到对应的hash表中。

worker执行完成后,这个表会被从hash表中去掉。

图3中,hash_table_1表示,现在worker_1的“待执行事务队列”里,有4个事务涉及到db1.t1表,有1个事务涉及到db2.t2表;hash_table_2表示,现在worker_2中有一个事务会更新到表t3的数据。

假设在图中的情况下,coordinator从中转日志中读入一个新事务T,这个事务修改的行涉及到表t1和t3。

现在我们用事务T的分配流程,来看一下分配规则。

  1. 由于事务T中涉及修改表t1,而worker_1队列中有事务在修改表t1,事务T和队列中的某个事务要修改同一个表的数据,这种情况我们说事务T和worker_1是冲突的。

  2. 按照这个逻辑,顺序判断事务T和每个worker队列的冲突关系,会发现事务T跟worker_2也冲突。

  3. 事务T跟多于一个worker冲突,coordinator线程就进入等待。

  4. 每个worker继续执行,同时修改hash_table。

假设hash_table_2里面涉及到修改表t3的事务先执行完成,就会从hash_table_2中把db1.t3这一项去掉。

  1. 这样coordinator会发现跟事务T冲突的worker只有worker_1了,因此就把它分配给worker_1。

  2. coordinator继续读下一个中转日志,继续分配事务。

也就是说,每个事务在分发的时候,跟所有worker的冲突关系包括以下三种情况:

  1. 如果跟所有worker都不冲突,coordinator线程就会把这个事务分配给最空闲的woker;2. 如果跟多于一个worker冲突,coordinator线程就进入等待状态,直到和这个事务存在冲突关系的worker只剩下1个;

  2. 如果只跟一个worker冲突,coordinator线程就会把这个事务分配给这个存在冲突关系的worker。

这个按表分发的方案,在多个表负载均匀的场景里应用效果很好。

但是,如果碰到热点表,比如所有的更新事务都会涉及到某一个表的时候,所有事务都会被分配到同一个worker中,就变成单线程复制了。

按行分发策略要解决热点表的并行复制问题,就需要一个按行并行复制的方案。

按行复制的核心思路是:如果两个事务没有更新相同的行,它们在备库上可以并行执行。

显然,这个模式要求binlog格式必须是row。

这时候,我们判断一个事务T和worker是否冲突,用的就规则就不是“修改同一个表”,而是“修改同一行”。

按行复制和按表复制的数据结构差不多,也是为每个worker,分配一个hash表。

只是要实现按行分发,这时候的key,就必须是“库名+表名+唯一键的值”。

但是,这个“唯一键”只有主键id还是不够的,我们还需要考虑下面这种场景,表t1中除了主键,还有唯一索引a:

假设,接下来我们要在主库执行这两个事务:

图4 唯一键冲突示例可以看到,这两个事务要更新的行的主键值不同,但是如果它们被分到不同的worker,就有可能session B的语句先执行。

这时候id=1的行的a的值还是1,就会报唯一键冲突。

因此,基于行的策略,事务hash表中还需要考虑唯一键,即key应该是“库名+表名+索引a的名字+a的值”。

比如,在上面这个例子中,我要在表t1上执行update t1 set a=1 where id=2语句,在binlog里面记录了整行的数据修改前各个字段的值,和修改后各个字段的值。

因此,coordinator在解析这个语句的binlog的时候,这个事务的hash表就有三个项:1. key=hash_func(db1+t1+“PRIMARY”+2), value=2; 这里value=2是因为修改前后的行id值不变,出现了两次。

  1. key=hash_func(db1+t1+“a”+2), value=1,表示会影响到这个表a=2的行。

  2. key=hash_func(db1+t1+“a”+1), value=1,表示会影响到这个表a=1的行。

可见,相比于按表并行分发策略,按行并行策略在决定线程分发的时候,需要消耗更多的计算资源。

你可能也发现了,这两个方案其实都有一些约束条件:

  1. 要能够从binlog里面解析出表名、主键值和唯一索引的值。

也就是说,主库的binlog格式必CREATE TABLE t̀1 ̀( ìd ̀int(11) NOT NULL, a ̀int(11) DEFAULT NULL, b ̀int(11) DEFAULT NULL, PRIMARY KEY (̀ id )̀, UNIQUE KEY `a ̀(̀ a )̀) ENGINE=InnoDB;insert into t1 values(1,1,1),(2,2,2),(3,3,3),(4,4,4),(5,5,5);须是row;

  1. 表必须有主键;

  2. 不能有外键。

表上如果有外键,级联更新的行不会记录在binlog中,这样冲突检测就不准确。

但,好在这三条约束规则,本来就是DBA之前要求业务开发人员必须遵守的线上使用规范,所以这两个并行复制策略在应用上也没有碰到什么麻烦。

对比按表分发和按行分发这两个方案的话,按行分发策略的并行度更高。

不过,如果是要操作很多行的大事务的话,按行分发的策略有两个问题:

  1. 耗费内存。

比如一个语句要删除100万行数据,这时候hash表就要记录100万个项。

  1. 耗费CPU。

解析binlog,然后计算hash值,对于大事务,这个成本还是很高的。

所以,我在实现这个策略的时候会设置一个阈值,单个事务如果超过设置的行数阈值(比如,如果单个事务更新的行数超过10万行),就暂时退化为单线程模式,退化过程的逻辑大概是这样的:

  1. coordinator暂时先hold住这个事务;

  2. 等待所有worker都执行完成,变成空队列;

  3. coordinator直接执行这个事务;

  4. 恢复并行模式。

读到这里,你可能会感到奇怪,这两个策略又没有被合到官方,我为什么要介绍这么详细呢?其实,介绍这两个策略的目的是抛砖引玉,方便你理解后面要介绍的社区版本策略。

MySQL 5.6版本的并行复制策略官方MySQL5.6版本,支持了并行复制,只是支持的粒度是按库并行。

理解了上面介绍的按表分发策略和按行分发策略,你就理解了,用于决定分发策略的hash表里,key就是数据库名。

这个策略的并行效果,取决于压力模型。

如果在主库上有多个DB,并且各个DB的压力均衡,使用这个策略的效果会很好。

相比于按表和按行分发,这个策略有两个优势:

  1. 构造hash值的时候很快,只需要库名;而且一个实例上DB数也不会很多,不会出现需要构造100万个项这种情况。

  2. 不要求binlog的格式。

因为statement格式的binlog也可以很容易拿到库名。

但是,如果你的主库上的表都放在同一个DB里面,这个策略就没有效果了;或者如果不同DB的热点不同,比如一个是业务逻辑库,一个是系统配置库,那也起不到并行的效果。

理论上你可以创建不同的DB,把相同热度的表均匀分到这些不同的DB中,强行使用这个策略。

不过据我所知,由于需要特地移动数据,这个策略用得并不多。

MariaDB的并行复制策略在第23篇文章中,我给你介绍了redo log组提交(group commit)优化, 而MariaDB的并行复制策略利用的就是这个特性:

  1. 能够在同一组里提交的事务,一定不会修改同一行;

  2. 主库上可以并行执行的事务,备库上也一定是可以并行执行的。

在实现上,MariaDB是这么做的:

  1. 在一组里面一起提交的事务,有一个相同的commit_id,下一组就是commit_id+1;

  2. commit_id直接写到binlog里面;

  3. 传到备库应用的时候,相同commit_id的事务分发到多个worker执行;

  4. 这一组全部执行完成后,coordinator再去取下一批。

当时,这个策略出来的时候是相当惊艳的。

因为,之前业界的思路都是在“分析binlog,并拆分到worker”上。

而MariaDB的这个策略,目标是“模拟主库的并行模式”。

但是,这个策略有一个问题,它并没有实现“真正的模拟主库并发度”这个目标。

在主库上,一组事务在commit的时候,下一组事务是同时处于“执行中”状态的。

如图5所示,假设了三组事务在主库的执行情况,你可以看到在trx1、trx2和trx3提交的时候,trx4、trx5和trx6是在执行的。

这样,在第一组事务提交完成的时候,下一组事务很快就会进入commit状态。

图5 主库并行事务而按照MariaDB的并行复制策略,备库上的执行效果如图6所示。

图6 MariaDB 并行复制,备库并行效果可以看到,在备库上执行的时候,要等第一组事务完全执行完成后,第二组事务才能开始执行,这样系统的吞吐量就不够。

另外,这个方案很容易被大事务拖后腿。

假设trx2是一个超大事务,那么在备库应用的时候,trx1和trx3执行完成后,就只能等trx2完全执行完成,下一组才能开始执行。

这段时间,只有一个worker线程在工作,是对资源的浪费。

不过即使如此,这个策略仍然是一个很漂亮的创新。

因为,它对原系统的改造非常少,实现也很优雅。

MySQL 5.7的并行复制策略在MariaDB并行复制实现之后,官方的MySQL5.7版本也提供了类似的功能,由参数slave-parallel-type来控制并行复制策略:

  1. 配置为DATABASE,表示使用MySQL 5.6版本的按库并行策略;

  2. 配置为 LOGICAL_CLOCK,表示的就是类似MariaDB的策略。

不过,MySQL 5.7这个策略,针对并行度做了优化。

这个优化的思路也很有趣儿。

你可以先考虑这样一个问题:同时处于“执行状态”的所有事务,是不是可以并行?答案是,不能。

因为,这里面可能有由于锁冲突而处于锁等待状态的事务。

如果这些事务在备库上被分配到不同的worker,就会出现备库跟主库不一致的情况。

而上面提到的MariaDB这个策略的核心,是“所有处于commit”状态的事务可以并行。

事务处于commit状态,表示已经通过了锁冲突的检验了。

这时候,你可以再回顾一下两阶段提交,我把前面第23篇文章中介绍过的两阶段提交过程图贴过来。

图7 两阶段提交细化过程图其实,不用等到commit阶段,只要能够到达redo log prepare阶段,就表示事务已经通过锁冲突的检验了。

因此,MySQL 5.7并行复制策略的思想是:

  1. 同时处于prepare状态的事务,在备库执行时是可以并行的;

  2. 处于prepare状态的事务,与处于commit状态的事务之间,在备库执行时也是可以并行的。

我在第23篇文章,讲binlog的组提交的时候,介绍过两个参数:

  1. binlog_group_commit_sync_delay参数,表示延迟多少微秒后才调用fsync;2. binlog_group_commit_sync_no_delay_count参数,表示累积多少次以后才调用fsync。

这两个参数是用于故意拉长binlog从write到fsync的时间,以此减少binlog的写盘次数。

在MySQL5.7的并行复制策略里,它们可以用来制造更多的“同时处于prepare阶段的事务”。

这样就增加了备库复制的并行度。

也就是说,这两个参数,既可以“故意”让主库提交得慢些,又可以让备库执行得快些。

在MySQL5.7处理备库延迟的时候,可以考虑调整这两个参数值,来达到提升备库复制并发度的目的。

MySQL 5.7.22的并行复制策略在2018年4月份发布的MySQL 5.7.22版本里,MySQL增加了一个新的并行复制策略,基于WRITESET的并行复制。

相应地,新增了一个参数binlog-transaction-dependency-tracking,用来控制是否启用这个新策略。

这个参数的可选值有以下三种。

  1. COMMIT_ORDER,表示的就是前面介绍的,根据同时进入prepare和commit来判断是否可以并行的策略。

  2. WRITESET,表示的是对于事务涉及更新的每一行,计算出这一行的hash值,组成集合writeset。

如果两个事务没有操作相同的行,也就是说它们的writeset没有交集,就可以并行。

  1. WRITESET_SESSION,是在WRITESET的基础上多了一个约束,即在主库上同一个线程先后执行的两个事务,在备库执行的时候,要保证相同的先后顺序。

当然为了唯一标识,这个hash值是通过“库名+表名+索引名+值”计算出来的。

如果一个表上除了有主键索引外,还有其他唯一索引,那么对于每个唯一索引,insert语句对应的writeset就要多增加一个hash值。

你可能看出来了,这跟我们前面介绍的基于MySQL 5.5版本的按行分发的策略是差不多的。

不过,MySQL官方的这个实现还是有很大的优势:

  1. writeset是在主库生成后直接写入到binlog里面的,这样在备库执行的时候,不需要解析binlog内容(event里的行数据),节省了很多计算量;

  2. 不需要把整个事务的binlog都扫一遍才能决定分发到哪个worker,更省内存;

  3. 由于备库的分发策略不依赖于binlog内容,所以binlog是statement格式也是可以的。

因此,MySQL 5.7.22的并行复制策略在通用性上还是有保证的。

当然,对于“表上没主键”和“外键约束”的场景,WRITESET策略也是没法并行的,也会暂时退化为单线程模型。

小结在今天这篇文章中,我和你介绍了MySQL的各种多线程复制策略。

为什么要有多线程复制呢?这是因为单线程复制的能力全面低于多线程复制,对于更新压力较大的主库,备库是可能一直追不上主库的。

从现象上看就是,备库上seconds_behind_master的值越来越大。

在介绍完每个并行复制策略后,我还和你分享了不同策略的优缺点:

如果你是DBA,就需要根据不同的业务场景,选择不同的策略;

如果是你业务开发人员,也希望你能从中获取灵感用到平时的开发工作中。

从这些分析中,你也会发现大事务不仅会影响到主库,也是造成备库复制延迟的主要原因之一。

因此,在平时的开发工作中,我建议你尽量减少大事务操作,把大事务拆成小事务。

官方MySQL5.7版本新增的备库并行策略,修改了binlog的内容,也就是说binlog协议并不是向上兼容的,在主备切换、版本升级的时候需要把这个因素也考虑进去。

最后,我给你留下一个思考题吧。

假设一个MySQL 5.7.22版本的主库,单线程插入了很多数据,过了3个小时后,我们要给这个主库搭建一个相同版本的备库。

这时候,你为了更快地让备库追上主库,要开并行复制。

在binlog-transaction-dependency-tracking参数的COMMIT_ORDER、WRITESET和WRITE_SESSION这三个取值中,你会选择哪一个呢?你选择的原因是什么?如果设置另外两个参数,你认为会出现什么现象呢?你可以把你的答案和分析写在评论区,我会在下一篇文章跟你讨论这个问题。

感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间上期的问题是,什么情况下,备库的主备延迟会表现为一个45度的线段?评论区有不少同学的回复都说到了重点:备库的同步在这段时间完全被堵住了。

产生这种现象典型的场景主要包括两种:

一种是大事务(包括大表DDL、一个事务操作很多行);

还有一种情况比较隐蔽,就是备库起了一个长事务,比如然后就不动了。

这时候主库对表t做了一个加字段操作,即使这个表很小,这个DDL在备库应用的时候也会被堵住,也不能看到这个现象。

评论区还有同学说是不是主库多线程、从库单线程,备库跟不上主库的更新节奏导致的?今天这篇文章,我们刚好讲的是并行复制。

所以,你知道了,这种情况会导致主备延迟,但不会表现为这种标准的呈45度的直线。

问题解析

MySQL是怎么保证高可用的?在上一篇文章中,我和你介绍了binlog的基本内容,在一个主备关系中,每个备库接收主库的binlog并执行。

正常情况下,只要主库执行更新生成的所有binlog,都可以传到备库并被正确地执行,备库就能达到跟主库一致的状态,这就是最终一致性。

但是,MySQL要提供高可用能力,只有最终一致性是不够的。

为什么这么说呢?今天我就着重和你分析一下。

这里,我再放一次上一篇文章中讲到的双M结构的主备切换流程图。

图 1 MySQL主备切换流程–双M结构主备延迟主备切换可能是一个主动运维动作,比如软件升级、主库所在机器按计划下线等,也可能是被动操作,比如主库所在机器掉电。

接下来,我们先一起看看主动切换的场景。

在介绍主动切换流程的详细步骤之前,我要先跟你说明一个概念,即“同步延迟”。

与数据同步有关的时间点主要包括以下三个:

  1. 主库A执行完成一个事务,写入binlog,我们把这个时刻记为T1;2. 之后传给备库B,我们把备库B接收完这个binlog的时刻记为T2;3. 备库B执行完成这个事务,我们把这个时刻记为T3。

所谓主备延迟,就是同一个事务,在备库执行完成的时间和主库执行完成的时间之间的差值,也就是T3-T1。

你可以在备库上执行show slave status命令,它的返回结果里面会显示seconds_behind_master,用于表示当前备库延迟了多少秒。

seconds_behind_master的计算方法是这样的:

  1. 每个事务的binlog 里面都有一个时间字段,用于记录主库上写入的时间;
  2. 备库取出当前正在执行的事务的时间字段的值,计算它与当前系统时间的差值,得到seconds_behind_master。

可以看到,其实seconds_behind_master这个参数计算的就是T3-T1。

所以,我们可以用seconds_behind_master来作为主备延迟的值,这个值的时间精度是秒。

你可能会问,如果主备库机器的系统时间设置不一致,会不会导致主备延迟的值不准?其实不会的。

因为,备库连接到主库的时候,会通过执行SELECT UNIX_TIMESTAMP()函数来获得当前主库的系统时间。

如果这时候发现主库的系统时间与自己不一致,备库在执行seconds_behind_master计算的时候会自动扣掉这个差值。

需要说明的是,在网络正常的时候,日志从主库传给备库所需的时间是很短的,即T2-T1的值是非常小的。

也就是说,网络正常情况下,主备延迟的主要来源是备库接收完binlog和执行完这个事务之间的时间差。

所以说,主备延迟最直接的表现是,备库消费中转日志(relay log)的速度,比主库生产binlog的速度要慢。

接下来,我就和你一起分析下,这可能是由哪些原因导致的。

主备延迟的来源首先,有些部署条件下,备库所在机器的性能要比主库所在的机器性能差。

一般情况下,有人这么部署时的想法是,反正备库没有请求,所以可以用差一点儿的机器。

或者,他们会把20个主库放在4台机器上,而把备库集中在一台机器上。

其实我们都知道,更新请求对IOPS的压力,在主库和备库上是无差别的。

所以,做这种部署时,一般都会将备库设置为“非双1”的模式。

但实际上,更新过程中也会触发大量的读操作。

所以,当备库主机上的多个备库都在争抢资源的时候,就可能会导致主备延迟了。

当然,这种部署现在比较少了。

因为主备可能发生切换,备库随时可能变成主库,所以主备库选用相同规格的机器,并且做对称部署,是现在比较常见的情况。

追问1:但是,做了对称部署以后,还可能会有延迟。

这是为什么呢?这就是第二种常见的可能了,即备库的压力大。

一般的想法是,主库既然提供了写能力,那么备库可以提供一些读能力。

或者一些运营后台需要的分析语句,不能影响正常业务,所以只能在备库上跑。

我真就见过不少这样的情况。

由于主库直接影响业务,大家使用起来会比较克制,反而忽视了备库的压力控制。

结果就是,备库上的查询耗费了大量的CPU资源,影响了同步速度,造成主备延迟。

这种情况,我们一般可以这么处理:

  1. 一主多从。

除了备库外,可以多接几个从库,让这些从库来分担读的压力。

  1. 通过binlog输出到外部系统,比如Hadoop这类系统,让外部系统提供统计类查询的能力。

其中,一主多从的方式大都会被采用。

因为作为数据库系统,还必须保证有定期全量备份的能力。

而从库,就很适合用来做备份。

追问2:采用了一主多从,保证备库的压力不会超过主库,还有什么情况可能导致主备延迟吗?这就是第三种可能了,即大事务。

大事务这种情况很好理解。

因为主库上必须等事务执行完成才会写入binlog,再传给备库。

所以,如果一个主库上的语句执行10分钟,那这个事务很可能就会导致从库延迟10分钟。

不知道你所在公司的DBA有没有跟你这么说过:不要一次性地用delete语句删除太多数据。

其实,这就是一个典型的大事务场景。

比如,一些归档类的数据,平时没有注意删除历史数据,等到空间快满了,业务开发人员要一次性地删掉大量历史数据。

同时,又因为要避免在高峰期操作会影响业务(至少有这个意识还是很不错的),所以会在晚上执行这些大量数据的删除操作。

结果,负责的DBA同学半夜就会收到延迟报警。

然后,DBA团队就要求你后续再删除数据的时候,要控制每个事务删除的数据量,分成多次删除。

另一种典型的大事务场景,就是大表DDL。

这个场景,我在前面的文章中介绍过。

处理方案就是,计划内的DDL,建议使用gh-ost方案(这里,你可以再回顾下第13篇文章《为什么表数据删掉一半,表文件大小不变?》中的相关内容)。

追问3:如果主库上也不做大事务了,还有什么原因会导致主备延迟吗?造成主备延迟还有一个大方向的原因,就是备库的并行复制能力。

这个话题,我会留在下一篇文章再和你详细介绍。

备注:这里需要说明一下,从库和备库在概念上其实差不多。

在我们这个专栏里,为了方便描述,我把会在HA过程中被选成新主库的,称为备库,其他的称为从库。

其实还是有不少其他情况会导致主备延迟,如果你还碰到过其他场景,欢迎你在评论区给我留言,我来和你一起分析、讨论。

由于主备延迟的存在,所以在主备切换的时候,就相应的有不同的策略。

可靠性优先策略在图1的双M结构下,从状态1到状态2切换的详细过程是这样的:

  1. 判断备库B现在的seconds_behind_master,如果小于某个值(比如5秒)继续下一步,否则持续重试这一步;
  2. 把主库A改成只读状态,即把readonly设置为true;
  3. 判断备库B的seconds_behind_master的值,直到这个值变成0为止;
  4. 把备库B改成可读写状态,也就是把readonly 设置为false;
  5. 把业务请求切到备库B。

这个切换流程,一般是由专门的HA系统来完成的,我们暂时称之为可靠性优先流程。

图2 MySQL可靠性优先主备切换流程备注:图中的SBM,是seconds_behind_master参数的简写。

可以看到,这个切换流程中是有不可用时间的。

因为在步骤2之后,主库A和备库B都处于readonly状态,也就是说这时系统处于不可写状态,直到步骤5完成后才能恢复。

在这个不可用状态中,比较耗费时间的是步骤3,可能需要耗费好几秒的时间。

这也是为什么需要在步骤1先做判断,确保seconds_behind_master的值足够小。

试想如果一开始主备延迟就长达30分钟,而不先做判断直接切换的话,系统的不可用时间就会长达30分钟,这种情况一般业务都是不可接受的。

当然,系统的不可用时间,是由这个数据可靠性优先的策略决定的。

你也可以选择可用性优先的策略,来把这个不可用时间几乎降为0。

可用性优先策略如果我强行把步骤4、5调整到最开始执行,也就是说不等主备数据同步,直接把连接切到备库B,并且让备库B可以读写,那么系统几乎就没有不可用时间了。

我们把这个切换流程,暂时称作可用性优先流程。

这个切换流程的代价,就是可能出现数据不一致的情况。

接下来,我就和你分享一个可用性优先流程产生数据不一致的例子。

假设有一个表 t:
这个表定义了一个自增主键id,初始化数据后,主库和备库上都是3行数据。

接下来,业务人员要继续在表t上执行两条插入语句的命令,依次是:
假设,现在主库上其他的数据表有大量的更新,导致主备延迟达到5秒。

在插入一条c=4的语句mysql> CREATE TABLE t̀ ̀( ìd ̀int(11) unsigned NOT NULL AUTO_INCREMENT, `c ̀int(11) unsigned DEFAULT NULL, PRIMARY KEY (̀ id )̀) ENGINE=InnoDB;insert into t(c) values(1),(2),(3);insert into t(c) values(4);insert into t(c) values(5);后,发起了主备切换。

图3是可用性优先策略,且binlog_format=mixed时的切换流程和数据结果。

图3 可用性优先策略,且binlog_format=mixed现在,我们一起分析下这个切换流程:

  1. 步骤2中,主库A执行完insert语句,插入了一行数据(4,4),之后开始进行主备切换。

  2. 步骤3中,由于主备之间有5秒的延迟,所以备库B还没来得及应用“插入c=4”这个中转日志,就开始接收客户端“插入 c=5”的命令。

  3. 步骤4中,备库B插入了一行数据(4,5),并且把这个binlog发给主库A。

  4. 步骤5中,备库B执行“插入c=4”这个中转日志,插入了一行数据(5,4)。

而直接在备库B执行的“插入c=5”这个语句,传到主库A,就插入了一行新数据(5,5)。

最后的结果就是,主库A和备库B上出现了两行不一致的数据。

可以看到,这个数据不一致,是由可用性优先流程导致的。

那么,如果我还是用可用性优先策略,但设置binlog_format=row,情况又会怎样呢?因为row格式在记录binlog的时候,会记录新插入的行的所有字段值,所以最后只会有一行不一致。

而且,两边的主备同步的应用线程会报错duplicate key error并停止。

也就是说,这种情况下,备库B的(5,4)和主库A的(5,5)这两行数据,都不会被对方执行。

图4中我画出了详细过程,你可以自己再分析一下。

图4 可用性优先策略,且binlog_format=row从上面的分析中,你可以看到一些结论:

  1. 使用row格式的binlog时,数据不一致的问题更容易被发现。

而使用mixed或者statement格式的binlog时,数据很可能悄悄地就不一致了。

如果你过了很久才发现数据不一致的问题,很可能这时的数据不一致已经不可查,或者连带造成了更多的数据逻辑不一致。

  1. 主备切换的可用性优先策略会导致数据不一致。

因此,大多数情况下,我都建议你使用可靠性优先策略。

毕竟对数据服务来说的话,数据的可靠性一般还是要优于可用性的。

但事无绝对,有没有哪种情况数据的可用性优先级更高呢?答案是,有的。

我曾经碰到过这样的一个场景:
有一个库的作用是记录操作日志。

这时候,如果数据不一致可以通过binlog来修补,而这个短暂的不一致也不会引发业务问题。

同时,业务系统依赖于这个日志写入逻辑,如果这个库不可写,会导致线上的业务操作无法执行。

这时候,你可能就需要选择先强行切换,事后再补数据的策略。

当然,事后复盘的时候,我们想到了一个改进措施就是,让业务逻辑不要依赖于这类日志的写入。

也就是说,日志写入这个逻辑模块应该可以降级,比如写到本地文件,或者写到另外一个临时库里面。

这样的话,这种场景就又可以使用可靠性优先策略了。

接下来我们再看看,按照可靠性优先的思路,异常切换会是什么效果?假设,主库A和备库B间的主备延迟是30分钟,这时候主库A掉电了,HA系统要切换B作为主库。

我们在主动切换的时候,可以等到主备延迟小于5秒的时候再启动切换,但这时候已经别无选择了。

图5 可靠性优先策略,主库不可用采用可靠性优先策略的话,你就必须得等到备库B的seconds_behind_master=0之后,才能切换。

但现在的情况比刚刚更严重,并不是系统只读、不可写的问题了,而是系统处于完全不可用的状态。

因为,主库A掉电后,我们的连接还没有切到备库B。

你可能会问,那能不能直接切换到备库B,但是保持B只读呢?这样也不行。

因为,这段时间内,中转日志还没有应用完成,如果直接发起主备切换,客户端查询看不到之前执行完成的事务,会认为有“数据丢失”。

虽然随着中转日志的继续应用,这些数据会恢复回来,但是对于一些业务来说,查询到“暂时丢失数据的状态”也是不能被接受的。

聊到这里你就知道了,在满足数据可靠性的前提下,MySQL高可用系统的可用性,是依赖于主备延迟的。

延迟的时间越小,在主库故障的时候,服务恢复需要的时间就越短,可用性就越高。

小结今天这篇文章,我先和你介绍了MySQL高可用系统的基础,就是主备切换逻辑。

紧接着,我又和你讨论了几种会导致主备延迟的情况,以及相应的改进方向。

然后,由于主备延迟的存在,切换策略就有不同的选择。

所以,我又和你一起分析了可靠性优先和可用性优先策略的区别。

在实际的应用中,我更建议使用可靠性优先的策略。

毕竟保证数据准确,应该是数据库服务的底线。

在这个基础上,通过减少主备延迟,提升系统的可用性。

最后,我给你留下一个思考题吧。

一般现在的数据库运维系统都有备库延迟监控,其实就是在备库上执行 show slave status,采集seconds_behind_master的值。

假设,现在你看到你维护的一个备库,它的延迟监控的图像类似图6,是一个45°斜向上的线段,你觉得可能是什么原因导致呢?你又会怎么去确认这个原因呢?图6 备库延迟你可以把你的分析写在评论区,我会在下一篇文章的末尾跟你讨论这个问题。

感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间上期我留给你的问题是,什么情况下双M结构会出现循环复制。

一种场景是,在一个主库更新事务后,用命令set global server_id=x修改了server_id。

等日志再传回来的时候,发现server_id跟自己的server_id不同,就只能执行了。

另一种场景是,有三个节点的时候,如图7所示,trx1是在节点 B执行的,因此binlog上的server_id就是B,binlog传给节点 A,然后A和A’搭建了双M结构,就会出现循环复制。

图7 三节点循环复制这种三节点复制的场景,做数据库迁移的时候会出现。

如果出现了循环复制,可以在A或者A’上,执行如下命令:
这样这个节点收到日志后就不会再执行。

过一段时间后,再执行下面的命令把这个值改回来。

问题解析

MySQL是怎么保证主备一致的?

binlog可以用来归档,也可以用来做主备同步,但它的内容是什么样的呢?为什么备库执行了binlog就可以跟主库保持一致了呢?今天我就正式地和你介绍一下它。

毫不夸张地说,MySQL能够成为现下最流行的开源数据库,binlog功不可没。

在最开始,MySQL是以容易学习和方便的高可用架构,被开发人员青睐的。

而它的几乎所有的高可用架构,都直接依赖于binlog。

虽然这些高可用架构已经呈现出越来越复杂的趋势,但都是从最基本的一主一备演化过来的。

今天这篇文章我主要为你介绍主备的基本原理。

理解了背后的设计原理,你也可以从业务开发的角度,来借鉴这些设计思想。

MySQL主备的基本原理如图1所示就是基本的主备切换流程。

图 1 MySQL主备切换流程在状态1中,客户端的读写都直接访问节点A,而节点B是A的备库,只是将A的更新都同步过来,到本地执行。

这样可以保持节点B和A的数据是相同的。

当需要切换的时候,就切成状态2。

这时候客户端读写访问的都是节点B,而节点A是B的备库。

在状态1中,虽然节点B没有被直接访问,但是我依然建议你把节点B(也就是备库)设置成只读(readonly)模式。

这样做,有以下几个考虑:

  1. 有时候一些运营类的查询语句会被放到备库上去查,设置为只读可以防止误操作;
  2. 防止切换逻辑有bug,比如切换过程中出现双写,造成主备不一致;
  3. 可以用readonly状态,来判断节点的角色。

你可能会问,我把备库设置成只读了,还怎么跟主库保持同步更新呢?这个问题,你不用担心。

因为readonly设置对超级(super)权限用户是无效的,而用于同步更新的线程,就拥有超级权限。

接下来,我们再看看节点A到B这条线的内部流程是什么样的。

图2中画出的就是一个update语句在节点A执行,然后同步到节点B的完整流程图。

图2 主备流程图图2中,包含了我在上一篇文章中讲到的binlog和redo log的写入机制相关的内容,可以看到:主库接收到客户端的更新请求后,执行内部事务的更新逻辑,同时写binlog。

备库B跟主库A之间维持了一个长连接。

主库A内部有一个线程,专门用于服务备库B的这个长连接。

一个事务日志同步的完整过程是这样的:

  1. 在备库B上通过change master命令,设置主库A的IP、端口、用户名、密码,以及要从哪个位置开始请求binlog,这个位置包含文件名和日志偏移量。

  2. 在备库B上执行start slave命令,这时候备库会启动两个线程,就是图中的io_thread和sql_thread。

其中io_thread负责与主库建立连接。

  1. 主库A校验完用户名、密码后,开始按照备库B传过来的位置,从本地读取binlog,发给B。

  2. 备库B拿到binlog后,写到本地文件,称为中转日志(relay log)。

  3. sql_thread读取中转日志,解析出日志里的命令,并执行。

这里需要说明,后来由于多线程复制方案的引入,sql_thread演化成为了多个线程,跟我们今天要介绍的原理没有直接关系,暂且不展开。

分析完了这个长连接的逻辑,我们再来看一个问题:binlog里面到底是什么内容,为什么备库拿过去可以直接执行。

binlog的三种格式对比我在第15篇答疑文章中,和你提到过binlog有两种格式,一种是statement,一种是row。

可能你在其他资料上还会看到有第三种格式,叫作mixed,其实它就是前两种格式的混合。

为了便于描述binlog的这三种格式间的区别,我创建了一个表,并初始化几行数据。

如果要在表中删除一行数据的话,我们来看看这个delete语句的binlog是怎么记录的。

注意,下面这个语句包含注释,如果你用MySQL客户端来做这个实验的话,要记得加-c参数,否则客户端会自动去掉注释。

当binlog_format=statement时,binlog里面记录的就是SQL语句的原文。

你可以用mysql> CREATE TABLE t̀ ̀( ìd ̀int(11) NOT NULL, a ̀int(11) DEFAULT NULL, t̀_modified ̀timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (̀ id )̀, KEY a ̀(̀ a )̀, KEY t̀_modified (̀̀ t_modified )̀) ENGINE=InnoDB;insert into t values(1,1,’2018-11-13’);insert into t values(2,2,’2018-11-12’);insert into t values(3,3,’2018-11-11’);insert into t values(4,4,’2018-11-10’);insert into t values(5,5,’2018-11-09’);mysql> delete from t /comment/ where a>=4 and t_modified<=’2018-11-10’ limit 1;mysql> show binlog events in ‘master.000001’;命令看binlog中的内容。

图3 statement格式binlog 示例现在,我们来看一下图3的输出结果。

第一行SET @@SESSION.GTID_NEXT=’ANONYMOUS’你可以先忽略,后面文章我们会在介绍主备切换的时候再提到;
第二行是一个BEGIN,跟第四行的commit对应,表示中间是一个事务;
第三行就是真实执行的语句了。

可以看到,在真实执行的delete命令之前,还有一个“use‘test’”命令。

这条命令不是我们主动执行的,而是MySQL根据当前要操作的表所在的数据库,自行添加的。

这样做可以保证日志传到备库去执行的时候,不论当前的工作线程在哪个库里,都能够正确地更新到test库的表t。

use ‘test’命令之后的delete 语句,就是我们输入的SQL原文了。

可以看到,binlog“忠实”地记录了SQL命令,甚至连注释也一并记录了。

最后一行是一个COMMIT。

你可以看到里面写着xid=61。

你还记得这个XID是做什么用的吗?如果记忆模糊了,可以再回顾一下第15篇文章中的相关内容。

为了说明statement 和 row格式的区别,我们来看一下这条delete命令的执行效果图:
图4 delete执行warnings可以看到,运行这条delete命令产生了一个warning,原因是当前binlog设置的是statement格式,并且语句中有limit,所以这个命令可能是unsafe的。

为什么这么说呢?这是因为delete 带limit,很可能会出现主备数据不一致的情况。

比如上面这个例子:

  1. 如果delete语句使用的是索引a,那么会根据索引a找到第一个满足条件的行,也就是说删除的是a=4这一行;
  2. 但如果使用的是索引t_modified,那么删除的就是 t_modified=’2018-11-09’也就是a=5这一行。

由于statement格式下,记录到binlog里的是语句原文,因此可能会出现这样一种情况:在主库执行这条SQL语句的时候,用的是索引a;而在备库执行这条SQL语句的时候,却使用了索引t_modified。

因此,MySQL认为这样写是有风险的。

那么,如果我把binlog的格式改为binlog_format=‘row’, 是不是就没有这个问题了呢?我们先来看看这时候binog中的内容吧。

图5 row格式binlog 示例可以看到,与statement格式的binlog相比,前后的BEGIN和COMMIT是一样的。

但是,row格式的binlog里没有了SQL语句的原文,而是替换成了两个event:Table_map和Delete_rows。

  1. Table_map event,用于说明接下来要操作的表是test库的表t;2. Delete_rows event,用于定义删除的行为。

其实,我们通过图5是看不到详细信息的,还需要借助mysqlbinlog工具,用下面这个命令解析和查看binlog中的内容。

因为图5中的信息显示,这个事务的binlog是从8900这个位置开始的,所以可以用start-position参数来指定从这个位置的日志开始解析。

mysqlbinlog -vv data/master.000001 –start-position=8900;图6 row格式binlog 示例的详细信息从这个图中,我们可以看到以下几个信息:
server id 1,表示这个事务是在server_id=1的这个库上执行的。

每个event都有CRC32的值,这是因为我把参数binlog_checksum设置成了CRC32。

Table_map event跟在图5中看到的相同,显示了接下来要打开的表,map到数字226。

现在我们这条SQL语句只操作了一张表,如果要操作多张表呢?每个表都有一个对应的Table_mapevent、都会map到一个单独的数字,用于区分对不同表的操作。

我们在mysqlbinlog的命令中,使用了-vv参数是为了把内容都解析出来,所以从结果里面可以看到各个字段的值(比如,@1=4、 @2=4这些值)。

binlog_row_image的默认配置是FULL,因此Delete_event里面,包含了删掉的行的所有字段的值。

如果把binlog_row_image设置为MINIMAL,则只会记录必要的信息,在这个例子里,就是只会记录id=4这个信息。

最后的Xid event,用于表示事务被正确地提交了。

你可以看到,当binlog_format使用row格式的时候,binlog里面记录了真实删除行的主键id,这样binlog传到备库去的时候,就肯定会删除id=4的行,不会有主备删除不同行的问题。

为什么会有mixed格式的binlog?基于上面的信息,我们来讨论一个问题:为什么会有mixed这种binlog格式的存在场景?推论过程是这样的:
因为有些statement格式的binlog可能会导致主备不一致,所以要使用row格式。

但row格式的缺点是,很占空间。

比如你用一个delete语句删掉10万行数据,用statement的话就是一个SQL语句被记录到binlog中,占用几十个字节的空间。

但如果用row格式的binlog,就要把这10万条记录都写到binlog中。

这样做,不仅会占用更大的空间,同时写binlog也要耗费IO资源,影响执行速度。

所以,MySQL就取了个折中方案,也就是有了mixed格式的binlog。

mixed格式的意思是,MySQL自己会判断这条SQL语句是否可能引起主备不一致,如果有可能,就用row格式,否则就用statement格式。

也就是说,mixed格式可以利用statment格式的优点,同时又避免了数据不一致的风险。

因此,如果你的线上MySQL设置的binlog格式是statement的话,那基本上就可以认为这是一个不合理的设置。

你至少应该把binlog的格式设置为mixed。

比如我们这个例子,设置为mixed后,就会记录为row格式;而如果执行的语句去掉limit 1,就会记录为statement格式。

当然我要说的是,现在越来越多的场景要求把MySQL的binlog格式设置成row。

这么做的理由有很多,我来给你举一个可以直接看出来的好处:恢复数据。

接下来,我们就分别从delete、insert和update这三种SQL语句的角度,来看看数据恢复的问题。

通过图6你可以看出来,即使我执行的是delete语句,row格式的binlog也会把被删掉的行的整行信息保存起来。

所以,如果你在执行完一条delete语句以后,发现删错数据了,可以直接把binlog中记录的delete语句转成insert,把被错删的数据插入回去就可以恢复了。

如果你是执行错了insert语句呢?那就更直接了。

row格式下,insert语句的binlog里会记录所有的字段信息,这些信息可以用来精确定位刚刚被插入的那一行。

这时,你直接把insert语句转成delete语句,删除掉这被误插入的一行数据就可以了。

如果执行的是update语句的话,binlog里面会记录修改前整行的数据和修改后的整行数据。

所以,如果你误执行了update语句的话,只需要把这个event前后的两行信息对调一下,再去数据库里面执行,就能恢复这个更新操作了。

其实,由delete、insert或者update语句导致的数据操作错误,需要恢复到操作之前状态的情况,也时有发生。

MariaDB的Flashback工具就是基于上面介绍的原理来回滚数据的。

虽然mixed格式的binlog现在已经用得不多了,但这里我还是要再借用一下mixed格式来说明一个问题,来看一下这条SQL语句:
如果我们把binlog格式设置为mixed,你觉得MySQL会把它记录为row格式还是statement格式呢?先不要着急说结果,我们一起来看一下这条语句执行的效果。

图7 mixed格式和now()可以看到,MySQL用的居然是statement格式。

你一定会奇怪,如果这个binlog过了1分钟才传给备库的话,那主备的数据不就不一致了吗?接下来,我们再用mysqlbinlog工具来看看:
mysql> insert into t values(10,10, now());图8 TIMESTAMP 命令从图中的结果可以看到,原来binlog在记录event的时候,多记了一条命令:SETTIMESTAMP=1546103491。

它用 SET TIMESTAMP命令约定了接下来的now()函数的返回时间。

因此,不论这个binlog是1分钟之后被备库执行,还是3天后用来恢复这个库的备份,这个insert语句插入的行,值都是固定的。

也就是说,通过这条SET TIMESTAMP命令,MySQL就确保了主备数据的一致性。

我之前看过有人在重放binlog数据的时候,是这么做的:用mysqlbinlog解析出日志,然后把里面的statement语句直接拷贝出来执行。

你现在知道了,这个方法是有风险的。

因为有些语句的执行结果是依赖于上下文命令的,直接执行的结果很可能是错误的。

所以,用binlog来恢复数据的标准做法是,用 mysqlbinlog工具解析出来,然后把解析结果整个发给MySQL执行。

类似下面的命令:
这个命令的意思是,将 master.000001 文件里面从第2738字节到第2973字节中间这段内容解析出来,放到MySQL去执行。

循环复制问题通过上面对MySQL中binlog基本内容的理解,你现在可以知道,binlog的特性确保了在备库执行相同的binlog,可以得到与主库相同的状态。

因此,我们可以认为正常情况下主备的数据是一致的。

也就是说,图1中A、B两个节点的内容是一致的。

其实,图1中我画的是M-S结构,但实际生产上使用比较多的是双M结构,也就是图9所示的主备切换流程。

mysqlbinlog master.000001 –start-position=2738 –stop-position=2973 | mysql -h127.0.0.1 -P13000 -u$user -p$pwd;图 9 MySQL主备切换流程–双M结构对比图9和图1,你可以发现,双M结构和M-S结构,其实区别只是多了一条线,即:节点A和B之间总是互为主备关系。

这样在切换的时候就不用再修改主备关系。

但是,双M结构还有一个问题需要解决。

业务逻辑在节点A上更新了一条语句,然后再把生成的binlog 发给节点B,节点B执行完这条更新语句后也会生成binlog。

(我建议你把参数log_slave_updates设置为on,表示备库执行relay log后生成binlog)。

那么,如果节点A同时是节点B的备库,相当于又把节点B新生成的binlog拿过来执行了一次,然后节点A和B间,会不断地循环执行这个更新语句,也就是循环复制了。

这个要怎么解决呢?从上面的图6中可以看到,MySQL在binlog中记录了这个命令第一次执行时所在实例的serverid。

因此,我们可以用下面的逻辑,来解决两个节点间的循环复制的问题:

  1. 规定两个库的server id必须不同,如果相同,则它们之间不能设定为主备关系;
  2. 一个备库接到binlog并在重放的过程中,生成与原binlog的server id相同的新的binlog;
  3. 每个库在收到从自己的主库发过来的日志后,先判断server id,如果跟自己的相同,表示这个日志是自己生成的,就直接丢弃这个日志。

按照这个逻辑,如果我们设置了双M结构,日志的执行流就会变成这样:

  1. 从节点A更新的事务,binlog里面记的都是A的server id;
  2. 传到节点B执行一次以后,节点B生成的binlog 的server id也是A的server id;
  3. 再传回给节点A,A判断到这个server id与自己的相同,就不会再处理这个日志。

所以,死循环在这里就断掉了。

小结今天这篇文章,我给你介绍了MySQL binlog的格式和一些基本机制,是后面我要介绍的读写分离等系列文章的背景知识,希望你可以认真消化理解。

binlog在MySQL的各种高可用方案上扮演了重要角色。

今天介绍的可以说是所有MySQL高可用方案的基础。

在这之上演化出了诸如多节点、半同步、MySQL group replication等相对复杂的方案。

我也跟你介绍了MySQL不同格式binlog的优缺点,和设计者的思考。

希望你在做系统开发时候,也能借鉴这些设计思想。

最后,我给你留下一个思考题吧。

说到循环复制问题的时候,我们说MySQL通过判断server id的方式,断掉死循环。

但是,这个机制其实并不完备,在某些场景下,还是有可能出现死循环。

你能构造出一个这样的场景吗?又应该怎么解决呢?你可以把你的设计和分析写在评论区,我会在下一篇文章跟你讨论这个问题。

感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间上期我留给你的问题是,你在什么时候会把线上生产库设置成“非双1”。

我目前知道的场景,有以下这些:

  1. 业务高峰期。

一般如果有预知的高峰期,DBA会有预案,把主库设置成“非双1”。

  1. 备库延迟,为了让备库尽快赶上主库。

@永恒记忆和@Second Sight提到了这个场景。

  1. 用备份恢复主库的副本,应用binlog的过程,这个跟上一种场景类似。

  2. 批量导入数据的时候。

一般情况下,把生产库改成“非双1”配置,是设置innodb_flush_logs_at_trx_commit=2、sync_binlog=1000。

问题解析

MySQL是怎么保证数据不丢的?今天这篇文章,我会继续和你介绍在业务高峰期临时提升性能的方法。

从文章标题“MySQL是怎么保证数据不丢的?”,你就可以看出来,今天我和你介绍的方法,跟数据的可靠性有关。

在专栏前面文章和答疑篇中,我都着重介绍了WAL机制(你可以再回顾下第2篇、第9篇、第12篇和第15篇文章中的相关内容),得到的结论是:只要redo log和binlog保证持久化到磁盘,就能确保MySQL异常重启后,数据可以恢复。

评论区有同学又继续追问,redo log的写入流程是怎么样的,如何保证redo log真实地写入了磁盘。

那么今天,我们就再一起看看MySQL写入binlog和redo log的流程。

binlog的写入机制其实,binlog的写入逻辑比较简单:事务执行过程中,先把日志写到binlog cache,事务提交的时候,再把binlog cache写到binlog文件中。

一个事务的binlog是不能被拆开的,因此不论这个事务多大,也要确保一次性写入。

这就涉及到了binlog cache的保存问题。

系统给binlog cache分配了一片内存,每个线程一个,参数 binlog_cache_size用于控制单个线程内binlog cache所占内存的大小。

如果超过了这个参数规定的大小,就要暂存到磁盘。

事务提交的时候,执行器把binlog cache里的完整事务写入到binlog中,并清空binlog cache。

状态如图1所示。

图1 binlog写盘状态可以看到,每个线程有自己binlog cache,但是共用同一份binlog文件。

图中的write,指的就是指把日志写入到文件系统的page cache,并没有把数据持久化到磁盘,所以速度比较快。

图中的fsync,才是将数据持久化到磁盘的操作。

一般情况下,我们认为fsync才占磁盘的IOPS。

write 和fsync的时机,是由参数sync_binlog控制的:

  1. sync_binlog=0的时候,表示每次提交事务都只write,不fsync;
  2. sync_binlog=1的时候,表示每次提交事务都会执行fsync;
  3. sync_binlog=N(N>1)的时候,表示每次提交事务都write,但累积N个事务后才fsync。

因此,在出现IO瓶颈的场景里,将sync_binlog设置成一个比较大的值,可以提升性能。

在实际的业务场景中,考虑到丢失日志量的可控性,一般不建议将这个参数设成0,比较常见的是将其设置为100~1000中的某个数值。

但是,将sync_binlog设置为N,对应的风险是:如果主机发生异常重启,会丢失最近N个事务的binlog日志。

redo log的写入机制接下来,我们再说说redo log的写入机制。

在专栏的第15篇答疑文章中,我给你介绍了redo log buffer。

事务在执行过程中,生成的redolog是要先写到redo log buffer的。

然后就有同学问了,redo log buffer里面的内容,是不是每次生成后都要直接持久化到磁盘呢?答案是,不需要。

如果事务执行期间MySQL发生异常重启,那这部分日志就丢了。

由于事务并没有提交,所以这时日志丢了也不会有损失。

那么,另外一个问题是,事务还没提交的时候,redo log buffer中的部分日志有没有可能被持久化到磁盘呢?答案是,确实会有。

这个问题,要从redo log可能存在的三种状态说起。

这三种状态,对应的就是图2 中的三个颜色块。

图2 MySQL redo log存储状态这三种状态分别是:

  1. 存在redo log buffer中,物理上是在MySQL进程内存中,就是图中的红色部分;
  2. 写到磁盘(write),但是没有持久化(fsync),物理上是在文件系统的page cache里面,也就是图中的黄色部分;
  3. 持久化到磁盘,对应的是hard disk,也就是图中的绿色部分。

日志写到redo log buffer是很快的,wirte到page cache也差不多,但是持久化到磁盘的速度就慢多了。

为了控制redo log的写入策略,InnoDB提供了innodb_flush_log_at_trx_commit参数,它有三种可能取值:

  1. 设置为0的时候,表示每次事务提交时都只是把redo log留在redo log buffer中;2. 设置为1的时候,表示每次事务提交时都将redo log直接持久化到磁盘;
  2. 设置为2的时候,表示每次事务提交时都只是把redo log写到page cache。

InnoDB有一个后台线程,每隔1秒,就会把redo log buffer中的日志,调用write写到文件系统的page cache,然后调用fsync持久化到磁盘。

注意,事务执行中间过程的redo log也是直接写在redo log buffer中的,这些redo log也会被后台线程一起持久化到磁盘。

也就是说,一个没有提交的事务的redo log,也是可能已经持久化到磁盘的。

实际上,除了后台线程每秒一次的轮询操作外,还有两种场景会让一个没有提交的事务的redolog写入到磁盘中。

  1. 一种是,redo log buffer占用的空间即将达到 innodb_log_buffer_size一半的时候,后台线程会主动写盘。

注意,由于这个事务并没有提交,所以这个写盘动作只是write,而没有调用fsync,也就是只留在了文件系统的page cache。

  1. 另一种是,并行的事务提交的时候,顺带将这个事务的redo log buffer持久化到磁盘。

假设一个事务A执行到一半,已经写了一些redo log到buffer中,这时候有另外一个线程的事务B提交,如果innodb_flush_log_at_trx_commit设置的是1,那么按照这个参数的逻辑,事务B要把redo log buffer里的日志全部持久化到磁盘。

这时候,就会带上事务A在redolog buffer里的日志一起持久化到磁盘。

这里需要说明的是,我们介绍两阶段提交的时候说过,时序上redo log先prepare, 再写binlog,最后再把redo log commit。

如果把innodb_flush_log_at_trx_commit设置成1,那么redo log在prepare阶段就要持久化一次,因为有一个崩溃恢复逻辑是要依赖于prepare 的redo log,再加上binlog来恢复的。

(如果你印象有点儿模糊了,可以再回顾下第15篇文章中的相关内容)。

每秒一次后台轮询刷盘,再加上崩溃恢复这个逻辑,InnoDB就认为redo log在commit的时候就不需要fsync了,只会write到文件系统的page cache中就够了。

通常我们说MySQL的“双1”配置,指的就是sync_binlog和innodb_flush_log_at_trx_commit都设置成 1。

也就是说,一个事务完整提交前,需要等待两次刷盘,一次是redo log(prepare 阶段),一次是binlog。

这时候,你可能有一个疑问,这意味着我从MySQL看到的TPS是每秒两万的话,每秒就会写四万次磁盘。

但是,我用工具测试出来,磁盘能力也就两万左右,怎么能实现两万的TPS?解释这个问题,就要用到组提交(group commit)机制了。

这里,我需要先和你介绍日志逻辑序列号(log sequence number,LSN)的概念。

LSN是单调递增的,用来对应redo log的一个个写入点。

每次写入长度为length的redo log, LSN的值就会加上length。

LSN也会写到InnoDB的数据页中,来确保数据页不会被多次执行重复的redo log。

关于LSN和redo log、checkpoint的关系,我会在后面的文章中详细展开。

如图3所示,是三个并发事务(trx1, trx2, trx3)在prepare 阶段,都写完redo log buffer,持久化到磁盘的过程,对应的LSN分别是50、120 和160。

图3 redo log 组提交从图中可以看到,1. trx1是第一个到达的,会被选为这组的 leader;
2. 等trx1要开始写盘的时候,这个组里面已经有了三个事务,这时候LSN也变成了160;
3. trx1去写盘的时候,带的就是LSN=160,因此等trx1返回时,所有LSN小于等于160的redolog,都已经被持久化到磁盘;
4. 这时候trx2和trx3就可以直接返回了。

所以,一次组提交里面,组员越多,节约磁盘IOPS的效果越好。

但如果只有单线程压测,那就只能老老实实地一个事务对应一次持久化操作了。

在并发更新场景下,第一个事务写完redo log buffer以后,接下来这个fsync越晚调用,组员可能越多,节约IOPS的效果就越好。

为了让一次fsync带的组员更多,MySQL有一个很有趣的优化:拖时间。

在介绍两阶段提交的时候,我曾经给你画了一个图,现在我把它截过来。

图4 两阶段提交图中,我把“写binlog”当成一个动作。

但实际上,写binlog是分成两步的:

  1. 先把binlog从binlog cache中写到磁盘上的binlog文件;
  2. 调用fsync持久化。

MySQL为了让组提交的效果更好,把redo log做fsync的时间拖到了步骤1之后。

也就是说,上面的图变成了这样:
图5 两阶段提交细化这么一来,binlog也可以组提交了。

在执行图5中第4步把binlog fsync到磁盘时,如果有多个事务的binlog已经写完了,也是一起持久化的,这样也可以减少IOPS的消耗。

不过通常情况下第3步执行得会很快,所以binlog的write和fsync间的间隔时间短,导致能集合到一起持久化的binlog比较少,因此binlog的组提交的效果通常不如redo log的效果那么好。

如果你想提升binlog组提交的效果,可以通过设置 binlog_group_commit_sync_delay 和binlog_group_commit_sync_no_delay_count来实现。

  1. binlog_group_commit_sync_delay参数,表示延迟多少微秒后才调用fsync;2. binlog_group_commit_sync_no_delay_count参数,表示累积多少次以后才调用fsync。

这两个条件是或的关系,也就是说只要有一个满足条件就会调用fsync。

所以,当binlog_group_commit_sync_delay设置为0的时候,binlog_group_commit_sync_no_delay_count也无效了。

之前有同学在评论区问到,WAL机制是减少磁盘写,可是每次提交事务都要写redo log和binlog,这磁盘读写次数也没变少呀?现在你就能理解了,WAL机制主要得益于两个方面:

  1. redo log 和 binlog都是顺序写,磁盘的顺序写比随机写速度要快;
  2. 组提交机制,可以大幅度降低磁盘的IOPS消耗。

分析到这里,我们再来回答这个问题:如果你的MySQL现在出现了性能瓶颈,而且瓶颈在IO上,可以通过哪些方法来提升性能呢?针对这个问题,可以考虑以下三种方法:

  1. 设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count参数,减少binlog的写盘次数。

这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险。

  1. 将sync_binlog 设置为大于1的值(比较常见是100~1000)。

这样做的风险是,主机掉电时会丢binlog日志。

  1. 将innodb_flush_log_at_trx_commit设置为2。

这样做的风险是,主机掉电的时候会丢数据。

我不建议你把innodb_flush_log_at_trx_commit 设置成0。

因为把这个参数设置成0,表示redolog只保存在内存中,这样的话MySQL本身异常重启也会丢数据,风险太大。

而redo log写到文件系统的page cache的速度也是很快的,所以将这个参数设置成2跟设置成0其实性能差不多,但这样做MySQL异常重启时就不会丢数据了,相比之下风险会更小。

小结在专栏的第2篇和第15篇文章中,我和你分析了,如果redo log和binlog是完整的,MySQL是如何保证crash-safe的。

今天这篇文章,我着重和你介绍的是MySQL是“怎么保证redo log和binlog是完整的”。

希望这三篇文章串起来的内容,能够让你对crash-safe这个概念有更清晰的理解。

之前的第15篇答疑文章发布之后,有同学继续留言问到了一些跟日志相关的问题,这里为了方便你回顾、学习,我再集中回答一次这些问题。

问题1:执行一个update语句以后,我再去执行hexdump命令直接查看ibd文件内容,为什么没有看到数据有改变呢?回答:这可能是因为WAL机制的原因。

update语句执行完成后,InnoDB只保证写完了redolog、内存,可能还没来得及将数据写到磁盘。

问题2:为什么binlog cache是每个线程自己维护的,而redo log buffer是全局共用的?回答:MySQL这么设计的主要原因是,binlog是不能“被打断的”。

一个事务的binlog必须连续写,因此要整个事务完成后,再一起写到文件里。

而redo log并没有这个要求,中间有生成的日志可以写到redo log buffer中。

redo log buffer中的内容还能“搭便车”,其他事务提交的时候可以被一起写到磁盘中。

问题3:事务执行期间,还没到提交阶段,如果发生crash的话,redo log肯定丢了,这会不会导致主备不一致呢?回答:不会。

因为这时候binlog 也还在binlog cache里,没发给备库。

crash以后redo log和binlog都没有了,从业务角度看这个事务也没有提交,所以数据是一致的。

问题4:如果binlog写完盘以后发生crash,这时候还没给客户端答复就重启了。

等客户端再重连进来,发现事务已经提交成功了,这是不是bug?回答:不是。

你可以设想一下更极端的情况,整个事务都提交成功了,redo log commit完成了,备库也收到binlog并执行了。

但是主库和客户端网络断开了,导致事务成功的包返回不回去,这时候客户端也会收到“网络断开”的异常。

这种也只能算是事务成功的,不能认为是bug。

实际上数据库的crash-safe保证的是:

  1. 如果客户端收到事务成功的消息,事务就一定持久化了;
  2. 如果客户端收到事务失败(比如主键冲突、回滚等)的消息,事务就一定失败了;
  3. 如果客户端收到“执行异常”的消息,应用需要重连后通过查询当前状态来继续后续的逻辑。

此时数据库只需要保证内部(数据和日志之间,主库和备库之间)一致就可以了。

最后,又到了课后问题时间。

今天我留给你的思考题是:你的生产库设置的是“双1”吗? 如果平时是的话,你有在什么场景下改成过“非双1”吗?你的这个操作又是基于什么决定的?另外,我们都知道这些设置可能有损,如果发生了异常,你的止损方案是什么?你可以把你的理解或者经验写在留言区,我会在下一篇文章的末尾选取有趣的评论和你一起分享和分析。

感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

问题解析

MySQL有哪些“饮鸩止渴”提高性能的方法?不知道你在实际运维过程中有没有碰到这样的情景:业务高峰期,生产环境的MySQL压力太大,没法正常响应,需要短期内、临时性地提升一些性能。

我以前做业务护航的时候,就偶尔会碰上这种场景。

用户的开发负责人说,不管你用什么方案,让业务先跑起来再说。

但,如果是无损方案的话,肯定不需要等到这个时候才上场。

今天我们就来聊聊这些临时方案,并着重说一说它们可能存在的风险。

短连接风暴正常的短连接模式就是连接到数据库后,执行很少的SQL语句就断开,下次需要的时候再重连。

如果使用的是短连接,在业务高峰期的时候,就可能出现连接数突然暴涨的情况。

我在第1篇文章《基础架构:一条SQL查询语句是如何执行的?》中说过,MySQL建立连接的过程,成本是很高的。

除了正常的网络连接三次握手外,还需要做登录权限判断和获得这个连接的数据读写权限。

在数据库压力比较小的时候,这些额外的成本并不明显。

但是,短连接模型存在一个风险,就是一旦数据库处理得慢一些,连接数就会暴涨。

max_connections参数,用来控制一个MySQL实例同时存在的连接数的上限,超过这个值,系统就会拒绝接下来的连接请求,并报错提示“Too many connections”。

对于被拒绝连接的请求来说,从业务角度看就是数据库不可用。

在机器负载比较高的时候,处理现有请求的时间变长,每个连接保持的时间也更长。

这时,再有新建连接的话,就可能会超过max_connections的限制。

碰到这种情况时,一个比较自然的想法,就是调高max_connections的值。

但这样做是有风险的。

因为设计max_connections这个参数的目的是想保护MySQL,如果我们把它改得太大,让更多的连接都可以进来,那么系统的负载可能会进一步加大,大量的资源耗费在权限验证等逻辑上,结果可能是适得其反,已经连接的线程拿不到CPU资源去执行业务的SQL请求。

那么这种情况下,你还有没有别的建议呢?我这里还有两种方法,但要注意,这些方法都是有损的。

第一种方法:先处理掉那些占着连接但是不工作的线程。

max_connections的计算,不是看谁在running,是只要连着就占用一个计数位置。

对于那些不需要保持的连接,我们可以通过kill connection主动踢掉。

这个行为跟事先设置wait_timeout的效果是一样的。

设置wait_timeout参数表示的是,一个线程空闲wait_timeout这么多秒之后,就会被MySQL直接断开连接。

但是需要注意,在show processlist的结果里,踢掉显示为sleep的线程,可能是有损的。

我们来看下面这个例子。

图1 sleep线程的两种状态在上面这个例子里,如果断开session A的连接,因为这时候session A还没有提交,所以MySQL只能按照回滚事务来处理;而断开session B的连接,就没什么大影响。

所以,如果按照优先级来说,你应该优先断开像session B这样的事务外空闲的连接。

但是,怎么判断哪些是事务外空闲的呢?session C在T时刻之后的30秒执行show processlist,看到的结果是这样的。

图2 sleep线程的两种状态,show processlist结果图中id=4和id=5的两个会话都是Sleep 状态。

而要看事务具体状态的话,你可以查information_schema库的innodb_trx表。

图3 从information_schema.innodb_trx查询事务状态这个结果里,trx_mysql_thread_id=4,表示id=4的线程还处在事务中。

因此,如果是连接数过多,你可以优先断开事务外空闲太久的连接;如果这样还不够,再考虑断开事务内空闲太久的连接。

从服务端断开连接使用的是kill connection + id的命令, 一个客户端处于sleep状态时,它的连接被服务端主动断开后,这个客户端并不会马上知道。

直到客户端在发起下一个请求的时候,才会收到这样的报错“ERROR 2013 (HY000): Lost connection to MySQL server during query”。

从数据库端主动断开连接可能是有损的,尤其是有的应用端收到这个错误后,不重新连接,而是直接用这个已经不能用的句柄重试查询。

这会导致从应用端看上去,“MySQL一直没恢复”。

你可能觉得这是一个冷笑话,但实际上我碰到过不下10次。

所以,如果你是一个支持业务的DBA,不要假设所有的应用代码都会被正确地处理。

即使只是一个断开连接的操作,也要确保通知到业务开发团队。

第二种方法:减少连接过程的消耗。

有的业务代码会在短时间内先大量申请数据库连接做备用,如果现在数据库确认是被连接行为打挂了,那么一种可能的做法,是让数据库跳过权限验证阶段。

跳过权限验证的方法是:重启数据库,并使用–skip-grant-tables参数启动。

这样,整个MySQL会跳过所有的权限验证阶段,包括连接过程和语句执行过程在内。

但是,这种方法特别符合我们标题里说的“饮鸩止渴”,风险极高,是我特别不建议使用的方案。

尤其你的库外网可访问的话,就更不能这么做了。

在MySQL 8.0版本里,如果你启用–skip-grant-tables参数,MySQL会默认把 –skip-networking参数打开,表示这时候数据库只能被本地的客户端连接。

可见,MySQL官方对skip-grant-tables这个参数的安全问题也很重视。

除了短连接数暴增可能会带来性能问题外,实际上,我们在线上碰到更多的是查询或者更新语句导致的性能问题。

其中,查询问题比较典型的有两类,一类是由新出现的慢查询导致的,一类是由QPS(每秒查询数)突增导致的。

而关于更新语句导致的性能问题,我会在下一篇文章和你展开说明。

慢查询性能问题在MySQL中,会引发性能问题的慢查询,大体有以下三种可能:

  1. 索引没有设计好;
  2. SQL语句没写好;
  3. MySQL选错了索引。

接下来,我们就具体分析一下这三种可能,以及对应的解决方案。

导致慢查询的第一种可能是,索引没有设计好。

这种场景一般就是通过紧急创建索引来解决。

MySQL 5.6版本以后,创建索引都支持Online DDL了,对于那种高峰期数据库已经被这个语句打挂了的情况,最高效的做法就是直接执行altertable 语句。

比较理想的是能够在备库先执行。

假设你现在的服务是一主一备,主库A、备库B,这个方案的大致流程是这样的:

  1. 在备库B上执行 set sql_log_bin=off,也就是不写binlog,然后执行alter table 语句加上索引;
  2. 执行主备切换;
  3. 这时候主库是B,备库是A。

在A上执行 set sql_log_bin=off,然后执行alter table 语句加上索引。

这是一个“古老”的DDL方案。

平时在做变更的时候,你应该考虑类似gh-ost这样的方案,更加稳妥。

但是在需要紧急处理时,上面这个方案的效率是最高的。

导致慢查询的第二种可能是,语句没写好。

比如,我们犯了在第18篇文章《为什么这些SQL语句逻辑相同,性能却差异巨大?》中提到的那些错误,导致语句没有使用上索引。

这时,我们可以通过改写SQL语句来处理。

MySQL 5.7提供了query_rewrite功能,可以把输入的一种语句改写成另外一种模式。

比如,语句被错误地写成了 select * from t where id + 1 = 10000,你可以通过下面的方式,增加一个语句改写规则。

这里,call query_rewrite.flush_rewrite_rules()这个存储过程,是让插入的新规则生效,也就是我们说的“查询重写”。

你可以用图4中的方法来确认改写规则是否生效。

图4 查询重写效果mysql> insert into query_rewrite.rewrite_rules(pattern, replacement, pattern_database) values (“select * from t where id + 1 = ?”, “select * from t where id = ? - 1”, “db1”);call query_rewrite.flush_rewrite_rules();导致慢查询的第三种可能,就是碰上了我们在第10篇文章《MySQL为什么有时候会选错索引?》中提到的情况,MySQL选错了索引。

这时候,应急方案就是给这个语句加上force index。

同样地,使用查询重写功能,给原来的语句加上force index,也可以解决这个问题。

上面我和你讨论的由慢查询导致性能问题的三种可能情况,实际上出现最多的是前两种,即:索引没设计好和语句没写好。

而这两种情况,恰恰是完全可以避免的。

比如,通过下面这个过程,我们就可以预先发现问题。

  1. 上线前,在测试环境,把慢查询日志(slow log)打开,并且把long_query_time设置成0,确保每个语句都会被记录入慢查询日志;
  2. 在测试表里插入模拟线上的数据,做一遍回归测试;
  3. 观察慢查询日志里每类语句的输出,特别留意Rows_examined字段是否与预期一致。

(我们在前面文章中已经多次用到过Rows_examined方法了,相信你已经动手尝试过了。

如果还有不明白的,欢迎给我留言,我们一起讨论)。

不要吝啬这段花在上线前的“额外”时间,因为这会帮你省下很多故障复盘的时间。

如果新增的SQL语句不多,手动跑一下就可以。

而如果是新项目的话,或者是修改了原有项目的表结构设计,全量回归测试都是必要的。

这时候,你需要工具帮你检查所有的SQL语句的返回结果。

比如,你可以使用开源工具pt-query-digest(https://www.percona.com/doc/percona-toolkit/3.0/pt-query-digest.html)。

QPS突增问题有时候由于业务突然出现高峰,或者应用程序bug,导致某个语句的QPS突然暴涨,也可能导致MySQL压力过大,影响服务。

我之前碰到过一类情况,是由一个新功能的bug导致的。

当然,最理想的情况是让业务把这个功能下掉,服务自然就会恢复。

而下掉一个功能,如果从数据库端处理的话,对应于不同的背景,有不同的方法可用。

我这里再和你展开说明一下。

  1. 一种是由全新业务的bug导致的。

假设你的DB运维是比较规范的,也就是说白名单是一个个加的。

这种情况下,如果你能够确定业务方会下掉这个功能,只是时间上没那么快,那么就可以从数据库端直接把白名单去掉。

  1. 如果这个新功能使用的是单独的数据库用户,可以用管理员账号把这个用户删掉,然后断开现有连接。

这样,这个新功能的连接不成功,由它引发的QPS就会变成0。

  1. 如果这个新增的功能跟主体功能是部署在一起的,那么我们只能通过处理语句来限制。

这时,我们可以使用上面提到的查询重写功能,把压力最大的SQL语句直接重写成”select 1”返回。

当然,这个操作的风险很高,需要你特别细致。

它可能存在两个副作用:

  1. 如果别的功能里面也用到了这个SQL语句模板,会有误伤;
  2. 很多业务并不是靠这一个语句就能完成逻辑的,所以如果单独把这一个语句以select 1的结果返回的话,可能会导致后面的业务逻辑一起失败。

所以,方案3是用于止血的,跟前面提到的去掉权限验证一样,应该是你所有选项里优先级最低的一个方案。

同时你会发现,其实方案1和2都要依赖于规范的运维体系:虚拟化、白名单机制、业务账号分离。

由此可见,更多的准备,往往意味着更稳定的系统。

小结今天这篇文章,我以业务高峰期的性能问题为背景,和你介绍了一些紧急处理的手段。

这些处理手段中,既包括了粗暴地拒绝连接和断开连接,也有通过重写语句来绕过一些坑的方法;既有临时的高危方案,也有未雨绸缪的、相对安全的预案。

在实际开发中,我们也要尽量避免一些低效的方法,比如避免大量地使用短连接。

同时,如果你做业务开发的话,要知道,连接异常断开是常有的事,你的代码里要有正确地重连并重试的机制。

DBA虽然可以通过语句重写来暂时处理问题,但是这本身是一个风险高的操作,做好SQL审计可以减少需要这类操作的机会。

其实,你可以看得出来,在这篇文章中我提到的解决方法主要集中在server层。

在下一篇文章中,我会继续和你讨论一些跟InnoDB有关的处理方法。

最后,又到了我们的思考题时间了。

今天,我留给你的课后问题是,你是否碰到过,在业务高峰期需要临时救火的场景?你又是怎么处理的呢?你可以把你的经历和经验写在留言区,我会在下一篇文章的末尾选取有趣的评论跟大家一起分享和分析。

感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间前两期我给你留的问题是,下面这个图的执行序列中,为什么session B的insert语句会被堵住。

我们用上一篇的加锁规则来分析一下,看看session A的select语句加了哪些锁:

  1. 由于是order by c desc,第一个要定位的是索引c上“最右边的”c=20的行,所以会加上间隙锁(20,25)和next-key lock (15,20]。

  2. 在索引c上向左遍历,要扫描到c=10才停下来,所以next-key lock会加到(5,10],这正是阻塞session B的insert语句的原因。

  3. 在扫描过程中,c=20、c=15、c=10这三行都存在值,由于是select *,所以会在主键id上加三个行锁。

因此,session A 的select语句锁的范围就是:

  1. 索引c上 (5, 25);
  2. 主键索引上id=15、20两个行锁。

这里,我再啰嗦下,你会发现我在文章中,每次加锁都会说明是加在“哪个索引上”的。

因为,锁就是加在索引上的,这是InnoDB的一个基础设定,需要你在分析问题的时候要一直记得。

问题解析

为什么我只改一行的语句,锁这么多在上一篇文章中,我和你介绍了间隙锁和next-key lock的概念,但是并没有说明加锁规则。

间隙锁的概念理解起来确实有点儿难,尤其在配合上行锁以后,很容易在判断是否会出现锁等待的问题上犯错。

所以今天,我们就先从这个加锁规则开始吧。

首先说明一下,这些加锁规则我没在别的地方看到过有类似的总结,以前我自己判断的时候都是想着代码里面的实现来脑补的。

这次为了总结成不看代码的同学也能理解的规则,是我又重新刷了代码临时总结出来的。

所以,这个规则有以下两条前提说明:

  1. MySQL后面的版本可能会改变加锁策略,所以这个规则只限于截止到现在的最新版本,即5.x系列<=5.7.24,8.0系列 <=8.0.13。

  2. 如果大家在验证中有发现bad case的话,请提出来,我会再补充进这篇文章,使得一起学习本专栏的所有同学都能受益。

因为间隙锁在可重复读隔离级别下才有效,所以本篇文章接下来的描述,若没有特殊说明,默认是可重复读隔离级别。

我总结的加锁规则里面,包含了两个“原则”、两个“优化”和一个“bug”。

  1. 原则1:加锁的基本单位是next-key lock。

希望你还记得,next-key lock是前开后闭区间。

  1. 原则2:查找过程中访问到的对象才会加锁。

  2. 优化1:索引上的等值查询,给唯一索引加锁的时候,next-key lock退化为行锁。

  3. 优化2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-keylock退化为间隙锁。

  4. 一个bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

我还是以上篇文章的表t为例,和你解释一下这些规则。

表t的建表语句和初始化语句如下。

接下来的例子基本都是配合着图片说明的,所以我建议你可以对照着文稿看,有些例子可能会“毁三观”,也建议你读完文章后亲手实践一下。

案例一:等值查询间隙锁第一个例子是关于等值条件操作间隙:

图1 等值查询的间隙锁由于表t中没有id=7的记录,所以用我们上面提到的加锁规则判断一下的话:

CREATE TABLE t̀ ̀( ìd ̀int(11) NOT NULL, c ̀int(11) DEFAULT NULL, d ̀int(11) DEFAULT NULL, PRIMARY KEY (̀ id )̀, KEY `c ̀(̀ c )̀) ENGINE=InnoDB;insert into t values(0,0,0),(5,5,5),(10,10,10),(15,15,15),(20,20,20),(25,25,25);1. 根据原则1,加锁单位是next-key lock,session A加锁范围就是(5,10];

  1. 同时根据优化2,这是一个等值查询(id=7),而id=10不满足查询条件,next-key lock退化成间隙锁,因此最终加锁的范围是(5,10)。

所以,session B要往这个间隙里面插入id=8的记录会被锁住,但是session C修改id=10这行是可以的。

案例二:非唯一索引等值锁第二个例子是关于覆盖索引上的锁:

图2 只加在非唯一索引上的锁看到这个例子,你是不是有一种“该锁的不锁,不该锁的乱锁”的感觉?我们来分析一下吧。

这里session A要给索引c上c=5的这一行加上读锁。

  1. 根据原则1,加锁单位是next-key lock,因此会给(0,5]加上next-key lock。

  2. 要注意c是普通索引,因此仅访问c=5这一条记录是不能马上停下来的,需要向右遍历,查到c=10才放弃。

根据原则2,访问到的都要加锁,因此要给(5,10]加next-key lock。

  1. 但是同时这个符合优化2:等值判断,向右遍历,最后一个值不满足c=5这个等值条件,因此退化成间隙锁(5,10)。

  2. 根据原则2 ,只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么session B的update语句可以执行完成。

但session C要插入一个(7,7,7)的记录,就会被session A的间隙锁(5,10)锁住。

需要注意,在这个例子中,lock in share mode只锁覆盖索引,但是如果是for update就不一样了。

执行 for update时,系统会认为你接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁。

这个例子说明,锁是加在索引上的;同时,它给我们的指导是,如果你要用lock in share mode来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化,在查询字段中加入索引中不存在的字段。

比如,将session A的查询语句改成select d from t where c=5 lock in share mode。

你可以自己验证一下效果。

案例三:主键索引范围锁第三个例子是关于范围查询的。

举例之前,你可以先思考一下这个问题:对于我们这个表t,下面这两条查询语句,加锁范围相同吗?你可能会想,id定义为int类型,这两个语句就是等价的吧?其实,它们并不完全等价。

在逻辑上,这两条查语句肯定是等价的,但是它们的加锁规则不太一样。

现在,我们就让session A执行第二个查询语句,来看看加锁效果。

图3 主键索引上范围查询的锁现在我们就用前面提到的加锁规则,来分析一下session A 会加什么锁呢?mysql> select * from t where id=10 for update;mysql> select * from t where id>=10 and id<11 for update;1. 开始执行的时候,要找到第一个id=10的行,因此本该是next-key lock(5,10]。

根据优化1,主键id上的等值条件,退化成行锁,只加了id=10这一行的行锁。

  1. 范围查找就往后继续找,找到id=15这一行停下来,因此需要加next-key lock(10,15]。

所以,session A这时候锁的范围就是主键索引上,行锁id=10和next-key lock(10,15]。

这样,session B和session C的结果你就能理解了。

这里你需要注意一点,首次session A定位查找id=10的行的时候,是当做等值查询来判断的,而向右扫描到id=15的时候,用的是范围查询判断。

案例四:非唯一索引范围锁接下来,我们再看两个范围查询加锁的例子,你可以对照着案例三来看。

需要注意的是,与案例三不同的是,案例四中查询语句的where部分用的是字段c。

图4 非唯一索引范围锁这次session A用字段c来判断,加锁规则跟案例三唯一的不同是:在第一次用c=10定位记录的时候,索引c上加了(5,10]这个next-key lock后,由于索引c是非唯一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终sesion A加的锁是,索引c上的(5,10] 和(10,15] 这两个next-keylock。

所以从结果上来看,sesson B要插入(8,8,8)的这个insert语句时就被堵住了。

这里需要扫描到c=15才停止扫描,是合理的,因为InnoDB要扫到c=15,才知道不需要继续往后找了。

案例五:唯一索引范围锁bug前面的四个案例,我们已经用到了加锁规则中的两个原则和两个优化,接下来再看一个关于加锁规则中bug的案例。

图5 唯一索引范围锁的bugsession A是一个范围查询,按照原则1的话,应该是索引id上只加(10,15]这个next-key lock,并且因为id是唯一键,所以循环判断到id=15这一行就应该停止了。

但是实现上,InnoDB会往前扫描到第一个不满足条件的行为止,也就是id=20。

而且由于这是个范围扫描,因此索引id上的(15,20]这个next-key lock也会被锁上。

所以你看到了,session B要更新id=20这一行,是会被锁住的。

同样地,session C要插入id=16的一行,也会被锁住。

照理说,这里锁住id=20这一行的行为,其实是没有必要的。

因为扫描到id=15,就可以确定不用往后再找了。

但实现上还是这么做了,因此我认为这是个bug。

我也曾找社区的专家讨论过,官方bug系统上也有提到,但是并未被verified。

所以,认为这是bug这个事儿,也只能算我的一家之言,如果你有其他见解的话,也欢迎你提出来。

案例六:非唯一索引上存在”等值”的例子接下来的例子,是为了更好地说明“间隙”这个概念。

这里,我给表t插入一条新记录。

新插入的这一行c=10,也就是说现在表里有两个c=10的行。

那么,这时候索引c上的间隙是什么状态了呢?你要知道,由于非唯一索引上包含主键的值,所以是不可能存在“相同”的两行的。

mysql> insert into t values(30,10,30);图6 非唯一索引等值的例子可以看到,虽然有两个c=10,但是它们的主键值id是不同的(分别是10和30),因此这两个c=10的记录之间,也是有间隙的。

图中我画出了索引c上的主键id。

为了跟间隙锁的开区间形式进行区别,我用(c=10,id=30)这样的形式,来表示索引上的一行。

现在,我们来看一下案例六。

这次我们用delete语句来验证。

注意,delete语句加锁的逻辑,其实跟select … for update 是类似的,也就是我在文章开始总结的两个“原则”、两个“优化”和一个“bug”。

图7 delete 示例这时,session A在遍历的时候,先访问第一个c=10的记录。

同样地,根据原则1,这里加的是(c=5,id=5)到(c=10,id=10)这个next-key lock。

然后,session A向右查找,直到碰到(c=15,id=15)这一行,循环才结束。

根据优化2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成(c=10,id=10) 到 (c=15,id=15)的间隙锁。

也就是说,这个delete语句在索引c上的加锁范围,就是下图中蓝色区域覆盖的部分。

图8 delete加锁效果示例这个蓝色区域左右两边都是虚线,表示开区间,即(c=5,id=5)和(c=15,id=15)这两行上都没有锁。

案例七:limit 语句加锁例子6也有一个对照案例,场景如下所示:

图9 limit 语句加锁这个例子里,session A的delete语句加了 limit 2。

你知道表t里c=10的记录其实只有两条,因此加不加limit 2,删除的效果都是一样的,但是加锁的效果却不同。

可以看到,session B的insert语句执行通过了,跟案例六的结果不同。

这是因为,案例七里的delete语句明确加了limit 2的限制,因此在遍历到(c=10, id=30)这一行之后,满足条件的语句已经有两条,循环就结束了。

因此,索引c上的加锁范围就变成了从(c=5,id=5)到(c=10,id=30)这个前开后闭区间,如下图所示:

图10 带limit 2的加锁效果可以看到,(c=10,id=30)之后的这个间隙并没有在加锁范围里,因此insert语句插入c=12是可以执行成功的。

这个例子对我们实践的指导意义就是,在删除数据的时候尽量加limit。

这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。

案例八:一个死锁的例子前面的例子中,我们在分析的时候,是按照next-key lock的逻辑来分析的,因为这样分析比较方便。

最后我们再看一个案例,目的是说明:next-key lock实际上是间隙锁和行锁加起来的结果。

你一定会疑惑,这个概念不是一开始就说了吗?不要着急,我们先来看下面这个例子:

图11 案例八的操作序列现在,我们按时间顺序来分析一下为什么是这样的结果。

  1. session A 启动事务后执行查询语句加lock in share mode,在索引c上加了next-keylock(5,10] 和间隙锁(10,15);

  2. session B 的update语句也要在索引c上加next-key lock(5,10] ,进入锁等待;

  3. 然后session A要再插入(8,8,8)这一行,被session B的间隙锁锁住。

由于出现了死锁,InnoDB让session B回滚。

你可能会问,session B的next-key lock不是还没申请成功吗?其实是这样的,session B的“加next-key lock(5,10] ”操作,实际上分成了两步,先是加(5,10)的间隙锁,加锁成功;然后加c=10的行锁,这时候才被锁住的。

也就是说,我们在分析加锁规则的时候可以用next-key lock来分析。

但是要知道,具体执行的时候,是要分成间隙锁和行锁两段来执行的。

小结这里我再次说明一下,我们上面的所有案例都是在可重复读隔离级别(repeatable-read)下验证的。

同时,可重复读隔离级别遵守两阶段锁协议,所有加锁的资源,都是在事务提交或者回滚的时候才释放的。

在最后的案例中,你可以清楚地知道next-key lock实际上是由间隙锁加行锁实现的。

如果切换到读提交隔离级别(read-committed)的话,就好理解了,过程中去掉间隙锁的部分,也就是只剩下行锁的部分。

其实读提交隔离级别在外键场景下还是有间隙锁,相对比较复杂,我们今天先不展开。

另外,在读提交隔离级别下还有一个优化,即:语句执行过程中加上的行锁,在语句执行完成后,就要把“不满足条件的行”上的行锁直接释放了,不需要等到事务提交。

也就是说,读提交隔离级别下,锁的范围更小,锁的时间更短,这也是不少业务都默认使用读提交隔离级别的原因。

不过,我希望你学过今天的课程以后,可以对next-key lock的概念有更清晰的认识,并且会用加锁规则去判断语句的加锁范围。

在业务需要使用可重复读隔离级别的时候,能够更细致地设计操作数据库的语句,解决幻读问题的同时,最大限度地提升系统并行处理事务的能力。

经过这篇文章的介绍,你再看一下上一篇文章最后的思考题,再来尝试分析一次。

我把题目重新描述和简化一下:还是我们在文章开头初始化的表t,里面有6条记录,图12的语句序列中,为什么session B的insert操作,会被锁住呢?图12 锁分析思考题另外,如果你有兴趣多做一些实验的话,可以设计好语句序列,在执行之前先自己分析一下,然后实际地验证结果是否跟你的分析一致。

对于那些你自己无法解释的结果,可以发到评论区里,后面我争取挑一些有趣的案例在文章中分析。

你可以把你关于思考题的分析写在留言区,也可以分享你自己设计的锁验证方案,我会在下一篇文章的末尾选取有趣的评论跟大家分享。

感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间上期的问题,我在本期继续作为了课后思考题,所以会在下篇文章再一起公布“答案”。

这里,我展开回答一下评论区几位同学的问题。

@令狐少侠 说,以前一直认为间隙锁只在二级索引上有。

现在你知道了,有间隙的地方就可能有间隙锁。

@浪里白条 同学问,如果是varchar类型,加锁规则是什么样的。

回答:实际上在判断间隙的时候,varchar和int是一样的,排好序以后,相邻两个值之间就有间隙。

有几位同学提到说,上一篇文章自己验证的结果跟案例一不同,就是在session A执行完这两个语句:

以后,session B 的update 和session C的insert 都会被堵住。

这是不是跟文章的结论矛盾?其实不是的,这个例子用的是反证假设,就是假设不堵住,会出现问题;然后,推导出sessionA需要锁整个表所有的行和所有间隙。

问题解析

幻读是什么,幻读有什么问题?在上一篇文章最后,我给你留了一个关于加锁规则的问题。

今天,我们就从这个问题说起吧。

为了便于说明问题,这一篇文章,我们就先使用一个小一点儿的表。

建表和初始化语句如下(为了便于本期的例子说明,我把上篇文章中用到的表结构做了点儿修改):

这个表除了主键id外,还有一个索引c,初始化语句在表中插入了6行数据。

上期我留给你的问题是,下面的语句序列,是怎么加锁的,加的锁又是什么时候释放的呢?CREATE TABLE t̀ ̀( ìd ̀int(11) NOT NULL, c ̀int(11) DEFAULT NULL, d ̀int(11) DEFAULT NULL, PRIMARY KEY (̀ id )̀, KEY `c ̀(̀ c )̀) ENGINE=InnoDB;insert into t values(0,0,0),(5,5,5),(10,10,10),(15,15,15),(20,20,20),(25,25,25);比较好理解的是,这个语句会命中d=5的这一行,对应的主键id=5,因此在select 语句执行完成后,id=5这一行会加一个写锁,而且由于两阶段锁协议,这个写锁会在执行commit语句的时候释放。

由于字段d上没有索引,因此这条查询语句会做全表扫描。

那么,其他被扫描到的,但是不满足条件的5行记录上,会不会被加锁呢?我们知道,InnoDB的默认事务隔离级别是可重复读,所以本文接下来没有特殊说明的部分,都是设定在可重复读隔离级别下。

幻读是什么?现在,我们就来分析一下,如果只在id=5这一行加锁,而其他行的不加锁的话,会怎么样。

下面先来看一下这个场景(注意:这是我假设的一个场景):

图 1 假设只在id=5这一行加行锁可以看到,session A里执行了三次查询,分别是Q1、Q2和Q3。

它们的SQL语句相同,都是select * from t where d=5 for update。

这个语句的意思你应该很清楚了,查所有d=5的行,而且使用的是当前读,并且加上写锁。

现在,我们来看一下这三条SQL语句,分别会返回什么结果。

  1. Q1只返回id=5这一行;

begin;select * from t where d=5 for update;commit;2. 在T2时刻,session B把id=0这一行的d值改成了5,因此T3时刻Q2查出来的是id=0和id=5这两行;

  1. 在T4时刻,session C又插入一行(1,1,5),因此T5时刻Q3查出来的是id=0、id=1和id=5的这三行。

其中,Q3读到id=1这一行的现象,被称为“幻读”。

也就是说,幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。

这里,我需要对“幻读”做一个说明:

  1. 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。

因此,幻读在“当前读”下才会出现。

  1. 上面session B的修改结果,被session A之后的select语句用“当前读”看到,不能称为幻读。

幻读仅专指“新插入的行”。

如果只从第8篇文章《事务到底是隔离的还是不隔离的?》我们学到的事务可见性规则来分析的话,上面这三条SQL语句的返回结果都没有问题。

因为这三个查询都是加了for update,都是当前读。

而当前读的规则,就是要能读到所有已经提交的记录的最新值。

并且,session B和sessionC的两条语句,执行后就会提交,所以Q2和Q3就是应该看到这两个事务的操作效果,而且也看到了,这跟事务的可见性规则并不矛盾。

但是,这是不是真的没问题呢?不,这里还真就有问题。

幻读有什么问题?首先是语义上的。

session A在T1时刻就声明了,“我要把所有d=5的行锁住,不准别的事务进行读写操作”。

而实际上,这个语义被破坏了。

如果现在这样看感觉还不明显的话,我再往session B和session C里面分别加一条SQL语句,你再看看会出现什么现象。

图 2 假设只在id=5这一行加行锁–语义被破坏session B的第二条语句update t set c=5 where id=0,语义是“我把id=0、d=5这一行的c值,改成了5”。

由于在T1时刻,session A 还只是给id=5这一行加了行锁, 并没有给id=0这行加上锁。

因此,session B在T2时刻,是可以执行这两条update语句的。

这样,就破坏了 session A 里Q1语句要锁住所有d=5的行的加锁声明。

session C也是一样的道理,对id=1这一行的修改,也是破坏了Q1的加锁声明。

其次,是数据一致性的问题。

我们知道,锁的设计是为了保证数据的一致性。

而这个一致性,不止是数据库内部数据状态在此刻的一致性,还包含了数据和日志在逻辑上的一致性。

为了说明这个问题,我给session A在T1时刻再加一个更新语句,即:update t set d=100 whered=5。

图 3 假设只在id=5这一行加行锁–数据一致性问题update的加锁语义和select …for update 是一致的,所以这时候加上这条update语句也很合理。

session A声明说“要给d=5的语句加上锁”,就是为了要更新数据,新加的这条update语句就是把它认为加上了锁的这一行的d值修改成了100。

现在,我们来分析一下图3执行完成后,数据库里会是什么结果。

  1. 经过T1时刻,id=5这一行变成 (5,5,100),当然这个结果最终是在T6时刻正式提交的;2. 经过T2时刻,id=0这一行变成(0,5,5);3. 经过T4时刻,表里面多了一行(1,5,5);4. 其他行跟这个执行序列无关,保持不变。

这样看,这些数据也没啥问题,但是我们再来看看这时候binlog里面的内容。

  1. T2时刻,session B事务提交,写入了两条语句;

  2. T4时刻,session C事务提交,写入了两条语句;

  3. T6时刻,session A事务提交,写入了update t set d=100 where d=5 这条语句。

我统一放到一起的话,就是这样的:

好,你应该看出问题了。

这个语句序列,不论是拿到备库去执行,还是以后用binlog来克隆一个库,这三行的结果,都变成了 (0,5,100)、(1,5,100)和(5,5,100)。

也就是说,id=0和id=1这两行,发生了数据不一致。

这个问题很严重,是不行的。

到这里,我们再回顾一下,这个数据不一致到底是怎么引入的?我们分析一下可以知道,这是我们假设“select * from t where d=5 for update这条语句只给d=5这一行,也就是id=5的这一行加锁”导致的。

所以我们认为,上面的设定不合理,要改。

那怎么改呢?我们把扫描过程中碰到的行,也都加上写锁,再来看看执行效果。

update t set d=5 where id=0; /(0,0,5)/update t set c=5 where id=0; /(0,5,5)/insert into t values(1,1,5); /(1,1,5)/update t set c=5 where id=1; /(1,5,5)/update t set d=100 where d=5;/所有d=5的行,d改成100/图 4 假设扫描到的行都被加上了行锁由于session A把所有的行都加了写锁,所以session B在执行第一个update语句的时候就被锁住了。

需要等到T6时刻session A提交以后,session B才能继续执行。

这样对于id=0这一行,在数据库里的最终结果还是 (0,5,5)。

在binlog里面,执行序列是这样的:

可以看到,按照日志顺序执行,id=0这一行的最终结果也是(0,5,5)。

所以,id=0这一行的问题解决了。

但同时你也可以看到,id=1这一行,在数据库里面的结果是(1,5,5),而根据binlog的执行结果是(1,5,100),也就是说幻读的问题还是没有解决。

为什么我们已经这么“凶残”地,把所有的记录都上了锁,还是阻止不了id=1这一行的插入和更新呢?原因很简单。

在T3时刻,我们给所有行加锁的时候,id=1这一行还不存在,不存在也就加不上锁。

也就是说,即使把所有的记录都加上锁,还是阻止不了新插入的记录,这也是为什么“幻读”会被单独拿出来解决的原因。

到这里,其实我们刚说明完文章的标题 :幻读的定义和幻读有什么问题。

接下来,我们再看看InnoDB怎么解决幻读的问题。

如何解决幻读?现在你知道了,产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。

因此,为了解决幻读问题,InnoDB只好引入新的锁,也就是间隙锁(GapLock)。

顾名思义,间隙锁,锁的就是两个值之间的空隙。

比如文章开头的表t,初始化插入了6个记录,这就产生了7个间隙。

insert into t values(1,1,5); /(1,1,5)/update t set c=5 where id=1; /(1,5,5)/update t set d=100 where d=5;/所有d=5的行,d改成100/update t set d=5 where id=0; /(0,0,5)/update t set c=5 where id=0; /(0,5,5)/图 5 表t主键索引上的行锁和间隙锁这样,当你执行 select * from t where d=5 for update的时候,就不止是给数据库中已有的6个记录加上了行锁,还同时加了7个间隙锁。

这样就确保了无法再插入新的记录。

也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。

现在你知道了,数据行是可以加上锁的实体,数据行之间的间隙,也是可以加上锁的实体。

但是间隙锁跟我们之前碰到过的锁都不太一样。

比如行锁,分成读锁和写锁。

下图就是这两种类型行锁的冲突关系。

图6 两种行锁间的冲突关系也就是说,跟行锁有冲突关系的是“另外一个行锁”。

但是间隙锁不一样,跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作。

间隙锁之间都不存在冲突关系。

这句话不太好理解,我给你举个例子:

图7 间隙锁之间不互锁这里session B并不会被堵住。

因为表t里并没有c=7这个记录,因此session A加的是间隙锁(5,10)。

而session B也是在这个间隙加的间隙锁。

它们有共同的目标,即:保护这个间隙,不允许插入值。

但,它们之间是不冲突的。

间隙锁和行锁合称next-key lock,每个next-key lock是前开后闭区间。

也就是说,我们的表t初始化以后,如果用select * from t for update要把整个表所有记录锁起来,就形成了7个next-keylock,分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。

你可能会问说,这个supremum从哪儿来的呢?这是因为+∞是开区间。

实现上,InnoDB给每个索引加了一个不存在的最大值supremum,这样才符合我们前面说的“都是前开后闭区间”。

间隙锁和next-key lock的引入,帮我们解决了幻读的问题,但同时也带来了一些“困扰”。

在前面的文章中,就有同学提到了这个问题。

我把他的问题转述一下,对应到我们这个例子的表来说,业务逻辑这样的:任意锁住一行,如果这一行不存在的话就插入,如果存在这一行就更新它的数据,代码如下:

备注:这篇文章中,如果没有特别说明,我们把间隙锁记为开区间,把next-key lock记为前开后闭区间。

begin;select * from t where id=N for update;/如果行不存在/insert into t values(N,N,N);/如果行存在/update t set d=N set id=N;commit;可能你会说,这个不是insert … on duplicate key update 就能解决吗?但其实在有多个唯一键的时候,这个方法是不能满足这位提问同学的需求的。

至于为什么,我会在后面的文章中再展开说明。

现在,我们就只讨论这个逻辑。

这个同学碰到的现象是,这个逻辑一旦有并发,就会碰到死锁。

你一定也觉得奇怪,这个逻辑每次操作前用for update锁起来,已经是最严格的模式了,怎么还会有死锁呢?这里,我用两个session来模拟并发,并假设N=9。

图8 间隙锁导致的死锁你看到了,其实都不需要用到后面的update语句,就已经形成死锁了。

我们按语句执行顺序来分析一下:

  1. session A 执行select … for update语句,由于id=9这一行并不存在,因此会加上间隙锁(5,10);2. session B 执行select … for update语句,同样会加上间隙锁(5,10),间隙锁之间不会冲突,因此这个语句可以执行成功;

  2. session B 试图插入一行(9,9,9),被session A的间隙锁挡住了,只好进入等待;

  3. session A试图插入一行(9,9,9),被session B的间隙锁挡住了。

至此,两个session进入互相等待状态,形成死锁。

当然,InnoDB的死锁检测马上就发现了这对死锁关系,让session A的insert语句报错返回了。

你现在知道了,间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的。

其实,这还只是一个简单的例子,在下一篇文章中我们还会碰到更多、更复杂的例子。

你可能会说,为了解决幻读的问题,我们引入了这么一大串内容,有没有更简单一点的处理方法呢。

我在文章一开始就说过,如果没有特别说明,今天和你分析的问题都是在可重复读隔离级别下的,间隙锁是在可重复读隔离级别下才会生效的。

所以,你如果把隔离级别设置为读提交的话,就没有间隙锁了。

但同时,你要解决可能出现的数据和日志不一致问题,需要把binlog格式设置为row。

这,也是现在不少公司使用的配置组合。

前面文章的评论区有同学留言说,他们公司就使用的是读提交隔离级别加binlog_format=row的组合。

他曾问他们公司的DBA说,你为什么要这么配置。

DBA直接答复说,因为大家都这么用呀。

所以,这个同学在评论区就问说,这个配置到底合不合理。

关于这个问题本身的答案是,如果读提交隔离级别够用,也就是说,业务不需要可重复读的保证,这样考虑到读提交下操作数据的锁范围更小(没有间隙锁),这个选择是合理的。

但其实我想说的是,配置是否合理,跟业务场景有关,需要具体问题具体分析。

但是,如果DBA认为之所以这么用的原因是“大家都这么用”,那就有问题了,或者说,迟早会出问题。

比如说,大家都用读提交,可是逻辑备份的时候,mysqldump为什么要把备份线程设置成可重复读呢?(这个我在前面的文章中已经解释过了,你可以再回顾下第6篇文章《全局锁和表锁 :给表加个字段怎么有这么多阻碍?》的内容)然后,在备份期间,备份线程用的是可重复读,而业务线程用的是读提交。

同时存在两种事务隔离级别,会不会有问题?进一步地,这两个不同的隔离级别现象有什么不一样的,关于我们的业务,“用读提交就够了”这个结论是怎么得到的?如果业务开发和运维团队这些问题都没有弄清楚,那么“没问题”这个结论,本身就是有问题的。

小结今天我们从上一篇文章的课后问题说起,提到了全表扫描的加锁方式。

我们发现即使给所有的行都加上行锁,仍然无法解决幻读问题,因此引入了间隙锁的概念。

我碰到过很多对数据库有一定了解的业务开发人员,他们在设计数据表结构和业务SQL语句的时候,对行锁有很准确的认识,但却很少考虑到间隙锁。

最后的结果,就是生产库上会经常出现由于间隙锁导致的死锁现象。

行锁确实比较直观,判断规则也相对简单,间隙锁的引入会影响系统的并发度,也增加了锁分析的复杂度,但也有章可循。

下一篇文章,我就会为你讲解InnoDB的加锁规则,帮你理顺这其中的“章法”。

作为对下一篇文章的预习,我给你留下一个思考题。

图9 事务进入锁等待状态如果你之前没有了解过本篇文章的相关内容,一定觉得这三个语句简直是风马牛不相及。

但实际上,这里session B和session C的insert 语句都会进入锁等待状态。

你可以试着分析一下,出现这种情况的原因是什么?这里需要说明的是,这其实是我在下一篇文章介绍加锁规则后才能回答的问题,是留给你作为预习的,其中session C被锁住这个分析是有点难度的。

如果你没有分析出来,也不要气馁,我会在下一篇文章和你详细说明。

你也可以说说,你的线上MySQL配置的是什么隔离级别,为什么会这么配置?你有没有碰到什么场景,是必须使用可重复读隔离级别的呢?你可以把你的碰到的场景和分析写在留言区里,我会在下一篇文章选取有趣的评论跟大家一起分享和分析。

感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间我们在本文的开头回答了上期问题。

有同学的回答中还说明了读提交隔离级别下,在语句执行完成后,是只有行锁的。

而且语句执行完成后,InnoDB就会把不满足条件的行行锁去掉。

当然了,c=5这一行的行锁,还是会等到commit的时候才释放的。

问题解析

为什么我只查一行的语句,也执行这么慢一般情况下,如果我跟你说查询性能优化,你首先会想到一些复杂的语句,想到查询需要返回大量的数据。

但有些情况下,“查一行”,也会执行得特别慢。

今天,我就跟你聊聊这个有趣的话题,看看什么情况下,会出现这个现象。

需要说明的是,如果MySQL数据库本身就有很大的压力,导致数据库服务器CPU占用率很高或ioutil(IO利用率)很高,这种情况下所有语句的执行都有可能变慢,不属于我们今天的讨论范围。

为了便于描述,我还是构造一个表,基于这个表来说明今天的问题。

这个表有两个字段id和c,并且我在里面插入了10万行记录。

接下来,我会用几个不同的场景来举例,有些是前面的文章中我们已经介绍过的知识点,你看看能不能一眼看穿,来检验一下吧。

第一类:查询长时间不返回如图1所示,在表t执行下面的SQL语句:

查询结果长时间不返回。

图1 查询长时间不返回一般碰到这种情况的话,大概率是表t被锁住了。

接下来分析原因的时候,一般都是首先执行一下show processlist命令,看看当前语句处于什么状态。

mysql> CREATE TABLE t̀ ̀( ìd ̀int(11) NOT NULL, `c ̀int(11) DEFAULT NULL, PRIMARY KEY (̀ id )̀) ENGINE=InnoDB;delimiter ;;create procedure idata()begin declare i int; set i=1; while(i<=100000)do insert into t values(i,i); set i=i+1; end while;end;;delimiter ;call idata();mysql> select * from t where id=1;然后我们再针对每种状态,去分析它们产生的原因、如何复现,以及如何处理。

等MDL锁如图2所示,就是使用show processlist命令查看Waiting for table metadata lock的示意图。

图2 Waiting for table metadata lock状态示意图出现这个状态表示的是,现在有一个线程正在表t上请求或者持有MDL写锁,把select语句堵住了。

在第6篇文章《全局锁和表锁 :给表加个字段怎么有这么多阻碍?》中,我给你介绍过一种复现方法。

但需要说明的是,那个复现过程是基于MySQL 5.6版本的。

而MySQL 5.7版本修改了MDL的加锁策略,所以就不能复现这个场景了。

不过,在MySQL 5.7版本下复现这个场景,也很容易。

如图3所示,我给出了简单的复现步骤。

图3 MySQL 5.7中Waiting for table metadata lock的复现步骤session A 通过lock table命令持有表t的MDL写锁,而session B的查询需要获取MDL读锁。

所以,session B进入等待状态。

这类问题的处理方式,就是找到谁持有MDL写锁,然后把它kill掉。

但是,由于在show processlist的结果里面,session A的Command列是“Sleep”,导致查找起来很不方便。

不过有了performance_schema和sys系统库以后,就方便多了。

(MySQL启动时需要设置performance_schema=on,相比于设置为off会有10%左右的性能损失)通过查询sys.schema_table_lock_waits这张表,我们就可以直接找出造成阻塞的process id,把这个连接用kill 命令断开即可。

图4 查获加表锁的线程id等flush接下来,我给你举另外一种查询被堵住的情况。

我在表t上,执行下面的SQL语句:

这里,我先卖个关子。

你可以看一下图5。

我查出来这个线程的状态是Waiting for table flush,你可以设想一下这是什么原因。

图5 Waiting for table flush状态示意图这个状态表示的是,现在有一个线程正要对表t做flush操作。

MySQL里面对表做flush操作的用法,一般有以下两个:

这两个flush语句,如果指定表t的话,代表的是只关闭表t;如果没有指定具体的表名,则表示关闭MySQL里所有打开的表。

但是正常这两个语句执行起来都很快,除非它们也被别的线程堵住了。

所以,出现Waiting for table flush状态的可能情况是:有一个flush tables命令被别的语句堵住mysql> select * from information_schema.processlist where id=1;flush tables t with read lock;flush tables with read lock;了,然后它又堵住了我们的select语句。

现在,我们一起来复现一下这种情况,复现步骤如图6所示:

图6 Waiting for table flush的复现步骤在session A中,我故意每行都调用一次sleep(1),这样这个语句默认要执行10万秒,在这期间表t一直是被session A“打开”着。

然后,session B的flush tables t命令再要去关闭表t,就需要等session A的查询结束。

这样,session C要再次查询的话,就会被flush 命令堵住了。

图7是这个复现步骤的show processlist结果。

这个例子的排查也很简单,你看到这个showprocesslist的结果,肯定就知道应该怎么做了。

图 7 Waiting for table flush的show processlist 结果等行锁现在,经过了表级锁的考验,我们的select 语句终于来到引擎里了。

上面这条语句的用法你也很熟悉了,我们在第8篇《事务到底是隔离的还是不隔离的?》文章介绍当前读时提到过。

由于访问id=1这个记录时要加读锁,如果这时候已经有一个事务在这行记录上持有一个写锁,我们的select语句就会被堵住。

复现步骤和现场如下:

mysql> select * from t where id=1 lock in share mode; 图 8 行锁复现图 9 行锁show processlist 现场显然,session A启动了事务,占有写锁,还不提交,是导致session B被堵住的原因。

这个问题并不难分析,但问题是怎么查出是谁占着这个写锁。

如果你用的是MySQL 5.7版本,可以通过sys.innodb_lock_waits 表查到。

查询方法是:

mysql> select * from t sys.innodb_lock_waits where locked_table= ‘̀test’.’t’̀ \G图10 通过sys.innodb_lock_waits 查行锁可以看到,这个信息很全,4号线程是造成堵塞的罪魁祸首。

而干掉这个罪魁祸首的方式,就是KILL QUERY 4或KILL 4。

不过,这里不应该显示“KILL QUERY 4”。

这个命令表示停止4号线程当前正在执行的语句,而这个方法其实是没有用的。

因为占有行锁的是update语句,这个语句已经是之前执行完成了的,现在执行KILL QUERY,无法让这个事务去掉id=1上的行锁。

实际上,KILL 4才有效,也就是说直接断开这个连接。

这里隐含的一个逻辑就是,连接被断开的时候,会自动回滚这个连接里面正在执行的线程,也就释放了id=1上的行锁。

第二类:查询慢经过了重重封“锁”,我们再来看看一些查询慢的例子。

先来看一条你一定知道原因的SQL语句:

mysql> select * from t where c=50000 limit 1;由于字段c上没有索引,这个语句只能走id主键顺序扫描,因此需要扫描5万行。

作为确认,你可以看一下慢查询日志。

注意,这里为了把所有语句记录到slow log里,我在连接后先执行了 set long_query_time=0,将慢查询日志的时间阈值设置为0。

图11 全表扫描5万行的slow logRows_examined显示扫描了50000行。

你可能会说,不是很慢呀,11.5毫秒就返回了,我们线上一般都配置超过1秒才算慢查询。

但你要记住:坏查询不一定是慢查询。

我们这个例子里面只有10万行记录,数据量大起来的话,执行时间就线性涨上去了。

扫描行数多,所以执行慢,这个很好理解。

但是接下来,我们再看一个只扫描一行,但是执行很慢的语句。

如图12所示,是这个例子的slow log。

可以看到,执行的语句是虽然扫描行数是1,但执行时间却长达800毫秒。

图12 扫描一行却执行得很慢是不是有点奇怪呢,这些时间都花在哪里了?如果我把这个slow log的截图再往下拉一点,你可以看到下一个语句,select * from t where id=1lock in share mode,执行时扫描行数也是1行,执行时间是0.2毫秒。

图 13 加上lock in share mode的slow log看上去是不是更奇怪了?按理说lock in share mode还要加锁,时间应该更长才对啊。

可能有的同学已经有答案了。

如果你还没有答案的话,我再给你一个提示信息,图14是这两个mysql> select * from t where id=1;

语句的执行输出结果。

图14 两个语句的输出结果第一个语句的查询结果里c=1,带lock in share mode的语句返回的是c=1000001。

看到这里应该有更多的同学知道原因了。

如果你还是没有头绪的话,也别着急。

我先跟你说明一下复现步骤,再分析原因。

图15 复现步骤你看到了,session A先用start transaction with consistent snapshot命令启动了一个事务,之后session B才开始执行update 语句。

session B执行完100万次update语句后,id=1这一行处于什么状态呢?你可以从图16中找到答案。

图16 id=1的数据状态session B更新完100万次,生成了100万个回滚日志(undo log)。

带lock in share mode的SQL语句,是当前读,因此会直接读到1000001这个结果,所以速度很快;而select * from t where id=1这个语句,是一致性读,因此需要从1000001开始,依次执行undo log,执行了100万次以后,才将1这个结果返回。

注意,undo log里记录的其实是“把2改成1”,“把3改成2”这样的操作逻辑,画成减1的目的是方便你看图。

小结今天我给你举了在一个简单的表上,执行“查一行”,可能会出现的被锁住和执行慢的例子。

这其中涉及到了表锁、行锁和一致性读的概念。

在实际使用中,碰到的场景会更复杂。

但大同小异,你可以按照我在文章中介绍的定位方法,来定位并解决问题。

最后,我给你留一个问题吧。

我们在举例加锁读的时候,用的是这个语句,select * from t where id=1 lock in share mode。

由于id上有索引,所以可以直接定位到id=1这一行,因此读锁也是只加在了这一行上。

但如果是下面的SQL语句,这个语句序列是怎么加锁的呢?加的锁又是什么时候释放呢?你可以把你的观点和验证方法写在留言区里,我会在下一篇文章的末尾给出我的参考答案。

感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间在上一篇文章最后,我留给你的问题是,希望你可以分享一下之前碰到过的、与文章中类似的场景。

@封建的风 提到一个有趣的场景,值得一说。

我把他的问题重写一下,表结构如下:

假设现在表里面,有100万行数据,其中有10万行数据的b的值是’1234567890’, 假设现在执行语句是这么写的:这时候,MySQL会怎么执行呢?最理想的情况是,MySQL看到字段b定义的是varchar(10),那肯定返回空呀。

可惜,MySQL并没有这么做。

那要不,就是把’1234567890abcd’拿到索引里面去做匹配,肯定也没能够快速判断出索引树b上并没有这个值,也很快就能返回空结果。

begin;select * from t where c=5 for update;commit;mysql> CREATE TABLE t̀able_a ̀( ìd ̀int(11) NOT NULL, b ̀varchar(10) DEFAULT NULL, PRIMARY KEY (̀ id )̀, KEY b ̀(̀ b )̀) ENGINE=InnoDB;mysql> select * from table_a where b=’1234567890abcd’;但实际上,MySQL也不是这么做的。

这条SQL语句的执行很慢,流程是这样的:

  1. 在传给引擎执行的时候,做了字符截断。

因为引擎里面这个行只定义了长度是10,所以只截了前10个字节,就是’1234567890’进去做匹配;

  1. 这样满足条件的数据有10万行;

  2. 因为是select *, 所以要做10万次回表;

  3. 但是每次回表以后查出整行,到server层一判断,b的值都不是’1234567890abcd’;5. 返回结果是空。

这个例子,是我们文章内容的一个很好的补充。

虽然执行过程中可能经过函数操作,但是最终在拿到结果后,server层还是要做一轮判断的。