Qi

Cogito ergo sum

问题解析

为什么表数据删掉一半,表文件大小不变?经常会有同学来问我,我的数据库占用空间太大,我把一个最大的表删掉了一半的数据,怎么表文件的大小还是没变?那么今天,我就和你聊聊数据库表的空间回收,看看如何解决这个问题。

这里,我们还是针对MySQL中应用最广泛的InnoDB引擎展开讨论。

一个InnoDB表包含两部分,即:表结构定义和数据。

在MySQL 8.0版本以前,表结构是存在以.frm为后缀的文件里。

而MySQL 8.0版本,则已经允许把表结构定义放在系统数据表中了。

因为表结构定义占用的空间很小,所以我们今天主要讨论的是表数据。

接下来,我会先和你说明为什么简单地删除表数据达不到表空间回收的效果,然后再和你介绍正确回收空间的方法。

参数innodb_file_per_table表数据既可以存在共享表空间里,也可以是单独的文件。

这个行为是由参数innodb_file_per_table控制的:

  1. 这个参数设置为OFF表示的是,表的数据放在系统共享表空间,也就是跟数据字典放在一起;

  2. 这个参数设置为ON表示的是,每个InnoDB表数据存储在一个以 .ibd为后缀的文件中。

从MySQL 5.6.6版本开始,它的默认值就是ON了。

我建议你不论使用MySQL的哪个版本,都将这个值设置为ON。

因为,一个表单独存储为一个文件更容易管理,而且在你不需要这个表的时候,通过drop table命令,系统就会直接删除这个文件。

而如果是放在共享表空间中,即使表删掉了,空间也是不会回收的。

所以,将innodb_file_per_table设置为ON,是推荐做法,我们接下来的讨论都是基于这个设置展开的。

我们在删除整个表的时候,可以使用drop table命令回收表空间。

但是,我们遇到的更多的删除数据的场景是删除某些行,这时就遇到了我们文章开头的问题:表中的数据被删除了,但是表空间却没有被回收。

我们要彻底搞明白这个问题的话,就要从数据删除流程说起了。

数据删除流程我们先再来看一下InnoDB中一个索引的示意图。

在前面第4和第5篇文章中,我和你介绍索引时曾经提到过,InnoDB里的数据都是用B+树的结构组织的。

图1 B+树索引示意图假设,我们要删掉R4这个记录,InnoDB引擎只会把R4这个记录标记为删除。

如果之后要再插入一个ID在300和600之间的记录时,可能会复用这个位置。

但是,磁盘文件的大小并不会缩小。

现在,你已经知道了InnoDB的数据是按页存储的,那么如果我们删掉了一个数据页上的所有记录,会怎么样?答案是,整个数据页就可以被复用了。

但是,数据页的复用跟记录的复用是不同的。

记录的复用,只限于符合范围条件的数据。

比如上面的这个例子,R4这条记录被删除后,如果插入一个ID是400的行,可以直接复用这个空间。

但如果插入的是一个ID是800的行,就不能复用这个位置了。

而当整个页从B+树里面摘掉以后,可以复用到任何位置。

以图1为例,如果将数据页page A上的所有记录删除以后,page A会被标记为可复用。

这时候如果要插入一条ID=50的记录需要使用新页的时候,page A是可以被复用的。

如果相邻的两个数据页利用率都很小,系统就会把这两个页上的数据合到其中一个页上,另外一个数据页就被标记为可复用。

进一步地,如果我们用delete命令把整个表的数据删除呢?结果就是,所有的数据页都会被标记为可复用。

但是磁盘上,文件不会变小。

你现在知道了,delete命令其实只是把记录的位置,或者数据页标记为了“可复用”,但磁盘文件的大小是不会变的。

也就是说,通过delete命令是不能回收表空间的。

这些可以复用,而没有被使用的空间,看起来就像是“空洞”。

实际上,不止是删除数据会造成空洞,插入数据也会。

如果数据是按照索引递增顺序插入的,那么索引是紧凑的。

但如果数据是随机插入的,就可能造成索引的数据页分裂。

假设图1中page A已经满了,这时我要再插入一行数据,会怎样呢?图2 插入数据导致页分裂可以看到,由于page A满了,再插入一个ID是550的数据时,就不得不再申请一个新的页面page B来保存数据了。

页分裂完成后,page A的末尾就留下了空洞(注意:实际上,可能不止1个记录的位置是空洞)。

另外,更新索引上的值,可以理解为删除一个旧的值,再插入一个新值。

不难理解,这也是会造成空洞的。

也就是说,经过大量增删改的表,都是可能是存在空洞的。

所以,如果能够把这些空洞去掉,就能达到收缩表空间的目的。

而重建表,就可以达到这样的目的。

重建表试想一下,如果你现在有一个表A,需要做空间收缩,为了把表中存在的空洞去掉,你可以怎么做呢?你可以新建一个与表A结构相同的表B,然后按照主键ID递增的顺序,把数据一行一行地从表A里读出来再插入到表B中。

由于表B是新建的表,所以表A主键索引上的空洞,在表B中就都不存在了。

显然地,表B的主键索引更紧凑,数据页的利用率也更高。

如果我们把表B作为临时表,数据从表A导入表B的操作完成后,用表B替换A,从效果上看,就起到了收缩表A空间的作用。

这里,你可以使用alter table A engine=InnoDB命令来重建表。

在MySQL 5.5版本之前,这个命令的执行流程跟我们前面描述的差不多,区别只是这个临时表B不需要你自己创建,MySQL会自动完成转存数据、交换表名、删除旧表的操作。

图3 改锁表DDL显然,花时间最多的步骤是往临时表插入数据的过程,如果在这个过程中,有新的数据要写入到表A的话,就会造成数据丢失。

因此,在整个DDL过程中,表A中不能有更新。

也就是说,这个DDL不是Online的。

而在MySQL 5.6版本开始引入的Online DDL,对这个操作流程做了优化。

我给你简单描述一下引入了Online DDL之后,重建表的流程:

  1. 建立一个临时文件,扫描表A主键的所有数据页;

  2. 用数据页中表A的记录生成B+树,存储到临时文件中;

  3. 生成临时文件的过程中,将所有对A的操作记录在一个日志文件(row log)中,对应的是图中state2的状态;

  4. 临时文件生成后,将日志文件中的操作应用到临时文件,得到一个逻辑数据上与表A相同的数据文件,对应的就是图中state3的状态;

  5. 用临时文件替换表A的数据文件。

图4 Online DDL可以看到,与图3过程的不同之处在于,由于日志文件记录和重放操作这个功能的存在,这个方案在重建表的过程中,允许对表A做增删改操作。

这也就是Online DDL名字的来源。

我记得有同学在第6篇讲表锁的文章《全局锁和表锁 :给表加个字段怎么索这么多阻碍?》的评论区留言说,DDL之前是要拿MDL写锁的,这样还能叫Online DDL吗?确实,图4的流程中,alter语句在启动的时候需要获取MDL写锁,但是这个写锁在真正拷贝数据之前就退化成读锁了。

为什么要退化呢?为了实现Online,MDL读锁不会阻塞增删改操作。

那为什么不干脆直接解锁呢?为了保护自己,禁止其他线程对这个表同时做DDL。

而对于一个大表来说,Online DDL最耗时的过程就是拷贝数据到临时表的过程,这个步骤的执行期间可以接受增删改操作。

所以,相对于整个DDL过程来说,锁的时间非常短。

对业务来说,就可以认为是Online的。

需要补充说明的是,上述的这些重建方法都会扫描原表数据和构建临时文件。

对于很大的表来说,这个操作是很消耗IO和CPU资源的。

因此,如果是线上服务,你要很小心地控制操作时间。

如果想要比较安全的操作的话,我推荐你使用GitHub开源的gh-ost来做。

Online 和 inplace说到Online,我还要再和你澄清一下它和另一个跟DDL有关的、容易混淆的概念inplace的区别。

你可能注意到了,在图3中,我们把表A中的数据导出来的存放位置叫作tmp_table。

这是一个临时表,是在server层创建的。

在图4中,根据表A重建出来的数据是放在“tmp_file”里的,这个临时文件是InnoDB在内部创建出来的。

整个DDL过程都在InnoDB内部完成。

对于server层来说,没有把数据挪动到临时表,是一个“原地”操作,这就是“inplace”名称的来源。

所以,我现在问你,如果你有一个1TB的表,现在磁盘间是1.2TB,能不能做一个inplace的DDL呢?答案是不能。

因为,tmp_file也是要占用临时空间的。

我们重建表的这个语句alter table t engine=InnoDB,其实隐含的意思是:

跟inplace对应的就是拷贝表的方式了,用法是:

当你使用ALGORITHM=copy的时候,表示的是强制拷贝表,对应的流程就是图3的操作过程。

但我这样说你可能会觉得,inplace跟Online是不是就是一个意思?其实不是的,只是在重建表这个逻辑中刚好是这样而已。

比如,如果我要给InnoDB表的一个字段加全文索引,写法是:

这个过程是inplace的,但会阻塞增删改操作,是非Online的。

如果说这两个逻辑之间的关系是什么的话,可以概括为:

  1. DDL过程如果是Online的,就一定是inplace的;

alter table t engine=innodb,ALGORITHM=inplace;alter table t engine=innodb,ALGORITHM=copy;alter table t add FULLTEXT(field_name);2. 反过来未必,也就是说inplace的DDL,有可能不是Online的。

截止到MySQL 8.0,添加全文索引(FULLTEXT index)和空间索引(SPATIAL index)就属于这种情况。

最后,我们再延伸一下。

在第10篇文章《MySQL为什么有时候会选错索引》的评论区中,有同学问到使用optimizetable、analyze table和alter table这三种方式重建表的区别。

这里,我顺便再简单和你解释一下。

从MySQL 5.6版本开始,alter table t engine = InnoDB(也就是recreate)默认的就是上面图4的流程了;

analyze table t 其实不是重建表,只是对表的索引信息做重新统计,没有修改数据,这个过程中加了MDL读锁;

optimize table t 等于recreate+analyze。

小结今天这篇文章,我和你讨论了数据库中收缩表空间的方法。

现在你已经知道了,如果要收缩一个表,只是delete掉表里面不用的数据的话,表文件的大小是不会变的,你还要通过alter table命令重建表,才能达到表文件变小的目的。

我跟你介绍了重建表的两种实现方式,Online DDL的方式是可以考虑在业务低峰期使用的,而MySQL 5.5及之前的版本,这个命令是会阻塞DML的,这个你需要特别小心。

最后,又到了我们的课后问题时间。

假设现在有人碰到了一个“想要收缩表空间,结果适得其反”的情况,看上去是这样的:

  1. 一个表t文件大小为1TB;

  2. 对这个表执行 alter table t engine=InnoDB;

  3. 发现执行完成后,空间不仅没变小,还稍微大了一点儿,比如变成了1.01TB。

你觉得可能是什么原因呢 ?你可以把你觉得可能的原因写在留言区里,我会在下一篇文章的末尾把大家描述的合理的原因都列出来,以后其他同学就不用掉到这样的坑里了。

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

上期问题时间在上期文章最后,我留给你的问题是,如果一个高配的机器,redo log设置太小,会发生什么情况。

每次事务提交都要写redo log,如果设置太小,很快就会被写满,也就是下面这个图的状态,这个“环”将很快被写满,write pos一直追着CP。

这时候系统不得不停止所有更新,去推进checkpoint。

这时,你看到的现象就是磁盘压力很小,但是数据库出现间歇性的性能下跌。

问题解析

为什么我的MySQL会“抖”一下平时的工作中,不知道你有没有遇到过这样的场景,一条SQL语句,正常执行的时候特别快,但是有时也不知道怎么回事,它就会变得特别慢,并且这样的场景很难复现,它不只随机,而且持续时间还很短。

看上去,这就像是数据库“抖”了一下。

今天,我们就一起来看一看这是什么原因。

你的SQL语句为什么变“慢”了在前面第2篇文章《日志系统:一条SQL更新语句是如何执行的?》中,我为你介绍了WAL机制。

现在你知道了,InnoDB在处理更新语句的时候,只做了写日志这一个磁盘操作。

这个日志叫作redo log(重做日志),也就是《孔乙己》里咸亨酒店掌柜用来记账的粉板,在更新内存写完redo log后,就返回给客户端,本次更新成功。

做下类比的话,掌柜记账的账本是数据文件,记账用的粉板是日志文件(redo log),掌柜的记忆就是内存。

掌柜总要找时间把账本更新一下,这对应的就是把内存里的数据写入磁盘的过程,术语就是flush。

在这个flush操作执行之前,孔乙己的赊账总额,其实跟掌柜手中账本里面的记录是不一致的。

因为孔乙己今天的赊账金额还只在粉板上,而账本里的记录是老的,还没把今天的赊账算进去。

当内存数据页跟磁盘数据页内容不一致的时候,我们称这个内存页为“脏页”。

内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为“干净页”。

不论是脏页还是干净页,都在内存中。

在这个例子里,内存对应的就是掌柜的记忆。

接下来,我们用一个示意图来展示一下“孔乙己赊账”的整个操作过程。

假设原来孔乙己欠账10文,这次又要赊9文。

图1 “孔乙己赊账”更新和flush过程回到文章开头的问题,你不难想象,平时执行很快的更新操作,其实就是在写内存和日志,而MySQL偶尔“抖”一下的那个瞬间,可能就是在刷脏页(flush)。

那么,什么情况会引发数据库的flush过程呢?我们还是继续用咸亨酒店掌柜的这个例子,想一想:掌柜在什么情况下会把粉板上的赊账记录改到账本上?第一种场景是,粉板满了,记不下了。

这时候如果再有人来赊账,掌柜就只得放下手里的活儿,将粉板上的记录擦掉一些,留出空位以便继续记账。

当然在擦掉之前,他必须先将正确的账目记录到账本中才行。

这个场景,对应的就是InnoDB的redo log写满了。

这时候系统会停止所有更新操作,把checkpoint往前推进,redo log留出空间可以继续写。

我在第二讲画了一个redo log的示意图,这里我改成环形,便于大家理解。

图2 redo log状态图checkpoint可不是随便往前修改一下位置就可以的。

比如图2中,把checkpoint位置从CP推进到CP’,就需要将两个点之间的日志(浅绿色部分),对应的所有脏页都flush到磁盘上。

之后,图中从write pos到CP’之间就是可以再写入的redo log的区域。

第二种场景是,这一天生意太好,要记住的事情太多,掌柜发现自己快记不住了,赶紧找出账本把孔乙己这笔账先加进去。

这种场景,对应的就是系统内存不足。

当需要新的内存页,而内存不够用的时候,就要淘汰一些数据页,空出内存给别的数据页使用。

如果淘汰的是“脏页”,就要先将脏页写到磁盘。

你一定会说,这时候难道不能直接把内存淘汰掉,下次需要请求的时候,从磁盘读入数据页,然后拿redo log出来应用不就行了?这里其实是从性能考虑的。

如果刷脏页一定会写盘,就保证了每个数据页有两种状态:

一种是内存里存在,内存里就肯定是正确的结果,直接返回;

