美团MySQL自治平台建设的内核原理及实践

1 背景&目标

MySQL的故障与SQL的性能,是DBA跟研发同学每天都需要关注的两个重要问题,它们直接影响着数据库跟业务应用程序的稳定性。而当故障或者SQL性能问题发生时,如何快速发现、分析以及处理这些问题,使得数据库或者业务系统快速恢复,是一项比较大的挑战。

针对此问题,美团数据库自治平台经过多轮的迭代建设,在多个场景下已经实现了异常的发现、分析以及处理的端到端能力。本文将跟大家分享一下我们平台建设的心路历程,同时提供一些经验、教训供同行参考,希望能够起到“抛砖引玉”的作用。本文主要介绍以下主题:

  • 异常发现:基于数理统计方式的动态阀值策略,来发现数据库系统的指标异常。
  • 故障分析:丰富完善数据库关键信息,来做精确的数据库异常根因分析;深入挖掘内核价值,来解决根因诊断方面的疑难杂症。
  • 故障处理:依据异常根因分析的不同结果,通过自助化或自动化的方式来进行故障的恢复处理。
  • 内核可观测性建设:如何跟数据库内核团队合作,从内核的角度来分析SQL性能问题,通过内核团队大量的内核代码改造,力求将数据库的可观测性跟诊断做到极致。
  • 单SQL优化建议:通过改造MySQL存储引擎,同时结合查询优化来打造基于Cost模式的索引优化建议。
  • 基于workload索引优化建议:基于整个DB或者实例的Workload策略的索引优化建议,为实现数据库的索引自维护提供前置条件。
  • 基于SQL生命周期的治理:实现从SQL上线前、执行过程中、执行完毕后几个环节,以期实现端到端的慢SQL治理。

2 平台演进策略

美团数据库自治平台从下到上总体分为四层,分别为接口与展示、平台功能层,计算与存储、数据采集层,平台的总体架构以及每一层的作用如下:

  • 数据库采集层:要进行数据库的诊断与分析,需要依靠关键的指标以及SQL文本数据,当前在每个数据库实例上部署一个数据采集程序(rds-agent)统一负责采集、上报关键数值指标以及SQL文本数据。
  • 数据计算与存储层:数据采集层上报上来的数据,依托Kafka、Flink&Spark作为数据缓冲,对关键组件进行相关的数据处理,如SQL解析、SQL模版化、数据聚合等操作,再把处理的结果存入ES、Blade(美团自研的分布式数据库)、Hive等分布式数据库或者大数据平台,提供给上层的平台功能层使用。
  • 平台功能层:此层是整个系统最为重要的部分,由于平台同时服务于DBA运维团队及研发团队,所以平台的建设分成了两条路:1)主要面向DBA用户,按照可观测性建设、异常发现、故障根因分析、故障处理几个阶段来进行建设;2)主要面向研发同学,按照SQL优化建议、风险SQL的发现、分析与SQL治理等跟SQL相关的几个阶段来建设。当然,两者并没有严格界限,这些功能所有的用户都可以同时使用。
  • 接口与展示:平台功能层提供的核心功能会通过Portal来展示,同时为了让平台提供的功能更好地集成在用户自己的系统中,我们也通过OpenAPI的方式对外提供服务。

3 异常发现

数据库产生异常时需要尽早地发现,才能防止异常一进步放大,避免造成真正的故障。异常发现的主要方式是对数据库或者OS的关键数值指标进行监控,相关指标包括seconds_behind_master、slow_queries、thread_running、system load、Threads_connected等等,也可以是业务端研发关注的关键指标,如“应用程序访问数据库的报错数量”、“SQL执行平均耗时”等指标来进行监控。如果这些指标短时间内发生比较大的波动,那么数据库很可能出现了一些异常,这就需要及时进行处理。

这些异常如何才能被发现呢?业界一般有基于静态阀值以及动态阀值的两种异常发现策略。前者很简单,如根据专家经验,人工设定seconds_behind_master或者Threads_connected的告警阀值,超过阀值就认为发生了异常。此方式虽然简单易用,但OLTP、OLAP等不同的业务场景,对于相同指标的敏感度是不一样的,如果所有场景都使用统一的静态阀值来做异常发现,难免会有很多误告。而如果每个场景都去手工去调整,既不灵活,成本又太高,解决方案是基于不同场景的历史时序数据,使用数理统计的方式来分别建模,通过拟合出各自场景的模型来作为异常发现的策略。

3.1 数据分布规律与算法选择

基于数理统计方法的异常发现,需要根据具体的场景使用特定的模型。一般来说,模型的选择跟时序数据的分布形态有很大的关系,时序数据的分布并不总是都像正态分布一样都是对称的,而是有些是左偏的,有些是右偏的,当然也有对称分布的。下图就展示典型的三种不同的时序数据分布形态。

针对上面的三种不同时序数据的分布形态,以及每种异常检测算法自身的特性,我们分别采用不同的异常检测算法。

对于低偏态高对称分布选择“绝对中位差(MAD)”,中等偏态分布选择“箱形图(Boxplot)”,高偏态分布选择“极值理论(EVT)”。没有选择3Sigma的主要原因是:它对异常容忍度较低(建模的时候,如果有噪音等异常点也不会对模型的形态产生很大的影响,则说明异常容忍度很高),而绝对中位差(MAD)从理论上而言具有更好的异常容忍度,所以在数据呈现高对称分布时,通过绝对中位差替代3Sigma来进行检测。

3.2 模型选择

数据分布跟算法适用场景的分析之后,对内部的时序数据进行检查,发现数据的规律主要呈现漂移、周期和平稳三种状态,对样本先进行时序的漂移(如果检测存在漂移的场景,则需要根据检测获得的漂移点t来切割输入时序,使用漂移点后的时序样本作为后续建模流程的输入)。

之后同时进行平稳性分析(如果输入时序S满足平稳性检验,则直接通过箱形图或绝对中位差的方式来进行建模)以及周期分析(存在周期性的情况下,将周期跨度记为T,将输入时序S根据跨度T进行切割,针对各个时间索引j∈{0,1,⋯,T−1} 所组成的数据桶进行建模流程。不存在周期性的情况下,针对全部输入时序S作为数据桶进行建模流程),再对时序数据分布特性进行偏度的计算,最后再根据不同的偏度特性选择不同的算法模型,具体如下:

在算法确定之后,先在离线环境针对不同的场景使用历史指标来训练模型,模型训练完毕之后会存入数据库,在生产环境运行过程中,对于不同场景下的数值指标根据其特性来加载不同的模型,并且结合Flink实时计算框架来实时的发现指标的异常并进行告警。

4 异常诊断

发现指标异常后,需要快速的给出异常的根因,我们可以根据具体的根因来选择不同的处理策略,然后进行自动或者手动的恢复工作。根因分析可以基于专家经验,也可以严格地按照内核代码的逻辑来进行分析。

本文重点讲述后者,强调如何使用“内核思维”来解决专家经验很难或者无法解决的诊断问题。本文将列举“内核代码路径分析”、”内核日志分析”、“内核功能增强“、“内核Core Dump分析”以及“内核埋点”等几种不同的范式,来说明数据库根因诊断的思路。

4.1 主从延迟(内核代码路径分析)

