Qi

Cogito ergo sum

问题解析

我查这么多数据,会不会把数据库内存打爆?我经常会被问到这样一个问题:我的主机内存只有100G,现在要对一个200G的大表做全表扫描,会不会把数据库主机的内存用光了?这个问题确实值得担心,被系统OOM(out of memory)可不是闹着玩的。

但是,反过来想想,逻辑备份的时候,可不就是做整库扫描吗?如果这样就会把内存吃光,逻辑备份不是早就挂了?所以说,对大表做全表扫描,看来应该是没问题的。

但是,这个流程到底是怎么样的呢?全表扫描对server层的影响假设,我们现在要对一个200G的InnoDB表db1. t,执行一个全表扫描。

当然,你要把扫描结果保存在客户端,会使用类似这样的命令:

你已经知道了,InnoDB的数据是保存在主键索引上的,所以全表扫描实际上是直接扫描表t的主键索引。

这条查询语句由于没有其他的判断条件,所以查到的每一行都可以直接放到结果集里面,然后返回给客户端。

那么,这个“结果集”存在哪里呢?mysql -h$host -P$port -u$user -p$pwd -e “select * from db1.t” > $target_file实际上,服务端并不需要保存一个完整的结果集。

取数据和发数据的流程是这样的:

  1. 获取一行,写到net_buffer中。

这块内存的大小是由参数net_buffer_length定义的,默认是16k。

  1. 重复获取行,直到net_buffer写满,调用网络接口发出去。

  2. 如果发送成功,就清空net_buffer,然后继续取下一行,并写入net_buffer。

  3. 如果发送函数返回EAGAIN或WSAEWOULDBLOCK,就表示本地网络栈(socket sendbuffer)写满了,进入等待。

直到网络栈重新可写,再继续发送。

这个过程对应的流程图如下所示。

图1 查询结果发送流程从这个流程中,你可以看到:

  1. 一个查询在发送过程中,占用的MySQL内部的内存最大就是net_buffer_length这么大,并不会达到200G;

  2. socket send buffer 也不可能达到200G(默认定义/proc/sys/net/core/wmem_default),如果socket send buffer被写满,就会暂停读数据的流程。

也就是说,MySQL是“边读边发的”,这个概念很重要。

这就意味着,如果客户端接收得慢,会导致MySQL服务端由于结果发不出去,这个事务的执行时间变长。

比如下面这个状态,就是我故意让客户端不去读socket receive buffer中的内容,然后在服务端show processlist看到的结果。

图2 服务端发送阻塞如果你看到State的值一直处于“Sending to client”,就表示服务器端的网络栈写满了。

我在上一篇文章中曾提到,如果客户端使用–quick参数,会使用mysql_use_result方法。

这个方法是读一行处理一行。

你可以想象一下,假设有一个业务的逻辑比较复杂,每读一行数据以后要处理的逻辑如果很慢,就会导致客户端要过很久才会去取下一行数据,可能就会出现如图2所示的这种情况。

因此,对于正常的线上业务来说,如果一个查询的返回结果不会很多的话,我都建议你使用mysql_store_result这个接口,直接把查询结果保存到本地内存。

当然前提是查询返回结果不多。

在第30篇文章评论区,有同学说到自己因为执行了一个大查询导致客户端占用内存近20G,这种情况下就需要改用mysql_use_result接口了。

另一方面,如果你在自己负责维护的MySQL里看到很多个线程都处于“Sending to client”这个状态,就意味着你要让业务开发同学优化查询结果,并评估这么多的返回结果是否合理。

而如果要快速减少处于这个状态的线程的话,将net_buffer_length参数设置为一个更大的值是一个可选方案。

与“Sending to client”长相很类似的一个状态是“Sending data”,这是一个经常被误会的问题。

有同学问我说,在自己维护的实例上看到很多查询语句的状态是“Sending data”,但查看网络也没什么问题啊,为什么Sending data要这么久?实际上,一个查询语句的状态变化是这样的(注意:这里,我略去了其他无关的状态):

MySQL查询语句进入执行阶段后,首先把状态设置成“Sending data”;

然后,发送执行结果的列相关的信息(meta data) 给客户端;

再继续执行语句的流程;

执行完成后,把状态设置成空字符串。

也就是说,“Sending data”并不一定是指“正在发送数据”,而可能是处于执行器过程中的任意阶段。

比如,你可以构造一个锁等待的场景,就能看到Sending data状态。

图3 读全表被锁图 4 Sending data状态可以看到,session B明显是在等锁,状态显示为Sending data。

也就是说,仅当一个线程处于“等待客户端接收结果”的状态,才会显示”Sending to client”;而如果显示成“Sending data”,它的意思只是“正在执行”。

现在你知道了,查询的结果是分段发给客户端的,因此扫描全表,查询返回大量的数据,并不会把内存打爆。

在server层的处理逻辑我们都清楚了,在InnoDB引擎里面又是怎么处理的呢? 扫描全表会不会对引擎系统造成影响呢?全表扫描对InnoDB的影响在第2和第15篇文章中,我介绍WAL机制的时候,和你分析了InnoDB内存的一个作用,是保存更新的结果,再配合redo log,就避免了随机写盘。

内存的数据页是在Buffer Pool (BP)中管理的,在WAL里Buffer Pool 起到了加速更新的作用。

而实际上,Buffer Pool 还有一个更重要的作用,就是加速查询。

在第2篇文章的评论区有同学问道,由于有WAL机制,当事务提交的时候,磁盘上的数据页是旧的,那如果这时候马上有一个查询要来读这个数据页,是不是要马上把redo log应用到数据页呢?答案是不需要。

因为这时候内存数据页的结果是最新的,直接读内存页就可以了。

你看,这时候查询根本不需要读磁盘,直接从内存拿结果,速度是很快的。

所以说,Buffer Pool还有加速查询的作用。

而Buffer Pool对查询的加速效果,依赖于一个重要的指标,即:内存命中率。

你可以在show engine innodb status结果中,查看一个系统当前的BP命中率。

一般情况下,一个稳定服务的线上系统,要保证响应时间符合要求的话,内存命中率要在99%以上。

执行show engine innodb status ,可以看到“Buffer pool hit rate”字样,显示的就是当前的命中率。

比如图5这个命中率,就是99.0%。

图5 show engine innodb status显示内存命中率如果所有查询需要的数据页都能够直接从内存得到,那是最好的,对应的命中率就是100%。

但,这在实际生产上是很难做到的。

InnoDB Buffer Pool的大小是由参数 innodb_buffer_pool_size确定的,一般建议设置成可用物理内存的60%~80%。

在大约十年前,单机的数据量是上百个G,而物理内存是几个G;现在虽然很多服务器都能有128G甚至更高的内存,但是单机的数据量却达到了T级别。

所以,innodb_buffer_pool_size小于磁盘的数据量是很常见的。

如果一个 Buffer Pool满了,而又要从磁盘读入一个数据页,那肯定是要淘汰一个旧数据页的。

InnoDB内存管理用的是最近最少使用 (Least Recently Used, LRU)算法,这个算法的核心就是淘汰最久未使用的数据。

下图是一个LRU算法的基本模型。

图6 基本LRU算法InnoDB管理Buffer Pool的LRU算法,是用链表来实现的。

  1. 在图6的状态1里,链表头部是P1,表示P1是最近刚刚被访问过的数据页;假设内存里只能放下这么多数据页;

  2. 这时候有一个读请求访问P3,因此变成状态2,P3被移到最前面;

  3. 状态3表示,这次访问的数据页是不存在于链表中的,所以需要在Buffer Pool中新申请一个数据页Px,加到链表头部。

但是由于内存已经满了,不能申请新的内存。

于是,会清空链表末尾Pm这个数据页的内存,存入Px的内容,然后放到链表头部。

  1. 从效果上看,就是最久没有被访问的数据页Pm,被淘汰了。

这个算法乍一看上去没什么问题,但是如果考虑到要做一个全表扫描,会不会有问题呢?假设按照这个算法,我们要扫描一个200G的表,而这个表是一个历史数据表,平时没有业务访问它。

那么,按照这个算法扫描的话,就会把当前的Buffer Pool里的数据全部淘汰掉,存入扫描过程中访问到的数据页的内容。

也就是说Buffer Pool里面主要放的是这个历史数据表的数据。

对于一个正在做业务服务的库,这可不妙。

你会看到,Buffer Pool的内存命中率急剧下降,磁盘压力增加,SQL语句响应变慢。

所以,InnoDB不能直接使用这个LRU算法。

实际上,InnoDB对LRU算法做了改进。

图 7 改进的LRU算法在InnoDB实现上,按照5:3的比例把整个LRU链表分成了young区域和old区域。

图中LRU_old指向的就是old区域的第一个位置,是整个链表的5/8处。

也就是说,靠近链表头部的5/8是young区域,靠近链表尾部的3/8是old区域。

改进后的LRU算法执行流程变成了下面这样。

  1. 图7中状态1,要访问数据页P3,由于P3在young区域,因此和优化前的LRU算法一样,将其移到链表头部,变成状态2。

  2. 之后要访问一个新的不存在于当前链表的数据页,这时候依然是淘汰掉数据页Pm,但是新插入的数据页Px,是放在LRU_old处。

  3. 处于old区域的数据页,每次被访问的时候都要做下面这个判断:

若这个数据页在LRU链表中存在的时间超过了1秒,就把它移动到链表头部;

如果这个数据页在LRU链表中存在的时间短于1秒,位置保持不变。

1秒这个时间,是由参数innodb_old_blocks_time控制的。

其默认值是1000,单位毫秒。

这个策略,就是为了处理类似全表扫描的操作量身定制的。

还是以刚刚的扫描200G的历史数据表为例,我们看看改进后的LRU算法的操作逻辑:

  1. 扫描过程中,需要新插入的数据页,都被放到old区域;2. 一个数据页里面有多条记录,这个数据页会被多次访问到,但由于是顺序扫描,这个数据页第一次被访问和最后一次被访问的时间间隔不会超过1秒,因此还是会被保留在old区域;

  2. 再继续扫描后续的数据,之前的这个数据页之后也不会再被访问到,于是始终没有机会移到链表头部(也就是young区域),很快就会被淘汰出去。

可以看到,这个策略最大的收益,就是在扫描这个大表的过程中,虽然也用到了Buffer Pool,但是对young区域完全没有影响,从而保证了Buffer Pool响应正常业务的查询命中率。

小结今天,我用“大查询会不会把内存用光”这个问题,和你介绍了MySQL的查询结果,发送给客户端的过程。

由于MySQL采用的是边算边发的逻辑,因此对于数据量很大的查询结果来说,不会在server端保存完整的结果集。

所以,如果客户端读结果不及时,会堵住MySQL的查询过程,但是不会把内存打爆。