另一种是内存里没有数据,就可以肯定数据文件上是正确的结果,读入内存后返回。

这样的效率最高。

第三种场景是,生意不忙的时候,或者打烊之后。

这时候柜台没事,掌柜闲着也是闲着,不如更新账本。

这种场景,对应的就是MySQL认为系统“空闲”的时候。

当然,MySQL“这家酒店”的生意好起来可是会很快就能把粉板记满的,所以“掌柜”要合理地安排时间,即使是“生意好”的时候,也要见缝插针地找时间,只要有机会就刷一点“脏页”。

第四种场景是,年底了咸亨酒店要关门几天,需要把账结清一下。

这时候掌柜要把所有账都记到账本上,这样过完年重新开张的时候,就能就着账本明确账目情况了。

这种场景,对应的就是MySQL正常关闭的情况。

这时候,MySQL会把内存的脏页都flush到磁盘上,这样下次MySQL启动的时候,就可以直接从磁盘上读数据,启动速度会很快。

接下来,你可以分析一下上面四种场景对性能的影响。

其中,第三种情况是属于MySQL空闲时的操作,这时系统没什么压力,而第四种场景是数据库本来就要关闭了。

这两种情况下,你不会太关注“性能”问题。

所以这里,我们主要来分析一下前两种场景下的性能问题。

第一种是“redo log写满了,要flush脏页”,这种情况是InnoDB要尽量避免的。

因为出现这种情况的时候,整个系统就不能再接受更新了,所有的更新都必须堵住。

如果你从监控上看,这时候更新数会跌为0。

第二种是“内存不够用了,要先将脏页写到磁盘”,这种情况其实是常态。

InnoDB用缓冲池(buffer pool)管理内存,缓冲池中的内存页有三种状态:

第一种是,还没有使用的;

第二种是,使用了并且是干净页;

第三种是,使用了并且是脏页。

InnoDB的策略是尽量使用内存,因此对于一个长时间运行的库来说,未被使用的页面很少。

而当要读入的数据页没有在内存的时候,就必须到缓冲池中申请一个数据页。

这时候只能把最久不使用的数据页从内存中淘汰掉:如果要淘汰的是一个干净页,就直接释放出来复用;但如果是脏页呢,就必须将脏页先刷到磁盘,变成干净页后才能复用。

所以,刷脏页虽然是常态,但是出现以下这两种情况,都是会明显影响性能的:

  1. 一个查询要淘汰的脏页个数太多,会导致查询的响应时间明显变长;

  2. 日志写满,更新全部堵住,写性能跌为0,这种情况对敏感业务来说,是不能接受的。

所以,InnoDB需要有控制脏页比例的机制,来尽量避免上面的这两种情况。

InnoDB刷脏页的控制策略接下来,我就来和你说说InnoDB脏页的控制策略,以及和这些策略相关的参数。

首先,你要正确地告诉InnoDB所在主机的IO能力,这样InnoDB才能知道需要全力刷脏页的时候,可以刷多快。

这就要用到innodb_io_capacity这个参数了,它会告诉InnoDB你的磁盘能力。

这个值我建议你设置成磁盘的IOPS。

磁盘的IOPS可以通过fio这个工具来测试,下面的语句是我用来测试磁盘随机读写的命令:

其实,因为没能正确地设置innodb_io_capacity参数,而导致的性能问题也比比皆是。

之前,就曾有其他公司的开发负责人找我看一个库的性能问题,说MySQL的写入速度很慢,TPS很低,但是数据库主机的IO压力并不大。

经过一番排查,发现罪魁祸首就是这个参数的设置出了问题。

他的主机磁盘用的是SSD,但是innodb_io_capacity的值设置的是300。

于是,InnoDB认为这个系统的能力就这么差,所以刷脏页刷得特别慢,甚至比脏页生成的速度还慢,这样就造成了脏页累积,影响了查询和更新性能。

虽然我们现在已经定义了“全力刷脏页”的行为,但平时总不能一直是全力刷吧?毕竟磁盘能力不能只用来刷脏页,还需要服务用户请求。

所以接下来,我们就一起看看InnoDB怎么控制引擎按照“全力”的百分比来刷脏页。

根据我前面提到的知识点,试想一下,如果你来设计策略控制刷脏页的速度,会参考哪些因素呢?这个问题可以这么想,如果刷太慢,会出现什么情况?首先是内存脏页太多,其次是redo log写满。

所以,InnoDB的刷盘速度就是要参考这两个因素:一个是脏页比例,一个是redo log写盘速度。

fio -filename=$filename -direct=1 -iodepth 1 -thread -rw=randrw -ioengine=psync -bs=16k -size=500M -numjobs=10 -runtime=10 -group_reporting -name=mytest InnoDB会根据这两个因素先单独算出两个数字。

参数innodb_max_dirty_pages_pct是脏页比例上限,默认值是75%。

InnoDB会根据当前的脏页比例(假设为M),算出一个范围在0到100之间的数字,计算这个数字的伪代码类似这样:

InnoDB每次写入的日志都有一个序号,当前写入的序号跟checkpoint对应的序号之间的差值,我们假设为N。

InnoDB会根据这个N算出一个范围在0到100之间的数字,这个计算公式可以记为F2(N)。

F2(N)算法比较复杂,你只要知道N越大,算出来的值越大就好了。

然后,根据上述算得的F1(M)和F2(N)两个值,取其中较大的值记为R,之后引擎就可以按照innodb_io_capacity定义的能力乘以R%来控制刷脏页的速度。

上述的计算流程比较抽象,不容易理解,所以我画了一个简单的流程图。

图中的F1、F2就是上面我们通过脏页比例和redo log写入速度算出来的两个值。

F1(M){ if M>=innodb_max_dirty_pages_pct then return 100; return 100*M/innodb_max_dirty_pages_pct;}图3 InnoDB刷脏页速度策略现在你知道了,InnoDB会在后台刷脏页,而刷脏页的过程是要将内存页写入磁盘。

所以,无论是你的查询语句在需要内存的时候可能要求淘汰一个脏页,还是由于刷脏页的逻辑会占用IO资源并可能影响到了你的更新语句,都可能是造成你从业务端感知到MySQL“抖”了一下的原因。

要尽量避免这种情况,你就要合理地设置innodb_io_capacity的值,并且平时要多关注脏页比例,不要让它经常接近75%。

其中,脏页比例是通过Innodb_buffer_pool_pages_dirty/Innodb_buffer_pool_pages_total得到的,具体的命令参考下面的代码:

接下来,我们再看一个有趣的策略。

一旦一个查询请求需要在执行过程中先flush掉一个脏页时,这个查询就可能要比平时慢了。

而MySQL中的一个机制,可能让你的查询会更慢:在准备刷一个脏页的时候,如果这个数据页旁边的数据页刚好是脏页,就会把这个“邻居”也带着一起刷掉;而且这个把“邻居”拖下水的逻辑还可以继续蔓延,也就是对于每个邻居数据页,如果跟它相邻的数据页也还是脏页的话,也会被放到一起刷。

在InnoDB中,innodb_flush_neighbors 参数就是用来控制这个行为的,值为1的时候会有上述的“连坐”机制,值为0时表示不找邻居,自己刷自己的。

找“邻居”这个优化在机械硬盘时代是很有意义的,可以减少很多随机IO。

机械硬盘的随机IOPS一般只有几百,相同的逻辑操作减少随机IO就意味着系统性能的大幅度提升。

而如果使用的是SSD这类IOPS比较高的设备的话,我就建议你把innodb_flush_neighbors的值设置成0。

因为这时候IOPS往往不是瓶颈,而“只刷自己”,就能更快地执行完必要的刷脏页操作,减少SQL语句响应时间。

在MySQL 8.0中,innodb_flush_neighbors参数的默认值已经是0了。

小结今天这篇文章,我延续第2篇中介绍的WAL的概念,和你解释了这个机制后续需要的刷脏页操作和执行时机。

利用WAL技术,数据库将随机写转换成了顺序写,大大提升了数据库的性能。

但是,由此也带来了内存脏页的问题。

脏页会被后台线程自动flush,也会由于数据页淘汰而触发flush,而刷脏页的过程由于会占用资源,可能会让你的更新和查询语句的响应时间长一些。

在文章里,我也给你介绍了控制刷脏页的方法和对应的监控方式。

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

mysql> select VARIABLE_VALUE into @a from global_status where VARIABLE_NAME = ‘Innodb_buffer_pool_pages_dirty’;select VARIABLE_VALUE into @b from global_status where VARIABLE_NAME = ‘Innodb_buffer_pool_pages_total’;select @a/@b;一个内存配置为128GB、innodb_io_capacity设置为20000的大规格实例,正常会建议你将redolog设置成4个1GB的文件。

但如果你在配置的时候不慎将redo log设置成了1个100M的文件,会发生什么情况呢?又为什么会出现这样的情况呢?你可以把你的分析结论写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。

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

上期问题时间上期我留给你的问题是,给一个学号字段创建索引,有哪些方法。

由于这个学号的规则,无论是正向还是反向的前缀索引,重复度都比较高。

因为维护的只是一个学校的,因此前面6位(其中,前三位是所在城市编号、第四到第六位是学校编号)其实是固定的,邮箱后缀都是@gamil.com,因此可以只存入学年份加顺序编号,它们的长度是9位。

而其实在此基础上,可以用数字类型来存这9位数字。

比如201100001,这样只需要占4个字节。

其实这个就是一种hash,只是它用了最简单的转换规则:字符串转数字的规则,而刚好我们设定的这个背景,可以保证这个转换后结果的唯一性。

评论区中,也有其他一些很不错的见解。

评论用户@封建的风 说,一个学校的总人数这种数据量,50年才100万学生,这个表肯定是小表。

为了业务简单,直接存原来的字符串。

这个答复里面包含了“优化成本和收益”的思想,我觉得值得at出来。

@小潘 同学提了另外一个极致的方向。

如果碰到表数据量特别大的场景,通过这种方式的收益是很不错的。

问题解析

怎么给字符串字段加索引?现在,几乎所有的系统都支持邮箱登录,如何在邮箱这样的字段上建立合理的索引,是我们今天要讨论的问题。

假设,你现在维护一个支持邮箱登录的系统,用户表是这么定义的:

由于要使用邮箱登录,所以业务代码中一定会出现类似于这样的语句:

从第4和第5篇讲解索引的文章中,我们可以知道,如果email这个字段上没有索引,那么这个语句就只能做全表扫描。

同时,MySQL是支持前缀索引的,也就是说,你可以定义字符串的一部分作为索引。

默认地,mysql> create table SUser(ID bigint unsigned primary key,email varchar(64), … )engine=innodb; mysql> select f1, f2 from SUser where email=’xxx’;如果你创建索引的语句不指定前缀长度,那么索引就会包含整个字符串。

比如,这两个在email字段上创建索引的语句:

第一个语句创建的index1索引里面,包含了每个记录的整个字符串;而第二个语句创建的index2索引里面,对于每个记录都是只取前6个字节。

那么,这两种不同的定义在数据结构和存储上有什么区别呢?如图2和3所示,就是这两个索引的示意图。

图1 email 索引结构mysql> alter table SUser add index index1(email);或mysql> alter table SUser add index index2(email(6));图2 email(6) 索引结构从图中你可以看到,由于email(6)这个索引结构中每个邮箱字段都只取前6个字节(即:

zhangs),所以占用的空间会更小,这就是使用前缀索引的优势。

但,这同时带来的损失是,可能会增加额外的记录扫描次数。

接下来,我们再看看下面这个语句,在这两个索引定义下分别是怎么执行的。

如果使用的是index1(即email整个字符串的索引结构),执行顺序是这样的:

  1. 从index1索引树找到满足索引值是’zhangssxyz@xxx.com’的这条记录,取得ID2的值;

  2. 到主键上查到主键值是ID2的行,判断email的值是正确的,将这行记录加入结果集;

  3. 取index1索引树上刚刚查到的位置的下一条记录,发现已经不满足email=‘zhangssxyz@xxx.com’的条件了,循环结束。

这个过程中,只需要回主键索引取一次数据,所以系统认为只扫描了一行。

select id,name,email from SUser where email=‘zhangssxyz@xxx.com‘;如果使用的是index2(即email(6)索引结构),执行顺序是这样的:

  1. 从index2索引树找到满足索引值是’zhangs’的记录,找到的第一个是ID1;

  2. 到主键上查到主键值是ID1的行,判断出email的值不是’zhangssxyz@xxx.com’,这行记录丢弃;

  3. 取index2上刚刚查到的位置的下一条记录,发现仍然是’zhangs’,取出ID2,再到ID索引上取整行然后判断,这次值对了,将这行记录加入结果集;

  4. 重复上一步,直到在idxe2上取到的值不是’zhangs’时,循环结束。

在这个过程中,要回主键索引取4次数据,也就是扫描了4行。

通过这个对比,你很容易就可以发现,使用前缀索引后,可能会导致查询语句读数据的次数变多。

但是,对于这个查询语句来说,如果你定义的index2不是email(6)而是email(7),也就是说取email字段的前7个字节来构建索引的话,即满足前缀’zhangss’的记录只有一个,也能够直接查到ID2,只扫描一行就结束了。

也就是说使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本。

于是,你就有个问题:当要给字符串创建前缀索引时,有什么方法能够确定我应该使用多长的前缀呢?实际上,我们在建立索引时关注的是区分度,区分度越高越好。

因为区分度越高,意味着重复的键值越少。

因此,我们可以通过统计索引上有多少个不同的值来判断要使用多长的前缀。

首先,你可以使用下面这个语句,算出这个列上有多少个不同的值:

然后,依次选取不同长度的前缀来看这个值,比如我们要看一下4~7个字节的前缀索引,可以用这个语句:

mysql> select count(distinct email) as L from SUser;当然,使用前缀索引很可能会损失区分度,所以你需要预先设定一个可以接受的损失比例,比如5%。

然后,在返回的L4~L7中,找出不小于 L * 95%的值,假设这里L6、L7都满足,你就可以选择前缀长度为6。

前缀索引对覆盖索引的影响前面我们说了使用前缀索引可能会增加扫描行数,这会影响到性能。

其实,前缀索引的影响不止如此,我们再看一下另外一个场景。

你先来看看这个SQL语句:

与前面例子中的SQL语句相比,这个语句只要求返回id和email字段。

所以,如果使用index1(即email整个字符串的索引结构)的话,可以利用覆盖索引,从index1查到结果后直接就返回了,不需要回到ID索引再去查一次。

而如果使用index2(即email(6)索引结构)的话,就不得不回到ID索引再去判断email字段的值。

即使你将index2的定义修改为email(18)的前缀索引,这时候虽然index2已经包含了所有的信息,但InnoDB还是要回到id索引再查一下,因为系统并不确定前缀索引的定义是否截断了完整信息。

也就是说,使用前缀索引就用不上覆盖索引对查询性能的优化了,这也是你在选择是否使用前缀索引时需要考虑的一个因素。

其他方式对于类似于邮箱这样的字段来说,使用前缀索引的效果可能还不错。

