数据仓库中的 SQL 性能优化(MySQL篇)

做数据仓库的头两年,使用高配置单机 + MySQL的方式来实现所有的计算(包括数据的ETL,以及报表计算。没有OLAP)。用过MySQL自带的MYISAM和列存储引擎Infobright。这篇文章总结了自己和团队在那段时间碰到的一些常见性能问题和解决方案。

P.S.如果没有特别指出,下面说的mysql都是指用MYISAM做存储引擎。

1.利用已有数据避免重复计算

业务需求中往往有计算一周/一个月的某某数据,比如计算最近一周某个特定页面的PV/UV。这里出现的问题就是实现的时候直接取整周的日志数据,然后进行计算。这样其实就出现了重复计算,某一天的数据在不同的日子里被重复计算了7次。

解决办法非常之简单,就是把计算进行切分,如果是算PV,做法就是每天算好当天的PV,那么一周的PV就把算好的7天的PV相加。如果是算UV,那么每天从日志数据取出相应的访客数据,把最近七天的访客数据单独保存在一个表里面,计算周UV的时候直接用这个表做计算,而不需要从原始日志数据中抓上一大把数据来算了。

这是一个非常简单的问题,甚至不需要多少SQL的知识,但是在开发过程中往往被视而不见。这就是只实现业务而忽略性能的表现。从小规模数据仓库做起的工程师,如果缺乏这方面的意识和做事规范,就容易出现这种问题,等到数据仓库的数据量变得比较大的时候,才会发现。需求决定能力。

2.case when关键字的使用方法

case when这个关键字,在做聚合的时候,可以很方便的将一份数据在一个SQL语句中进行分类的统计。举个例子,比如下面有一张成绩表(表名定为scores):

 数据仓库中的 SQL 性能优化(MySQL篇)

现在需要统计小张的平均成绩,小明的平均成绩和小明的语文成绩。也就是最终结果应该是:

数据仓库中的 SQL 性能优化(MySQL篇)

SQL实现如下:

数据仓库中的 SQL 性能优化(MySQL篇)

如果现在这个成绩表有1200万条数据,包含了400万的名字 * 3个科目,上面的计算需要多长时间?我做了一个简单的测试,答案是5.5秒。

而如果我们在name列上面加了索引,并且把sql改成下面的写法:

数据仓库中的 SQL 性能优化(MySQL篇)

这样的话,只需要0.05秒就能完成。

那么如果有索引的话,前面的一种实现方法会不会变快?答案是不会,时间还是跟原来一样。

而如果没有索引,后面一种写法会用多少时间?测试结果是3.3秒。

把几种情况再理一遍:

数据仓库中的 SQL 性能优化(MySQL篇)

之所以后面一种写法总是比前面一种写法快,不同之处就在于是否先在where里面把数据过滤掉。用where有两个好处:一个是有索引的话就能使用,而case when很有可能用不到索引(关于索引的具体使用这里就不详细解释了,至少在这个例子中前一种写法没有用到索引),第二是能够提前过滤数据,哪怕没有索引,前一种写法扫描了三遍全表的数据(做一个case when扫一遍),后面的写法扫描一遍全表,把数据过滤了之后,case when就不用过这么多数据量了。

而实际情况是,开发经常只是为了实现功能逻辑,而习惯了在case when中限制条件取数据。这样在出现类似例子中的需求时,没有把应该限制的条件写到where里面。这是在实际代码中发现最多的一类问题。

3.分页取数方式

在数据仓库中有一个重要的基础步骤,就是对数据进行清洗。比如数据源的数据如果以JSON方式存储,在mysql的数据仓库就必须将json中需要的字段提取出来,做成单独的表字段。这个步骤用sql直接处理很麻烦,所以可以用主流编程语言(比如java)的json库进行解析。解析的时候需要读取数据,一次性读取进来是不可能的,所以要分批读取(相当于分页了)。

最初的实现方式就是标记住每次取数据的偏移量,然后一批批读取:

数据仓库中的 SQL 性能优化(MySQL篇)

这样的代码,在开始几句sql的时候执行速度还行,但是到后面会越来越慢,因为每次要读取大量数据再丢弃,其实是一种浪费。

高效的实现方式,可以是用表中的主键进行分页。如果数据是按照主键排序的,那么可以是这样(这么做是要求主键的取值序列是连续的。假设主键的取值序列我们比较清楚,是从10001-1000000的连续值):

数据仓库中的 SQL 性能优化(MySQL篇)