而对于InnoDB引擎内部,由于有淘汰策略,大查询也不会导致内存暴涨。

并且,由于InnoDB对LRU算法做了改进,冷数据的全表扫描,对Buffer Pool的影响也能做到可控。

当然,我们前面文章有说过,全表扫描还是比较耗费IO资源的,所以业务高峰期还是不能直接在线上主库执行全表扫描的。

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

我在文章中说到,如果由于客户端压力太大,迟迟不能接收结果,会导致MySQL无法发送结果而影响语句执行。

但,这还不是最糟糕的情况。

你可以设想出由于客户端的性能问题,对数据库影响更严重的例子吗?或者你是否经历过这样的场景?你又是怎么优化的?你可以把你的经验和分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。

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

上期问题时间上期的问题是,如果一个事务被kill之后,持续处于回滚状态,从恢复速度的角度看,你是应该重启等它执行结束,还是应该强行重启整个MySQL进程。

因为重启之后该做的回滚动作还是不能少的,所以从恢复速度的角度来说,应该让它自己结束。

当然,如果这个语句可能会占用别的锁,或者由于占用IO资源过多,从而影响到了别的语句执行的话,就需要先做主备切换,切到新主库提供服务。

切换之后别的线程都断开了连接,自动停止执行。

接下来还是等它自己执行完成。

这个操作属于我们在文章中说到的,减少系统压力,加速终止逻辑。

问题解析

为什么还有kill不掉的语句?在MySQL中有两个kill命令:一个是kill query +线程id,表示终止这个线程中正在执行的语句;一个是kill connection +线程id,这里connection可缺省,表示断开这个线程的连接,当然如果这个线程有语句正在执行,也是要先停止正在执行的语句的。

不知道你在使用MySQL的时候,有没有遇到过这样的现象:使用了kill命令,却没能断开这个连接。

再执行show processlist命令,看到这条语句的Command列显示的是Killed。

你一定会奇怪,显示为Killed是什么意思,不是应该直接在show processlist的结果里看不到这个线程了吗?今天,我们就来讨论一下这个问题。

其实大多数情况下,kill query/connection命令是有效的。

比如,执行一个查询的过程中,发现执行时间太久,要放弃继续查询,这时我们就可以用kill query命令,终止这条查询语句。

还有一种情况是,语句处于锁等待的时候,直接使用kill命令也是有效的。

我们一起来看下这个例子:

图1 kill query 成功的例子可以看到,session C 执行kill query以后,session B几乎同时就提示了语句被中断。

这,就是我们预期的结果。

收到kill以后,线程做什么?但是,这里你要停下来想一下:session B是直接终止掉线程,什么都不管就直接退出吗?显然,这是不行的。

我在第6篇文章中讲过,当对一个表做增删改查操作时,会在表上加MDL读锁。

所以,session B虽然处于blocked状态,但还是拿着一个MDL读锁的。

如果线程被kill的时候,就直接终止,那之后这个MDL读锁就没机会被释放了。

这样看来,kill并不是马上停止的意思,而是告诉执行线程说,这条语句已经不需要继续执行了,可以开始“执行停止的逻辑了”。

实现上,当用户执行kill query thread_id_B时,MySQL里处理kill命令的线程做了两件事:

  1. 把session B的运行状态改成THD::KILL_QUERY(将变量killed赋值为THD::KILL_QUERY);

  2. 给session B的执行线程发一个信号。

为什么要发信号呢?因为像图1的我们例子里面,session B处于锁等待状态,如果只是把session B的线程状态设置THD::KILL_QUERY,线程B并不知道这个状态变化,还是会继续等待。

发一个信号的目的,就是让session B退出等待,来处理这个THD::KILL_QUERY状态。

其实,这跟Linux的kill命令类似,kill -N pid并不是让进程直接停止,而是给进程发一个信号,然后进程处理这个信号,进入终止逻辑。

只是对于MySQL的kill命令来说,不需要传信号量参数,就只有“停止”这个命令。

上面的分析中,隐含了这么三层意思:

  1. 一个语句执行过程中有多处“埋点”,在这些“埋点”的地方判断线程状态,如果发现线程状态是THD::KILL_QUERY,才开始进入语句终止逻辑;

  2. 如果处于等待状态,必须是一个可以被唤醒的等待,否则根本不会执行到“埋点”处;

  3. 语句从开始进入终止逻辑,到终止逻辑完全完成,是有一个过程的。

到这里你就知道了,原来不是“说停就停的”。

接下来,我们再看一个kill不掉的例子,也就是我们在前面第29篇文章中提到的innodb_thread_concurrency 不够用的例子。

首先,执行set global innodb_thread_concurrency=2,将InnoDB的并发线程上限数设置为2;然后,执行下面的序列:

图2 kill query 无效的例子可以看到:

  1. sesssion C执行的时候被堵住了;

  2. 但是session D执行的kill query C命令却没什么效果,3. 直到session E执行了kill connection命令,才断开了session C的连接,提示“Lostconnection to MySQL server during query”,4. 但是这时候,如果在session E中执行show processlist,你就能看到下面这个图。

图3 kill connection之后的效果这时候,id=12这个线程的Commnad列显示的是Killed。

也就是说,客户端虽然断开了连接,但实际上服务端上这条语句还在执行过程中。

为什么在执行kill query命令时,这条语句不像第一个例子的update语句一样退出呢?在实现上,等行锁时,使用的是pthread_cond_timedwait函数,这个等待状态可以被唤醒。

但是,在这个例子里,12号线程的等待逻辑是这样的:每10毫秒判断一下是否可以进入InnoDB执行,如果不行,就调用nanosleep函数进入sleep状态。

也就是说,虽然12号线程的状态已经被设置成了KILL_QUERY,但是在这个等待进入InnoDB的循环过程中,并没有去判断线程的状态,因此根本不会进入终止逻辑阶段。

而当session E执行kill connection 命令时,是这么做的,1. 把12号线程状态设置为KILL_CONNECTION;

  1. 关掉12号线程的网络连接。

因为有这个操作,所以你会看到,这时候session C收到了断开连接的提示。

那为什么执行show processlist的时候,会看到Command列显示为killed呢?其实,这就是因为在执行show processlist的时候,有一个特别的逻辑:

所以其实,即使是客户端退出了,这个线程的状态仍然是在等待中。

那这个线程什么时候会退出呢?答案是,只有等到满足进入InnoDB的条件后,session C的查询语句继续执行,然后才有可能判断到线程状态已经变成了KILL_QUERY或者KILL_CONNECTION,再进入终止逻辑阶段。

到这里,我们来小结一下。

这个例子是kill无效的第一类情况,即:线程没有执行到判断线程状态的逻辑。

跟这种情况相同的,还有由于IO压力过大,读写IO的函数一直无法返回,导致不能及时判断线程的状态。

如果一个线程的状态是KILL_CONNECTION,就把Command列显示成Killed。

另一类情况是,终止逻辑耗时较长。

这时候,从show processlist结果上看也是Command=Killed,需要等到终止逻辑完成,语句才算真正完成。

这类情况,比较常见的场景有以下几种:

  1. 超大事务执行期间被kill。

这时候,回滚操作需要对事务执行期间生成的所有新数据版本做回收操作,耗时很长。

  1. 大查询回滚。

如果查询过程中生成了比较大的临时文件,加上此时文件系统压力大,删除临时文件可能需要等待IO资源,导致耗时较长。

  1. DDL命令执行到最后阶段,如果被kill,需要删除中间过程的临时文件,也可能受IO资源影响耗时较久。

之前有人问过我,如果直接在客户端通过Ctrl+C命令,是不是就可以直接终止线程呢?答案是,不可以。

这里有一个误解,其实在客户端的操作只能操作到客户端的线程,客户端和服务端只能通过网络交互,是不可能直接操作服务端线程的。

而由于MySQL是停等协议,所以这个线程执行的语句还没有返回的时候,再往这个连接里面继续发命令也是没有用的。

实际上,执行Ctrl+C的时候,是MySQL客户端另外启动一个连接,然后发送一个kill query 命令。

所以,你可别以为在客户端执行完Ctrl+C就万事大吉了。

因为,要kill掉一个线程,还涉及到后端的很多操作。

另外两个关于客户端的误解在实际使用中,我也经常会碰到一些同学对客户端的使用有误解。

接下来,我们就来看看两个最常见的误解。

第一个误解是:如果库里面的表特别多,连接就会很慢。

有些线上的库,会包含很多表(我见过最多的一个库里有6万个表)。

这时候,你就会发现,每次用客户端连接都会卡在下面这个界面上。

图4 连接等待而如果db1这个库里表很少的话,连接起来就会很快,可以很快进入输入命令的状态。

因此,有同学会认为是表的数目影响了连接性能。

从第一篇文章你就知道,每个客户端在和服务端建立连接的时候,需要做的事情就是TCP握手、用户校验、获取权限。

但这几个操作,显然跟库里面表的个数无关。

但实际上,正如图中的文字提示所说的,当使用默认参数连接的时候,MySQL客户端会提供一个本地库名和表名补全的功能。

为了实现这个功能,客户端在连接成功后,需要多做一些操作:

  1. 执行show databases;

  2. 切到db1库,执行show tables;

  3. 把这两个命令的结果用于构建一个本地的哈希表。

在这些操作中,最花时间的就是第三步在本地构建哈希表的操作。

所以,当一个库中的表个数非常多的时候,这一步就会花比较长的时间。

也就是说,我们感知到的连接过程慢,其实并不是连接慢,也不是服务端慢,而是客户端慢。

图中的提示也说了,如果在连接命令中加上-A,就可以关掉这个自动补全的功能,然后客户端就可以快速返回了。

这里自动补全的效果就是,你在输入库名或者表名的时候,输入前缀,可以使用Tab键自动补全表名或者显示提示。

实际使用中,如果你自动补全功能用得并不多,我建议你每次使用的时候都默认加-A。

其实提示里面没有说,除了加-A以外,加–quick(或者简写为-q)参数,也可以跳过这个阶段。

但是,这个–quick是一个更容易引起误会的参数,也是关于客户端常见的一个误解。

你看到这个参数,是不是觉得这应该是一个让服务端加速的参数?但实际上恰恰相反,设置了这个参数可能会降低服务端的性能。

为什么这么说呢?MySQL客户端发送请求后,接收服务端返回结果的方式有两种:

  1. 一种是本地缓存,也就是在本地开一片内存,先把结果存起来。

如果你用API开发,对应的就是mysql_store_result 方法。

  1. 另一种是不缓存,读一个处理一个。

如果你用API开发,对应的就是mysql_use_result方法。

MySQL客户端默认采用第一种方式,而如果加上–quick参数,就会使用第二种不缓存的方式。

采用不缓存的方式时,如果本地处理得慢,就会导致服务端发送结果被阻塞,因此会让服务端变慢。