但是,遇到前缀的区分度不mysql> select count(distinct left(email,4))as L4, count(distinct left(email,5))as L5, count(distinct left(email,6))as L6, count(distinct left(email,7))as L7,from SUser;select id,email from SUser where email=‘zhangssxyz@xxx.com‘;select id,name,email from SUser where email=‘zhangssxyz@xxx.com‘;够好的情况时,我们要怎么办呢?比如,我们国家的身份证号,一共18位,其中前6位是地址码,所以同一个县的人的身份证号前6位一般会是相同的。

假设你维护的数据库是一个市的公民信息系统,这时候如果对身份证号做长度为6的前缀索引的话,这个索引的区分度就非常低了。

按照我们前面说的方法,可能你需要创建长度为12以上的前缀索引,才能够满足区分度要求。

但是,索引选取的越长,占用的磁盘空间就越大,相同的数据页能放下的索引值就越少,搜索的效率也就会越低。

那么,如果我们能够确定业务需求里面只有按照身份证进行等值查询的需求,还有没有别的处理方法呢?这种方法,既可以占用更小的空间,也能达到相同的查询效率。

答案是,有的。

第一种方式是使用倒序存储。

如果你存储身份证号的时候把它倒过来存,每次查询的时候,你可以这么写:

由于身份证号的最后6位没有地址码这样的重复逻辑,所以最后这6位很可能就提供了足够的区分度。

当然了,实践中你不要忘记使用count(distinct)方法去做个验证。

第二种方式是使用hash字段。

你可以在表上再创建一个整数字段,来保存身份证的校验码,同时在这个字段上创建索引。

然后每次插入新记录的时候,都同时用crc32()这个函数得到校验码填到这个新字段。

由于校验码可能存在冲突,也就是说两个不同的身份证号通过crc32()函数得到的结果可能是相同的,所以你的查询语句where部分要判断id_card的值是否精确相同。

这样,索引的长度变成了4个字节,比原来小了很多。

mysql> select field_list from t where id_card = reverse(‘input_id_card_string’);mysql> alter table t add id_card_crc int unsigned, add index(id_card_crc);mysql> select field_list from t where id_card_crc=crc32(‘input_id_card_string’) and id_card=’input_id_card_string’接下来,我们再一起看看使用倒序存储和使用hash字段这两种方法的异同点。

首先,它们的相同点是,都不支持范围查询。

倒序存储的字段上创建的索引是按照倒序字符串的方式排序的,已经没有办法利用索引方式查出身份证号码在[ID_X, ID_Y]的所有市民了。

同样地,hash字段的方式也只能支持等值查询。

它们的区别,主要体现在以下三个方面:

  1. 从占用的额外空间来看,倒序存储方式在主键索引上,不会消耗额外的存储空间,而hash字段方法需要增加一个字段。

当然,倒序存储方式使用4个字节的前缀长度应该是不够的,如果再长一点,这个消耗跟额外这个hash字段也差不多抵消了。

  1. 在CPU消耗方面,倒序方式每次写和读的时候,都需要额外调用一次reverse函数,而hash字段的方式需要额外调用一次crc32()函数。

如果只从这两个函数的计算复杂度来看的话,reverse函数额外消耗的CPU资源会更小些。

  1. 从查询效率上看,使用hash字段方式的查询性能相对更稳定一些。

因为crc32算出来的值虽然有冲突的概率,但是概率非常小,可以认为每次查询的平均扫描行数接近1。

而倒序存储方式毕竟还是用的前缀索引的方式,也就是说还是会增加扫描行数。

小结在今天这篇文章中,我跟你聊了聊字符串字段创建索引的场景。

我们来回顾一下,你可以使用的方式有:

  1. 直接创建完整索引,这样可能比较占用空间;

  2. 创建前缀索引,节省空间,但会增加查询扫描次数,并且不能使用覆盖索引;

  3. 倒序存储,再创建前缀索引,用于绕过字符串本身前缀的区分度不够的问题;

  4. 创建hash字段索引,查询性能稳定,有额外的存储和计算消耗,跟第三种方式一样,都不支持范围扫描。

在实际应用中,你要根据业务字段的特点选择使用哪种方式。

好了,又到了最后的问题时间。

如果你在维护一个学校的学生信息数据库,学生登录名的统一格式是”学号@gmail.com”, 而学号的规则是:十五位的数字,其中前三位是所在城市编号、第四到第六位是学校编号、第七位到第十位是入学年份、最后五位是顺序编号。

系统登录的时候都需要学生输入登录名和密码,验证正确后才能继续使用系统。

就只考虑登录验证这个行为的话,你会怎么设计这个登录名的索引呢?你可以把你的分析思路和设计结果写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。

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

上期问题时间上篇文章中的第一个例子,评论区有几位同学说没有复现,大家要检查一下隔离级别是不是RR(Repeatable Read,可重复读),创建的表t是不是InnoDB引擎。

我把复现过程做成了一个视频,供你参考。

在上一篇文章最后,我给你留的问题是,为什么经过这个操作序列,explain的结果就不对了?这里,我来为你分析一下原因。

delete 语句删掉了所有的数据,然后再通过call idata()插入了10万行数据,看上去是覆盖了原来的10万行。

但是,session A开启了事务并没有提交,所以之前插入的10万行数据是不能删除的。

这样,之前的数据每一行数据都有两个版本,旧版本是delete之前的数据,新版本是标记为deleted的数据。

这样,索引a上的数据其实就有两份。

然后你会说,不对啊,主键上的数据也不能删,那没有使用force index的语句,使用explain命令看到的扫描行数为什么还是100000左右?(潜台词,如果这个也翻倍,也许优化器还会认为选字段a作为索引更合适)是的,不过这个是主键,主键是直接按照表的行数来估计的。

而表的行数,优化器直接用的是show table status的值。

这个值的计算方法,我会在后面有文章为你详细讲解。

问题解析

MySQL为什么有时候会选错索引?

前面我们介绍过索引,你已经知道了在MySQL中一张表其实是可以支持多个索引的。

但是,你写SQL语句的时候,并没有主动指定使用哪个索引。

也就是说,使用哪个索引是由MySQL来确定的。

不知道你有没有碰到过这种情况,一条本来可以执行得很快的语句,却由于MySQL选错了索引,而导致执行速度变得很慢?我们一起来看一个例子吧。

我们先建一个简单的表,表里有a、b两个字段,并分别建上索引:
CREATE TABLE t ( id int(11) NOT NULL, a int(11) DEFAULT NULL, b int(11) DEFAULT NULL, PRIMARY KEY (id), KEY a (a), KEY b (b)) ENGINE=InnoDB;
然后,我们往表t中插入10万行记录,取值按整数递增,即:(1,1,1),(2,2,2),(3,3,3) 直到(100000,100000,100000)。

我是用存储过程来插入数据的,这里我贴出来方便你复现:
接下来,我们分析一条SQL语句:
你一定会说,这个语句还用分析吗,很简单呀,a上有索引,肯定是要使用索引a的。

你说得没错,图1显示的就是使用explain命令看到的这条语句的执行情况。

图1 使用explain命令查看语句执行情况从图1看上去,这条查询语句的执行也确实符合预期,key这个字段值是’a’,表示优化器选择了索引a。

不过别急,这个案例不会这么简单。

在我们已经准备好的包含了10万行数据的表上,我们再做如下操作。

delimiter ;;create procedure idata()begin declare i int; set i=1; while(i<=100000)do insert into t values(i, i, i); set i=i+1; end while;end;;delimiter ;call idata();mysql> select * from t where a between 10000 and 20000;图2 session A和session B的执行流程这里,session A的操作你已经很熟悉了,它就是开启了一个事务。

随后,session B把数据都删除后,又调用了 idata这个存储过程,插入了10万行数据。

这时候,session B的查询语句select * from t where a between 10000 and 20000就不会再选择索引a了。

我们可以通过慢查询日志(slow log)来查看一下具体的执行情况。

为了说明优化器选择的结果是否正确,我增加了一个对照,即:使用force index(a)来让优化器强制使用索引a(这部分内容,我还会在这篇文章的后半部分中提到)。

下面的三条SQL语句,就是这个实验过程。

第一句,是将慢查询日志的阈值设置为0,表示这个线程接下来的语句都会被记录入慢查询日志中;
第二句,Q1是session B原来的查询;
第三句,Q2是加了force index(a)来和session B原来的查询语句执行情况对比。

如图3所示是这三条SQL语句执行完成后的慢查询日志。

set long_query_time=0;select * from t where a between 10000 and 20000; /Q1/select * from t force index(a) where a between 10000 and 20000;/Q2/图3 slow log结果可以看到,Q1扫描了10万行,显然是走了全表扫描,执行时间是40毫秒。

Q2扫描了10001行,执行了21毫秒。

也就是说,我们在没有使用force index的时候,MySQL用错了索引,导致了更长的执行时间。

这个例子对应的是我们平常不断地删除历史数据和新增数据的场景。

这时,MySQL竟然会选错索引,是不是有点奇怪呢?今天,我们就从这个奇怪的结果说起吧。

优化器的逻辑在第一篇文章中,我们就提到过,选择索引是优化器的工作。

而优化器选择索引的目的,是找到一个最优的执行方案,并用最小的代价去执行语句。

在数据库里面,扫描行数是影响执行代价的因素之一。

扫描的行数越少,意味着访问磁盘数据的次数越少,消耗的CPU资源越少。

当然,扫描行数并不是唯一的判断标准,优化器还会结合是否使用临时表、是否排序等因素进行综合判断。

我们这个简单的查询语句并没有涉及到临时表和排序,所以MySQL选错索引肯定是在判断扫描行数的时候出问题了。

那么,问题就是:扫描行数是怎么判断的?MySQL在真正开始执行语句之前,并不能精确地知道满足这个条件的记录有多少条,而只能根据统计信息来估算记录数。

这个统计信息就是索引的“区分度”。

显然,一个索引上不同的值越多,这个索引的区分度就越好。

而一个索引上不同的值的个数,我们称之为“基数”(cardinality)。

也就是说,这个基数越大,索引的区分度越好。

我们可以使用show index方法,看到一个索引的基数。

如图4所示,就是表t的show index 的结果。

虽然这个表的每一行的三个字段值都是一样的,但是在统计信息中,这三个索引的基数值并不同,而且其实都不准确。

图4 表t的show index 结果那么,MySQL是怎样得到索引的基数的呢?这里,我给你简单介绍一下MySQL采样统计的方法。

为什么要采样统计呢?因为把整张表取出来一行行统计,虽然可以得到精确的结果,但是代价太高了,所以只能选择“采样统计”。

采样统计的时候,InnoDB默认会选择N个数据页,统计这些页面上的不同值,得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数。

而数据表是会持续更新的,索引统计信息也不会固定不变。

所以,当变更的数据行数超过1/M的时候,会自动触发重新做一次索引统计。

在MySQL中,有两种存储索引统计的方式,可以通过设置参数innodb_stats_persistent的值来选择:
设置为on的时候,表示统计信息会持久化存储。

这时,默认的N是20,M是10。

设置为off的时候,表示统计信息只存储在内存中。

这时,默认的N是8,M是16。

由于是采样统计,所以不管N是20还是8,这个基数都是很容易不准的。

但,这还不是全部。

你可以从图4中看到,这次的索引统计值(cardinality列)虽然不够精确,但大体上还是差不多的,选错索引一定还有别的原因。

其实索引统计只是一个输入,对于一个具体的语句来说,优化器还要判断,执行这个语句本身要扫描多少行。

接下来,我们再一起看看优化器预估的,这两个语句的扫描行数是多少。

图5 意外的explain结果rows这个字段表示的是预计扫描行数。

其中,Q1的结果还是符合预期的,rows的值是104620;但是Q2的rows值是37116,偏差就大了。

而图1中我们用explain命令看到的rows是只有10001行,是这个偏差误导了优化器的判断。

到这里,可能你的第一个疑问不是为什么不准,而是优化器为什么放着扫描37000行的执行计划不用,却选择了扫描行数是100000的执行计划呢?这是因为,如果使用索引a,每次从索引a上拿到一个值,都要回到主键索引上查出整行数据,这个代价优化器也要算进去的。

而如果选择扫描10万行,是直接在主键索引上扫描的,没有额外的代价。

优化器会估算这两个选择的代价,从结果看来,优化器认为直接扫描主键索引更快。

当然,从执行时间看来,这个选择并不是最优的。

使用普通索引需要把回表的代价算进去,在图1执行explain的时候,也考虑了这个策略的代价 ,但图1的选择是对的。

也就是说,这个策略并没有问题。

所以冤有头债有主,MySQL选错索引,这件事儿还得归咎到没能准确地判断出扫描行数。

至于为什么会得到错误的扫描行数,这个原因就作为课后问题,留给你去分析了。

既然是统计信息不对,那就修正。

analyze table t 命令,可以用来重新统计索引信息。

我们来看一下执行效果。

图6 执行analyze table t 命令恢复的explain结果这回对了。

所以在实践中,如果你发现explain的结果预估的rows值跟实际情况差距比较大,可以采用这个方法来处理。

其实,如果只是索引统计不准确,通过analyze命令可以解决很多问题,但是前面我们说了,优化器可不止是看扫描行数。

依然是基于这个表t,我们看看另外一个语句:
从条件上看,这个查询没有符合条件的记录,因此会返回空集合。

在开始执行这条语句之前,你可以先设想一下,如果你来选择索引,会选择哪一个呢?为了便于分析,我们先来看一下a、b这两个索引的结构图。

图7 a、b索引的结构图如果使用索引a进行查询,那么就是扫描索引a的前1000个值,然后取到对应的id,再到主键索引上去查出每一行,然后根据字段b来过滤。

显然这样需要扫描1000行。

如果使用索引b进行查询,那么就是扫描索引b的最后50001个值,与上面的执行过程相同,也是需要回到主键索引上取值再判断,所以需要扫描50001行。

所以你一定会想,如果使用索引a的话,执行速度明显会快很多。

那么,下面我们就来看看到底是不是这么一回事儿。

mysql> select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 1;图8是执行explain的结果。

图8 使用explain方法查看执行计划 2可以看到,返回结果中key字段显示,这次优化器选择了索引b,而rows字段显示需要扫描的行数是50198。

从这个结果中,你可以得到两个结论:

  1. 扫描行数的估计值依然不准确;
  2. 这个例子里MySQL又选错了索引。

索引选择异常和处理其实大多数时候优化器都能找到正确的索引,但偶尔你还是会碰到我们上面举例的这两种情况:
原本可以执行得很快的SQL语句,执行速度却比你预期的慢很多,你应该怎么办呢?一种方法是,像我们第一个例子一样,采用force index强行选择一个索引。

MySQL会根据词法解析的结果分析出可能可以使用的索引作为候选项,然后在候选列表中依次判断每个索引需要扫描多少行。

如果force index指定的索引在候选索引列表中,就直接选择这个索引,不再评估其他索引的执行代价。

我们来看看第二个例子。

刚开始分析时,我们认为选择索引a会更好。

现在,我们就来看看执行效果:
图9 使用不同索引的语句执行耗时可以看到,原本语句需要执行2.23秒,而当你使用force index(a)的时候,只用了0.05秒,比优化器的选择快了40多倍。