这里先介绍“内核代码路径分析”这个方式来诊断根因。对于数据一致性要求比较高的应用程序,seconds_behind_master是一个十分重要的指标,如果其值过大就需要诊断其根因,防止应用程序读取到不一致的数据。根据专家经验,其值过大可能由“QPS突增”、“大事务”、“大表DDL”、“锁阻塞”、“表缺少主键或者唯一健”、“低效执行计划”、“硬件资源不足”等因数造成,把这些专家经验总结成规则列表,当异常产生时逐个迭代去验证是不是符合某个规则,据此来诊断根因,然而此方式存在如下两大问题:

  1. 无法枚举所有根因:经验由于其固有的局限性不可能考虑到所有的故障场景,如何完整的给出造成seconds_behind_master值异常的所有规则是一个挑战;另外,如果需要对一个全新的指标进行诊断,而在没有任何的专家经验情况下,如何能快速地整理出完整的规则列表?
  2. 缺乏对根因的深层次理解:“QPS突增”、“大事务”、“大表DDL”、“锁阻塞”、“低效执行计划”、“硬件资源不足”等因素会造成seconds_behind_master指标值的异常,但是为什么这些因数会造成指标值的异常呢?如果不从内核源码角度来了解这些因素跟seconds_behind_master之间的逻辑计算关系,那么是无法了解真正原因的。

4.1.1 内核代码路径分析

针对上面两个问题,具体策略是直接看seconds_behind_master这个变量在内核层面是如何计算的,只有这样才能完整的枚举出所有影响seconds_behind_master值计算的因数。

源码角度看,seconds_behind_master的值由①time(0)、②mi->rli->last_master_timestamp和③mi->clock_diff_with_master这三个变量来决定(代码层面seconds_behind_master的计算逻辑是:seconds_behind_master=((long)(time(0) – mi->rli->last_master_timestamp)- mi->clock_diff_with_master),其中time(0)是系统当前时间(用秒表示),clock_diff_with_master这个值的计算很复杂、又很关键,会放到下一节详细进行说明。

而针对mi->clock_diff_with_master的计算,这个变量从源码层面看就是主、从实例之间的时间差;根据当前的信息就可以看出来,从库的当前时间以及主从库之间的时间差都会影响seconds_behind_master值的计算。seconds_behind_master的计算和事务在主从库执行的情况如下:

last_master_timestamp计算逻辑

从上面分析可以知道,last_master_timestamp值是影响seconds_behind_master值计算的关键变量,所以很有必要从源码角度分析影响last_master_timestamp值的因数有哪些(从而间接获取了影响seconds_behind_master值的因素)。

last_master_timestamp的值来自于一个叫rli->gaq->head_queue()的成员变量new_ts(此处的rli->gaq->head_queue()是指代某个最新的已经完成replay的事务对应的event group,event group是指一个事务在binlog文件里生成一组event来表示某个事务,这个event group里的event从主库传输到从库后进行replay操作来还原主库的事务)。new_ts值来源于rli->gaq->head_queue())->ts,而rli->gaq->head_queue())->ts的值是通过ptr_group->ts= common_header->when.tv_sec + (time_t) exec_time计算获取的。

再看一下when.tv_sec跟exec_time的含义,前者指代SQL在主库上的SQL执行的开始时间,后者指代SQL在主库上的“执行时长”,“执行时长”又跟“锁阻塞”、“低效执行计划”、“硬件资源不足”等因素息息相关。

值得注意的是,前面提到的rli->gaq->head_queue())->ts的计算跟slave_checkpoint_period以及sql_delay两个变量也有关系,按照这个思路层层迭代下去找出所有影响seconds_behind_master值的因素,这些因素都是潜在的主从延迟异常的根源,这样也解决了前面说的“无法枚举所有根因”跟“缺乏对根因的深层次理解”两大问题。

为了便于理解上诉的逻辑,放出关键的源代码:获取last_master_timestamp值的来源rli->gaq->head_queue()的成员变量new_ts

bool mts_checkpoint_routine(Relay_log_info *rli, ulonglong period,
                            bool force, bool need_data_lock)
{
 do
  { 
    cnt= rli->gaq->move_queue_head(&rli->workers);
  } 
 .......................
  ts= rli->gaq->empty()
    ? 0
    : reinterpret_cast<Slave_job_group*>(rli->gaq->head_queue())->ts; //其中的ts来自下面的get_slave_worker函数;
  rli->reset_notified_checkpoint(cnt, ts, need_data_lock, true);
  //  社区版本的代码 rli->reset_notified_checkpoint(cnt, rli->gaq->lwm.ts, need_data_lock);
  /* end-of "Coordinator::"commit_positions" */
 ......................
}

获取Master实例上执行的SQL的开始跟执行时长信息tv_sec跟exec_time。

Slave_worker *Log_event::get_slave_worker(Relay_log_info *rli)
{
if (ends_group() || (!rli->curr_group_seen_begin && (get_type_code() == binary_log::QUERY_EVENT || !rli->curr_group_seen_gtid)))
  {
  ..............
    ptr_group->checkpoint_seqno= rli->checkpoint_seqno;
    ptr_group->ts= common_header->when.tv_sec + (time_t) exec_time; // Seconds_behind_master related
    rli->checkpoint_seqno++;
  }
} 

根因层叠图

如果进一步分析内核代码,可以发现影响seconds_behind_master变量计算的因素还有很多,但是找出这些因素的思路是相同的。这个思路的好处是:无论之前有没有相关专家经验,理论上这种分析方式都能尽可能地枚举出所有的根因。

除了seconds_behind_master,其他的像thread_running、Threads_connected,slow_queries等指标异常的分析也都可以套用这种思路。下面为按照上述思路整理出来的影响seconds_behind_master值的部分因素的层次结构图:

4.1.2 流程分析

把影响seconds_behind_master值的相关因素确认后,可以画一个流程图把这些因素都体现在流程图中的具体位置。这样既能比较形象地理解影响seconds_behind_master的因素在整个主从复制流程中的所处的位置,又便于对整体规则进行查漏补缺。

下面我们使用一个具体的例子,来说明一下上面分析的因素是如何影响seconds_behind_master的。从下图可以看出在执行SQL的过程中影响seconds_behind_master计算的两个变量thd->start_time跟exec_time的计算在master实例。假设start_time的值为2023-07-03 00:00:00,SQL执行了60秒,所以exec_time为60,2023-07-03 00:01:00,SQL在主库上执行完毕,在从库上replay这个SQL,可以看到seconds_behind_master值会从0开始并且逐渐增加60秒,然后再返回0。

具体原因是:假设我们忽略binlog日志的传输时间,那么从库开始执行replay这个SQL的开始时间也是2023-07-03 00:01:00,所以根据seconds_behind_master=((long)(time(0) – mi->rli->last_master_timestamp)- mi->clock_diff_with_master)=2023-07-03 00:01:00 – 2023-07-03 00:00:00-60s,结果就是0,然后SQL的执行时间是60秒,并且(long)(time(0)(当前时间)的时间一秒一秒的在增加,所以seconds_behind_master值会从0开始逐渐增加至60秒。

再看一下其他的因数,协调器(Coordinator)会把Group放入一个叫做GAP Group的队列中,Coordinator会以slave_checkpoint_period值为周期来扫描GAP Group中的元素并且更新rli->gaq->head_queue())->ts值,所以果slave_checkpoint_period的值被设置的很大,那么rli->gaq->head_queue())->ts的值因为没有及时更新而变得比较旧,从而引起seconds_behind_master值变大。