就算数据不是按主键排序的,也可以通过限制主键的范围来分页。这样处理的话,主键的取值序列不连续也没有太大问题,就是每次拿到的数据会比理想中的少一些,反正是用在数据处理,不影响正确性:

数据仓库中的 SQL 性能优化(MySQL篇)

这样的话,由于主键上面有索引,取数据速度就不会受到数据的具体位置的影响了。

4.索引使用

索引的使用是关系数据库的SQL优化中一个非常重要的主题,也是一个常识性的东西。但是工程师在实际开发中往往是加完索引就觉得万事大吉了,也不去检查索引是否被正确的使用了,所以还是简单的提一下关于索引的案例。

还是举例说明。假如有一个电商网站,积累了某一天的访问日志表item_visits,每条记录表示某一个商品(item)被访问了一次,包括访问者的一些信息,比如用户的id,昵称等等,有1200多万条数据。示例如下:

数据仓库中的 SQL 性能优化(MySQL篇)

商品本身有一个商品表items,包含800多种商品,表名了商品名字和所属种类:

数据仓库中的 SQL 性能优化(MySQL篇)

现在要计算每个商品种类(item_type)被访问的次数。sql的实现不难:

数据仓库中的 SQL 性能优化(MySQL篇)

然后既然是join,那么在join key上需要加索引。这时候有的工程师就随手在items的item_id上面加了索引。跑了一下,需要95秒。(p.s.在我的测试场景中,这个日志表有20多个字段,所以虽然这个表的记录数跟问题2中的那个表的记录数差不多,但是大小会差很多,了解这个背景可以解释这里的计算用时为什么会远远超过问题2中的用时。)

前面说是随手加的索引,其实就已经在暗示加的有问题。那我们在item_visit的item_id上面再加个索引,需要跑多久?80秒。

用explain查一下执行计划:

数据仓库中的 SQL 性能优化(MySQL篇)

注意到这里是以日志表作为驱动表的(即从日志表开始扫描数据,而商品表是nest loop的内层嵌套),这样的话两个表的item_id都用到了,商品表的索引做join,日志表的索引可以做覆盖索引(这个覆盖索引就是比前面快的原因)。看上去挺“划算”的,实际上由于放弃了item小表驱动,速度反而慢了很多。

接下来用straight_join的连接方式把这个sql强制改成小表驱动:

数据仓库中的 SQL 性能优化(MySQL篇)

再来看执行计划:

数据仓库中的 SQL 性能优化(MySQL篇)

虽然这样一来商品表的索引就用不到了,但是这其实是正确的做法(当然如果条件允许,也未必要用straight join,把商品表上的索引去掉其实是最合理的做法,这样mysql就会自己选择正确的执行计划了。),测试下来只需要8秒。原因就在于大表驱动时,根据标准的Block Nested Loop Join算法,小表的数据会被反复循环读取。当然实际上小表是可以进cache而不用重复读取的,但是由于mysql只认索引有没有用上,所以还是会反复读取小表(这个问题在这个slides的35页也有描述)。而如果小表驱动,就不会有这个问题。

后续更新:严格来说,这个场景有一个限制条件,就是大表中的商品item_id只占全部item_id的一部分。如果大表中的商品item_id几乎均匀覆盖所有item_id,那么无论join时用哪个表的索引,其实运行时间都差不多。原来做实验的时候忽视了这一点,后来重新尝试的时候发现了这个问题。特此补充。

小结一下:这里说了两个问题,一个是添加索引的时候需要想想如何去加,在不是很肯定的时候可以看看执行计划,而不是教条式的知道“join要加索引”。学习sql优化切忌只是背几个tips。另外就是mysql在选择执行计划的时候也不一定能够做到最好,如果发现mysql的执行计划有很大问题,那么就需要工程师进行调整,mysql中一样有类似oracle中的hint帮助我们达到想要的目的,就像例子中的straight_join。

5.过多的join

在mysql中,需要join的表如果太多,会对性能造成很显著的下降。同样,举个例子来说明。

首先生成一个表(命名为test),这个表只有60条记录,6个字段,其中第一个字段为主键:

数据仓库中的 SQL 性能优化(MySQL篇)

然后做一个查询:

数据仓库中的 SQL 性能优化(MySQL篇)

也就是说让test表跟自己关联。计算的结果显然是60,而且几乎不费时间。

但是如果是这样的查询(十个test表关联),会花费多少时间?

数据仓库中的 SQL 性能优化(MySQL篇)