关于服务端的具体行为,我会在下一篇文章再和你展开说明。

那你会说,既然这样,为什么要给这个参数取名叫作quick呢?这是因为使用这个参数可以达到以下三点效果:

第一点,就是前面提到的,跳过表名自动补全功能。

第二点,mysql_store_result需要申请本地内存来缓存查询结果,如果查询结果太大,会耗费较多的本地内存,可能会影响客户端本地机器的性能;

第三点,是不会把执行命令记录到本地的命令历史文件。

所以你看到了,–quick参数的意思,是让客户端变得更快。

小结在今天这篇文章中,我首先和你介绍了MySQL中,有些语句和连接“kill不掉”的情况。

这些“kill不掉”的情况,其实是因为发送kill命令的客户端,并没有强行停止目标线程的执行,而只是设置了个状态,并唤醒对应的线程。

而被kill的线程,需要执行到判断状态的“埋点”,才会开始进入终止逻辑阶段。

并且,终止逻辑本身也是需要耗费时间的。

所以,如果你发现一个线程处于Killed状态,你可以做的事情就是,通过影响系统环境,让这个Killed状态尽快结束。

比如,如果是第一个例子里InnoDB并发度的问题,你就可以临时调大innodb_thread_concurrency的值,或者停掉别的线程,让出位子给这个线程执行。

而如果是回滚逻辑由于受到IO资源限制执行得比较慢,就通过减少系统压力让它加速。

做完这些操作后,其实你已经没有办法再对它做什么了,只能等待流程自己完成。

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

如果你碰到一个被killed的事务一直处于回滚状态,你认为是应该直接把MySQL进程强行重启,还是应该让它自己执行完成呢?为什么呢?你可以把你的结论和分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。

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

上期问题时间我在上一篇文章末尾,给你留下的问题是,希望你分享一下误删数据的处理经验。

@苍茫 同学提到了一个例子,我觉得值得跟大家分享一下。

运维的同学直接拷贝文本去执行,SQL语句截断,导致数据库执行出错。

从浏览器拷贝文本执行,是一个非常不规范的操作。

除了这个例子里面说的SQL语句截断问题,还可能存在乱码问题。

一般这种操作,如果脚本的开发和执行不是同一个人,需要开发同学把脚本放到git上,然后把git地址,以及文件的md5发给运维同学。

这样就要求运维同学在执行命令之前,确认要执行的文件的md5,跟之前开发同学提供的md5相同才能继续执行。

另外,我要特别点赞一下@苍茫 同学复现问题的思路和追查问题的态度。

@linhui0705 同学提到的“四个脚本”的方法,我非常推崇。

这四个脚本分别是:备份脚本、执行脚本、验证脚本和回滚脚本。

如果能够坚持做到,即使出现问题,也是可以很快恢复的,一定能降低出现故障的概率。

不过,这个方案最大的敌人是这样的思想:这是个小操作,不需要这么严格。

@Knight²º¹ 给了一个保护文件的方法,我之前没有用过这种方法,不过这确实是一个不错的思路。

为了数据安全和服务稳定,多做点预防方案的设计讨论,总好过故障处理和事后复盘。

方案设计讨论会和故障复盘会,这两种会议的会议室气氛完全不一样。

经历过的同学一定懂的。

Leon  2kill connection本质上只是把客户端的sql连接断开,后面的执行流程还是要走kill query的,是这样理解吧2019-01-30 作者回复这个理解非常到位额外的一个不同就是show processlist的时候,kill connection会显示“killed”这两句加起来可以用来替换我们文中的描述2019-01-30Mr.sylar  2老师,我想问下这些原理的”渔”的方法除了看源码,还有别的建议吗2019-01-25 作者回复不同的知识点不太一样哈,有些可以看文档;

有些可以自己验证;

还有就是看其他人文章,加验证;(就是我们这个专栏的方法^_^)2019-01-25夹心面包  2对于结尾的问题,我觉得肯定是等待,即便是mysql重启,也是需要对未提交的事务进行回滚操作的,保证数据库的一致性2019-01-25Ryoma  1想得简单点:既然事务处于回滚状态了,重启MySQL这部分事务还是需要回滚。

私以为让它执行完成比较好。

2019-01-25斜面镜子 Bill  0“采用不缓存的方式时,如果本地处理得慢,就会导致服务端发送结果被阻塞,因此会让服务端变慢” 这个怎么理解?2019-01-28 作者回复堵住了不就变慢了2019-01-28700  0精选留言 0老师,您好。

客户端版本如下:

mysql Ver 14.14 Distrib 5.7.24, for linux-glibc2.12 (x86_64) using EditLine wrapper老师,再请教另一个问题。

并非所有的 DDL 操作都可以通过主从切换来实现吧?不适用的场景有哪些呢?2019-01-27 作者回复对,其实只有 改索引、 加最后一列、删最后一列其他的大多数不行,比如删除中间一列这种2019-01-28千年孤独  0可能不是本章讨论的问题,我想请问老师“MySQL使用自增ID和UUID作为主键的优劣”,基于什么样的业务场景用哪种好?2019-01-27 作者回复后面会有文章会提到这个问题哈:)2019-01-27Geek_a67865  0老师好,我猜发条橙子的问题 因为很多日志监控会统计error日志,这样并不很优雅,觉得他是想有什么办法规避这种并发引起的问题,2019-01-26 作者回复嗯嗯 不过我也确实没有想到更好的方法毕竟两个线程要同时发起一个insert操作,这个服务端也拦不住呀2019-01-26路过  0老师,kill语法是:

KILL [CONNECTION | QUERY] processlist_idprocesslist_id是conn_id,不是thd_id.通过对比sys.processlist表中的信息就可以知道了。

通过查询官方文档也说明了:

thd_id:The thread ID.conn_id:The connection ID.所以,这篇文章开头的:

在 MySQL 中有两个 kill 命令:一个是 kill query + 线程 id感觉有点不对。

请老师指正。

谢谢!2019-01-26 作者回复这两个是一样的吧?都是对应show processlist这个命令结果里的第一列2019-01-26HuaMax  0课后题。

我认为需要看当时的业务场景。

重启会导致其他的连接也断开,返回给其他业务连接丢失的错误。

如果有很多事务在等待该事务的锁,则应该重启,让其他事务快速重试获取锁。

另外如果是RR的事务隔离级别,长事务会因为数据可见性的问题,对于多版本的数据需要找到正确的版本,对读性能是不是也会有影响,这时候重启也更好。

个人理解,请老师指正。

2019-01-26 作者回复有考虑到对其他线程的影响,这个其实这种时候往往是要先考虑切换(当然重启也是切换的)如果只看恢复时间的话,等待会更快 2019-01-26Geek_a67865  0也遇到@发条橙子一样的问题,例如队列两个消息同时查询库存,发现都不存在,然后就都执行插入语句,一条成功,一条报唯一索引异常,这样程序日志会一直显示一个唯一索引报错,然后重试执行更新,我暂时是强制查主库2019-01-26 作者回复“我暂时是强制查主库” 从这就看你是因为读是读的备库,才出现这个问题的是吧。

发条橙子的问题是,他都是操作主库。

其实如果索引有唯一键,就直接上insert。

然后碰到违反唯一键约束就报错,这个应该就是唯一键约束正常的用法吧2019-01-26gaohueric  0老师您好,一个表中 1个主键,2个唯一索引,1个普通索引 4个普通字段,当插入一条全部字段不为空的数据时,此时假设有4个索引文件,分别对应 主键 唯一性索引,普通索引,假设内存中没有这个数据页,那么server是直接调用innodb的接口,然后依次校验 (读取磁盘数据,验证唯一性)主键,唯一性索引,然后确认无误A时刻之后,吧主键和唯一性索引的写入内存,再把普通索引写入change buffer?那普通数据呢,是不是跟着主键一块写入内存了?2019-01-26 作者回复1. 是的,如果普通索引上的数据页这时候没有在内存中,就会使用change buffer2. “那普通数据呢,是不是跟着主键一块写入内存了?” 你说的是无索引的字段是吧,这些数据就在主键索引上,其实改的就是主键索引。

2019-01-26700  0老师,您好。

我继续接着我上条留言。

关于2),因为是测试机,我是直接 tail -0f 观察 general log 输出的。

确实没看到 KILL QUERY 等字眼。

数据库版本是 MySQL 5.7.24。

关于4),文中您不是这样说的吗?2.但是 session D 执行的 kill query C 命令却没什么效果, 3.直到 session E 执行了 kill connection 命令,才断开了 session C 的连接,提示“Lost connection to MySQL server during query”, 感谢您的解答。

2019-01-26 作者回复1. 你的客户端版本是什么 mysql –version 看看3. 嗯,是的,连接会断开,但是这个语句在server端还是会继续执行 (如果kill query 无效的话)2019-01-26700  0老师,请教。

1)文中开头说“当然如果这个线程有语句正在执行,也是要先停止正在执行的语句的”。

我个人在平时使用中就是按默认的执行,不管这个线程有无正在执行语句。

不知这样会有什么潜在问题?2)文中说“实际上,执行 Ctrl+C 的时候,是 MySQL 客户端另外启动一个连接,然后发送一个 kill query 命令“。

这个怎么解释呢?我开启 general log 的时候执行 Ctrl+C 或 Ctrl+D 并没有看到有另外启动一个连接,也没有看到 kill query 命令。

general log 中仅看到对应线程 id 和 Quit。

3)MySQL 为什么要同时存在 kill query 和 kill connection,既然 kill query 有无效的场景,干嘛不直接存在一个 kill connection 命令就好了?那它俩分别对应的适用场景是什么,什么时候考虑 kill query,什么时候考虑 kill connection?我个人觉得连接如果直接被 kill 掉大不了再重连一次好了。

也没啥损失。

4)小小一个总结,不知对否?kill query - 会出现无法 kill 掉的情况,只能再次执行 kill connection。

kill connection - 会出现 Command 列显示成 Killed 的情况。

2019-01-25 作者回复1. 一般你执行kill就是要停止正在执行的语句,所以问题不大2. 不应该呀, KILL QUERY 是大写哦,你再grep一下日志;

  1. 多提供一种方法嘛。

kill query是指你只是想停止这个语句,但是事务不会回滚。

一般kill query是发生在客户端执行ctrl+c的时候啦。

平时紧急处理确实直接用kill + thread_id。

好问题4. 对,另外,在kill query无效的时候,其实kill connection也是无效的2019-01-26Justin  0想咨询一个问题 如果走索引找寻比如age=11的人的时候是只会锁age=10到age=12吗 如果那个索引页包含了从5到13的数据 是只会锁离11最近的还是说二分查找时候每一个访问到的都会锁呢2019-01-25 作者回复只会锁左右。