另外,每个Worker读取自己队列的Group元素,进行repaly操作,需要注意的是sql_delay这个变量,如果当前时间还没有达到sql_delay规定的时间(假设sql_delay被设置为100秒,那么SQL对应的binlog日志到达从库后需要等待100秒再执行),那么worker就不会进行repaly工作,这间接导致影响计算seconds_behind_master变量thd->start_time值比正常情况下小了100秒,所以当worker进行replay的时候,seconds_behind_master的值就会相应的增加100秒。

4.1.3 产品展示

下面的产品展示了因为流量突增跟MDL锁造成的主从延迟的诊断分析报告的产品页面。我们可以看到,流量突增的具体SQL以及MDL锁的持有者,方便用户进行限流或者Kill掉阻塞者SQL。

4.2 大事务诊断分析(内核功能增强)

大事务的存在,对整个数据库系统的稳定性与总体SQL的性能都会产生很大的挑战,如大事务长时间持有某个锁会造成大面积阻塞,或者更改过多的行数造成整个实例硬件资源的不足。我们需要及时发现这些场景,并且将其信息发送给用户治理,但在实践过程中,往往面临如下的两大挑战:

第一个挑战:无法得到大事务所包含的完整的SQL列表,造成用户不清楚大事务的全貌是什么,用户也就无法识别需要优化的大事务。

  • 解决方案:每个事务来MySQL会在内核层面生成一个唯一的事务ID:trx_id,如果事务包含的每条SQL,都给其附加一个事务ID trx_id字段,并且把这些SQL连同trx_id一起输出(通过全量SQL输出),问题就可以解决;不过这里还有一个挑战,这个事务ID到底是何时产生的呢?如何大家熟悉内核内部事务的执行过程,就会知道事务ID的只有在事务修改数据的时候才会通过trx_assign_id_for_rw这个方法被获取,这意味着就下面这个图上展示的事务而言,是无法获取SQL4之前执行的读SQL语句列表,所以获取到的事务的SQL列表还是残缺的,那么如何获取到完整的SQL列表呢?解决方案也很简单,可以把事务ID的生成逻辑提前到在事务刚开始执行的时候生成就可以了。

第二个挑战:大事务的耗时组成不明确。数据库规定执行时长大于某个阀值的事务被定义为大事务,但是不清楚耗时到底是SQL本身的执行时间还是SQL执行之外的耗时,SQL执行之外的耗时可能是在执行上下两个SQL之间,业务端在处理一些跟数据库无关的业务逻辑,也可能是网络延迟造成的。

  • 解决方案:上述问题可以通过在数据库内核内对SQL执行开始时、结束时分别埋点时间戳来解决,这样整个大事务执行总时间中有多少时间是在执行SQL,有多少时间是在Sleep就一目了然;当然,这一块还可以做的更加的细致,比如两条SQL之间的Sleep时间到底是网络延迟还是应用程序端的延迟等,可以进一步细分大事务造成的原因到底是在MySQL端、网络端还是用户自己的应用程序造成的等待;关于计算网络端的延迟计算,可以参考MySQL内部的mysql_socket_send_time跟vio_socket_io_wait_time这2个关键指标的实现思路,下图是一个大事务的SQL列表以及耗时组成列表。

4.2.1 产品展示

内核团队通过内核改造,对事务中所包含的SQL都提供了trx_id后就可以根据trx_id把整个事务所有的SQL串起来。根据SQL执行的开始跟结束时间,我们也提供了所有SQL之间的Sleep时间,成功解决了上面的两个挑战,产品效果图如下:

4.3 MySQL Crash分析(内核Core Dump分析)

MySQL实例突然Crash了怎么进行根因诊断?进程Crash的根因分析,也是数据库故障中最难分析的问题之一。本节提供一些思路尝试去分析各种场景下的MySQL Crash的根因。

4.3.1 Crash的触发方式

在分析Crash的根因之前,我们可以先了解一下MySQL进程是如何被Crash的整个过程十分有必要。一般来说,触发Crash的原因分成两类:①MySQL进程自己触发了Crash(这里称之为MySQL自杀);②MySQL进程被OS杀死。

针对前者,比如MySQL发现某个关键数据发生了Data Corruption、磁盘空间不足、硬件错误、等待内核锁时间过长、MySQL 内核Bug等场景,都可能导致MySQL自杀。尤其是检查到MySQL内核里有些数据的状态不符合预期时,是必要要让那个实例Crash也不能继续执行,否则可能会产生更加严重的数据不一致性问题;而后者,OS如果发现系统内存严重不足或者空指针引用等情况,就会把包括MySQL在内相关的进程杀掉。