也就是说,优化器没有选择正确的索引,force index起到了“矫正”的作用。

不过很多程序员不喜欢使用force index,一来这么写不优美,二来如果索引改了名字,这个语句mysql> explain select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 1;也得改,显得很麻烦。

而且如果以后迁移到别的数据库的话,这个语法还可能会不兼容。

但其实使用force index最主要的问题还是变更的及时性。

因为选错索引的情况还是比较少出现的,所以开发的时候通常不会先写上force index。

而是等到线上出现问题的时候,你才会再去修改SQL语句、加上force index。

但是修改之后还要测试和发布,对于生产系统来说,这个过程不够敏捷。

所以,数据库的问题最好还是在数据库内部来解决。

那么,在数据库里面该怎样解决呢?既然优化器放弃了使用索引a,说明a还不够合适,所以第二种方法就是,我们可以考虑修改语句,引导MySQL使用我们期望的索引。

比如,在这个例子里,显然把“order by b limit 1” 改成 “order by b,a limit 1” ,语义的逻辑是相同的。

我们来看看改之后的效果:
图10 order by b,a limit 1 执行结果之前优化器选择使用索引b,是因为它认为使用索引b可以避免排序(b本身是索引,已经是有序的了,如果选择索引b的话,不需要再做排序,只需要遍历),所以即使扫描行数多,也判定为代价更小。

现在order by b,a 这种写法,要求按照b,a排序,就意味着使用这两个索引都需要排序。

因此,扫描行数成了影响决策的主要条件,于是此时优化器选了只需要扫描1000行的索引a。

当然,这种修改并不是通用的优化手段,只是刚好在这个语句里面有limit 1,因此如果有满足条件的记录, order by b limit 1和order by b,a limit 1 都会返回b是最小的那一行,逻辑上一致,才可以这么做。

如果你觉得修改语义这件事儿不太好,这里还有一种改法,图11是执行效果。

图11 改写SQL的explain在这个例子里,我们用limit 100让优化器意识到,使用b索引代价是很高的。

其实是我们根据数mysql> select * from (select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 100)alias limit 1;据特征诱导了一下优化器,也不具备通用性。

第三种方法是,在有些场景下,我们可以新建一个更合适的索引,来提供给优化器做选择,或删掉误用的索引。

不过,在这个例子中,我没有找到通过新增索引来改变优化器行为的方法。

这种情况其实比较少,尤其是经过DBA索引优化过的库,再碰到这个bug,找到一个更合适的索引一般比较难。

如果我说还有一个方法是删掉索引b,你可能会觉得好笑。

但实际上我碰到过两次这样的例子,最终是DBA跟业务开发沟通后,发现这个优化器错误选择的索引其实根本没有必要存在,于是就删掉了这个索引,优化器也就重新选择到了正确的索引。

小结今天我们一起聊了聊索引统计的更新机制,并提到了优化器存在选错索引的可能性。

对于由于索引统计信息不准确导致的问题,你可以用analyze table来解决。

而对于其他优化器误判的情况,你可以在应用端用force index来强行指定索引,也可以通过修改语句来引导优化器,还可以通过增加或者删除索引来绕过这个问题。

你可能会说,今天这篇文章后面的几个例子,怎么都没有展开说明其原理。

我要告诉你的是,今天的话题,我们面对的是MySQL的bug,每一个展开都必须深入到一行行代码去量化,实在不是我们在这里应该做的事情。

所以,我把我用过的解决方法跟你分享,希望你在碰到类似情况的时候,能够有一些思路。

你平时在处理MySQL优化器bug的时候有什么别的方法,也发到评论区分享一下吧。

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

前面我们在构造第一个例子的过程中,通过session A的配合,让session B删除数据后又重新插入了一遍数据,然后就发现explain结果中,rows字段从10001变成37000多。

而如果没有session A的配合,只是单独执行delete from t 、call idata()、explain这三句话,会看到rows字段其实还是10000左右。

你可以自己验证一下这个结果。

这是什么原因呢?也请你分析一下吧。

你可以把你的分析结论写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。

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

上期问题时间我在上一篇文章最后留给你的问题是,如果某次写入使用了change buffer机制,之后主机异常重启,是否会丢失change buffer和数据。

这个问题的答案是不会丢失,留言区的很多同学都回答对了。

虽然是只更新内存,但是在事务提交的时候,我们把change buffer的操作也记录到redo log里了,所以崩溃恢复的时候,changebuffer也能找回来。

在评论区有同学问到,merge的过程是否会把数据直接写回磁盘,这是个好问题。

这里,我再为你分析一下。

merge的执行流程是这样的:

  1. 从磁盘读入数据页到内存(老版本的数据页);
  2. 从change buffer里找出这个数据页的change buffer 记录(可能有多个),依次应用,得到新版数据页;
  3. 写redo log。

这个redo log包含了数据的变更和change buffer的变更。

到这里merge过程就结束了。

这时候,数据页和内存中change buffer对应的磁盘位置都还没有修改,属于脏页,之后各自刷回自己的物理数据,就是另外一个过程了。

问题解析

普通索引和唯一索引,应该怎么选择今天的正文开始前,我要特意感谢一下评论区几位留下高质量留言的同学。

用户名是 @某、人 的同学,对文章的知识点做了梳理,然后提了关于事务可见性的问题,就是先启动但是后提交的事务,对数据可见性的影响。

@夏日雨同学也提到了这个问题,我在置顶评论中回复了,今天的文章末尾也会再展开说明。

@Justin和@倪大人两位同学提了两个好问题。

对于能够引发更深一步思考的问题,我会在回复的内容中写上“好问题”三个字,方便你搜索,你也可以去看看他们的留言。

非常感谢大家很细致地看文章,并且留下了那么多和很高质量的留言。

知道文章有给大家带来一些新理解,对我来说是一个很好的鼓励。

同时,也让其他认真看评论区的同学,有机会发现一些自己还没有意识到的、但可能还不清晰的知识点,这也在总体上提高了整个专栏的质量。

再次谢谢你们。

好了,现在就回到我们今天的正文内容。

在前面的基础篇文章中,我给你介绍过索引的基本概念,相信你已经了解了唯一索引和普通索引的区别。

今天我们就继续来谈谈,在不同的业务场景下,应该选择普通索引,还是唯一索引?假设你在维护一个市民系统,每个人都有一个唯一的身份证号,而且业务代码已经保证了不会写入两个重复的身份证号。

如果市民系统需要按照身份证号查姓名,就会执行类似这样的SQL语句:

所以,你一定会考虑在id_card字段上建索引。

由于身份证号字段比较大,我不建议你把身份证号当做主键,那么现在你有两个选择,要么给id_card字段创建唯一索引,要么创建一个普通索引。

如果业务代码已经保证了不会写入重复的身份证号,那么这两个选择逻辑上都是正确的。

现在我要问你的是,从性能的角度考虑,你选择唯一索引还是普通索引呢?选择的依据是什么呢?简单起见,我们还是用第4篇文章《深入浅出索引(上)》中的例子来说明,假设字段 k 上的值都不重复。

图1 InnoDB的索引组织结构接下来,我们就从这两种索引对查询语句和更新语句的性能影响来进行分析。

查询过程select name from CUser where id_card = ‘xxxxxxxyyyyyyzzzzz’;假设,执行查询的语句是 select id from T where k=5。

这个查询语句在索引树上查找的过程,先是通过B+树从树根开始,按层搜索到叶子节点,也就是图中右下角的这个数据页,然后可以认为数据页内部通过二分法来定位记录。

对于普通索引来说,查找到满足条件的第一个记录(5,500)后,需要查找下一个记录,直到碰到第一个不满足k=5条件的记录。

对于唯一索引来说,由于索引定义了唯一性,查找到第一个满足条件的记录后,就会停止继续检索。

那么,这个不同带来的性能差距会有多少呢?答案是,微乎其微。

你知道的,InnoDB的数据是按数据页为单位来读写的。

也就是说,当需要读一条记录的时候,并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。

在InnoDB中,每个数据页的大小默认是16KB。

因为引擎是按页读写的,所以说,当找到k=5的记录的时候,它所在的数据页就都在内存里了。

那么,对于普通索引来说,要多做的那一次“查找和判断下一条记录”的操作,就只需要一次指针寻找和一次计算。

当然,如果k=5这个记录刚好是这个数据页的最后一个记录,那么要取下一个记录,必须读取下一个数据页,这个操作会稍微复杂一些。

但是,我们之前计算过,对于整型字段,一个数据页可以放近千个key,因此出现这种情况的概率会很低。

所以,我们计算平均性能差异时,仍可以认为这个操作成本对于现在的CPU来说可以忽略不计。

更新过程为了说明普通索引和唯一索引对更新语句性能的影响这个问题,我需要先跟你介绍一下changebuffer。

当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中的话,在不影响数据一致性的前提下,InooDB会将这些更新操作缓存在change buffer中,这样就不需要从磁盘中读入这个数据页了。

在下次查询需要访问这个数据页的时候,将数据页读入内存,然后执行change buffer中与这个页有关的操作。

通过这种方式就能保证这个数据逻辑的正确性。

需要说明的是,虽然名字叫作change buffer,实际上它是可以持久化的数据。

也就是说,change buffer在内存中有拷贝,也会被写入到磁盘上。

将change buffer中的操作应用到原数据页,得到最新结果的过程称为merge。

除了访问这个数据页会触发merge外,系统有后台线程会定期merge。

在数据库正常关闭(shutdown)的过程中,也会执行merge操作。

显然,如果能够将更新操作先记录在change buffer,减少读磁盘,语句的执行速度会得到明显的提升。

而且,数据读入内存是需要占用buffer pool的,所以这种方式还能够避免占用内存,提高内存利用率。

那么,什么条件下可以使用change buffer呢?对于唯一索引来说,所有的更新操作都要先判断这个操作是否违反唯一性约束。

比如,要插入(4,400)这个记录,就要先判断现在表中是否已经存在k=4的记录,而这必须要将数据页读入内存才能判断。

如果都已经读入到内存了,那直接更新内存会更快,就没必要使用change buffer了。

因此,唯一索引的更新就不能使用change buffer,实际上也只有普通索引可以使用。

change buffer用的是buffer pool里的内存,因此不能无限增大。

change buffer的大小,可以通过参数innodb_change_buffer_max_size来动态设置。

这个参数设置为50的时候,表示changebuffer的大小最多只能占用buffer pool的50%。

现在,你已经理解了change buffer的机制,那么我们再一起来看看如果要在这张表中插入一个新记录(4,400)的话,InnoDB的处理流程是怎样的。

第一种情况是,这个记录要更新的目标页在内存中。

这时,InnoDB的处理流程如下:

对于唯一索引来说,找到3和5之间的位置,判断到没有冲突,插入这个值,语句执行结束;

对于普通索引来说,找到3和5之间的位置,插入这个值,语句执行结束。

这样看来,普通索引和唯一索引对更新语句性能影响的差别,只是一个判断,只会耗费微小的CPU时间。

但,这不是我们关注的重点。

第二种情况是,这个记录要更新的目标页不在内存中。

这时,InnoDB的处理流程如下:

对于唯一索引来说,需要将数据页读入内存,判断到没有冲突,插入这个值,语句执行结束;

对于普通索引来说,则是将更新记录在change buffer,语句执行就结束了。

将数据从磁盘读入内存涉及随机IO的访问,是数据库里面成本最高的操作之一。

change buffer因为减少了随机磁盘访问,所以对更新性能的提升是会很明显的。

之前我就碰到过一件事儿,有个DBA的同学跟我反馈说,他负责的某个业务的库内存命中率突然从99%降低到了75%,整个系统处于阻塞状态,更新语句全部堵住。

而探究其原因后,我发现这个业务有大量插入数据的操作,而他在前一天把其中的某个普通索引改成了唯一索引。

change buffer的使用场景通过上面的分析,你已经清楚了使用change buffer对更新过程的加速作用,也清楚了changebuffer只限于用在普通索引的场景下,而不适用于唯一索引。

那么,现在有一个问题就是:普通索引的所有场景,使用change buffer都可以起到加速作用吗?因为merge的时候是真正进行数据更新的时刻,而change buffer的主要目的就是将记录的变更动作缓存下来,所以在一个数据页做merge之前,change buffer记录的变更越多(也就是这个页面上要更新的次数越多),收益就越大。

因此,对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时changebuffer的使用效果最好。

这种业务模型常见的就是账单类、日志类的系统。

反过来,假设一个业务的更新模式是写入之后马上会做查询,那么即使满足了条件,将更新先记录在change buffer,但之后由于马上要访问这个数据页,会立即触发merge过程。

这样随机访问IO的次数不会减少,反而增加了change buffer的维护代价。

所以,对于这种业务模式来说,change buffer反而起到了副作用。

索引选择和实践回到我们文章开头的问题,普通索引和唯一索引应该怎么选择。

其实,这两类索引在查询能力上是没差别的,主要考虑的是对更新性能的影响。

所以,我建议你尽量选择普通索引。

如果所有的更新后面,都马上伴随着对这个记录的查询,那么你应该关闭change buffer。

而在其他情况下,change buffer都能提升更新性能。

在实际使用中,你会发现,普通索引和change buffer的配合使用,对于数据量大的表的更新优化还是很明显的。

特别地,在使用机械硬盘时,change buffer这个机制的收效是非常显著的。

所以,当你有一个类似“历史数据”的库,并且出于成本考虑用的是机械硬盘时,那你应该特别关注这些表里的索引,尽量使用普通索引,然后把change buffer 尽量开大,以确保这个“历史数据”表的数据写入速度。

change buffer 和 redo log理解了change buffer的原理,你可能会联想到我在前面文章中和你介绍过的redo log和WAL。

在前面文章的评论中,我发现有同学混淆了redo log和change buffer。

WAL 提升性能的核心机制,也的确是尽量减少随机读写,这两个概念确实容易混淆。

所以,这里我把它们放到了同一个流程里来说明,便于你区分这两个概念。

现在,我们要在表上执行这个插入语句:

这里,我们假设当前k索引树的状态,查找到位置后,k1所在的数据页在内存(InnoDB bufferpool)中,k2所在的数据页不在内存中。

如图2所示是带change buffer的更新状态图。

图2 带change buffer的更新过程分析这条更新语句,你会发现它涉及了四个部分:内存、redo log(ib_log_fileX)、 数据表空间(t.ibd)、系统表空间(ibdata1)。

这条更新语句做了如下的操作(按照图中的数字顺序):

  1. Page 1在内存中,直接更新内存;

  2. Page 2没有在内存中,就在内存的change buffer区域,记录下“我要往Page 2插入一行”这个信息备注:这里,你可以再回顾下第2篇文章《日志系统:一条SQL更新语句是如何执行的?》中的相关内容。

mysql> insert into t(id,k) values(id1,k1),(id2,k2);3. 将上述两个动作记入redo log中(图中3和4)。

做完上面这些,事务就可以完成了。