2019-01-26往事随风,顺其自然  012 号线程的等待逻辑是这样的:每 10 毫秒判断一下是否可以进入 InnoDB 执行,如果不行,如果不行,就调用 nanosleep 函数进入 sleep状态。

这里为什么是10毫秒判断一下?怎么查看和设置这个参数?2019-01-25发条橙子 。

 0老师我这里问一下唯一索引的问题 ,希望老师能给点思路背景 : 一张商品库存表 , 如果表里没这个商品则插入 ,如果已经存在就更新库存 。

同步这个库存表是异步的 ,每次添加商品库存成功后会发消息 , 收到消息后会去表里新增/更新库存问题 : 商品库存表会有一个 商品的唯一索引。

当我们批量添加同一商品库存后会批量发消息 ,消息同时生效后去处理就有了并发的问题 。

这时候两个消息都判断表里没有该商品记录, 但是插入的时候就会有一个消息插入成功,另一个消息执行失败报唯一索引的错误, 之后消息重试走更新的逻辑。

这个这样做对业务没有影响 ,但是现在批量添加的需求量上来了 ,线上一直报这种错误日志也不是个办法, 我能想到的除了 catch 掉这个异常就没什么其他思路了。

老师能给一些其他的思路么2019-01-25 作者回复有唯一索引了,就直接插入,然后出现唯一性约束就放弃,这个逻辑的问题是啥,我感觉挺好的呀是不是我没有get到问题的点2019-01-25AI杜嘉嘉  0我想请问下老师,一个事务执行很长时间,我去kill。

那么,执行这个事务过程中的数据会不会回滚?2019-01-25 作者回复这个事务执行过程中新生成的数据吗? 会回滚的2019-01-25曾剑  0曾剑 0今天的问题,我觉得得让他自己执行完成后自动恢复。

因为强制重启后该做的回滚还是会继续做。

2019-01-25Dkey  0老师,请教一个 第八章 的问题。

关于可见性判断,文中都是说事务id大于高水位都不可见。

如果等于是不是也不可见。

还有一个,readview中是否不包含当前事务id。

谢谢老师2019-01-25 作者回复代码实现上,事务生成trxid后,trxid的分配器会+1,以这个加1以后的数作为高水位,所以“等于”是不算的。

其实有没有包含是一样的,实现上没有包含。