分析一下MySQL自身触发Crash这个场景,在MySQL内部有很多地方通过ut_a(如果是ut_error的话,则直接触发Crash断言对程序的内部数据状态进行异常检查,如果发现数据状态不符合预期,那么势必发生了Data Corruption,这个时候程序会调用ut_dbg_assertion_failed在进程Crash之前做一些关键信息(如thread id、发生Crash的文件名字跟code line等)的记录后,会继续调用abort()向进程发送SIGABRT信号量让进程Crash掉。

需要注意的是,从abort()的源码可知,这里调用了两次raise (SIGABRT),第一次调用raise (SIGABRT)触发处理函数handle_fatal_signal(此函数在MySQL实例初始化时通过sigaction的sa_handler注册)被调用,这个函数主要是打印一些重要的调试信息,便于做Core Dump分析;第二次调用raise (SIGABRT)的目的,是为了让OS生成Core Dump文件(core Dump文件非常重要,因为所有引起MySQL Crash的现场信息都包含在Core Dump里面);如果是在MySQL自杀的情况下发生了Crash ,一般会在errorlog里会产生如下的一段跟Crash相关的现场信息,其中的“signal”、“触发Crash的线程堆栈”、“正在执行的SQL”等信息都是分析Crash根因的关键信息。

下图为MySQL通过ut_a断言检查异常问题后再到OS触发进程Crash的整体流程图。总体来说,MySQL通过raise来发送SIGABRT信号到一个队列,OS内核处理这个队列的信号并调用MySQL的处理程序handle_fatal_signal来保留一些关键的现场信息。这里需要注意到的是,OS内核在__setup_rt_frame中执行“regs->ip = (unsigned long) ka->sa.sa_handler;”,这个步骤正是让MySQL的handle_fatal_signal方法被顺利的调用的原因。

4.3.2 根据Signal类型做根因分析

分析Crash根因的第一步就是看触发了什么类型的signal,常见类型有“signal 6”、“signal 7”、“signal 11”几种类型,当知道了Signal类型后就有一个根因分析的大方向。根据经验,我们将常见的signal类型以及可能引起的原因大致如下图所示,下面对于常见的signal类型以及引起的根因做一个简单的分析。

(1) 如果是signal 6,一般是实例的磁盘空间不足或者磁盘只读,MySQL的数据发生了data corruption,内核层面latch锁的长时间的锁等待造成。不过这里的data corruption、长时间的锁等待可能是硬盘损坏或者MySQL的Bug造成的,判断逻辑如下:

  1. 磁盘空间不足或者磁盘只读
    • 磁盘写数据时,如果磁盘没有剩余空间或者数据库被设置为read only就会造成实例的Crash,日志中有“Either disk is full or file system is read only while opening the binlog”的字样。
  2. data corruption
    • MySQL在运行过程中如果断言(比如这里ut_a(table != NULL)返回false,很可能是数据发生了corruption并且MySQL就会自行Crash掉;发生data corruption时一般在MySQL的error log中有“Database page corruption on disk or a failed file read of tablespace”的字样,所以查看日志来判断否有硬盘故障问题,如果没有硬件故障信息,则可能是MySQL Bug造成的data corruption,具体分析看下面 “MySQL Bug”那部分。
  3. 长时间无法获取Latch锁
    • 如果MySQL长时间没有办法获取到Latch锁,那么MySQL认为系统可能是Hang住了也会引起实例的Crash,并且日志中打印“We intentionally crash the server because it appears to be hung”字样,一般是硬件故障造成的性能问题或者MySQL自身的设计缺陷形成的性能问题造成的,这次场景根因分析比较有挑战。
  4. MySQL Bug
    • 如果不属于上面任何一种情况,那么有可能是MySQL自身的Bug造成的Crash,比如MySQL对一些SQL解析或者执行时会发生Crash;这种场景一般先看一下Crash发生时正在执行的SQL是什么,这个SQL可能存在于Crash日志中(这个Crash日志中有个例子),可以先把SQL提取出来再次执行查看能否复现问题;如果在Crash日志中看不到SQL语句,就需要从core dump文件中提取SQL了,提取方式是MySQL每个链接对应的THD的成员变量m_query_string就包含了SQL文本,只需要打开Core Dump文件切换到某个包含THD实例的方法内,通过命令“p this->thd->m_query_string.str ”来打印,这里有个例子
    • 再举个MySQL的Bug造成data corruption的例子,从Crash日志里“InnoDB: Assertion failure in thread 139605476095744 in file rem0rec.cc line 578”看出,从rec_get_offsets_func函数中触发ut_error而导致的Crash,之所以触发这个Crash是因为rec_get_offsets_func中的rec_get_status(rec)获取到的MySQL的记录类型不符合预期(因为记录类型只有固定的REC_STATUS_ORDINARY、REC_STATUS_NODE_PTR,REC_STATUS_INFIMUM,REC_STATUS_SUPREMUM这4种类型),如果内核发现一个记录类型不属于这4种类型的任何一种,那么就是发生了data corruption,所以必须要把自己Crash掉。为了验证刚才的结论,看一下Crash发生时的rec的类型是什么,从源码可知rec的类型是通过rec_get_status获取,并且其通过调用的rec_get_bit_field_1mach_read_from_1两个函数可以知道rec的类型其实就是rec这个指针往前三个byte(通过#define REC_NEW_STATUS_MASK 0x7UL可知)代表的值。
    • 通过gdb加载core dump文件后,切换到抛出exception的线程,因为异常是在rec_get_offsets_func里抛出的,切换到rec_get_offsets_func对应的frame 7来验证rec的类型,看到rec的指针地址为0x7f14b7f5685d(相关分析数据可以看此链接)。前面说过,rec的类型值在rec指针往前三个byte里,也就是指针0x7f14b7f5685a(0x7f14b7f5685d-3)那个位置的值,发现是0x1f,执行与计算(11111(1f)&00111(0x7UL)=00111=7 )得到的类型是7,而记录类型的范围是(0~3),很明显这个指针指向的记录类型值信息发生了data corruption(分析过程查看此链接),这里做了一个rec的类型在正常情况下跟本例异常情况下的类型值计算的对比表,发现正常情况下,rec的类型值就应该是3。
  • 这里有个重要问题是,为什么rec的类型是无效的呢?很可能是MySQL搜索满足条件的记录的时候,rec指向的记录很可能被page_cleaner在后台被清理掉了,所以rec指针指向的记录就是无效了。官方有个bugfix,解决方案就是把prev_rec设置为NULL(这里的prev_rec是persistent cursor指向的记录,这里说一下persistent cursor,它是MySQL从InnoDB 层获得记录后进入SQL层前在B-tree上的cursor会被暂时存储到row_prebuilt_t::pcur中,当再次从InnoDB层拿数据时,如果对应的buf_block_t没有发生修改,则可以继续沿用之前存储的cursor,否则需要重新定位,如果没有persistent cursor则每次都需要重新定位则对性能有影响),这样prev_rec != NULL这个条件不满足,也就没有机会进入rec_get_offsets_g_func里去检查rec的类型值而引发Crash了。

(2) 如果为signal 7,那么大概率是内存硬件错误,并且日志里一般有“mce: [Hardware Error]: Machine check events logged , mce: Uncorrected hardware memory error in user-access at xxx MCE xxx: Killing mysqld:xxx due to hardware memory corruption”等字样。

(3) 如果为signal 9,表示这个进程被Kill命令杀掉了。

(4) 如果为signal 11,表示是由MySQL的Bug造成的,这类问题较难分析特别是MySQL Crash现场(通过core dump打印出来的堆栈信息)往往还不是第一现场,由于篇幅关系具体的例子分析不在本文中给出,但是分析的思路跟上面的“MySQL Bug”是类似的。

5 本文作者

裕锋,来自美团基础研发平台-基础技术部,负责美团数据库自治平台的相关工作。

针对这些疑难杂症Case,通过本篇可以了解到,如何通过内核可观测性以及全量SQL来解决这些问题。除此之外,在得出根因后,如何处理异常,如何对SQL进行优化,以及如何进行SQL治理等相关方面问题,也将在本篇中给予解答。

1 内核可观测性建设

1.1 内核可观测性建设

1.1.1 性能诊断挑战

在自治性能诊断平台的建设过程中,我们发现如下两大挑战:

  1. 很多SQL性能抖动的问题找不出根因,比如SQL的执行时长莫名其妙的突然变大,其执行计划良好、扫描跟返回的行数都很少,也没有行锁、MDL锁相关锁阻塞;查看慢查询日志,也没有哪个字段的耗时比较高,但是SQL的执行时长就是突然变长,有时候达到几十秒长,而平时往往是几毫秒,各种分析后找不出原因。
  2. 有时候在诊断一些指标异常的根因时,凭借的是不太严格的经验,而不是量化分析,比如thread_running或者slow_queries值突然升高的时候,可能会通过表information_schema.processlist查看当前的活跃会话中线程的状态,看一下状态是不是有行锁或者MDL锁之类的阻塞,或者通过慢查询日志里的相关数据来诊断根因。这里的挑战是:我们看到的是整个SQL某个时间点的瞬时状态,或者只是整个SQL执行过程中的部分数据,而不是整体的数据,所以得出的根因诊断可能是片面的,也许一瞬间看到的是行锁,但是大部分时间被MDL锁阻塞。

1.1.2 解决思路

如果使用的是社区版本的MySQL,基本上都会面临上面两大问题。我们先从内核的角度分析一下这两个挑战,对于第一个挑战,主要是对MySQL在内核层面执行细节不够了解,比如一条SQL执行了10s,而从内核层面来看的话,这十秒的时间可能会有几百个步骤组成,检查后可能发现row或者MDL锁等待时间加起来只有1秒,那么其他的9秒的耗时在哪里呢?可能发生在这几百个步骤中的任何一个或者多个,所以如果没有这几百个步骤的明细数据,就无法诊断出突然出现的性能问题的根因。

第二个问题跟第一个问题从本质上来说是一样的。由于采集的数据是某个时间点的快照数据(通过活跃会话),或者只是部分指标的数据(通过慢查询日志),所以我们看到的只是片面的信息,而没有办法获取到整个SQL的完整的耗时分布信息。

1.1.3 Wait耗时量化分析法

在分析完原因之后,我们参考了TSA的思想,同时结合MySQL自身的特点来做基于Wait的内核可观测性的建设。从TSA可以看出,SQL执行的总耗时无非就是由Off-CPU跟ON-CPU两大部分组成,如果SQL有耗时长的问题,首先要确认是在OnCPU还是在OffCPU上耗时多。如果是前者,那么说明SQL本身有问题,比如消耗资源太多(比如无效索引造成扫描行数过多);如果是后者,那么说明SQL本身没有问题,而是受到干扰或者系统资源不足,进而造成OffCPU层面耗时过多。

无论是何种情况,我们都需要把OnCPU跟OffCPU的耗时做进一步的拆分,从而来查看耗时瓶颈点到底在哪里,并且对耗时比较多的关键代码路径交由内核团队来进行埋点;相对MySQL performance_schema库中统计的Wait信息,实现非常轻量,所以对总体的性能的影响很小,并且耗时指标都是SQL明细粒度的,而不是类似performance_schema中的SQL模版级别的聚合数据。

耗时在OnCPU

在分析一条SQL执行耗时,如果发现像下图一样,耗时分布大部分都在OnCPU,这说明SQL本身有严重的性能问题(全表扫描、过滤效果不佳或者查询优化器的bug等原因),我们可以把诊断的方向集中在如何优化SQL本身就可以了,而不需要去关注其他的方面,如锁阻塞、磁盘或者网络延迟等OffCPU方面的问题。通过使用getrusage方法来获取某条SQL的OnCPU耗时时长,比如在SQL执行前,获取当前线程的ru_utime、ru_stime时间,然后在SQL执行完毕时再次获取ru_utime、ru_stime值,就可以得到SQL执行的CPU Time。

耗时在OffCPU

如果发现OnCPU的耗时占总耗时的比率比较低,通过OffCPU的相关指标发现其占总耗时的比率比较高,说明SQL本身没有问题,可能是被锁住了、硬件资源不足或者是被内核层面某个内核锁给卡住了。我们就需要查看到底是OffCPU相关的哪个,或者哪些指标耗时比较高,需要在内核层面对执行过程中的可能的、耗时比较长的执行代码路径进行埋点,只有获取到了跟性能相关的关键的数据,才能做出准确的判断。

如何选择合适而全面的OffCPU相关的代码路径进行埋点?经过探索,这里提供了如下几个方式:

  1. 分析setup_instruments表中包含的关键埋点信息,大致知道有哪些关键的指标可以埋点,比如wait/IO、Mutex类等,再结合自身的经验来判断哪些指标可能有性能瓶颈的问题。
  2. 根据实际疑难case来判断选择哪些Wait指标,比如看到”Thread xxx has waited at trx0trx.cc line 1193 for 241.00 seconds the semaphore: xxxxx mutex REDO_RSEG created trx0rseg.cc:211, lock var 1”,则对mutex REDO_RSEG进行埋点,看到”Thread xxx has waited at dict0dict.cc line 1239 for 245.00 seconds the semaphore: xxxxxx Mutex DICT_SYS created dict0dict.cc:1173, lock var 1”则对 Mutex DICT_SYS进行埋点。
  3. 源码分析,在内核层面对SQL的执行过程进行逐步的Debug,根据经验分析可能的瓶颈点来埋点;下面举两个例子分别就纯源码的角度来分析,SQL在读、写过程中哪些地方可能会造成性能抖动并做埋点。

例子1,在SQL读操作的执行过程中,需要从buffer pool中请求内存资源,如果一直满足不了内存资源的请求则一直会循环尝试获取,如果在2000ms内还是没有从Buffer pool中请求到需要的内存,会打印日志“Difficult to find free blocks in the buffer pool……”,来表明内存很紧张;这种长时间等待内存资源的获取会生产了性能抖动,这个时候就需要在对应的内核代码处进行埋点获取BP内存资源等待时间。

例子2,在SQL写操作的执行过程中是需要写redo log的,如果redo log空间不够,则需要刷盘redo log跟BP中的脏页,而刷脏页可能是个很耗时的操作,并会引起包括活跃会话突增、慢查询等性能抖动问题。这个场景跟上面的类似,也需要对源码进行分析,并且在关键的位置进行埋点来统计刷脏的耗时。

1.1.4 Wait指标层次图

通过上面的三种方式我们不断地迭代累计的关键耗时指标,整理成如下的OffCPU跟OnCPU两大类的分层架构图。截止目前为止,我们内核团队在内核层面埋点了100多个关键指标来辅助诊断,指标分为Statement跟Wait两大层级。

Statement

SQL语句级别的指标,如QUERY_TIME、 Row_time行锁时间、ROWS_EXAMINED、ROWS_SENT、Bytes_sent、NO_INDEX_USED、Full_SCAN等,但是这个层面的数据不足于判断出SQL性能问题的根因,比如QUERY_TIME很长,但是ROWS_EXAMINED、ROWS_SENT都很小,就需要进入下一个Wait级别的指标进行进一步的分析。

Wait

Wait层面主要是MySQL内核层面的指标,比如Latch(Mutex、rw_lock、sx_lock、cond)的指标,这里需要说明的是上面的分层图跟MySQL自带的performance_schema中的wait type分层图看上去很像,那为什么不直接使用而需要自研呢?这里主要从如下几个原因且内核团队都很好的解决了:

  1. performance_schema相关的统计信息是自实例启动后总体的wait时间的聚合数据,而更需要的是任何时间段、单SQL例子的明细数据,因为诊断的往往是某个时间点或者时间段的异常SQL问题。
  2. performance_schema中其有不少Bug,除此之外,它只支持OffCPU类型的指标,OnCPU耗时的统计不支持,甚至有些非常重要OffCPU的指标,比如row lock的wait统计信息都不支持。
  3. 开启performance_schema相关的wait后,对总体的性能影响比较大,而基于内核埋点的实现方式很轻量级,对数据库整体性能影响很小。

2 全量SQL

全量SQL,指的是把应用程序或者用户访问DB的所有SQL集合,需要捕获到这些SQL并且发至后端进行分析。在内核可观测性建设之前,原来采用的方式是根据MySQL的协议,来解析TCP报文来提取出SQL。此种实现方式的缺陷也很明显,就是除了SQL文本外,其他的能获取到的关键SQL指标信息非常少,这样做数据库的故障诊断跟SQL性能分析,会因为缺少关键指标而带来挑战。

针对此挑战,我们的解决方案是:改造为直接从MySQL内核吐数据,来作为全量SQL的数据来源(前面的内核可观测性分析介绍可知,单条SQL维度上能从内核层面输出100多个关键指标,包含故障诊断跟SQL性能分析需要的重要指标)。

2.1 实现方式

全量SQL其实有好几个实现方式,初版使用了抓TCP包的方式,现在逐步从TCP抓包过渡到了基于内核输出SQL文本跟关键指标的方案。

2.2 内核实现方式

从内核层面来说,MySQL对于用户的链接将创建独立的thd结构体,所以采集的全量SQL对应的SQL文本跟关键指标数据,可以存于thd结构体中。然后用户线程将thd保存的指标数据,复制到一个自定义内存池中(无锁队列);输出模块有一个线程从内存池中依次获取保存的信息后,写入全量SQL文件。具体流程如下图所示:

2.3 全量SQL总体架构

如何把海量信息(日均PB级别)上报到后端系统,来赋能故障诊断、SQL审核、索引优化建议等场景是一个较大的挑战。我们首先通过数据采集器(rds-agent)读取Full SQL File文件内的内容,再往后端传输。

但是由于数据量特别大,为了节省存储资源,分析后选用Snappy压缩算法进行压缩,默认情况下压缩效果只有1~2倍,为此分析了Snappy的源码,发现如果让相似的SQL文本聚合在一起,那么压缩效果会有很大的提高,所以按照SQL文本的前N个字符(N取50,可以根据SQL文本的实际情况做调整)进行了排序后再压缩,发现压缩比提高到了7~8倍。后端的SQL存储分成了两种形式,一种是存明细SQL,另一种是存聚合后的SQL模版信息。

产品展示

采集到的全量SQL既可以模版化展示,点击模版后也可以看到明细的SQL。

3 异常处理

根因分析后,就需要根据具体根因来进行相应的处理,但是如何安全、可靠的进行处理其实是一个很大的挑战。具体的策略是根据其操作是“无损”还是“有损”来采取不同的处理策略。

如果是对业务“无损”的操作,比如磁盘空间清理、参数值调整、缺失索引添加等,目标是让操作尽量的自动化。如果是对业务是“有损”的操作,比如需要Kill或者限流,把相关操作进行包装,发送相关的聊天群让DBA或者用户确认后再进行相关的操作。

对于MySQL Hang的情况,高可用团队会进行周期性探测,如果发现主库MySQL Hang了,则会自动进行主从切换,如果发现从库MySQL Hang了则会进行相应的MySQL实例下线替换动作。

3.1系统架构

整个异常处理的系统架构图如下,由“异常发现与分析”系统以及DBA团队开发的“预案服务”系统组成,前者提供精确的故障根因,后者根据具体的根因来安全、可靠的执行对应的预案,使故障快速恢复。

3.2 产品展示

下图是两个异常发现、分析、给出建议以及处理的例子:1)异常发现、确认根因后,发现缺索引,系统会自动调用索引优化建议系统给出建议,用户可一键添加索引;2)对于慢查询或者主从延迟问题,分析根因后提供用户进行限流、Kill、参数调优等操作的建议,避免异常被进一步放大。

4 索引优化建议与治理

众所周知,很大一部分的数据库的异常都是跟SQL性能有关。很显然,日常的SQL性能问题的治理就很有必要,一个低成本但效果显著的SQL性能优化方案是提供索引优化建议,并且可以将这种索引优化建议的能力应用于SQL生命周期的三个阶段,这三个阶段下文会进一步阐述。

  1. 应用程序“发布前”的性能问题SQL审核(事前);
  2. SQL执行过程中的实时性能问题SQL发现(事中);
  3. SQL“执行完毕”后批量的SQL治理(事后)。

4.1 索引优化建议

建立适合的索引对SQL的性能提升效果很明显,如何添加适合的索引是一个有挑战性的任务。索引的添加不能光凭经验,因为索引是否被用到不但跟表的数据的分布有关,还跟MySQL查询优化器的Cost逻辑有关系,所以“人肉”进行索引建议的评估并不是一个好的方案。

解决方式是尽量要基于MySQL的Cost模型,利用MySQL自身提供的查询优化器的能力,来给出最佳的索引;索引优化建议的建设一般分为三个阶段:1)单SQL维度的优化建议;2)基于workload的整体优化建议;3)索引自维护。这几个阶段的建设需要按顺序来进行,并且不可跳跃。下面我们将分别进行阐述。