所以,你会看到,执行这条更新语句的成本很低,就是写了两处内存,然后写了一处磁盘(两次操作合在一起写了一次磁盘),而且还是顺序写的。

同时,图中的两个虚线箭头,是后台操作,不影响更新的响应时间。

那在这之后的读请求,要怎么处理呢?比如,我们现在要执行 select * from t where k in (k1, k2)。

这里,我画了这两个读请求的流程图。

如果读语句发生在更新语句后不久,内存中的数据都还在,那么此时的这两个读操作就与系统表空间(ibdata1)和 redo log(ib_log_fileX)无关了。

所以,我在图中就没画出这两部分。

图3 带change buffer的读过程从图中可以看到:

  1. 读Page 1的时候,直接从内存返回。

有几位同学在前面文章的评论中问到,WAL之后如果读数据,是不是一定要读盘,是不是一定要从redo log里面把数据更新以后才可以返回?其实是不用的。

你可以看一下图3的这个状态,虽然磁盘上还是之前的数据,但是这里直接从内存返回结果,结果是正确的。

  1. 要读Page 2的时候,需要把Page 2从磁盘读入内存中,然后应用change buffer里面的操作日志,生成一个正确的版本并返回结果。

可以看到,直到需要读Page 2的时候,这个数据页才会被读入内存。

所以,如果要简单地对比这两个机制在提升更新性能上的收益的话,redo log 主要节省的是随机写磁盘的IO消耗(转成顺序写),而change buffer主要节省的则是随机读磁盘的IO消耗。

小结今天,我从普通索引和唯一索引的选择开始,和你分享了数据的查询和更新过程,然后说明了change buffer的机制以及应用场景,最后讲到了索引选择的实践。

由于唯一索引用不上change buffer的优化机制,因此如果业务可以接受,从性能角度出发我建议你优先考虑非唯一索引。

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

通过图2你可以看到,change buffer一开始是写内存的,那么如果这个时候机器掉电重启,会不会导致change buffer丢失呢?change buffer丢失可不是小事儿,再从磁盘读入数据可就没有了merge过程,就等于是数据丢失了。

会不会出现这种情况呢?你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。

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

补充:

评论区大家对“是否使用唯一索引”有比较多的讨论,主要是纠结在“业务可能无法确保”的情况。

这里,我再说明一下:

首先,业务正确性优先。

咱们这篇文章的前提是“业务代码已经保证不会写入重复数据”的情况下,讨论性能问题。

如果业务不能保证,或者业务就是要求数据库来做约束,那么没得选,必须创建唯一索引。

这种情况下,本篇文章的意义在于,如果碰上了大量插入数据慢、内存命中率低的时候,可以给你多提供一个排查思路。

然后,在一些“归档库”的场景,你是可以考虑使用唯一索引的。

比如,线上数据只需要保留半年,然后历史数据保存在归档库。

这时候,归档数据已经是确保没有唯一键冲突了。

要提高归档效率,可以考虑把表里面的唯一索引改成普通索引。

上期问题时间上期的问题是:如何构造一个“数据无法修改”的场景。

评论区里已经有不少同学给出了正确答案,这里我再描述一下。

这样,session A看到的就是我截图的效果了。

其实,还有另外一种场景,同学们在留言区都还没有提到。

这个操作序列跑出来,session A看的内容也是能够复现我截图的效果的。

这个session B’启动的事务比A要早,其实是上期我们描述事务版本的可见性规则时留的彩蛋,因为规则里还有一个“活跃事务的判断”,我是准备留到这里再补充的。

当我试图在这里讲述完整规则的时候,发现第8篇文章《事务到底是隔离的还是不隔离的?》中的解释引入了太多的概念,以致于分析起来非常复杂。

因此,我重写了第8篇,这样我们人工去判断可见性的时候,才会更方便。

【看到这里,我建议你能够再重新打开第8篇文章并认真学习一次。

如果学习的过程中,有任何问题,也欢迎你给我留言】用新的方式来分析session B’的更新为什么对session A不可见就是:在session A视图数组创建的瞬间,session B’是活跃的,属于“版本未提交,不可见”这种情况。

业务中如果要绕过这类问题,@约书亚提供了一个“乐观锁”的解法,大家可以去上一篇的留言区看一下。

问题解析

事务到底是隔离的还是不隔离的?我在第3篇文章和你讲事务隔离级别的时候提到过,如果是可重复读隔离级别,事务T启动的时候会创建一个视图read-view,之后事务T执行期间,即使有其他事务修改了数据,事务T看到的仍然跟在启动时看到的一样。

也就是说,一个在可重复读隔离级别下执行的事务,好像与世无争,不受外界影响。

但是,我在上一篇文章中,和你分享行锁的时候又提到,一个事务要更新一行,如果刚好有另外一个事务拥有这一行的行锁,它又不能这么超然了,会被锁住,进入等待状态。

问题是,既然进入了等待状态,那么等到这个事务自己获取到行锁要更新数据的时候,它读到的值又是什么呢?我给你举一个例子吧。

下面是一个只有两行的表的初始化语句。

mysql> CREATE TABLE t ( id int(11) NOT NULL, k int(11) DEFAULT NULL, PRIMARY KEY (id)) ENGINE=InnoDB;insert into t(id, k) values(1,1),(2,2);图1 事务A、B、C的执行流程这里,我们需要注意的是事务的启动时机。

begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作InnoDB表的语句,事务才真正启动。

如果你想要马上启动一个事务,可以使用start transaction withconsistent snapshot 这个命令。

还需要注意的是,在整个专栏里面,我们的例子中如果没有特别说明,都是默认autocommit=1。

在这个例子中,事务C没有显式地使用begin/commit,表示这个update语句本身就是一个事务,语句完成的时候会自动提交。

事务B在更新了行之后查询; 事务A在一个只读事务中查询,并且时间顺序上是在事务B的查询之后。

这时,如果我告诉你事务B查到的k的值是3,而事务A查到的k的值是1,你是不是感觉有点晕呢?所以,今天这篇文章,我其实就是想和你说明白这个问题,希望借由把这个疑惑解开的过程,能够帮助你对InnoDB的事务和锁有更进一步的理解。

在MySQL里,有两个“视图”的概念:

一个是view。

它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。

创建视图的语法是create view … ,而它的查询方法与表一样。

另一个是InnoDB在实现MVCC时用到的一致性读视图,即consistent read view,用于支持RC(Read Committed,读提交)和RR(Repeatable Read,可重复读)隔离级别的实现。

它没有物理结构,作用是事务执行期间用来定义“我能看到什么数据”。

在第3篇文章《事务隔离:为什么你改了我还看不见?》中,我跟你解释过一遍MVCC的实现逻辑。

今天为了说明查询和更新的区别,我换一个方式来说明,把read view拆开。

你可以结合这两篇文章的说明来更深一步地理解MVCC。

“快照”在MVCC里是怎么工作的?在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。

注意,这个快照是基于整库的。

这时,你会说这看上去不太现实啊。

如果一个库有100G,那么我启动一个事务,MySQL就要拷贝100G的数据出来,这个过程得多慢啊。

可是,我平时的事务执行起来很快啊。

实际上,我们并不需要拷贝出这100G的数据。

我们先来看看这个快照是怎么实现的。

InnoDB里面每个事务有一个唯一的事务ID,叫作transaction id。

它是在事务开始的时候向InnoDB的事务系统申请的,是按申请顺序严格递增的。

而每行数据也都是有多个版本的。

每次事务更新数据的时候,都会生成一个新的数据版本,并且把transaction id赋值给这个数据版本的事务ID,记为row trx_id。

同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。

也就是说,数据表中的一行记录,其实可能有多个版本(row),每个版本有自己的row trx_id。

如图2所示,就是一个记录被多个事务连续更新后的状态。

图2 行状态变更图图中虚线框里是同一行数据的4个版本,当前最新版本是V4,k的值是22,它是被transaction id为25的事务更新的,因此它的row trx_id也是25。

你可能会问,前面的文章不是说,语句更新会生成undo log(回滚日志)吗?那么,undo log在哪呢?实际上,图2中的三个虚线箭头,就是undo log;而V1、V2、V3并不是物理上真实存在的,而是每次需要的时候根据当前版本和undo log计算出来的。

比如,需要V2的时候,就是通过V4依次执行U3、U2算出来。

明白了多版本和row trx_id的概念后,我们再来想一下,InnoDB是怎么定义那个“100G”的快照的。

按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。

但是之后,这个事务执行期间,其他事务的更新对它不可见。

因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。

当然,如果“上一个版本”也不可见,那就得继续往前找。

还有,如果是这个事务自己更新的数据,它自己还是要认的。

在实现上, InnoDB为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务ID。

“活跃”指的就是,启动了但还没提交。

数组里面事务ID的最小值记为低水位,当前系统里面已经创建过的事务ID的最大值加1记为高水位。

这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。

而数据版本的可见性规则,就是基于数据的row trx_id和这个一致性视图的对比结果得到的。

这个视图数组把所有的row trx_id 分成了几种不同的情况。

图3 数据版本可见性规则这样,对于当前事务的启动瞬间来说,一个数据版本的row trx_id,有以下几种可能:

  1. 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;

  2. 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;

  3. 如果落在黄色部分,那就包括两种情况a. 若 row trx_id在数组中,表示这个版本是由还没提交的事务生成的,不可见;

b. 若 row trx_id不在数组中,表示这个版本是已经提交了的事务生成的,可见。

比如,对于图2中的数据来说,如果有一个事务,它的低水位是18,那么当它访问这一行数据时,就会从V4通过U3计算出V3,所以在它看来,这一行的值是11。

你看,有了这个声明后,系统里面随后发生的更新,是不是就跟这个事务看到的内容无关了呢?因为之后的更新,生成的版本一定属于上面的2或者3(a)的情况,而对它来说,这些新的数据版本是不存在的,所以这个事务的快照,就是“静态”的了。

所以你现在知道了,InnoDB利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。

接下来,我们继续看一下图1中的三个事务,分析下事务A的语句返回的结果,为什么是k=1。

这里,我们不妨做如下假设:

  1. 事务A开始前,系统里面只有一个活跃事务ID是99;

  2. 事务A、B、C的版本号分别是100、101、102,且当前系统里只有这四个事务;

  3. 三个事务开始前,(1,1)这一行数据的row trx_id是90。

这样,事务A的视图数组就是[99,100], 事务B的视图数组是[99,100,101], 事务C的视图数组是[99,100,101,102]。

为了简化分析,我先把其他干扰语句去掉,只画出跟事务A查询逻辑有关的操作:

图4 事务A查询数据逻辑图从图中可以看到,第一个有效更新是事务C,把数据从(1,1)改成了(1,2)。

这时候,这个数据的最新版本的row trx_id是102,而90这个版本已经成为了历史版本。

第二个有效更新是事务B,把数据从(1,2)改成了(1,3)。

这时候,这个数据的最新版本(即rowtrx_id)是101,而102又成为了历史版本。

你可能注意到了,在事务A查询的时候,其实事务B还没有提交,但是它生成的(1,3)这个版本已经变成当前版本了。

但这个版本对事务A必须是不可见的,否则就变成脏读了。

好,现在事务A要来读数据了,它的视图数组是[99,100]。

当然了,读数据都是从当前版本读起的。

所以,事务A查询语句的读数据流程是这样的:

找到(1,3)的时候,判断出row trx_id=101,比高水位大,处于红色区域,不可见;

接着,找到上一个历史版本,一看row trx_id=102,比高水位大,处于红色区域,不可见;

再往前找,终于找到了(1,1),它的row trx_id=90,比低水位小,处于绿色区域,可见。

这样执行下来,虽然期间这一行数据被修改过,但是事务A不论在什么时候查询,看到这行数据的结果都是一致的,所以我们称之为一致性读。

这个判断规则是从代码逻辑直接转译过来的,但是正如你所见,用于人肉分析可见性很麻烦。

所以,我来给你翻译一下。

一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:

  1. 版本未提交,不可见;

  2. 版本已提交,但是是在视图创建后提交的,不可见;

  3. 版本已提交,而且是在视图创建前提交的,可见。

现在,我们用这个规则来判断图4中的查询结果,事务A的查询语句的视图数组是在事务A启动的时候生成的,这时候:

(1,3)还没提交,属于情况1,不可见;

(1,2)虽然提交了,但是是在视图数组创建之后提交的,属于情况2,不可见;

(1,1)是在视图数组创建之前提交的,可见。

你看,去掉数字对比后,只用时间先后顺序来判断,分析起来是不是轻松多了。

所以,后面我们就都用这个规则来分析。

更新逻辑细心的同学可能有疑问了:事务B的update语句,如果按照一致性读,好像结果不对哦?你看图5中,事务B的视图数组是先生成的,之后事务C才提交,不是应该看不见(1,2)吗,怎么能算出(1,3)来?图5 事务B更新逻辑图是的,如果事务B在更新之前查询一次数据,这个查询返回的k的值确实是1。

但是,当它要去更新数据的时候,就不能再在历史版本上更新了,否则事务C的更新就丢失了。

因此,事务B此时的set k=k+1是在(1,2)的基础上进行的操作。

所以,这里就用到了这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。

因此,在更新的时候,当前读拿到的数据是(1,2),更新后生成了新版本的数据(1,3),这个新版本的row trx_id是101。

所以,在执行事务B查询语句的时候,一看自己的版本号是101,最新数据的版本号也是101,是自己的更新,可以直接使用,所以查询得到的k的值是3。

这里我们提到了一个概念,叫作当前读。

其实,除了update语句外,select语句如果加锁,也是当前读。

所以,如果把事务A的查询语句select * from t where id=1修改一下,加上lock in share mode 或for update,也都可以读到版本号是101的数据,返回的k的值是3。

下面这两个select语句,就是分别加了读锁(S锁,共享锁)和写锁(X锁,排他锁)。

再往前一步,假设事务C不是马上提交的,而是变成了下面的事务C’,会怎么样呢?图6 事务A、B、C’的执行流程事务C’的不同是,更新后并没有马上提交,在它提交前,事务B的更新语句先发起了。

前面说过了,虽然事务C’还没提交,但是(1,2)这个版本也已经生成了,并且是当前的最新版本。

那么,事务B的更新语句会怎么处理呢?这时候,我们在上一篇文章中提到的“两阶段锁协议”就要上场了。

事务C’没提交,也就是说(1,2)这个版本上的写锁还没释放。

而事务B是当前读,必须要读最新版本,而且必须加锁,因此就被锁住了,必须等到事务C’释放这个锁,才能继续它的当前读。

mysql> select k from t where id=1 lock in share mode;mysql> select k from t where id=1 for update;图7 事务B更新逻辑图(配合事务C’)到这里,我们把一致性读、当前读和行锁就串起来了。

现在,我们再回到文章开头的问题:事务的可重复读的能力是怎么实现的?可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。

如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。

而读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:

在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;

在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。

那么,我们再看一下,在读提交隔离级别下,事务A和事务B的查询语句查到的k,分别应该是多少呢?这里需要说明一下,“start transaction with consistent snapshot; ”的意思是从这个语句开始,创建一个持续整个事务的一致性快照。

所以,在读提交隔离级别下,这个用法就没意义了,等效于普通的start transaction。