2019-01-25```

问题解析

误删数据后除了跑路,还能怎么办?今天我要和你讨论的是一个沉重的话题:误删数据。

在前面几篇文章中,我们介绍了MySQL的高可用架构。

当然,传统的高可用架构是不能预防误删数据的,因为主库的一个drop table命令,会通过binlog传给所有从库和级联从库,进而导致整个集群的实例都会执行这个命令。

虽然我们之前遇到的大多数的数据被删,都是运维同学或者DBA背锅的。

但实际上,只要有数据操作权限的同学,都有可能踩到误删数据这条线。

今天我们就来聊聊误删数据前后,我们可以做些什么,减少误删数据的风险,和由误删数据带来的损失。

为了找到解决误删数据的更高效的方法,我们需要先对和MySQL相关的误删数据,做下分类:

  1. 使用delete语句误删数据行;

  2. 使用drop table或者truncate table语句误删数据表;

  3. 使用drop database语句误删数据库;

  4. 使用rm命令误删整个MySQL实例。

误删行在第24篇文章中,我们提到如果是使用delete语句误删了数据行,可以用Flashback工具通过闪回把数据恢复回来。

Flashback恢复数据的原理,是修改binlog的内容,拿回原库重放。

而能够使用这个方案的前提是,需要确保binlog_format=row 和 binlog_row_image=FULL。

具体恢复数据时,对单个事务做如下处理:

  1. 对于insert语句,对应的binlog event类型是Write_rows event,把它改成Delete_rows event即可;

  2. 同理,对于delete语句,也是将Delete_rows event改为Write_rows event;

  3. 而如果是Update_rows的话,binlog里面记录了数据行修改前和修改后的值,对调这两行的位置即可。

如果误操作不是一个,而是多个,会怎么样呢?比如下面三个事务:

现在要把数据库恢复回这三个事务操作之前的状态,用Flashback工具解析binlog后,写回主库的命令是:

也就是说,如果误删数据涉及到了多个事务的话,需要将事务的顺序调过来再执行。

需要说明的是,我不建议你直接在主库上执行这些操作。

恢复数据比较安全的做法,是恢复出一个备份,或者找一个从库作为临时库,在这个临时库上执行这些操作,然后再将确认过的临时库的数据,恢复回主库。

为什么要这么做呢?这是因为,一个在执行线上逻辑的主库,数据状态的变更往往是有关联的。

可能由于发现数据问题的时间晚了一点儿,就导致已经在之前误操作的基础上,业务代码逻辑又继续修改了其他数据。

所以,如果这时候单独恢复这几行数据,而又未经确认的话,就可能会出现对数据的二次破(A)delete …(B)insert …(C)update …(reverse C)update …(reverse B)delete …(reverse A)insert …坏。

当然,我们不止要说误删数据的事后处理办法,更重要是要做到事前预防。

我有以下两个建议:

  1. 把sql_safe_updates参数设置为on。

这样一来,如果我们忘记在delete或者update语句中写where条件,或者where条件里面没有包含索引字段的话,这条语句的执行就会报错。

  1. 代码上线前,必须经过SQL审计。

你可能会说,设置了sql_safe_updates=on,如果我真的要把一个小表的数据全部删掉,应该怎么办呢?如果你确定这个删除操作没问题的话,可以在delete语句中加上where条件,比如where id>=0。

但是,delete全表是很慢的,需要生成回滚日志、写redo、写binlog。

所以,从性能角度考虑,你应该优先考虑使用truncate table或者drop table命令。

使用delete命令删除的数据,你还可以用Flashback来恢复。

而使用truncate /drop table和dropdatabase命令删除的数据,就没办法通过Flashback来恢复了。

为什么呢?这是因为,即使我们配置了binlog_format=row,执行这三个命令时,记录的binlog还是statement格式。

binlog里面就只有一个truncate/drop 语句,这些信息是恢复不出数据的。

那么,如果我们真的是使用这几条命令误删数据了,又该怎么办呢?误删库/表这种情况下,要想恢复数据,就需要使用全量备份,加增量日志的方式了。

这个方案要求线上有定期的全量备份,并且实时备份binlog。

在这两个条件都具备的情况下,假如有人中午12点误删了一个库,恢复数据的流程如下:

  1. 取最近一次全量备份,假设这个库是一天一备,上次备份是当天0点;

  2. 用备份恢复出一个临时库;

  3. 从日志备份里面,取出凌晨0点之后的日志;

  4. 把这些日志,除了误删除数据的语句外,全部应用到临时库。

这个流程的示意图如下所示:

图1 数据恢复流程-mysqlbinlog方法关于这个过程,我需要和你说明如下几点:

  1. 为了加速数据恢复,如果这个临时库上有多个数据库,你可以在使用mysqlbinlog命令时,加上一个–database参数,用来指定误删表所在的库。

这样,就避免了在恢复数据时还要应用其他库日志的情况。

  1. 在应用日志的时候,需要跳过12点误操作的那个语句的binlog:

如果原实例没有使用GTID模式,只能在应用到包含12点的binlog文件的时候,先用–stop-position参数执行到误操作之前的日志,然后再用–start-position从误操作之后的日志继续执行;

如果实例使用了GTID模式,就方便多了。

假设误操作命令的GTID是gtid1,那么只需要执行set gtid_next=gtid1;begin;commit; 先把这个GTID加到临时实例的GTID集合,之后按顺序执行binlog的时候,就会自动跳过误操作的语句。

不过,即使这样,使用mysqlbinlog方法恢复数据还是不够快,主要原因有两个:

  1. 如果是误删表,最好就是只恢复出这张表,也就是只重放这张表的操作,但是mysqlbinlog工具并不能指定只解析一个表的日志;

  2. 用mysqlbinlog解析出日志应用,应用日志的过程就只能是单线程。

我们在第26篇文章中介绍的那些并行复制的方法,在这里都用不上。

一种加速的方法是,在用备份恢复出临时实例之后,将这个临时实例设置成线上备库的从库,这样:

  1. 在start slave之前,先通过执行change replication filter replicate_do_table = (tbl_name) 命令,就可以让临时库只同步误操作的表;

  2. 这样做也可以用上并行复制技术,来加速整个数据恢复过程。

这个过程的示意图如下所示。

图2 数据恢复流程-master-slave方法可以看到,图中binlog备份系统到线上备库有一条虚线,是指如果由于时间太久,备库上已经删除了临时实例需要的binlog的话,我们可以从binlog备份系统中找到需要的binlog,再放回备库中。

假设,我们发现当前临时实例需要的binlog是从master.000005开始的,但是在备库上执行showbinlogs 显示的最小的binlog文件是master.000007,意味着少了两个binlog文件。

这时,我们就需要去binlog备份系统中找到这两个文件。

把之前删掉的binlog放回备库的操作步骤,是这样的:

  1. 从备份系统下载master.000005和master.000006这两个文件,放到备库的日志目录下;

  2. 打开日志目录下的master.index文件,在文件开头加入两行,内容分别是“./master.000005”和“./master.000006”;3. 重启备库,目的是要让备库重新识别这两个日志文件;

  3. 现在这个备库上就有了临时库需要的所有binlog了,建立主备关系,就可以正常同步了。

不论是把mysqlbinlog工具解析出的binlog文件应用到临时库,还是把临时库接到备库上,这两个方案的共同点是:误删库或者表后,恢复数据的思路主要就是通过备份,再加上应用binlog的方式。

也就是说,这两个方案都要求备份系统定期备份全量日志,而且需要确保binlog在被从本地删除之前已经做了备份。

但是,一个系统不可能备份无限的日志,你还需要根据成本和磁盘空间资源,设定一个日志保留的天数。

如果你的DBA团队告诉你,可以保证把某个实例恢复到半个月内的任意时间点,这就表示备份系统保留的日志时间就至少是半个月。

另外,我建议你不论使用上述哪种方式,都要把这个数据恢复功能做成自动化工具,并且经常拿出来演练。

为什么这么说呢?这里的原因,主要包括两个方面:

  1. 虽然“发生这种事,大家都不想的”,但是万一出现了误删事件,能够快速恢复数据,将损失降到最小,也应该不用跑路了。

  2. 而如果临时再手忙脚乱地手动操作,最后又误操作了,对业务造成了二次伤害,那就说不过去了。

延迟复制备库虽然我们可以通过利用并行复制来加速恢复数据的过程,但是这个方案仍然存在“恢复时间不可控”的问题。

如果一个库的备份特别大,或者误操作的时间距离上一个全量备份的时间较长,比如一周一备的实例,在备份之后的第6天发生误操作,那就需要恢复6天的日志,这个恢复时间可能是要按天来计算的。

那么,我们有什么方法可以缩短恢复数据需要的时间呢?如果有非常核心的业务,不允许太长的恢复时间,我们可以考虑搭建延迟复制的备库。

这个功能是MySQL 5.6版本引入的。

一般的主备复制结构存在的问题是,如果主库上有个表被误删了,这个命令很快也会被发给所有从库,进而导致所有从库的数据表也都一起被误删了。

延迟复制的备库是一种特殊的备库,通过 CHANGE MASTER TO MASTER_DELAY = N命令,可以指定这个备库持续保持跟主库有N秒的延迟。

比如你把N设置为3600,这就代表了如果主库上有数据被误删了,并且在1小时内发现了这个误操作命令,这个命令就还没有在这个延迟复制的备库执行。

这时候到这个备库上执行stopslave,再通过之前介绍的方法,跳过误操作命令,就可以恢复出需要的数据。

这样的话,你就随时可以得到一个,只需要最多再追1小时,就可以恢复出数据的临时实例,也就缩短了整个数据恢复需要的时间。

预防误删库/表的方法虽然常在河边走,很难不湿鞋,但终究还是可以找到一些方法来避免的。

所以这里,我也会给你一些减少误删操作风险的建议。

第一条建议是,账号分离。

这样做的目的是,避免写错命令。

比如:

我们只给业务开发同学DML权限,而不给truncate/drop权限。

而如果业务开发人员有DDL需求的话,也可以通过开发管理系统得到支持。

即使是DBA团队成员,日常也都规定只使用只读账号,必要的时候才使用有更新权限的账号。

第二条建议是,制定操作规范。

这样做的目的,是避免写错要删除的表名。

比如:

在删除数据表之前,必须先对表做改名操作。

然后,观察一段时间,确保对业务无影响以后再删除这张表。

改表名的时候,要求给表名加固定的后缀(比如加_to_be_deleted),然后删除表的动作必须通过管理系统执行。

并且,管理系删除表的时候,只能删除固定后缀的表。

rm删除数据其实,对于一个有高可用机制的MySQL集群来说,最不怕的就是rm删除数据了。

只要不是恶意地把整个集群删除,而只是删掉了其中某一个节点的数据的话,HA系统就会开始工作,选出一个新的主库,从而保证整个集群的正常工作。

这时,你要做的就是在这个节点上把数据恢复回来,再接入整个集群。

当然了,现在不止是DBA有自动化系统,SA(系统管理员)也有自动化系统,所以也许一个批量下线机器的操作,会让你整个MySQL集群的所有节点都全军覆没。

应对这种情况,我的建议只能是说尽量把你的备份跨机房,或者最好是跨城市保存。

小结今天,我和你讨论了误删数据的几种可能,以及误删后的处理方法。

但,我要强调的是,预防远比处理的意义来得大。

另外,在MySQL的集群方案中,会时不时地用到备份来恢复实例,因此定期检查备份的有效性也很有必要。

如果你是业务开发同学,你可以用show grants命令查看账户的权限,如果权限过大,可以建议DBA同学给你分配权限低一些的账号;你也可以评估业务的重要性,和DBA商量备份的周期、是否有必要创建延迟复制的备库等等。

数据和服务的可靠性不止是运维团队的工作,最终是各个环节一起保障的结果。

今天的课后话题是,回忆下你亲身经历过的误删数据事件吧,你用了什么方法来恢复数据呢?你在这个过程中得到的经验又是什么呢?你可以把你的经历和经验写在留言区,我会在下一篇文章的末尾选取有趣的评论和你一起讨论。

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

上期问题时间我在上一篇文章给你留的问题,是关于空表的间隙的定义。

一个空表就只有一个间隙。

比如,在空表上执行:

这个查询语句加锁的范围就是next-key lock (-∞, supremum]。

验证方法的话,你可以使用下面的操作序列。

你可以在图4中看到显示的结果。

begin;select * from t where id>1 for update;图3 复现空表的next-key lock图4 show engine innodb status

问题解析

答疑文章(二):用动态的观点看加锁
在第20和21篇文章中,我和你介绍了InnoDB的间隙锁、next-key lock,以及加锁规则。

在这两篇文章的评论区,出现了很多高质量的留言。

我觉得通过分析这些问题,可以帮助你加深对加锁规则的理解。

所以,我就从中挑选了几个有代表性的问题,构成了今天这篇答疑文章的主题,即:用动态的观点看加锁。

为了方便你理解,我们再一起复习一下加锁规则。

这个规则中,包含了两个“原则”、两个“优化”和一个“bug”:

原则1:加锁的基本单位是next-key lock。

希望你还记得,next-key lock是前开后闭区间。

原则2:查找过程中访问到的对象才会加锁。

优化1:索引上的等值查询,给唯一索引加锁的时候,next-key lock退化为行锁。

优化2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock退化为间隙锁。

一个bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

接下来,我们的讨论还是基于下面这个表t:

不等号条件里的等值查询有同学对“等值查询”提出了疑问:等值查询和“遍历”有什么区别?为什么我们文章的例子里面,where条件是不等号,这个过程里也有等值查询?我们一起来看下这个例子,分析一下这条查询语句的加锁范围:

利用上面的加锁规则,我们知道这个语句的加锁范围是主键索引上的 (0,5]、(5,10]和(10, 15)。

也就是说,id=15这一行,并没有被加上行锁。

为什么呢?我们说加锁单位是next-key lock,都是前开后闭区间,但是这里用到了优化2,即索引上的等值查询,向右遍历的时候id=15不满足条件,所以next-key lock退化为了间隙锁 (10, 15)。

但是,我们的查询语句中where条件是大于号和小于号,这里的“等值查询”又是从哪里来的呢?要知道,加锁动作是发生在语句执行过程中的,所以你在分析加锁行为的时候,要从索引上的数据结构开始。

这里,我再把这个过程拆解一下。

如图1所示,是这个表的索引id的示意图。

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);begin;select * from t where id>9 and id<12 order by id desc for update;图1 索引id示意图1. 首先这个查询语句的语义是order by id desc,要拿到满足条件的所有行,优化器必须先找到“第一个id<12的值”。

  1. 这个过程是通过索引树的搜索过程得到的,在引擎内部,其实是要找到id=12的这个值,只是最终没找到,但找到了(10,15)这个间隙。

  2. 然后向左遍历,在遍历过程中,就不是等值查询了,会扫描到id=5这一行,所以会加一个next-key lock (0,5]。

也就是说,在执行过程中,通过树搜索的方式定位记录的时候,用的是“等值查询”的方法。

等值查询的过程与上面这个例子对应的,是@发条橙子同学提出的问题:下面这个语句的加锁范围是什么?这条查询语句里用的是in,我们先来看这条语句的explain结果。

begin;select id from t where c in(5,20,10) lock in share mode;图2 in语句的explain结果可以看到,这条in语句使用了索引c并且rows=3,说明这三个值都是通过B+树搜索定位的。

在查找c=5的时候,先锁住了(0,5]。

但是因为c不是唯一索引,为了确认还有没有别的记录c=5,就要向右遍历,找到c=10才确认没有了,这个过程满足优化2,所以加了间隙锁(5,10)。

同样的,执行c=10这个逻辑的时候,加锁的范围是(5,10] 和 (10,15);执行c=20这个逻辑的时候,加锁的范围是(15,20] 和 (20,25)。

通过这个分析,我们可以知道,这条语句在索引c上加的三个记录锁的顺序是:先加c=5的记录锁,再加c=10的记录锁,最后加c=20的记录锁。

你可能会说,这个加锁范围,不就是从(5,25)中去掉c=15的行锁吗?为什么这么麻烦地分段说呢?因为我要跟你强调这个过程:这些锁是“在执行过程中一个一个加的”,而不是一次性加上去的。

理解了这个加锁过程之后,我们就可以来分析下面例子中的死锁问题了。

如果同时有另外一个语句,是这么写的:

此时的加锁范围,又是什么呢?我们现在都知道间隙锁是不互锁的,但是这两条语句都会在索引c上的c=5、10、20这三行记录上加记录锁。

这里你需要注意一下,由于语句里面是order by c desc, 这三个记录锁的加锁顺序,是先锁c=20,然后c=10,最后是c=5。

也就是说,这两条语句要加锁相同的资源,但是加锁顺序相反。

当这两条语句并发执行的时候,就可能出现死锁。

关于死锁的信息,MySQL只保留了最后一个死锁的现场,但这个现场还是不完备的。

有同学在评论区留言到,希望我能展开一下怎么看死锁。

现在,我就来简单分析一下上面这个例子的死锁现场。

select id from t where c in(5,20,10) order by c desc for update;怎么看死锁?图3是在出现死锁后,执行show engine innodb status命令得到的部分输出。

这个命令会输出很多信息,有一节LATESTDETECTED DEADLOCK,就是记录的最后一次死锁信息。

图3 死锁现场我们来看看这图中的几个关键信息。

  1. 这个结果分成三部分:

(1) TRANSACTION,是第一个事务的信息;

(2) TRANSACTION,是第二个事务的信息;

WE ROLL BACK TRANSACTION (1),是最终的处理结果,表示回滚了第一个事务。

  1. 第一个事务的信息中:

WAITING FOR THIS LOCK TO BE GRANTED,表示的是这个事务在等待的锁信息;

index c of table t̀est .̀̀ t`,说明在等的是表t的索引c上面的锁;

lock mode S waiting 表示这个语句要自己加一个读锁,当前的状态是等待中;

Record lock说明这是一个记录锁;

n_fields 2表示这个记录是两列,也就是字段c和主键字段id;

0: len 4; hex 0000000a; asc ;;是第一个字段,也就是c。

值是十六进制a,也就是10;

1: len 4; hex 0000000a; asc ;;是第二个字段,也就是主键id,值也是10;

这两行里面的asc表示的是,接下来要打印出值里面的“可打印字符”,但10不是可打印字符,因此就显示空格。

第一个事务信息就只显示出了等锁的状态,在等待(c=10,id=10)这一行的锁。

当然你是知道的,既然出现死锁了,就表示这个事务也占有别的锁,但是没有显示出来。

别着急,我们从第二个事务的信息中推导出来。

  1. 第二个事务显示的信息要多一些:

“ HOLDS THE LOCK(S)”用来显示这个事务持有哪些锁;

index c of table t̀est .̀̀ t ̀表示锁是在表t的索引c上;

hex 0000000a和hex 00000014表示这个事务持有c=10和c=20这两个记录锁;

WAITING FOR THIS LOCK TO BE GRANTED,表示在等(c=5,id=5)这个记录锁。

从上面这些信息中,我们就知道:

  1. “lock in share mode”的这条语句,持有c=5的记录锁,在等c=10的锁;

  2. “for update”这个语句,持有c=20和c=10的记录锁,在等c=5的记录锁。