4.2 单SQL索引优化建议

4.2.1 实现思路

单SQL索引优化建议,就是指输入一条SQL语句后,优化建议系统给出一个索引优化建议的过程。想要利用查询优化器本身的能力来实现索引建议的目标,就必须先要了解MySQL的查询优化器是如何工作的。

这里举个简单的单表SQL查询的例子,帮助我们来初步了解其工作原理。我们先了解查询优化器是如何从众多的候选执行计划中选择最终的执行计划的;在有了这个基础之后,再来讲是如何利用查询优化器自身的能力来做索引优化建议。

比如有SQL语句“select * from test_db.table1 where c2=3 and c3=4 and c4<‘3’”,分别在字段id、c2、c3以及c4上面建立了主健索引Primary以及三个二级索引idx_c2、idx_c3及dx_c4。假设最终查询优化器从多个候选执行计划中选择了“执行计划2”(通过索引idx_c2做index seek操作来执行SQL),问题是此SQL的候选执行计划有多个,为什么最后选中了通过索引idx_c2而不是其他的索引做index seek操作的执行计划呢?总的来说,选择idx_c2为索引的执行计划,是因为相比于选择其他索引生成的执行计划的Cost更小。

具体来说,针对SQL的两类表访问方式:1)基于全表扫描的访问方式;2)非全表扫描的方式(比如Index_scan、ref类型或者range_access等),下图是这些访问方式Cost计算过程中涉及到的查询优化器跟存储引擎的关键函数,目标是假设某个索引存在的情况下,修改这些函数来模拟Cost的计算过程,来发现哪个索引存在的情况下对应的Cost值最小。