下面是读提交时的状态图,可以看到这两个查询语句的创建视图数组的时机发生了变化,就是图中的read view框。

(注意:这里,我们用的还是事务C的逻辑直接提交,而不是事务C’)图8 读提交隔离级别下的事务状态图这时,事务A的查询语句的视图数组是在执行这个语句的时候创建的,时序上(1,2)、(1,3)的生成时间都在创建这个视图数组的时刻之前。

但是,在这个时刻:

(1,3)还没提交,属于情况1,不可见;

(1,2)提交了,属于情况3,可见。

所以,这时候事务A查询语句返回的是k=2。

显然地,事务B查询结果k=3。

小结InnoDB的行数据有多个版本,每个数据版本有自己的row trx_id,每个事务或者语句有自己的一致性视图。

普通查询语句是一致性读,一致性读会根据row trx_id和一致性视图确定数据版本的可见性。

对于可重复读,查询只承认在事务启动前就已经提交完成的数据;

对于读提交,查询只承认在语句启动前就已经提交完成的数据;

而当前读,总是读取已经提交完成的最新版本。

你也可以想一下,为什么表结构不支持“可重复读”?这是因为表结构没有对应的行数据,也没有row trx_id,因此只能遵循当前读的逻辑。

当然,MySQL 8.0已经可以把表结构放在InnoDB字典里了,也许以后会支持表结构的可重复读。

又到思考题时间了。

我用下面的表结构和初始化语句作为试验环境,事务隔离级别是可重复读。

现在,我要把所有“字段c和id值相等的行”的c值清零,但是却发现了一个“诡异”的、改不掉的情况。

请你构造出这种情况,并说明其原理。

mysql> CREATE TABLE t ( id int(11) NOT NULL, c int(11) DEFAULT NULL, PRIMARY KEY (id)) ENGINE=InnoDB;insert into t(id, c) values(1,1),(2,2),(3,3),(4,4);复现出来以后,请你再思考一下,在实际的业务开发中有没有可能碰到这种情况?你的应用代码会不会掉进这个“坑”里,你又是怎么解决的呢?你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。

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

上期问题时间我在上一篇文章最后,留给你的问题是:怎么删除表的前10000行。

比较多的留言都选择了第二种方式,即:在一个连接中循环执行20次 delete from T limit 500。

确实是这样的,第二种方式是相对较好的。

第一种方式(即:直接执行delete from T limit 10000)里面,单个语句占用时间长,锁的时间也比较长;而且大事务还会导致主从延迟。

第三种方式(即:在20个连接中同时执行delete from T limit 500),会人为造成锁冲突。

问题解析

行锁功过:怎么减少行锁对性能的影响?在上一篇文章中,我跟你介绍了MySQL的全局锁和表级锁,今天我们就来讲讲MySQL的行锁。

MySQL的行锁是在引擎层由各个引擎自己实现的。

但并不是所有的引擎都支持行锁,比如MyISAM引擎就不支持行锁。

不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同一张表上任何时刻只能有一个更新在执行,这就会影响到业务并发度。

InnoDB是支持行锁的,这也是MyISAM被InnoDB替代的重要原因之一。

我们今天就主要来聊聊InnoDB的行锁,以及如何通过减少锁冲突来提升业务并发度。

顾名思义,行锁就是针对数据表中行记录的锁。

这很好理解,比如事务A更新了一行,而这时候事务B也要更新同一行,则必须等事务A的操作完成后才能进行更新。

当然,数据库中还有一些没那么一目了然的概念和设计,这些概念如果理解和使用不当,容易导致程序出现非预期行为,比如两阶段锁。

从两阶段锁说起我先给你举个例子。

在下面的操作序列中,事务B的update语句执行时会是什么现象呢?假设字段id是表t的主键。

这个问题的结论取决于事务A在执行完两条update语句后,持有哪些锁,以及在什么时候释放。

你可以验证一下:实际上事务B的update语句会被阻塞,直到事务A执行commit之后,事务B才能继续执行。

知道了这个答案,你一定知道了事务A持有的两个记录的行锁,都是在commit的时候才释放的。

也就是说,在InnoDB事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。

这个就是两阶段锁协议。

知道了这个设定,对我们使用事务有什么帮助呢?那就是,如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。

我给你举个例子。

假设你负责实现一个电影票在线交易业务,顾客A要在影院B购买电影票。

我们简化一点,这个业务需要涉及到以下操作:

  1. 从顾客A账户余额中扣除电影票价;

  2. 给影院B的账户余额增加这张电影票价;

  3. 记录一条交易日志。

也就是说,要完成这个交易,我们需要update两条记录,并insert一条记录。

当然,为了保证交易的原子性,我们要把这三个操作放在一个事务中。

那么,你会怎样安排这三个语句在事务中的顺序呢?试想如果同时有另外一个顾客C要在影院B买票,那么这两个事务冲突的部分就是语句2了。

因为它们要更新同一个影院账户的余额,需要修改同一行数据。

根据两阶段锁协议,不论你怎样安排语句顺序,所有的操作需要的行锁都是在事务提交的时候才释放的。

所以,如果你把语句2安排在最后,比如按照3、1、2这样的顺序,那么影院账户余额这一行的锁时间就最少。

这就最大程度地减少了事务之间的锁等待,提升了并发度。

好了,现在由于你的正确设计,影院余额这一行的行锁在一个事务中不会停留很长时间。

但是,这并没有完全解决你的困扰。

如果这个影院做活动,可以低价预售一年内所有的电影票,而且这个活动只做一天。

于是在活动时间开始的时候,你的MySQL就挂了。

你登上服务器一看,CPU消耗接近100%,但整个数据库每秒就执行不到100个事务。

这是什么原因呢?这里,我就要说到死锁和死锁检测了。

死锁和死锁检测当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。

这里我用数据库中的行锁举个例子。

这时候,事务A在等待事务B释放id=2的行锁,而事务B在等待事务A释放id=1的行锁。

事务A和事务B在互相等待对方的资源释放,就是进入了死锁状态。

当出现死锁以后,有两种策略:

一种策略是,直接进入等待,直到超时。

这个超时时间可以通过参数innodb_lock_wait_timeout来设置。

另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。

将参数innodb_deadlock_detect设置为on,表示开启这个逻辑。

在InnoDB中,innodb_lock_wait_timeout的默认值是50s,意味着如果采用第一个策略,当出现死锁以后,第一个被锁住的线程要过50s才会超时退出,然后其他线程才有可能继续执行。

对于在线服务来说,这个等待时间往往是无法接受的。

但是,我们又不可能直接把这个时间设置成一个很小的值,比如1s。

这样当出现死锁的时候,确实很快就可以解开,但如果不是死锁,而是简单的锁等待呢?所以,超时时间设置太短的话,会出现很多误伤。

所以,正常情况下我们还是要采用第二种策略,即:主动死锁检测,而且innodb_deadlock_detect的默认值本身就是on。

主动死锁检测在发生死锁的时候,是能够快速发现并进行处理的,但是它也是有额外负担的。

你可以想象一下这个过程:每当一个事务被锁的时候,就要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了循环等待,也就是死锁。

那如果是我们上面说到的所有事务都要更新同一行的场景呢?每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是O(n)的操作。

假设有1000个并发线程要同时更新同一行,那么死锁检测操作就是100万这个量级的。

虽然最终检测的结果是没有死锁,但是这期间要消耗大量的CPU资源。

因此,你就会看到CPU利用率很高,但是每秒却执行不了几个事务。

根据上面的分析,我们来讨论一下,怎么解决由这种热点行更新导致的性能问题呢?问题的症结在于,死锁检测要耗费大量的CPU资源。

一种头痛医头的方法,就是如果你能确保这个业务一定不会出现死锁,可以临时把死锁检测关掉。

但是这种操作本身带有一定的风险,因为业务设计的时候一般不会把死锁当做一个严重错误,毕竟出现死锁了,就回滚,然后通过业务重试一般就没问题了,这是业务无损的。

而关掉死锁检测意味着可能会出现大量的超时,这是业务有损的。

另一个思路是控制并发度。

根据上面的分析,你会发现如果并发能够控制住,比如同一行同时最多只有10个线程在更新,那么死锁检测的成本很低,就不会出现这个问题。

一个直接的想法就是,在客户端做并发控制。

但是,你会很快发现这个方法不太可行,因为客户端很多。

我见过一个应用,有600个客户端,这样即使每个客户端控制到只有5个并发线程,汇总到数据库服务端以后,峰值并发数也可能要达到3000。

因此,这个并发控制要做在数据库服务端。

如果你有中间件,可以考虑在中间件实现;如果你的团队有能修改MySQL源码的人,也可以做在MySQL里面。

基本思路就是,对于相同行的更新,在进入引擎之前排队。

这样在InnoDB内部就不会有大量的死锁检测工作了。

可能你会问,如果团队里暂时没有数据库方面的专家,不能实现这样的方案,能不能从设计上优化这个问题呢?你可以考虑通过将一行改成逻辑上的多行来减少锁冲突。

还是以影院账户为例,可以考虑放在多条记录上,比如10个记录,影院的账户总额等于这10个记录的值的总和。

这样每次要给影院账户加金额的时候,随机选其中一条记录来加。

这样每次冲突概率变成原来的1/10,可以减少锁等待个数,也就减少了死锁检测的CPU消耗。

这个方案看上去是无损的,但其实这类方案需要根据业务逻辑做详细设计。

如果账户余额可能会减少,比如退票逻辑,那么这时候就需要考虑当一部分行记录变成0的时候,代码要有特殊处理。

小结今天,我和你介绍了MySQL的行锁,涉及了两阶段锁协议、死锁和死锁检测这两大部分内容。

其中,我以两阶段协议为起点,和你一起讨论了在开发的时候如何安排正确的事务语句。

这里的原则/我给你的建议是:如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁的申请时机尽量往后放。

但是,调整语句顺序并不能完全避免死锁。

所以我们引入了死锁和死锁检测的概念,以及提供了三个方案,来减少死锁对数据库的影响。

减少死锁的主要方向,就是控制访问相同资源的并发事务量。

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

如果你要删除一个表里面的前10000行数据,有以下三种方法可以做到:

第一种,直接执行delete from T limit 10000;第二种,在一个连接中循环执行20次 delete from T limit 500;第三种,在20个连接中同时执行delete from T limit 500。

你会选择哪一种方法呢?为什么呢?你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。

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

上期问题时间上期我给你留的问题是:当备库用–single-transaction做逻辑备份的时候,如果从主库的binlog传来一个DDL语句会怎么样?假设这个DDL是针对表t1的, 这里我把备份过程中几个关键的语句列出来:

Q1:SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;Q2:START TRANSACTION WITH CONSISTENT SNAPSHOT;

/* other tables /Q3:SAVEPOINT sp;/ 时刻 1 /Q4:show create table t1;/ 时刻 2 /Q5:SELECT * FROM t1;/ 时刻 3 /Q6:ROLLBACK TO SAVEPOINT sp;/ 时刻 4 // other tables */在备份开始的时候,为了确保RR(可重复读)隔离级别,再设置一次RR隔离级别(Q1);启动事务,这里用 WITH CONSISTENT SNAPSHOT确保这个语句执行完就可以得到一个一致性视图(Q2);

设置一个保存点,这个很重要(Q3);