因此导致了死锁。

这里,我们可以得到两个结论:

  1. 由于锁是一个个加的,要避免死锁,对同一组资源,要按照尽量相同的顺序访问;

  2. 在发生死锁的时刻,for update 这条语句占有的资源更多,回滚成本更大,所以InnoDB选择了回滚成本更小的lock in share mode语句,来回滚。

怎么看锁等待?看完死锁,我们再来看一个锁等待的例子。

在第21篇文章的评论区,@Geek_9ca34e 同学做了一个有趣验证,我把复现步骤列出来:

图4 delete导致间隙变化可以看到,由于session A并没有锁住c=10这个记录,所以session B删除id=10这一行是可以的。

但是之后,session B再想insert id=10这一行回去就不行了。

现在我们一起看一下此时show engine innodb status的结果,看看能不能给我们一些提示。

锁信息是在这个命令输出结果的TRANSACTIONS这一节。

你可以在文稿中看到这张图片图 5 锁等待信息我们来看几个关键信息。

  1. index PRIMARY of table t̀est .̀̀ t ̀,表示这个语句被锁住是因为表t主键上的某个锁。

  2. lock_mode X locks gap before rec insert intention waiting 这里有几个信息:

insert intention表示当前线程准备插入一个记录,这是一个插入意向锁。

为了便于理解,你可以认为它就是这个插入动作本身。

gap before rec 表示这是一个间隙锁,而不是记录锁。

  1. 那么这个gap是在哪个记录之前的呢?接下来的0~4这5行的内容就是这个记录的信息。

  2. n_fields 5也表示了,这一个记录有5列:

0: len 4; hex 0000000f; asc ;;第一列是主键id字段,十六进制f就是id=15。

所以,这时我们就知道了,这个间隙就是id=15之前的,因为id=10已经不存在了,它表示的就是(5,15)。

1: len 6; hex 000000000513; asc ;;第二列是长度为6字节的事务id,表示最后修改这一行的是trx id为1299的事务。

2: len 7; hex b0000001250134; asc % 4;; 第三列长度为7字节的回滚段信息。

可以看到,这里的acs后面有显示内容(%和4),这是因为刚好这个字节是可打印字符。

后面两列是c和d的值,都是15。

因此,我们就知道了,由于delete操作把id=10这一行删掉了,原来的两个间隙(5,10)、(10,15)变成了一个(5,15)。

说到这里,你可以联合起来再思考一下这两个现象之间的关联:

  1. session A执行完select语句后,什么都没做,但它加锁的范围突然“变大”了;

  2. 第21篇文章的课后思考题,当我们执行select * from t where c>=15 and c<=20 order by cdesc lock in share mode; 向左扫描到c=10的时候,要把(5, 10]锁起来。

也就是说,所谓“间隙”,其实根本就是由“这个间隙右边的那个记录”定义的。

update的例子看过了insert和delete的加锁例子,我们再来看一个update语句的案例。

在留言区中@信信 同学做了这个试验:

图 6 update 的例子你可以自己分析一下,session A的加锁范围是索引c上的 (5,10]、(10,15]、(15,20]、(20,25]和(25,supremum]。

之后session B的第一个update语句,要把c=5改成c=1,你可以理解为两步:

  1. 插入(c=1, id=5)这个记录;

  2. 删除(c=5, id=5)这个记录。

按照我们上一节说的,索引c上(5,10)间隙是由这个间隙右边的记录,也就是c=10定义的。

所以通过这个操作,session A的加锁范围变成了图7所示的样子:

注意:根据c>5查到的第一个记录是c=10,因此不会加(0,5]这个next-key lock。

图 7 session B修改后, session A的加锁范围好,接下来session B要执行 update t set c = 5 where c = 1这个语句了,一样地可以拆成两步:

  1. 插入(c=5, id=5)这个记录;

  2. 删除(c=1, id=5)这个记录。

第一步试图在已经加了间隙锁的(1,10)中插入数据,所以就被堵住了。

小结今天这篇文章,我用前面第20和第21篇文章评论区的几个问题,再次跟你复习了加锁规则。

并且,我和你重点说明了,分析加锁范围时,一定要配合语句执行逻辑来进行。

在我看来,每个想认真了解MySQL原理的同学,应该都要能够做到:通过explain的结果,就能够脑补出一个SQL语句的执行流程。

达到这样的程度,才算是对索引组织表、索引、锁的概念有了比较清晰的认识。

你同样也可以用这个方法,来验证自己对这些知识点的掌握程度。

在分析这些加锁规则的过程中,我也顺便跟你介绍了怎么看show engine innodb status输出结果中的事务信息和死锁信息,希望这些内容对你以后分析现场能有所帮助。

老规矩,即便是答疑文章,我也还是要留一个课后问题给你的。

上面我们提到一个很重要的点:所谓“间隙”,其实根本就是由“这个间隙右边的那个记录”定义的。

那么,一个空表有间隙吗?这个间隙是由谁定义的?你怎么验证这个结论呢?你可以把你关于分析和验证方法写在留言区,我会在下一篇文章的末尾和你讨论这个问题。

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

上期问题时间我在上一篇文章最后留给的问题,是分享一下你关于业务监控的处理经验。

在这篇文章的评论区,很多同学都分享了不错的经验。

这里,我就选择几个比较典型的留言,和你分享吧:

@老杨同志 回答得很详细。

他的主要思路就是关于服务状态和服务质量的监控。

其中,服务状态的监控,一般都可以用外部系统来实现;而服务的质量的监控,就要通过接口的响应时间来统计。

@Ryoma 同学,提到服务中使用了healthCheck来检测,其实跟我们文中提到的select 1的模式类似。

@强哥 同学,按照监控的对象,将监控分成了基础监控、服务监控和业务监控,并分享了每种监控需要关注的对象。

这些都是很好的经验,你也可以根据具体的业务场景借鉴适合自己的方案。

令狐少侠  2有个问题想确认下,在死锁日志里,lock_mode X waiting是间隙锁+行锁,lock_mode X locks rec but not gap这种加but not gap才是行锁?老师你后面能说下group by的原理吗,我看目录里面没有2019-01-22 作者回复对, 好问题lock_mode X waiting表示next-key lock;

lock_mode X locks rec but not gap是只有行锁;

还有一种 “locks gap before rec”,就是只有间隙锁;

2019-01-23Ryoma  2删除数据,导致锁扩大的描述:“因此,我们就知道了,由于 delete 操作把 id=10 这一行删掉了,原来的两个间隙 (5,10)、(10,15)变成了一个 (5,15)。

”我觉得这个提到的(5, 10) 和 (10, 15)两个间隙会让人有点误解,实际上在删除之前间隙锁只有一个(10, 15),删除了数据之后,导致间隙锁左侧扩张成了5,间隙锁成为了(5, 15)。

2019-01-22 作者回复嗯 所以我这里特别小心地没有写“锁“这个字。

间隙 (5,10)、(10,15)是客观存在的。

你提得也很对,“锁”是执行过程中才加的,是一个动态的概念。

这个问题也能够让大家更了解我们标题的意思,置顶了哈 2019-01-22  1老师好:

select * from t where c>=15 and c<=20 order by c desc for update;为什么这种c=20就是用来查数据的就不是向右遍历select * from t where c>=15 and c<=20 这种就是向右遍历怎么去判断合适是查找数据,何时又是遍历呢,是因为第一个有order by desc,然后反向向左遍历了吗?所以只需要[20,25)来判断已经是最后一个20就可以了是吧2019-01-22 作者回复索引搜索就是 “找到第一个值,然后向左或向右遍历”,order by desc 就是要用最大的值来找第一个;

精选留言order by就是要用做小的值来找第一个;

“所以只需要[20,25)来判断已经是最后一个20就可以了是吧”,你描述的意思是对的,但是在MySQL里面不建议写这样的前闭后开区间哈,容易造成误解。

可以描述为:

“取第一个id=20后,向右遍历(25,25)这个间隙”^_^2019-01-22老杨同志  1先说结论:空表锁 (-supernum,supernum],老师提到过mysql的正无穷是supernum,在没有数据的情况下,next-key lock 应该是supernum前面的间隙加 supernum的行锁。

但是前开后闭的区间,前面的值是什么我也不知道,就写了一个-supernum。

稍微验证一下session 1)begin;select * from t where id>9 for update;session 2)begin;insert into t values(0,0,0),(5,5,5);(block)2019-01-21 作者回复赞show engine innodb status 有惊喜2019-01-21Long  0感觉这篇文章以及前面加锁的文章,提升了自己的认知。

还有,谢谢老师讲解了日志的对应细节……还愿了2019-01-28 作者回复 2019-01-28滔滔  0老师,有个疑问,select * from t where c>=15 and c<=20 order by c desc lock in share mode; 向左扫描到 c=10 的时候,为什么要把 (5, 10] 锁起来?不锁也不会出现幻读或者逻辑上的不一致吧2019-01-23 作者回复会加锁,insert into t values (6,6,6) 被堵住了2019-01-23尘封  0尘封  0老师,咨询个问题,本来想在后面分区表的文章问,发现大纲里没有分区表这一讲。

1,timestamp类型为什么不支持分区?2,前面的文章讲过分区不要太多,这个多了会怎么样?比如一个表一千多个分区谢谢2019-01-23 作者回复会讲的哈新春快乐2019-02-04长杰  0老师,还是select * from t where c>=15 and c<=20 order by c desc in share mode与select * from t where id>10 and id<=15 for update的问题,为何select * from t where id>10 and id<=15 for update不能解释为:根据id=15来查数据,加锁(15, 20]的时候,可以使用优化2,这个等值查询是根据什么规则来定的? 如果select * from t where id>10 and id<=15 for update加上order by id desc是否可以按照id=15等值查询,利用优化2?多谢指教。

2019-01-22 作者回复1. 代码实现上,传入的就是id>10里面的这个102. 可以的,不过因为id是主键,而且id=15这一行存在,我觉得用优化1解释更好哦2019-01-23堕落天使  0老师,您好:

我执行“explain select id from t where c in(5,20,10) lock in share mode;” 时,显示的rows对应的值是4。

为什么啊?我的mysql版本是:5.7.23-0ubuntu0.16.04.1,具体sql语句如下:

mysql> select * from t;+—-+——+——+| id | c | d |+—-+——+——+| 0 | 0 | 0 || 5 | 5 | 5 || 10 | 10 | 10 || 15 | 15 | 15 || 20 | 20 | 20 || 25 | 25 | 25 || 30 | 10 | 30 |+—-+——+——+7 rows in set (0.00 sec)mysql> explain select id from t where c in(5,20,10) lock in share mode;+—-+————-+——-+————+——-+—————+——+———+——+——+———-+————————–+| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |+—-+————-+——-+————+——-+—————+——+———+——+——+———-+————————–+| 1 | SIMPLE | t | NULL | range | c | c | 5 | NULL | 4 | 100.00 | Using where; Using index |+—-+————-+——-+————+——-+—————+——+———+——+——+———-+————————–+1 row in set, 1 warning (0.00 sec)2019-01-22 作者回复你这个例子里面有两行c=102019-01-23Ivan  0Jan 17 23:52:27 prod-mysql-01 kernel: [ pid ] uid tgid total_vm rss cpu oom_adj oom_score_adjnameJan 17 23:52:27 prod-mysql-01 kernel: [125254] 0 125254 27087 5 0 0 0 mysqld_safeJan 17 23:52:27 prod-mysql-01 kernel: [126004] 498 126004 24974389 22439356 5 0 0 mysqldJan 17 23:52:27 prod-mysql-01 kernel: [ 5733] 0 5733 7606586 6077037 7 0 0 mysql—————————系统日志——————————————————————————–老师你好,请教一个问题 ,我在mysql服务器上本地登录,执行了一个SQL(select b.id,b.status from rb_bak b where id not in (select id from rb );该语句问了找不同数据, rb和 rb_bak 数据量均为500万左右),SQL很慢,30分钟也没结果;

在SQL语句执行期间,发生了OOM,mysql服务被kill。

查看系统日志发现 mysqld 占用内存基本没有变,但是本机连接mysql的客户端进程(5733)却占用了内存近20G,这很让人费解,SQL没有执行完,客户端怎么会占用这么多内存?用其他SQL查询查询不同数据,也就十几条数据,更不可能占用这么多内存呀。

还请老师帮忙分析一下,谢谢。

2019-01-22 作者回复好问题,第33篇会说到哈你可以在mysql客户端参数增加 –quick 再试试2019-01-23PengfeiWang  0老师,您好:

对文中以下语句感到有困惑:

我们说加锁单位是 next-key lock,都是前开后闭区,但是这里用到了优化 2,即索引上的等值查询,向右遍历的时候id=15不满足条件,所以 next-key lock 退化为了间隙锁 (10, 15)。

SQL语句中条件中使用的是id字段(唯一索引),那么根据加锁规则这里不应该用的是优化 2,而是优化 1,因为优化1中明确指出给唯一索引加锁,从而优化 2的字面意思来理解,它适用于普通索引。

不知道是不是我理解的不到位?2019-01-22 作者回复主要是这里这一行不存在。

如果能够明确找到一行锁住的话,使用优化1就更准确些2019-01-23Justin  0想咨询一下 普通索引 如果索引中包括的元素都相同 在索引中顺序是怎么排解的呢 是按主键排列的吗 比如(name ,age ) 索引 name age都一样 那索引中会按照主键排序吗?2019-01-22 作者回复会的2019-01-23ServerCoder  0林老师我有个问题想请教一下,描述如下,望给予指点,先谢谢了!环境:虚拟机,CPU 4核,内存8G,系统CentOS7.4,MySQL版本5.6.40数据库配置:

bulk_insert_buffer_size = 256Msql_mode=NO_ENGINE_SUBSTITUTION,STRICT_TRANS_TABLESsecure_file_priv=’’default-storage-engine=MYISAM测试场景修改过的参数(以下这些参数得调整对加载效率没有实质的提升):

myisam_repair_threads=3myisam_sort_buffer_size=256Mnet_buffer_length=1Mmyisam_use_mmap=ONkey_buffer_size=256M测试场景:测试程序多线程,通过客户端API,执行load data infile语句加载数据文件三个线程,三个文件(每个文件100万条数据、150MB),三张表(表结构相同,字段类型均为整形,没有定义主键,有一个字段加了非唯一索引),一一对应进行数据加载,数据库没有使用多核,而是把一个核心的利用率均分给了三个线程。

单个线程加载一个文件大约耗时3秒单线程加载三个文件到三张表大约耗时9秒三个线程分别加载三个文件到三张表,则每个线程均耗时大约9秒。

从这个效果看,单线程顺序加载和三线程并发加载耗时相同,没有提升效果。

三线程加载过程中查看processlist发现时间主要耗费在了网络读取上。

问题:为啥这种场景下MySQL不利用多核?这种并行加载的情况要如何才能让其利用多核,提升加载速度2019-01-22 作者回复可以用到多核呀,你是怎么得到 “时间主要耗费在了网络读取上。

”这个结论的?另外,把这三个文件先拷贝到数据库本地,然后本地执行load看看什么效果?2019-01-23慕塔  0是这样的 假设只有一主一从 1)是集群只有一个sysbench实例,产生的数据流通过中间件,主机分全部写,和30%的读,另外70%的读全部分给从机。

2)有两个sysbench,一个读写加压到主机,另一个只有加压到从机。

主从复制之间通过binlog。

问题在1)的QPS累加与2)QPS累加 意义一样吗 1)的一条事务有读写,而2)的情况,主机与1)一样,从机的读事务与主机里的读不一样吧2019-01-22 作者回复我觉得这两个对比不太公平^_^1)的测试可能会出现中间件瓶颈,a)网络环节中间增加了一跳;

b) 如果是小查询,可能proxy先打到瓶颈2)的测试结论一般会比1)好些但是有这个架构,你肯定是从中间件访问数据库的,所以应该以1的测试结果为准2019-01-23Jason_鹏  0最后一个update的例子,为没有加(0,5)的间隙呢?我理解应该是先拿c=5去b+树搜索,按照间隙索最右原则,应该会加(0,5]的间隙,然后c=5不满足大于5条件,根据优化2原则退化成(0,5)的间隙索,我是这样理解的2019-01-22 作者回复根据c>5查到的第一个记录是c=10,因此不会加(0,5]这个next-key lock。

你提醒得对,我应该多说明这句, 我加到文稿中啦2019-01-22长杰  0老师,之前讲这个例子时,select * from t where c>=15 and c<=20 order by c desc in share mode;最右边加的是 (20, 25)的间隙锁,而这个例子select * from t where id>10 and id<=15 for update中,最右边加的是(15,20]的next-key锁,这两个查询为何最后边一个加的gap锁,一个加的next-key锁,他们都是<=的等值范围查询,区别在哪里?2019-01-22 作者回复select * from t where c>=15 and c<=20 order by c desc in share mode;这个语句是根据 c=20 来查数据的,所以加锁(20,25]的时候,可以使用优化2;

select * from t where id>10 and id<=15 for update;

这里的id=20,是用“向右遍历”的方式得到的,没有优化,按照“以next-key lock”为加锁单位来执行2019-01-22库淘淘  0对于问题 我理解是这样 session 1:

delete from t;begin; select * from t for update;session 2:insert into t values(1,1,1);发生等待show engine innodb status\G; …..——- TRX HAS BEEN WAITING 5 SEC FOR THIS LOCK TO BE GRANTED:RECORD LOCKS space id 75 page no 3 n bits 72 index PRIMARY of table test.t trx id 752090 lock_mode X insert intention waitingRecord lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 00: len 8; hex 73757072656d756d; asc supremum;;其中申请插入意向锁与间隙锁 冲突,supremum这个能否理解为 间隙右边的那个记录2019-01-21 作者回复发现了2019-01-22慕塔  0大佬 请教下一主多从集群性能测试性能计算问题 如果使用基准测试工具sysbench。

数据流有两种1)sysbench—mycat—mysql主机(读写) TPS QPS1| |binlogmysql从机(只读)QPS2那性能指标 TPS QPS=QPS1+QPS22)sysbench—mysql主机(读写) TPS QPS1| binlogsysbench—mysql从机(只读)TPS QPS2集群性能指标TPS QPS=QPS1+QPS2这两种哪种严谨些啊?mycat的损失忽略。

生产中的集群性能怎么算的呢???(还是学生 谢谢!)2019-01-21 作者回复TPS就看主库的写入QPS就看所有从库的读能力加和不过没看懂你问题中1)和2)的区别2019-01-22HuaMax  0删除导致锁范围扩大那个例子,id>10 and id<=15,锁范围为什么没有10呢?不是应该(5,10]吗?2019-01-21 作者回复不是的,要找id>10的,并没有命中id=10哦,你可以理解成就是查到了(10,15)这个间隙2019-01-21llx  0回复@往事随风,顺其自然前面有解释为什么,这篇文章有更详细的解释。

Gap lock 由右值指定的,由于 c 不是唯一键,需要到10,遍历到10的时候,就把 5-10 锁了2019-01-21 作者回复2019-01-21```

问题解析

如何判断一个数据库是不是出问题了?我在第25和27篇文章中,和你介绍了主备切换流程。

通过这些内容的讲解,你应该已经很清楚了:在一主一备的双M架构里,主备切换只需要把客户端流量切到备库;而在一主多从架构里,主备切换除了要把客户端流量切到备库外,还需要把从库接到新主库上。

主备切换有两种场景,一种是主动切换,一种是被动切换。

而其中被动切换,往往是因为主库出问题了,由HA系统发起的。

这也就引出了我们今天要讨论的问题:怎么判断一个主库出问题了?你一定会说,这很简单啊,连上MySQL,执行个select 1就好了。

但是select 1成功返回了,就表示主库没问题吗?select 1判断实际上,select 1成功返回,只能说明这个库的进程还在,并不能说明主库没问题。

现在,我们来看一下这个场景。

图1 查询blocked我们设置innodb_thread_concurrency参数的目的是,控制InnoDB的并发线程上限。

也就是说,一旦并发线程数达到这个值,InnoDB在接收到新请求的时候,就会进入等待状态,直到有线程退出。

这里,我把innodb_thread_concurrency设置成3,表示InnoDB只允许3个线程并行执行。

而在我们的例子中,前三个session 中的sleep(100),使得这三个语句都处于“执行”状态,以此来模拟大查询。

你看到了, session D里面,select 1是能执行成功的,但是查询表t的语句会被堵住。

也就是说,如果这时候我们用select 1来检测实例是否正常的话,是检测不出问题的。

在InnoDB中,innodb_thread_concurrency这个参数的默认值是0,表示不限制并发线程数量。

但是,不限制并发线程数肯定是不行的。

因为,一个机器的CPU核数有限,线程全冲进来,上下文切换的成本就会太高。

所以,通常情况下,我们建议把innodb_thread_concurrency设置为64~128之间的值。

这时,你一定会有疑问,并发线程上限数设置为128够干啥,线上的并发连接数动不动就上千了。

产生这个疑问的原因,是搞混了并发连接和并发查询。

set global innodb_thread_concurrency=3;CREATE TABLE t̀ ̀( ìd ̀int(11) NOT NULL, `c ̀int(11) DEFAULT NULL, PRIMARY KEY (̀ id )̀) ENGINE=InnoDB; insert into t values(1,1)并发连接和并发查询,并不是同一个概念。