流程中的各种数据访问方式,对其Cost计算方式逐个分析。

1)Table scan访问方式

全表扫描cost计算公式可以知道Table scan的Cost分为IO的Cost跟CPU Cost两个部分之和,大致的公式为:IO-cost:#pages in table * IO_BLOCK_READ_COST + CPU cost:#records * ROW_EVALUATE_COST,其中IO-cost是通过table_scan_cost来计算的。

这里有两个关键的变量records跟pages in table,分别表示这个表有多少行跟多少page,这两个变量的值是怎么来的呢?如果使用的是innodb,那么前者来自于ha_innobase::info_low(ha_innobase::info),后者来自于ha_innobase::scan_time()(而这两个函数都是SQL层handler.h定义的虚函数,不同的存储引擎分别实现之),知道了这两个变量的值,就知道具体的Cost值了。

2)Index scan访问方式

覆盖索引的扫描,从index_scan_cost得知Index scan的Cost为:O-cost:#(records/keys_per_block) * IO_BLOCK_READ_COST+ CPU cost:#records * ROW_EVALUATE_COST,由于IO_BLOCK_READ_COST跟ROW_EVALUATE_COST都是常量,所以需要关注是keys_per_block(keys_per_block的计算跟block_size等有关)跟records这两个变量,也就是说如果知道了这两个变量的值,就知道了具体的Cost值了。

3)range access访问方式

multi_range_read_info_const得知range access的Cost为:IO-cost:#records_in_range * IO_BLOCK_READ_COST + CPU cost:2#records_in_range ROW_EVALUATE_COST,由于IO_BLOCK_READ_COST跟ROW_EVALUATE_COST都是常量,所以只需要知道records_in_range这个变量的值,就能计算其Cost了。

4)ref访问方式

find_best_ref得知ref访问方式的Cost大致为:IO-cost:#prefix_rowcount * IO_BLOCK_READ_COST + CPU cost:#prefix_rowcount * cur_fanout * ROW_EVALUATE_COST,需要实现cur_fanout这个变量,而这个变量跟info中的 rec_per_key(rec_per_key的意思是,比如对于select * from test_db.table1 where c2=3,table1中有多少行数据满足c2=3)有关系,所以实现rec_per_key算法后就可以知道其Cost值了。