show create 是为了拿到表结构(Q4),然后正式导数据 (Q5),回滚到SAVEPOINT sp,在这里的作用是释放 t1的MDL锁 (Q6。

当然这部分属于“超纲”,上文正文里面都没提到。

DDL从主库传过来的时间按照效果不同,我打了四个时刻。

题目设定为小表,我们假定到达后,如果开始执行,则很快能够执行完成。

参考答案如下:

  1. 如果在Q4语句执行之前到达,现象:没有影响,备份拿到的是DDL后的表结构。

  2. 如果在“时刻 2”到达,则表结构被改过,Q5执行的时候,报 Table definition has changed,please retry transaction,现象:mysqldump终止;

  3. 如果在“时刻2”和“时刻3”之间到达,mysqldump占着t1的MDL读锁,binlog被阻塞,现象:

主从延迟,直到Q6执行完成。

  1. 从“时刻4”开始,mysqldump释放了MDL读锁,现象:没有影响,备份拿到的是DDL前的表结构。

问题解析

全局锁和表锁 :给表加个字段怎么有这么多阻碍?今天我要跟你聊聊MySQL的锁。

数据库锁设计的初衷是处理并发问题。

作为多用户共享的资源,当出现并发访问的时候,数据库需要合理地控制资源的访问规则。

而锁就是用来实现这些访问规则的重要数据结构。

根据加锁的范围,MySQL里面的锁大致可以分成全局锁、表级锁和行锁三类。

今天这篇文章,我会和你分享全局锁和表级锁。

而关于行锁的内容,我会留着在下一篇文章中再和你详细介绍。

这里需要说明的是,锁的设计比较复杂,这两篇文章不会涉及锁的具体实现细节,主要介绍的是碰到锁时的现象和其背后的原理。

全局锁顾名思义,全局锁就是对整个数据库实例加锁。

MySQL提供了一个加全局读锁的方法,命令是Flush tables with read lock (FTWRL)。

当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。

全局锁的典型使用场景是,做全库逻辑备份。

也就是把整库每个表都select出来存成文本。

以前有一种做法,是通过FTWRL确保不会有其他线程对数据库做更新,然后对整个库做备份。

注意,在备份过程中整个库完全处于只读状态。

但是让整库都只读,听上去就很危险:

如果你在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆;

如果你在从库上备份,那么备份期间从库不能执行主库同步过来的binlog,会导致主从延迟。

看来加全局锁不太好。

但是细想一下,备份为什么要加锁呢?我们来看一下不加锁会有什么问题。

假设你现在要维护“极客时间”的购买系统,关注的是用户账户余额表和用户课程表。

现在发起一个逻辑备份。

假设备份期间,有一个用户,他购买了一门课程,业务逻辑里就要扣掉他的余额,然后往已购课程里面加上一门课。

如果时间顺序上是先备份账户余额表(u_account),然后用户购买,然后备份用户课程表(u_course),会怎么样呢?你可以看一下这个图:

图1 业务和备份状态图可以看到,这个备份结果里,用户A的数据状态是“账户余额没扣,但是用户课程表里面已经多了一门课”。

如果后面用这个备份来恢复数据的话,用户A就发现,自己赚了。

作为用户可别觉得这样可真好啊,你可以试想一下:如果备份表的顺序反过来,先备份用户课程表再备份账户余额表,又可能会出现什么结果?也就是说,不加锁的话,备份系统备份的得到的库不是一个逻辑时间点,这个视图是逻辑不一致的。

说到视图你肯定想起来了,我们在前面讲事务隔离的时候,其实是有一个方法能够拿到一致性视图的,对吧?是的,就是在可重复读隔离级别下开启一个事务。

官方自带的逻辑备份工具是mysqldump。

当mysqldump使用参数–single-transaction的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。

而由于MVCC的支持,这个过程中数据是可以正常更新的。

你一定在疑惑,有了这个功能,为什么还需要FTWRL呢?一致性读是好,但前提是引擎要支持这个隔离级别。

比如,对于MyISAM这种不支持事务的引擎,如果备份过程中有更新,总是只能取到最新的数据,那么就破坏了备份的一致性。

这时,我们就需要使用FTWRL命令了。

所以,single-transaction方法只适用于所有的表使用事务引擎的库。

如果有的表使用了不支持事务的引擎,那么备份就只能通过FTWRL方法。

这往往是DBA要求业务开发人员使用InnoDB替代MyISAM的原因之一。

你也许会问,既然要全库只读,为什么不使用set global readonly=true的方式呢?确实readonly方式也可以让全库进入只读状态,但我还是会建议你用FTWRL方式,主要有两个原因:

一是,在有些系统中,readonly的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。

因此,修改global变量的方式影响面更大,我不建议你使用。

二是,在异常处理机制上有差异。

如果执行FTWRL命令之后由于客户端发生异常断开,那么MySQL会自动释放这个全局锁,整个库回到可以正常更新的状态。

而将整个库设置为readonly之后,如果客户端发生异常,则数据库就会一直保持readonly状态,这样会导致整个库长时间处于不可写状态,风险较高。

业务的更新不只是增删改数据(DML),还有可能是加字段等修改表结构的操作(DDL)。

不论是哪种方法,一个库被全局锁上以后,你要对里面任何一个表做加字段操作,都是会被锁住的。

但是,即使没有被全局锁住,加字段也不是就能一帆风顺的,因为你还会碰到接下来我们要介绍的表级锁。

备注:如果你对事务隔离级别的概念不是很清晰的话,可以再回顾一下第3篇文章《事务隔离:为什么你改了我还看不见?》中的相关内容。

表级锁MySQL里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。

表锁的语法是 lock tables … read/write。

与FTWRL类似,可以用unlock tables主动释放锁,也可以在客户端断开的时候自动释放。

需要注意,lock tables语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。

举个例子, 如果在某个线程A中执行lock tables t1 read, t2 write; 这个语句,则其他线程写t1、读写t2的语句都会被阻塞。

同时,线程A在执行unlock tables之前,也只能执行读t1、读写t2的操作。

连写t1都不允许,自然也不能访问其他表。

在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。

而对于InnoDB这种支持行锁的引擎,一般不使用lock tables命令来控制并发,毕竟锁住整个表的影响面还是太大。

另一类表级的锁是MDL(metadata lock)。

MDL不需要显式使用,在访问一个表的时候会被自动加上。

MDL的作用是,保证读写的正确性。

你可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。

因此,在MySQL 5.5版本中引入了MDL,当对一个表做增删改查操作的时候,加MDL读锁;当要对表做结构变更操作的时候,加MDL写锁。

读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。

读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。

因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。

虽然MDL锁是系统默认会加的,但却是你不能忽略的一个机制。

比如下面这个例子,我经常看到有人掉到这个坑里:给一个小表加个字段,导致整个库挂了。

你肯定知道,给一个表加字段,或者修改字段,或者加索引,需要扫描全表的数据。

在对大表操作的时候,你肯定会特别小心,以免对线上服务造成影响。

而实际上,即使是小表,操作不慎也会出问题。

我们来看一下下面的操作序列,假设表t是一个小表。

备注:这里的实验环境是MySQL 5.6。

我们可以看到session A先启动,这时候会对表t加一个MDL读锁。

由于session B需要的也是MDL读锁,因此可以正常执行。

之后session C会被blocked,是因为session A的MDL读锁还没有释放,而session C需要MDL写锁,因此只能被阻塞。

如果只有session C自己被阻塞还没什么关系,但是之后所有要在表t上新申请MDL读锁的请求也会被session C阻塞。

前面我们说了,所有对表的增删改查操作都需要先申请MDL读锁,就都被锁住,等于这个表现在完全不可读写了。

如果某个表上的查询语句频繁,而且客户端有重试机制,也就是说超时后会再起一个新session再请求的话,这个库的线程很快就会爆满。

你现在应该知道了,事务中的MDL锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放。

基于上面的分析,我们来讨论一个问题,如何安全地给小表加字段?首先我们要解决长事务,事务不提交,就会一直占着MDL锁。

在MySQL的information_schema库的 innodb_trx 表中,你可以查到当前执行中的事务。

如果你要做DDL变更的表刚好有长事务在执行,要考虑先暂停DDL,或者kill掉这个长事务。

但考虑一下这个场景。

如果你要变更的表是一个热点表,虽然数据量不大,但是上面的请求很频繁,而你不得不加个字段,你该怎么做呢?这时候kill可能未必管用,因为新的请求马上就来了。

比较理想的机制是,在alter table语句里面设定等待时间,如果在这个指定的等待时间里面能够拿到MDL写锁最好,拿不到也不要阻塞后面的业务语句,先放弃。

之后开发人员或者DBA再通过重试命令重复这个过程。

MariaDB已经合并了AliSQL的这个功能,所以这两个开源分支目前都支持DDL NOWAIT/WAIT n这个语法。

小结今天,我跟你介绍了MySQL的全局锁和表级锁。

全局锁主要用在逻辑备份过程中。

对于全部是InnoDB引擎的库,我建议你选择使用–single-transaction参数,对应用会更友好。

表锁一般是在数据库引擎不支持行锁的时候才会被用到的。

如果你发现你的应用程序里有locktables这样的语句,你需要追查一下,比较可能的情况是:

要么是你的系统现在还在用MyISAM这类不支持事务的引擎,那要安排升级换引擎;

要么是你的引擎升级了,但是代码还没升级。

我见过这样的情况,最后业务开发就是把locktables 和 unlock tables 改成 begin 和 commit,问题就解决了。

MDL会直到事务提交才释放,在做表结构变更的时候,你一定要小心不要导致锁住线上查询和更新。

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

备份一般都会在备库上执行,你在用–single-transaction方法做逻辑备份的过程中,如果主库上的一个小表做了一个DDL,比如给一个表上加了一列。

这时候,从备库上会看到什么现象呢?你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。

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

说明:这篇文章没有介绍到物理备份,物理备份会有一篇单独的文章。

上期问题时间上期的问题是关于对联合主键索引和InnoDB索引组织表的理解。

ALTER TABLE tbl_name NOWAIT add column …ALTER TABLE tbl_name WAIT N add column … 我直接贴@老杨同志 的回复略作修改如下(我修改的部分用橙色标出):

表记录–a–|–b–|–c–|–d–1 2 3 d1 3 2 d1 4 3 d2 1 3 d2 2 2 d2 3 4 d主键 a,b的聚簇索引组织顺序相当于 order by a,b ,也就是先按a排序,再按b排序,c无序。

索引 ca 的组织是先按c排序,再按a排序,同时记录主键–c–|–a–|–主键部分b– (注意,这里不是ab,而是只有b)2 1 32 2 23 1 23 1 43 2 14 2 3这个跟索引c的数据是一模一样的。

索引 cb 的组织是先按c排序,在按b排序,同时记录主键–c–|–b–|–主键部分a– (同上)2 2 22 3 13 1 23 2 13 4 14 3 2所以,结论是ca可以去掉,cb需要保留。

问题解析

深入浅出索引(下)在上一篇文章中,我和你介绍了InnoDB索引的数据结构模型,今天我们再继续聊聊跟MySQL索引有关的概念。

在开始这篇文章之前,我们先来看一下这个问题:

在下面这个表T中,如果我执行 select * from T where k between 3 and 5,需要执行几次树的搜索操作,会扫描多少行?下面是这个表的初始化语句。

mysql> create table T (ID int primary key,k int NOT NULL DEFAULT 0, s varchar(16) NOT NULL DEFAULT ‘’,index k(k))engine=InnoDB;insert into T values(100,1, ‘aa’),(200,2,’bb’),(300,3,’cc’),(500,5,’ee’),(600,6,’ff’),(700,7,’gg’);图1 InnoDB的索引组织结构现在,我们一起来看看这条SQL查询语句的执行流程:

  1. 在k索引树上找到k=3的记录,取得 ID = 300;

  2. 再到ID索引树查到ID=300对应的R3;

  3. 在k索引树取下一个值k=5,取得ID=500;

  4. 再回到ID索引树查到ID=500对应的R4;

  5. 在k索引树取下一个值k=6,不满足条件,循环结束。

在这个过程中,回到主键索引树搜索的过程,我们称为回表。

可以看到,这个查询过程读了k索引树的3条记录(步骤1、3和5),回表了两次(步骤2和4)。

在这个例子中,由于查询结果所需要的数据只在主键索引上有,所以不得不回表。

那么,有没有可能经过索引优化,避免回表过程呢?覆盖索引如果执行的语句是select ID from T where k between 3 and 5,这时只需要查ID的值,而ID的值已经在k索引树上了,因此可以直接提供查询结果,不需要回表。

也就是说,在这个查询里面,索引k已经“覆盖了”我们的查询需求,我们称为覆盖索引。

由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。

需要注意的是,在引擎内部使用覆盖索引在索引k上其实读了三个记录,R3~R5(对应的索引k上的记录项),但是对于MySQL的Server层来说,它就是找引擎拿到了两条记录,因此MySQL认为扫描行数是2。

基于上面覆盖索引的说明,我们来讨论一个问题:在一个市民信息表上,是否有必要将身份证号和名字建立联合索引?假设这个市民表的定义是这样的:

我们知道,身份证号是市民的唯一标识。

也就是说,如果有根据身份证号查询市民信息的需求,我们只要在身份证号字段上建立索引就够了。

而再建立一个(身份证号、姓名)的联合索引,是不是浪费空间?如果现在有一个高频请求,要根据市民的身份证号查询他的姓名,这个联合索引就有意义了。

它可以在这个高频请求上用到覆盖索引,不再需要回表查整行记录,减少语句的执行时间。

当然,索引字段的维护总是有代价的。

因此,在建立冗余索引来支持覆盖索引时就需要权衡考虑了。

这正是业务DBA,或者称为业务数据架构师的工作。

最左前缀原则备注:关于如何查看扫描行数的问题,我将会在第16文章《如何正确地显示随机消息?》中,和你详细讨论。

CREATE TABLE tuser ( id int(11) NOT NULL, id_card varchar(32) DEFAULT NULL, name varchar(32) DEFAULT NULL, age int(11) DEFAULT NULL, ismale tinyint(1) DEFAULT NULL, PRIMARY KEY (id), KEY id_card (id_card), KEY name_age (name,age)) ENGINE=InnoDB看到这里你一定有一个疑问,如果为每一种查询都设计一个索引,索引是不是太多了。

如果我现在要按照市民的身份证号去查他的家庭地址呢?虽然这个查询需求在业务中出现的概率不高,但总不能让它走全表扫描吧?反过来说,单独为一个不频繁的请求创建一个(身份证号,地址)的索引又感觉有点浪费。

应该怎么做呢?这里,我先和你说结论吧。

B+树这种索引结构,可以利用索引的“最左前缀”,来定位记录。

为了直观地说明这个概念,我们用(name,age)这个联合索引来分析。

图2 (name,age)索引示意图可以看到,索引项是按照索引定义里面出现的字段顺序排序的。

当你的逻辑需求是查到所有名字是“张三”的人时,可以快速定位到ID4,然后向后遍历得到所有需要的结果。

如果你要查的是所有名字第一个字是“张”的人,你的SQL语句的条件是”where name like‘张%’”。

这时,你也能够用上这个索引,查找到第一个符合条件的记录是ID3,然后向后遍历,直到不满足条件为止。

可以看到,不只是索引的全部定义,只要满足最左前缀,就可以利用索引来加速检索。

这个最左前缀可以是联合索引的最左N个字段,也可以是字符串索引的最左M个字符。

基于上面对最左前缀索引的说明,我们来讨论一个问题:在建立联合索引的时候,如何安排索引内的字段顺序。

这里我们的评估标准是,索引的复用能力。

因为可以支持最左前缀,所以当已经有了(a,b)这个联合索引后,一般就不需要单独在a上建立索引了。

因此,第一原则是,如果通过调整顺序,可以少维护一个索引,那么这个顺序往往就是需要优先考虑采用的。

所以现在你知道了,这段开头的问题里,我们要为高频请求创建(身份证号,姓名)这个联合索引,并用这个索引支持“根据身份证号查询地址”的需求。

那么,如果既有联合查询,又有基于a、b各自的查询呢?查询条件里面只有b的语句,是无法使用(a,b)这个联合索引的,这时候你不得不维护另外一个索引,也就是说你需要同时维护(a,b)、(b) 这两个索引。

这时候,我们要考虑的原则就是空间了。

比如上面这个市民表的情况,name字段是比age字段大的 ,那我就建议你创建一个(name,age)的联合索引和一个(age)的单字段索引。

索引下推上一段我们说到满足最左前缀原则的时候,最左前缀可以用于在索引中定位记录。

这时,你可能要问,那些不符合最左前缀的部分,会怎么样呢?我们还是以市民表的联合索引(name, age)为例。

如果现在有一个需求:检索出表中“名字第一个字是张,而且年龄是10岁的所有男孩”。

那么,SQL语句是这么写的:

你已经知道了前缀索引规则,所以这个语句在搜索索引树的时候,只能用 “张”,找到第一个满足条件的记录ID3。

当然,这还不错,总比全表扫描要好。

然后呢?当然是判断其他条件是否满足。

在MySQL 5.6之前,只能从ID3开始一个个回表。

到主键索引上找出数据行,再对比字段值。

而MySQL 5.6 引入的索引下推优化(index condition pushdown), 可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。

图3和图4,是这两个过程的执行流程图。

mysql> select * from tuser where name like ‘张%’ and age=10 and ismale=1;图3 无索引下推执行流程图4 索引下推执行流程在图3和4这两个图里面,每一个虚线箭头表示回表一次。

图3中,在(name,age)索引里面我特意去掉了age的值,这个过程InnoDB并不会去看age的值,只是按顺序把“name第一个字是’张’”的记录一条条取出来回表。

因此,需要回表4次。

图4跟图3的区别是,InnoDB在(name,age)索引内部就判断了age是否等于10,对于不等于10的记录,直接判断并跳过。

在我们的这个例子中,只需要对ID4、ID5这两条记录回表取数据判断,就只需要回表2次。

小结今天这篇文章,我和你继续讨论了数据库索引的概念,包括了覆盖索引、前缀索引、索引下推。

你可以看到,在满足语句需求的情况下, 尽量少地访问资源是数据库设计的重要原则之一。

我们在使用数据库的时候,尤其是在设计表结构时,也要以减少资源消耗作为目标。

接下来我给你留下一个问题吧。

实际上主键索引也是可以使用多个字段的。

DBA小吕在入职新公司的时候,就发现自己接手维护的库里面,有这么一个表,表结构定义类似这样的:

公司的同事告诉他说,由于历史原因,这个表需要a、b做联合主键,这个小吕理解了。

但是,学过本章内容的小吕又纳闷了,既然主键包含了a、b这两个字段,那意味着单独在字段c上创建一个索引,就已经包含了三个字段了呀,为什么要创建“ca”“cb”这两个索引?同事告诉他,是因为他们的业务里面有这样的两种语句:

我给你的问题是,这位同事的解释对吗,为了这两个查询模式,这两个索引是否都是必须的?为什么呢?你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。

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

上期问题时间上期的问题是,通过两个alter 语句重建索引k,以及通过两个alter语句重建主键索引是否合理。

在评论区,有同学问到为什么要重建索引。

我们文章里面有提到,索引可能因为删除,或者页分裂等原因,导致数据页有空洞,重建索引的过程会创建一个新的索引,把数据按顺序插入,这样页面的利用率最高,也就是索引更紧凑、更省空间。

这道题目,我给你的“参考答案”是:

重建索引k的做法是合理的,可以达到省空间的目的。

但是,重建主键的过程不合理。

不论是删除主键还是创建主键,都会将整个表重建。

所以连着执行这两个语句的话,第一个语句就白做了。

这两个语句,你可以用这个语句代替 : alter table T engine=InnoDB。

在专栏的第12篇文章CREATE TABLE geek ( a int(11) NOT NULL, b int(11) NOT NULL, c int(11) NOT NULL, d int(11) NOT NULL, PRIMARY KEY (a,b), KEY c (c), KEY ca (c,a), KEY cb (c,b)) ENGINE=InnoDB;select * from geek where c=N order by a limit 1;select * from geek where c=N order by b limit 1;《为什么表数据删掉一半,表文件大小不变?》中,我会和你分析这条语句的执行流程。

评论区留言中, @壹笙☞漂泊 做了很详细的笔记,@高枕 帮同学解答了问题,@约书亚 提了一个很不错的面试问题。

在这里,我要和你们道一声感谢。

PS:如果你在面试中,曾有过被MySQL相关问题难住的经历,也可以把这个问题发到评论区,我们一起来讨论。

如果解答这个问题,需要的篇幅会很长的话,我可以放到答疑文章展开。


问题解析

04 | 深入浅出索引(上)提到数据库索引,我想你并不陌生,在日常工作中会经常接触到。

比如某一个SQL查询比较慢,分析完原因之后,你可能就会说“给某个字段加个索引吧”之类的解决方案。

但到底什么是索引,索引又是如何工作的呢?今天就让我们一起来聊聊这个话题吧。

数据库索引的内容比较多,我分成了上下两篇文章。

索引是数据库系统里面最重要的概念之一,所以我希望你能够耐心看完。

在后面的实战文章中,我也会经常引用这两篇文章中提到的知识点,加深你对数据库索引的理解。

一句话简单来说,索引的出现其实就是为了提高数据查询的效率,就像书的目录一样。

一本500页的书,如果你想快速找到其中的某一个知识点,在不借助目录的情况下,那我估计你可得找一会儿。

同样,对于数据库的表而言,索引其实就是它的“目录”。

索引的常见模型索引的出现是为了提高查询效率,但是实现索引的方式却有很多种,所以这里也就引入了索引模型的概念。

可以用于提高读写效率的数据结构很多,这里我先给你介绍三种常见、也比较简单的数据结构,它们分别是哈希表、有序数组和搜索树。

下面我主要从使用的角度,为你简单分析一下这三种模型的区别。

哈希表是一种以键-值(key-value)存储数据的结构,我们只要输入待查找的值即key,就可以找到其对应的值即Value。

哈希的思路很简单,把值放在数组里,用一个哈希函数把key换算成一个确定的位置,然后把value放在数组的这个位置。

不可避免地,多个key值经过哈希函数的换算,会出现同一个值的情况。

处理这种情况的一种方法是,拉出一个链表。

假设,你现在维护着一个身份证信息和姓名的表,需要根据身份证号查找对应的名字,这时对应的哈希索引的示意图如下所示:

图1 哈希表示意图图中,User2和User4根据身份证号算出来的值都是N,但没关系,后面还跟了一个链表。

假设,这时候你要查ID_card_n2对应的名字是什么,处理步骤就是:首先,将ID_card_n2通过哈希函数算出N;然后,按顺序遍历,找到User2。

需要注意的是,图中四个ID_card_n的值并不是递增的,这样做的好处是增加新的User时速度会很快,只需要往后追加。

但缺点是,因为不是有序的,所以哈希索引做区间查询的速度是很慢的。

你可以设想下,如果你现在要找身份证号在[ID_card_X, ID_card_Y]这个区间的所有用户,就必须全部扫描一遍了。

所以,哈希表这种结构适用于只有等值查询的场景,比如Memcached及其他一些NoSQL引擎。

而有序数组在等值查询和范围查询场景中的性能就都非常优秀。

还是上面这个根据身份证号查名字的例子,如果我们使用有序数组来实现的话,示意图如下所示:

图2 有序数组示意图这里我们假设身份证号没有重复,这个数组就是按照身份证号递增的顺序保存的。

这时候如果你要查ID_card_n2对应的名字,用二分法就可以快速得到,这个时间复杂度是O(log(N))。

同时很显然,这个索引结构支持范围查询。

你要查身份证号在[ID_card_X, ID_card_Y]区间的User,可以先用二分法找到ID_card_X(如果不存在ID_card_X,就找到大于ID_card_X的第一个User),然后向右遍历,直到查到第一个大于ID_card_Y的身份证号,退出循环。

如果仅仅看查询效率,有序数组就是最好的数据结构了。

但是,在需要更新数据的时候就麻烦了,你往中间插入一个记录就必须得挪动后面所有的记录,成本太高。

所以,有序数组索引只适用于静态存储引擎,比如你要保存的是2017年某个城市的所有人口信息,这类不会再修改的数据。

二叉搜索树也是课本里的经典数据结构了。

还是上面根据身份证号查名字的例子,如果我们用二叉搜索树来实现的话,示意图如下所示:

图3 二叉搜索树示意图二叉搜索树的特点是:每个节点的左儿子小于父节点,父节点又小于右儿子。

这样如果你要查ID_card_n2的话,按照图中的搜索顺序就是按照UserA -> UserC -> UserF -> User2这个路径得到。

这个时间复杂度是O(log(N))。

当然为了维持O(log(N))的查询复杂度,你就需要保持这棵树是平衡二叉树。

为了做这个保证,更新的时间复杂度也是O(log(N))。

树可以有二叉,也可以有多叉。

多叉树就是每个节点有多个儿子,儿子之间的大小保证从左到右递增。

二叉树是搜索效率最高的,但是实际上大多数的数据库存储却并不使用二叉树。

其原因是,索引不止存在内存中,还要写到磁盘上。

你可以想象一下一棵100万节点的平衡二叉树,树高20。

一次查询可能需要访问20个数据块。

在机械硬盘时代,从磁盘随机读一个数据块需要10 ms左右的寻址时间。

也就是说,对于一个100万行的表,如果使用二叉树来存储,单独访问一个行可能需要20个10 ms的时间,这个查询可真够慢的。

为了让一个查询尽量少地读磁盘,就必须让查询过程访问尽量少的数据块。

那么,我们就不应该使用二叉树,而是要使用“N叉”树。

这里,“N叉”树中的“N”取决于数据块的大小。

以InnoDB的一个整数字段索引为例,这个N差不多是1200。

这棵树高是4的时候,就可以存1200的3次方个值,这已经17亿了。

考虑到树根的数据块总是在内存中的,一个10亿行的表上一个整数字段的索引,查找一个值最多只需要访问3次磁盘。

其实,树的第二层也有很大概率在内存中,那么访问磁盘的平均次数就更少了。

N叉树由于在读写上的性能优点,以及适配磁盘的访问模式,已经被广泛应用在数据库引擎中了。

不管是哈希还是有序数组,或者N叉树,它们都是不断迭代、不断优化的产物或者解决方案。

数据库技术发展到今天,跳表、LSM树等数据结构也被用于引擎设计中,这里我就不再一一展开了。

你心里要有个概念,数据库底层存储的核心就是基于这些数据模型的。

每碰到一个新数据库,我们需要先关注它的数据模型,这样才能从理论上分析出这个数据库的适用场景。

截止到这里,我用了半篇文章的篇幅和你介绍了不同的数据结构,以及它们的适用场景,你可能会觉得有些枯燥。

但是,我建议你还是要多花一些时间来理解这部分内容,毕竟这是数据库处理数据的核心概念之一,在分析问题的时候会经常用到。

当你理解了索引的模型后,就会发现在分析问题的时候会有一个更清晰的视角,体会到引擎设计的精妙之处。

现在,我们一起进入相对偏实战的内容吧。

在MySQL中,索引是在存储引擎层实现的,所以并没有统一的索引标准,即不同存储引擎的索引的工作方式并不一样。

而即使多个存储引擎支持同一种类型的索引,其底层的实现也可能不同。

由于InnoDB存储引擎在MySQL数据库中使用最为广泛,所以下面我就以InnoDB为例,和你分析一下其中的索引模型。

InnoDB 的索引模型在InnoDB中,表都是根据主键顺序以索引的形式存放的,这种存储方式的表称为索引组织表。

又因为前面我们提到的,InnoDB使用了B+树索引模型,所以数据都是存储在B+树中的。

每一个索引在InnoDB里面对应一棵B+树。

假设,我们有一个主键列为ID的表,表中有字段k,并且在k上有索引。

这个表的建表语句是:

表中R1~R5的(ID,k)值分别为(100,1)、(200,2)、(300,3)、(500,5)和(600,6),两棵树的示例示意图如下。

图4 InnoDB的索引组织结构从图中不难看出,根据叶子节点的内容,索引类型分为主键索引和非主键索引。

主键索引的叶子节点存的是整行数据。

在InnoDB里,主键索引也被称为聚簇索引(clusteredindex)。

非主键索引的叶子节点内容是主键的值。

在InnoDB里,非主键索引也被称为二级索引(secondary index)。

根据上面的索引结构说明,我们来讨论一个问题:基于主键索引和普通索引的查询有什么区mysql> create table T(id int primary key, k int not null, name varchar(16),index (k))engine=InnoDB;别?如果语句是select * from T where ID=500,即主键查询方式,则只需要搜索ID这棵B+树;

如果语句是select * from T where k=5,即普通索引查询方式,则需要先搜索k索引树,得到ID的值为500,再到ID索引树搜索一次。

这个过程称为回表。

也就是说,基于非主键索引的查询需要多扫描一棵索引树。

因此,我们在应用中应该尽量使用主键查询。

索引维护B+树为了维护索引有序性,在插入新值的时候需要做必要的维护。

以上面这个图为例,如果插入新的行ID值为700,则只需要在R5的记录后面插入一个新记录。

如果新插入的ID值为400,就相对麻烦了,需要逻辑上挪动后面的数据,空出位置。

而更糟的情况是,如果R5所在的数据页已经满了,根据B+树的算法,这时候需要申请一个新的数据页,然后挪动部分数据过去。

这个过程称为页分裂。

在这种情况下,性能自然会受影响。

除了性能外,页分裂操作还影响数据页的利用率。

原本放在一个页的数据,现在分到两个页中,整体空间利用率降低大约50%。

当然有分裂就有合并。

当相邻两个页由于删除了数据,利用率很低之后,会将数据页做合并。

合并的过程,可以认为是分裂过程的逆过程。

基于上面的索引维护过程说明,我们来讨论一个案例:

自增主键是指自增列上定义的主键,在建表语句中一般是这么定义的: NOT NULL PRIMARYKEY AUTO_INCREMENT。

插入新记录的时候可以不指定ID的值,系统会获取当前ID最大值加1作为下一条记录的ID值。

也就是说,自增主键的插入数据模式,正符合了我们前面提到的递增插入的场景。

每次插入一条新记录,都是追加操作,都不涉及到挪动其他记录,也不会触发叶子节点的分裂。

而有业务逻辑的字段做主键,则往往不容易保证有序插入,这样写数据成本相对较高。

除了考虑性能外,我们还可以从存储空间的角度来看。

假设你的表中确实有一个唯一字段,比如字符串类型的身份证号,那应该用身份证号做主键,还是用自增字段做主键呢?由于每个非主键索引的叶子节点上都是主键的值。

如果用身份证号做主键,那么每个二级索引的叶子节点占用约20个字节,而如果用整型做主键,则只要4个字节,如果是长整型(bigint)则是你可能在一些建表规范里面见到过类似的描述,要求建表语句里一定要有自增主键。

当然事无绝对,我们来分析一下哪些场景下应该使用自增主键,而哪些场景下不应该。

8个字节。

显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。

所以,从性能和存储空间方面考量,自增主键往往是更合理的选择。

有没有什么场景适合用业务字段直接做主键的呢?还是有的。

比如,有些业务的场景需求是这样的:

  1. 只有一个索引;

  2. 该索引必须是唯一索引。

你一定看出来了,这就是典型的KV场景。

由于没有其他索引,所以也就不用考虑其他索引的叶子节点大小的问题。

这时候我们就要优先考虑上一段提到的“尽量使用主键查询”原则,直接将这个索引设置为主键,可以避免每次查询需要搜索两棵树。

小结今天,我跟你分析了数据库引擎可用的数据结构,介绍了InnoDB采用的B+树结构,以及为什么InnoDB要这么选择。

B+树能够很好地配合磁盘的读写特性,减少单次查询的磁盘访问次数。

由于InnoDB是索引组织表,一般情况下我会建议你创建一个自增主键,这样非主键索引占用的空间最小。

但事无绝对,我也跟你讨论了使用业务逻辑字段做主键的应用场景。

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

对于上面例子中的InnoDB表T,如果你要重建索引 k,你的两个SQL语句可以这么写:

如果你要重建主键索引,也可以这么写:

我的问题是,对于上面这两个重建索引的作法,说出你的理解。

如果有不合适的,为什么,更好的方法是什么?alter table T drop index k;alter table T add index(k);alter table T drop primary key;alter table T add primary key(id);你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾给出我的参考答案。

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

上期问题时间我在上一篇文章末尾给你留下的问题是:如何避免长事务对业务的影响?这个问题,我们可以从应用开发端和数据库端来看。

首先,从应用开发端来看:

  1. 确认是否使用了set autocommit=0。

这个确认工作可以在测试环境中开展,把MySQL的general_log开起来,然后随便跑一个业务逻辑,通过general_log的日志来确认。

一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成1。

  1. 确认是否有不必要的只读事务。

有些框架会习惯不管什么语句先用begin/commit框起来。

我见过有些是业务并没有这个需要,但是也把好几个select语句放到了事务中。

这种只读事务可以去掉。

  1. 业务连接数据库的时候,根据业务本身的预估,通过SET MAX_EXECUTION_TIME命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间。

(为什么会意外?在后续的文章中会提到这类案例)其次,从数据库端来看:

  1. 监控 information_schema.Innodb_trx表,设置长事务阈值,超过就报警/或者kill;

  2. Percona的pt-kill这个工具不错,推荐使用;

  3. 在业务功能测试阶段要求输出所有的general_log,分析日志行为提前发现问题;

  4. 如果使用的是MySQL 5.6或者更新版本,把innodb_undo_tablespaces设置成2(或更大的值)。

如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。

感谢 @壹笙☞漂泊 @王凯 @易翔 留下的高质量评论。


0%