答案是:肯定超过5分钟。因为做了实际测试,5分钟还没有出结果。这里的测试为了方便起见,用了一个表自己关联10次,实际上如果是不同的表,效果也是一样的。

那么mysql到底在干什么呢?用show processlist去看一下运行时情况:

数据仓库中的 SQL 性能优化(MySQL篇)

原来是处在statistics的状态。这个状态,根据mysql的解释是在根据统计信息去生成执行计划,当然这个解释肯定是没有追根溯源。实际上mysql在生成执行计划的时候,其中有一个步骤,是确定表的join顺序。默认情况下,mysql会把所有join顺序全部排列出来,依次计算各个join顺序的执行代价并且取最优的那个。这样一来,n个表join会有n!种情况。十个表join就是10!,大概300万,所以难怪mysql要分析半天了。

而在实际开发过程中,曾经出现过30多个表关联的情况(有10^32种join顺序)。一旦出现,花费在statistics状态的时间往往是在1个小时以上。这还只是在表数据量都非常小,需要做顺序分析的点比较少的情况下。至于出现这种情况的原因,无外乎我们需要计算的汇总报表的字段太多,需要从各种各样的地方计算出来数据,然后再把数据拼接起来,报表在维护过程中不断添加字段,又由于种种原因没有去掉已经废弃的字段,这样字段必定会越来愈多,实现这些字段计算就需要用更多的临时计算结果表去关联到一起,结果需要关联的表也越来越多,成了mysql无法承受之重。

这个问题的解决方法有两个。从开发角度来说,可以控制join的表个数。如果需要join的表太多,可以根据业务上的分类,先做一轮join,把表的数量控制在一定范围内,然后拿到第一轮的join结果,再做第二轮全局join,这样就不会有问题了。从运维角度来说,可以设置optimizer_search_depth这个参数。它能够控制join顺序遍历的深度,进行贪婪搜索得到局部最优的顺序。一般有好多个表join的情况,都是上面说的相同维度的数据需要拼接成一张大表,对于join顺序基本上没什么要求。所以适当的把这个值调低,对于性能应该说没有影响。

6.列存储引擎Infobright

Infobright是基于mysql的存储引擎,具有列存储/列压缩和知识网格等特性,比较适合数据仓库的计算。使用起来也不需要考虑索引之类的问题,非常方便。不过经过一段时间的运用,也发现了个别需要注意的问题。

一个问题和MYISAM类似,不要取不需要的数据。这里说的不需要的数据,包括不需要的列(Infobright的使用常识。当然行存储也要注意,只不过影响相对比较小,所以没有专门提到),和不需要的行(行数是可以扩展的,行存储一行基本上都能存在一个存储单元中,但是列存储一列明显不可能存在一个存储单元中)。

第二个问题,就是Infobright在长字符检索的时候并不给力。一般来说,网站的访问日志中会有URL字段用来标识访问的具体地址。这样就有查找特定URL的需求。比如我要在cnblog的访问日志中查找到我的blog的访问次数:

数据仓库中的 SQL 性能优化(MySQL篇)

类似这样在一个长字符串里面检索子串的需求,Infobright的执行时间测试下来是mysql的1.5-3倍。

至于速度慢的原因,这里给出一个简要的解释:Infobright作为列式数据库使用了列存储的常用特性,就是压缩(列式数据库的压缩率一般要能做到10%以内,Infobright也不例外)。另外为了加快查找速度,它还使用了一种叫知识网格检索方式,一般情况下能够极大的减少需要读取的数据量。关于知识网格的原理已经超出了本篇文章的讨论篇幅,可以看这里了解。但是在查询url的时候,知识网格的优点无法体现出来,但是使用知识网格本身带来的检索代价和解压长字符串的代价却仍然存在,甚至比查询一般的数字类字段要来的大。

然后根据其原理可以给出一个能够说明问题的解决方法(虽然实用度不算高):如果整个表里面就有一个长字符串字段查询起来比较麻烦,可以把数据根据这个字段排序后再导入。这样一来按照该字段查询时,通过知识网格就能够屏蔽掉比较多的“数据包”(Infobright的数据压缩单元),而未排序的情况下符合条件的数据散布在各个“数据包”中,其解压工作量就大得多了。使用这个方法进行查询,测试下来其执行时间就只有mysql的0.5倍左右了。

数据仓库中的 SQL 性能优化(MySQL篇)

上一篇:WordPress – wp-rocket插件的简单设置以及如何加速网站


下一篇:Ctfshow web入门-web38 WP