4.2.2 What_If索引建议策略

如何把上面的Cost计算方式的理论分析应用到索引优化建议中?假设表table1除了主键外没有任何索引,索引优化建议工具最后怎么能给用户推荐出索引idx_2呢?关于这个问题可以用到一种称之为What_If的策略(在微软的AutoAdmin “what-if” index analysis utility中提出),思路就是假设索引idx_2存在并且能计算出在索引idx_2存在的情况下,此执行计划的Cost值,如果发现其Cost只要比当前存在的其他执行计划的Cost值更小,就推荐这个索引。

但是MySQL计算Cost的时候,要求索引是真实存在的,为了给出索引优化建议在生产环境实际去创建各种索引是不可能的。解决的策略就是在非生产环境的数据库实例上创建一个空表,并且添加所有可能被用到的索引(只是一个元数据的create index操作而不去真正的向表内添加数据),最后让优化器去自己选择最佳的索引来生成执行计划,这就是索引优化建议总体思路。

那么问题来了,在非生产环境下创建的索引只是一个元数据,不包含任何数据,如果就这样让查询优化器去计算Cost肯定是行不通。主要原因是缺乏上面的分析的各个访问方式的关键变量值,所以最关键的如何获取到那些影响cost计算的关键的变量信息。

从上面的“实现思路”分析可知,在计算Table scan、Index scan、range access或者ref的cost的时候,主要是从scan_time()、records_in_range()、info()这些在innodb存储引擎里实现的方法获取相关统计信息,来计算Cost大小。而索引没有真实的数据,所以必须要修改存储引擎里scan_time()、records_in_range()、info()这些跟计算Cost相关的方法的实现代码,让其在索引数据不存在的情况下,也能比较真实地获取到跟计算Cost值相关的统计数据。

比如,上面提到的records、keys_per_block、pages in table、records_in_range、cur_fanout等这些变量,就需要自己去计算;比如records这个变量,发现其是在Innodb中info()的实现,通过n_rows(records)赋值给records,所以只需要在info()中从生产环境的表里获取到真实的行数,赋值给records就可以了,其实就是一个元数据的查询而已。

再看一下ref类型访问方式的Cost计算过程中用到的cur_fanout变量,而这个值是来自info()中innodb_rec_per_key的值,它表示有多少个值满足等值条件,只需要在info()中实现innodb_rec_per_key的计算就可以了。

那么innodb_rec_per_key又怎么计算呢?我们可以对表进行了采样(采样的方案可以参考这篇《Random Sampling for Histogram Construction: How Much is Enough?》论文),采样后的数据对某列统计有多少个不同的值,之后使用采集到的总行数/不同值的个数来获取到innodb_rec_per_key值。

这里还有一个非常重要的问题,上面的方案是需要修改对应存储引擎的scan_time()、records_in_range()、info()这些关键的方法,但是innodb存储引擎的代码太复杂了,修改的难度比较大,一个比较好的方案是去修改federated存储引擎里的scan_time()、records_in_range()、info()这些方法,去获取上面不同访问方式中需要用到的关键变量,因为其实现代码比较简单。

4.2.3 索引建议整体流程图

把之前的步骤整理后的索引优化建议的流程图如下:

  1. 从待优化的SQL的关键位置,比如Where、On、Ordr By等位置提取关键字段信息,利用提取到的字段信息生成候选索引;
  2. 通过对涉及到的表的数据进行采样来获取查询优化器计算Cost时需要用到的统计信息,之后创建不包含任何数据的索引;
  3. 修改存储引擎的代码重新实现的scan_time()、records_in_range()、info()这些函数,使得查询优化器能利用数据采样后计算出来的统计信息来比较真实的获取Cost值;
  4. 选择一个Cost代价最小的执行计划,而此执行计划背后的索引就是优化建议工具推荐的索引,整体思路就是这样。

4.2.4 验证与跟踪

通过全量SQL等系统获取待优化的SQL语句后,输入索引优化建议系统,在给真实的生产环境添加这些索引之前,需要在非生产环境进行验证,看一下添加了相关的索引后性能是否真的有提升。如果有提升,那么把索引在生产环境添加后(比如通过开源的改表工具Ghost等),需要通过跟踪被影响SQL的性能,来查看其执行时间性能是否真的有所提高。如果有性能有回退,需要及时进行告警。

4.2.5 产品展示

下面的三个图分别展示了提供给用户的索引建议,以及索引添加前后的执行时间效果数据对比图。

4.3 基于Workload的索引优化建议

具备了单SQL的索引优化建议能力后,那么在有限的存储空间的限制下(比如某个实例最多只能添加100G空间的索引),如果想给某个DB或者实例添加索引,添加哪些索引才能使整个数据库的性能提升达到最大化呢?思路主要是参考了微软在基于Workload索引优化建议方面的一序列相关工作。

4.3.1 总体架构

基于Workload的索引优化总体架构如下,总共有6大步骤组成,每个步骤对应着一个核心组件。

1)COLUMN GROUP RESTRICTION组件

如果一个workload(典型业务周期内包含的所有SQL总和称之为workload)包含大量的SQL模版,那么潜在的索引组合的数量是极其庞大的,从中挑选出让系统性能提升最大的索引组合是一个非常困难并且耗时的过程。所以,我们需要一种称为列组合限制(COLUMN GROUP RESTRICTION)的技术,它的目的是消除大量可能组成索引的列组,因为这些列组最多只能对最终推荐的质量产生很小的边际影响(就是说就算推荐了索引,最后对数据库整体的性能的提升很有限,比如某个SQL只执行了一次,那么给之对应的表加索引就没意义了),所以本步骤的输出是workload的一组“有趣”的、对整体性能提升可能有最大提升的列组。

那么,怎么样选出这个“有趣”列组呢?我们需要定义一个叫CG-Cost (g) 的函数,其值为引用列组合g的workload中所有查询SQL的Cost的某个分数值,查询SQL的Cost可以通过优化器估计的成本(MySQL中explain给出的Cost)来获取。如果CG-Cost(g) ≥ f ,列组g需要保留下来(其中的0 ≤ f ≤ 1是预先确定的阈值),否则可以丢失这个列组g。看一个具体的例子:考虑下面的表T (A,B,C,D) 的workload由“Q1,… Q10”等10个SQL组成。如果查询引用该列,则上述矩阵中的单元格包含1,否则包含0。为了简单起见,假设所有查询的成本均为1个单位。

假设指定的阈值f = 0.2,那么工作负载的有趣列组是{A}、{B}、{C}、{A,B}、{A,C}、{B,C} 和 {A,B,C} ,以及各组的 CG-Cost(g)为1.0、0.3、0.9、0.3、0.9、0.2、0.2,而D相关的列因为CG-Cost (g) 值小于0.2而不被考虑,这样就可以在庞大的列组合的情况下排除掉大部分列组合,加快整个索引选择的迭代优化的过程。

2)Candidate index selection组件

单列索引(假设索引)的建立,如果考虑workload的每一个可能的索引集,那么就会有太多的what_if索引需要考虑,这样整体的优化过程会非常缓慢。

一个比较好的思路是为每个Query独立确定最佳的索引集,并将属于这些最佳的索引集中的一个或多个索引视为候选索引集。该算法背后的直觉是,如果对于workload中的单个查询,该索引不是最佳的索引集的一部分,也不可能是整个workload的最佳的索引集的一部分。