你在show processlist的结果里,看到的几千个连接,指的就是并发连接。

而“当前正在执行”的语句,才是我们所说的并发查询。

并发连接数达到几千个影响并不大,就是多占一些内存而已。

我们应该关注的是并发查询,因为并发查询太高才是CPU杀手。

这也是为什么我们需要设置innodb_thread_concurrency参数的原因。

然后,你可能还会想起我们在第7篇文章中讲到的热点更新和死锁检测的时候,如果把innodb_thread_concurrency设置为128的话,那么出现同一行热点更新的问题时,是不是很快就把128消耗完了,这样整个系统是不是就挂了呢?实际上,在线程进入锁等待以后,并发线程的计数会减一,也就是说等行锁(也包括间隙锁)的线程是不算在128里面的。

MySQL这样设计是非常有意义的。

因为,进入锁等待的线程已经不吃CPU了;更重要的是,必须这么设计,才能避免整个系统锁死。

为什么呢?假设处于锁等待的线程也占并发线程的计数,你可以设想一下这个场景:

  1. 线程1执行begin; update t set c=c+1 where id=1, 启动了事务trx1, 然后保持这个状态。

这时候,线程处于空闲状态,不算在并发线程里面。

  1. 线程2到线程129都执行 update t set c=c+1 where id=1; 由于等行锁,进入等待状态。

这样就有128个线程处于等待状态;

  1. 如果处于锁等待状态的线程计数不减一,InnoDB就会认为线程数用满了,会阻止其他语句进入引擎执行,这样线程1不能提交事务。

而另外的128个线程又处于锁等待状态,整个系统就堵住了。

下图2显示的就是这个状态。

图2 系统锁死状态(假设等行锁的语句占用并发计数)这时候InnoDB不能响应任何请求,整个系统被锁死。

而且,由于所有线程都处于等待状态,此时占用的CPU却是0,而这明显不合理。

所以,我们说InnoDB在设计时,遇到进程进入锁等待的情况时,将并发线程的计数减1的设计,是合理而且是必要的。

虽然说等锁的线程不算在并发线程计数里,但如果它在真正地执行查询,就比如我们上面例子中前三个事务中的select sleep(100) from t,还是要算进并发线程的计数的。

在这个例子中,同时在执行的语句超过了设置的innodb_thread_concurrency的值,这时候系统其实已经不行了,但是通过select 1来检测系统,会认为系统还是正常的。

因此,我们使用select 1的判断逻辑要修改一下。

查表判断为了能够检测InnoDB并发线程数过多导致的系统不可用情况,我们需要找一个访问InnoDB的场景。

一般的做法是,在系统库(mysql库)里创建一个表,比如命名为health_check,里面只放一行数据,然后定期执行:

mysql> select * from mysql.health_check; 使用这个方法,我们可以检测出由于并发线程过多导致的数据库不可用的情况。

但是,我们马上还会碰到下一个问题,即:空间满了以后,这种方法又会变得不好使。

我们知道,更新事务要写binlog,而一旦binlog所在磁盘的空间占用率达到100%,那么所有的更新语句和事务提交的commit语句就都会被堵住。

但是,系统这时候还是可以正常读数据的。

因此,我们还是把这条监控语句再改进一下。

接下来,我们就看看把查询语句改成更新语句后的效果。

更新判断既然要更新,就要放个有意义的字段,常见做法是放一个timestamp字段,用来表示最后一次执行检测的时间。

这条更新语句类似于:

节点可用性的检测都应该包含主库和备库。

如果用更新来检测主库的话,那么备库也要进行更新检测。

但,备库的检测也是要写binlog的。

由于我们一般会把数据库A和B的主备关系设计为双M结构,所以在备库B上执行的检测命令,也要发回给主库A。

但是,如果主库A和备库B都用相同的更新命令,就可能出现行冲突,也就是可能会导致主备同步停止。

所以,现在看来mysql.health_check 这个表就不能只有一行数据了。

为了让主备之间的更新不产生冲突,我们可以在mysql.health_check表上存入多行数据,并用A、B的server_id做主键。

由于MySQL规定了主库和备库的server_id必须不同(否则创建主备关系的时候就会报错),这mysql> update mysql.health_check set t_modified=now();mysql> CREATE TABLE `health_check ̀( ìd ̀int(11) NOT NULL, t̀_modified ̀timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (̀ id )̀) ENGINE=InnoDB;/* 检测命令 */insert into mysql.health_check(id, t_modified) values (@@server_id, now()) on duplicate key update t_modified=now();样就可以保证主、备库各自的检测命令不会发生冲突。

更新判断是一个相对比较常用的方案了,不过依然存在一些问题。

其中,“判定慢”一直是让DBA头疼的问题。

你一定会疑惑,更新语句,如果失败或者超时,就可以发起主备切换了,为什么还会有判定慢的问题呢?其实,这里涉及到的是服务器IO资源分配的问题。

首先,所有的检测逻辑都需要一个超时时间N。

执行一条update语句,超过N秒后还不返回,就认为系统不可用。

你可以设想一个日志盘的IO利用率已经是100%的场景。

这时候,整个系统响应非常慢,已经需要做主备切换了。

但是你要知道,IO利用率100%表示系统的IO是在工作的,每个请求都有机会获得IO资源,执行自己的任务。

而我们的检测使用的update命令,需要的资源很少,所以可能在拿到IO资源的时候就可以提交成功,并且在超时时间N秒未到达之前就返回给了检测系统。

检测系统一看,update命令没有超时,于是就得到了“系统正常”的结论。

也就是说,这时候在业务系统上正常的SQL语句已经执行得很慢了,但是DBA上去一看,HA系统还在正常工作,并且认为主库现在处于可用状态。

之所以会出现这个现象,根本原因是我们上面说的所有方法,都是基于外部检测的。

外部检测天然有一个问题,就是随机性。

因为,外部检测都需要定时轮询,所以系统可能已经出问题了,但是却需要等到下一个检测发起执行语句的时候,我们才有可能发现问题。

而且,如果你的运气不够好的话,可能第一次轮询还不能发现,这就会导致切换慢的问题。

所以,接下来我要再和你介绍一种在MySQL内部发现数据库问题的方法。

内部统计针对磁盘利用率这个问题,如果MySQL可以告诉我们,内部每一次IO请求的时间,那我们判断数据库是否出问题的方法就可靠得多了。

其实,MySQL 5.6版本以后提供的performance_schema库,就在file_summary_by_event_name表里统计了每次IO请求的时间。

file_summary_by_event_name表里有很多行数据,我们先来看看event_name=’wait/io/file/innodb/innodb_log_file’这一行。

图3 performance_schema.file_summary_by_event_name的一行图中这一行表示统计的是redo log的写入时间,第一列EVENT_NAME 表示统计的类型。

接下来的三组数据,显示的是redo log操作的时间统计。

第一组五列,是所有IO类型的统计。

其中,COUNT_STAR是所有IO的总次数,接下来四列是具体的统计项, 单位是皮秒;前缀SUM、MIN、AVG、MAX,顾名思义指的就是总和、最小值、平均值和最大值。

第二组六列,是读操作的统计。

最后一列SUM_NUMBER_OF_BYTES_READ统计的是,总共从redo log里读了多少个字节。

第三组六列,统计的是写操作。

最后的第四组数据,是对其他类型数据的统计。

在redo log里,你可以认为它们就是对fsync的统计。

在performance_schema库的file_summary_by_event_name表里,binlog对应的是event_name =”wait/io/file/sql/binlog”这一行。

各个字段的统计逻辑,与redo log的各个字段完全相同。

这里,我就不再赘述了。

因为我们每一次操作数据库,performance_schema都需要额外地统计这些信息,所以我们打开这个统计功能是有性能损耗的。

我的测试结果是,如果打开所有的performance_schema项,性能大概会下降10%左右。

所以,我建议你只打开自己需要的项进行统计。

你可以通过下面的方法打开或者关闭某个具体项的统计。

如果要打开redo log的时间监控,你可以执行这个语句:

假设,现在你已经开启了redo log和binlog这两个统计信息,那要怎么把这个信息用在实例状态诊断上呢?很简单,你可以通过MAX_TIMER的值来判断数据库是否出问题了。

比如,你可以设定阈值,单次IO请求时间超过200毫秒属于异常,然后使用类似下面这条语句作为检测逻辑。

发现异常后,取到你需要的信息,再通过下面这条语句:

把之前的统计信息清空。

这样如果后面的监控中,再次出现这个异常,就可以加入监控累积值了。

小结今天,我和你介绍了检测一个MySQL实例健康状态的几种方法,以及各种方法存在的问题和演进的逻辑。

你看完后可能会觉得,select 1这样的方法是不是已经被淘汰了呢,但实际上使用非常广泛的MHA(Master High Availability),默认使用的就是这个方法。

MHA中的另一个可选方法是只做连接,就是 “如果连接成功就认为主库没问题”。

不过据我所知,选择这个方法的很少。

其实,每个改进的方案,都会增加额外损耗,并不能用“对错”做直接判断,需要你根据业务实际情况去做权衡。

我个人比较倾向的方案,是优先考虑update系统表,然后再配合增加检测performance_schema的信息。

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

今天,我想问你的是:业务系统一般也有高可用的需求,在你开发和维护过的服务中,你是怎么判断服务有没有出问题的呢?mysql> update setup_instruments set ENABLED=’YES’, Timed=’YES’ where name like ‘%wait/io/file/innodb/innodb_log_file%’;mysql> select event_name,MAX_TIMER_WAIT FROM performance_schema.file_summary_by_event_name where event_name in (‘wait/io/file/innodb/innodb_log_file’,’wait/io/file/sql/binlog’) and MAX_TIMER_WAIT>200*1000000000;mysql> truncate table performance_schema.file_summary_by_event_name;你可以把你用到的方法和分析写在留言区,我会在下一篇文章中选取有趣的方案一起来分享和分析。

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

上期问题时间上期的问题是,如果使用GTID等位点的方案做读写分离,在对大表做DDL的时候会怎么样。

假设,这条语句在主库上要执行10分钟,提交后传到备库就要10分钟(典型的大事务)。

那么,在主库DDL之后再提交的事务的GTID,去备库查的时候,就会等10分钟才出现。

这样,这个读写分离机制在这10分钟之内都会超时,然后走主库。

这种预期内的操作,应该在业务低峰期的时候,确保主库能够支持所有业务查询,然后把读请求都切到主库,再在主库上做DDL。

等备库延迟追上以后,再把读请求切回备库。

通过这个思考题,我主要想让关注的是,大事务对等位点方案的影响。

当然了,使用gh-ost方案来解决这个问题也是不错的选择。

问题解析

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

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

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

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

图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。

0%