这个算法的挑战在于如何为workload中的每个查询Q确定最佳的索引集呢?其实针对单个查询来确定最佳的索引集这个问题与根据整体的基于workload来选择最佳的索引集这个问题,两者没有本质的不同。所以可以设计一个特殊的workload,而这个workload只包含一个查询,之后可以通过上面已经实现的索引优化建议工具本身,来为单个Query获取最佳的索引集。

单查询获取最佳的索引集,如何为单个SQL(SQL模版)获取最佳的索引集呢?一个方法是单个SQL模版进行解析,在SQL关键的位置比如where、on、order by、group by、select等的取出关键的列先创建单个列作为候选索引列,再结合下面的从“Multi-column Index Generation组件”那节创建出来的多列候选索引,让查询优化器自己去选择最好的索引。同时这个步骤依靠优化器选出来的索引,作为下一轮算法(索引合并)的输入。

3)Index merging组件

上面的“CANDIDATE SELECTION”组建选出来的索引是专门针对单SQL优化的索引集,但是对于整体workload来说,这个方法会导致较大的存储和索引维护成本。比如,针对单SQL选出的索引都是最佳的,但是这些索引加起来后的存储大小已经超出了允许的范围,需要进行一些索引的合并。

如果两个索引的列存在部分重叠,则可能可以合并,比如包括关系的索引[a,b,c] 跟[a,b] 以及有部分重叠的索引[a,b,c] 跟[c,d}] 的合并,当然[a,b] 跟[c,d}]是不能合并的。索引合并的主要思想就是采用现有的一组索引生成一组具有更低存储和维护开销的新索引,同时索引被合并的情况下,SQL查询的性能能保持不变或者影响比较小,索引合并的架构如下:

4)Configuration Enumeration组件

如果基于workload的索引集有n个候选索引,但是因为存储大小限制,要求workload工具最多只能选择k个索引(一般是受到了存储大小跟Cost大小的限制),那么应该如何选择这个k个索引呢?一般来说,原始的暴力枚举算法将枚举大小为k或更小的候选索引的所有子集,并选择总成本最低的一个索引集,这个枚举算法确实保证了最优解。

然而,这在工程上不切实际的。因为对于n=40和k=10的情况,枚举的索引集的数量实在太庞大,无法进行详尽的枚举,所以需要一个性能更好的算法的“贪心算法”,其思想是假设有n个SQL(一般是实施的时候,是n个SQL模版来替代n个SQL实例),整个workload W表示为 {Q1, .., Qn}。

方案是利用暴力跟贪心算法的组合,运行Greedy(m, k)算法枚举索引集合(m为通过暴力算法选择的m个最佳索引,k为选择的索引的总大小),选择一组索引,直到选出所需数量的索引或总Cost无法进一步降低,该算法选择大小为m(其中m <= k)的最佳索引集作为“种子”,然后以贪心算法扩展这个种子,直到选择了所有k个索引,探索算法就是从所有的候选索引中选择一个索引,使得加了这个索引后,整体SQL的性能提升效果最大化,虽然从全局角度看未必是提升最大化的。

5)Multi-column Index Generation组件

多列索引的选择有一个比较大的挑战,就是候选索引实在太多,比如对于表中给定的一组k列,最多可能有k!多列索引,考虑所有排列组合将会显著增加Configuration(索引)空间,所以需要减少列组合的搜索空间。采用迭代方法来考虑多列索引的宽度(列的个数)。在第一次迭代中,只考虑单列索引,基于在第一次迭代中选择的单列索引,在往后的迭代中选择一组可接受的双列索引。这组“两列索引”与单列索引一起成为第二次迭代的输入。

那么,具体是如何在单列的基础上选择其他的列组成多列索引呢?这里有多种算法,先使用符号M (a, b) 来表示列a和b上的双列索引,其中a是双列索引的前导列。多列索引选择一般有MC_LEAD、MC_ALL、MC_BASIC三种算法,这几种算法在实现的时候一般会采用MC_LEAD算法 ,因为被证明效果最好。

  • MC_LEAD:从CANDIDATE SELECTION被优化器选择的索引,再加上某个“indexable column” 列,这个“indexable column” 列对应的索引不一定在第一轮迭代中被选择;
  • MC_ALL:从CANDIDATE SELECTION被优化器选择的索引,再加上某个“indexable column” 列,这个“indexable column” 列对应的索引一定在第一轮迭代中被选择,这种条件比较苛刻,业界相关的测试表明效果明确不如MC_LEAD跟MC_BASIC,虽然多列索引的搜索空间明显减少,提升了总体性能;
  • MC_BASIC:则是把单列跟多列索引混在一个迭代周期里建立,而不是先根据单列建议一个索引,然后再通过迭代的方式在单列的基础上逐渐增加索引的宽度。

6)Final Indexes组件

经过几轮迭代,当同时满足Cost跟Storage Size的要求后退出迭代优化过程,输出一组最优的索引优化建议集合。

4.4 SQL治理

具备了SQL索引优化建议的能力后,就可以对有性能问题的SQL进行三个阶段的治理,1)SQL发布生产环境前,起到防患于未然、把问题扼杀在摇篮中的作用;2)SQL生产环境执行过程中,起到实时监控发现正在执行的问题SQL,快速止损,比如误删索引的作用;3)SQL生产环境执行过后,对于执行过的SQL,基于整体的Workload优化策略,通过离线的方式进行批量的风险SQL治理。如下图所示:

4.4.1 风险SQL审核(事前)

此阶段是程序发布前对潜在的风险SQL进行卡点,在公司的CI/CD平台集成流水线里增设SQL审核卡控点,尽量防止风险SQL被带到生产环境引发故障,起防患于未然的作用。

具体来说,指定了一序列的规则,发现风险SQL后可以暂停发布,并且给出相应的索引优化建议来优化SQL。

4.4.2 产品展示

根据审核规则,展示审核结果与建议,包括风险提示跟SQL索引优化建议等内容,审核的规则包括是否全表扫描、扫描行数过多等内容。

4.4.3 性能问题SQL实时发现(事中)

此阶段是风险SQL的实时发现功能,主要针对在SQL运行过程中因为表结构的更改(如索引误删除)、事前审计阶段遗漏掉的性能问题SQL等场景,实时地发现有性能问题的SQL,并且给出优化建议。对于实时的性能问题SQL发现使用了两种发现策略,一种是基于规则的发现策略,另一种通过数据建模的方式来发现策略。

基于规则的风险发现策略,其中的规则包括单位时间内的慢查询数量、SQL执行时间、扫描行数等内容。

基于数据建模的风险发现策略,是通过全量SQL获取SQL的历史执行时长来建模,再通过Process List等数据源,把当前正在执行SQL的执行时间输入模型,从执行时间的角度来判断是否有异常。

4.4.4 批量SQL治理(事后)

此阶段是通过对SQL执行的历史记录进行批量分析,从全局的角度、利用workload索引优化建议的思想提供最佳SQL索引优化建议,批量推送优化建议给用户进行风险SQL的治理。

4.4.5 产品展示

对于批量的SQL优化建议,这里提供了一键审批索引添加的功能,可以更快的让被建议的索引添加到生产环境上。

5 本文作者

裕锋,来自美团基础研发平台-基础技术部,负责美团数据库自治平台的相关工作。

美团MySQL自治平台建设的内核原理及实践

发表评论

您的电子邮箱地址不会被公开。

滚动到顶部