数据仓库中的SQL性能优化(MySQL篇)
做数据仓库的头两年,使用高配置单机 + MySQL的方式来实现所有的计算(包括数据的ETL,以及报表计算。没有OLAP)。用过MySQL自带的MYISAM和列存储引擎Infobright。这篇文章总结了自己和团队在那段时间碰到的一些常见性能问题和解决方案。
P.S.如果没有特别指出,下面说的mysql都是指用MYISAM做存储引擎。
利用已有数据,避免重复计算
业务需求中往往有计算一周/一个月的某某数据,比如计算最近一周某个特定页面的PV/UV。这里出现的问题就是实现的时候直接取整周的日志数据,然后进行计算。这样其实就出现了重复计算,某一天的数据在不同的日子里被重复计算了7次。
解决办法非常之简单,就是把计算进行切分,如果是算PV,做法就是每天算好当天的PV,那么一周的PV就把算好的7天的PV相加。如果是算UV,那么每天从日志数据取出相应的访客数据,把最近七天的访客数据单独保存在一个表里面,计算周UV的时候直接用这个表做计算,而不需要从原始日志数据中抓上一大把数据来算了。
这是一个非常简单的问题,甚至不需要多少SQL的知识,但是在开发过程中往往被视而不见。这就是只实现业务而忽略性能的表现。从小规模数据仓库做起的工程师,如果缺乏这方面的意识和做事规范,就容易出现这种问题,等到数据仓库的数据量变得比较大的时候,才会发现。
case when关键字的使用方法
case when
这个关键字,在做聚合的时候,可以很方便的将一份数据在一个SQL语句中进行分类的统计。举个例子,比如下面有一张成绩表(表名定为 scores
):
name | course | score |
---|---|---|
小明 | 语文 | 90 |
小张 | 语文 | 94 |
小红 | 语文 | 95 |
小明 | 数学 | 96 |
小张 | 数学 | 98 |
小红 | 数学 | 94 |
小明 | 英语 | 99 |
小张 | 英语 | 96 |
小红 | 英语 | 93 |
现在需要统计小张的平均成绩,小明的平均成绩和小明的语文成绩。SQL实现如下:
select |
如果现在这个成绩表有1200万条的数据,包含了400万的名字 * 3个科目,上面的计算需要多长时间?我做了一个简单的测试,答案是5.5秒。
而如果我们把sql改成下面的写法:
select |
这样的话,只需要3.3秒就能完成。
之所以后面一种写法总是比前面一种写法快,不同之处就在于是否先在 where
里面把数据过滤掉。前一种写法扫描了三遍全表的数据(做一个 case when
扫一遍),后面的写法扫描一遍全表,把数据过滤了之后, case when
就不用过这么多数据量了。
跟进一步说,如果在name字段上有索引,那么后一种写法将会更快,测试结果只用0.05秒,而前面一种情况,sql优化器是判断不出来能用索引的,时间依然是5.5秒。
在实际工作中,开发经常只是为了实现功能逻辑,而习惯了在 case when
中限制条件取数据。这样在出现类似例子中的需求时,没有把应该限制的条件写到 where
里面。这是在实际代码中发现最多的一类问题。
分页取数方式
在数据仓库中有一个重要的基础步骤,就是对数据进行清洗。比如数据源的数据如果以JSON方式存储,在mysql的数据仓库就必须将json中需要的字段提取出来,做成单独的表字段。这个步骤用sql直接处理很麻烦,所以可以用主流编程语言(比如java)的json库进行解析。解析的时候需要读取数据,一次性读取进来是不可能的,所以要分批读取(相当于分页了)。
最初的实现方式就是标记住每次取数据的偏移量,然后一批批读取:
select json_obj from t limit 10000,10000; |
这样的代码,在开始几句sql的时候执行速度还行,但是到后面会越来越慢,因为每次要读取大量数据再丢弃,其实是一种浪费。
高效的实现方式,可以是用表中的主键进行分页。如果数据是按照主键排序的,那么可以是这样(这么做是要求主键的取值序列是连续的。假设主键的取值序列我们比较清楚,是从10001-1000000的连续值):
select json_obj from t where t.id > 10000 limit 10000; |
就算数据不是按主键排序的,也可以通过限制主键的范围来分页。这样处理的话,主键的取值序列不连续也没有太大问题,就是每次拿到的数据会比理想中的少一些,反正是用在数据处理,不影响正确性:
select json_obj from t where t.id > 10000 and t.id <= 20000; |
这样的话,由于主键上面有索引,取数据速度就不会受到数据的具体位置的影响了。
索引使用
索引的使用是关系数据库的SQL优化中一个非常重要的主题,也是一个常识性的东西。但是工程师在实际开发中往往是加完索引就觉得万事大吉了,也不去检查索引是否被正确的使用了,所以会经常出一些瞎猫碰到死耗子或者是似是而非的情况。
索引调整
前面说到开发人员在对索引的了解似是而非的时候只知道要加索引,而不知道为什么加。
比如现在有一个数据集,对应非常常见的网站统计场景。这个数据集有两个表组成,其中一个是流量表 item_visits
,每条记录表示某一个商品(item)被访问了一次,包括访问者的一些信息,比如用户id,用户名等等,有将近800万条数据。示例如下:
item_id | visitor_id | visitor_name | visitor_city | url | …… |
---|---|---|---|---|---|
1 | 55 | 用户001 | 1 | …… | …… |
10 | 245 | 用户002 | 2 | …… | …… |
3 | 2 | 用户003 | 1 | …… | …… |
10 | 148 | 用户004 | 3 | …… | …… |
3 | 75 | 用户005 | 4 | …… | …… |
7 | 422 | 用户006 | 4 | …… | …… |
3 | 10 | 用户007 | …… | …… | …… |
…… | …… | …… | …… | …… | …… |
另一个表是商品表 items
,包含1200多种商品,字段有商品名字和所属种类:
item_id | item_name | item_type |
---|---|---|
1 | iphone 5 | 手机 |
2 | iphone 5s | 手机 |
…… | …… | …… |
现在有一个需求,计算每个商品种类(item_type)被访问的次数。sql的实现不难:
select item_type, count(*) as visit_num |
开发人员知道,在join的时候,其中的一个表的join key要加索引,然后他发现 visit
表在 item_id
字段上已经有索引了,所以就打完收工了。到这里为止一切都没有问题。但是后来这个需求有改动,需要限制用户的城市是某个固定城市,比如 visitor_city = 1
,那么显然sql变成了:
select item_type, count(*) as visit_num |
开发人员按照需求修改sql之后对于索引的调整无动于衷,因为他觉得,我已经用上索引了呀。而实际上很明显的,只需要在 visitor_city
和 items
表的 item_id
上都加上索引,就能极大的减少时间。原因就在于开发人员“感觉”能用上索引,而且开发阶段试运行时间没问题就OK,并不关心是不是有更好的索引使用方式,甚至不确认是否用上了索引。在这样的一个真实案例中,原有的sql在后期出现了运行缓慢的现象,才逐渐被发掘出问题。
覆盖索引
针对上面那个需求(不限制city),假设现在两个表的的item_id字段都有索引,而且把 count(*)
换成 count(visitor_city)
,还是会得到完全一样的结果,但是会对执行时间有什么影响?
select item_type, count(visitor_city) as visit_num |
测试结果表明, count(*)
版本用时57秒, count(visitor_city)
版本用时70秒。原因在哪里?主要就在于 count(*)
版本中 item_visits
表只需要用到 item_id
,所以可以直接用索引来代替数据访问,这就是覆盖索引。
在实际开发中,一方面要创造使用覆盖索引的机会,不要无谓的增加不需要的字段到查询语句中,上面的案例就是反面例子,实际开发中就有这样的情况发生。另一方面,根据具体的查询也要在成本允许的情况下构造覆盖索引,这样比普通的索引有更少的IO,自然有更快的访问速度。
强制索引
有时候mysql的执行计划会不恰当的使用索引,这个时候就要求开发人员有一定的排查能力,并且根据实际情况调整索引。当然,这种情况还是比较少见的。Mysql用错索引的主要原因在于mysql是根据IO和CPU的代价来估算是否用索引,或者用哪种索引,而这个估算基于的统计信息有可能不准确。
强制使用索引主要在两种场景下碰到。第一种是针对where过滤条件的索引,这个时候的语法是 force index (index_name)
。比如在ETL中常见的数据抽取,利用时间戳增量抽取当天新增或者更新的数据:
select * from user where update_timestamp >= curdate() – 1; |
一般在更新时间戳上会有索引,但是有时候mysql会判断出某一天的更新量特别大,比如超过了20%,那么根据数据的选择性,mysql决定不用索引。但实际上这个判断有可能是不准确的,如果表比较大而且在线上服务时间较长,还是有可能发生的,这个时候可以通过强制使用索引保证抽取的稳定性(当然,这要基于你对业务的了解,保证抽取量能维持在一个稳定的水平,不会发生超大更新量的情况):
select * from user force index (update_timestamp) |
强制使用索引的第二种情况,是join时对索引的选择。数据仓库中有时候会出现一种计算场景,对一个按日统计的报表中某一天的小部分数据进行更新。比如有一个按日统计的用户pv表( user_pv_byday
,一天约50万用户,表中有1个月数据,共1500万):
user_id | pv | stat_date |
---|---|---|
1 | 10 | 2013-01-01 |
2 | 15 | 2013-01-01 |
…… | …… | …… |
1 | 14 | 2013-01-02 |
2 | 19 | 2013-01-02 |
…… | …… | …… |
另一个表是当天小部分用户的pv表( user_pv_to_update
,1000条数据):
user_id | pv |
---|---|
1 | 18 |
2 | 20 |
…… | …… |
两个表的索引情况是, user_pv_byday
表的 user_id
和 stat_date
字段有索引, user_pv_to_update
表的 user_id
字段有索引。
现在要把 user_pv_to_update
的pv数据更新到 user_pv_byday
当天的数据中:
update user_pv_byday a |
经过一段时间的线上运行之后,发现这个步骤越来越慢了。查了一下执行计划,发现mysql选择了 user_pv_byday
表的 user_id
做索引。于是,决定强制用上 stat_date
的索引。这样一来join的时候需要用到的就是 user_pv_to_update
上的 user_id
字段。这个时候就需要指定顺序,强制 user_pv_byday
表作为外层驱动表( user_pv_to_update
则是nest loop的内层嵌套):
update user_pv_byday a |
然后来看一下两种写法的执行计划。
原有写法:
…… | table | type | …… | key | …… | ref | rows | extra |
---|---|---|---|---|---|---|---|---|
…… | b | ALL | …… | …… | 1000 | |||
…… | a | ref | …… | user_id | …… | b.user_id | 368 | Using where |
强制join顺序的写法:
…… | table | type | …… | key | …… | ref | rows | extra |
---|---|---|---|---|---|---|---|---|
…… | a | ref | …… | stat_date | …… | const | 485228 | Using where |
…… | b | ref | …… | user_id | …… | a.user_id | 1 | Using where |
根据执行计划中的数据,可以说mysql的选择没有错。原有写法需要读取的数据大致是1000 * 368约合37万,修改写法则是48万,修改写法读取的代价更大。但实际运行情况则是修改写法快过原来写法数倍。
不过,这个情况却不能随时重现。真要把这两个表写入空表并且重建索引再来查询,会发现 straight_join
的结果确实会更慢,也就是说在初始状态下,mysql的判断是对的。所以这样的问题只在日常运营中才会发生,无法重现,却是真实存在的,而且碰到类似这一类的应用场景,则必然发生。
究其原因,在这种情况下,由于数据不是一次性建成,而是按天陆续写入,所以 user_pv_byday
的 user_id
索引会进行反复的修改,造成索引碎片,极端严重的情况下还会导致索引完全失效。而 stat_date
上的索引由于是每天递增,所以完全没有碎片问题,而且读取数据是还是顺序读取,效率自然要高不少。另外,根据实际情况的观察,随着数据的积累,上面执行计划中368这个值还会变得更小,也就是统计信息会越来越不准确。
总之,mysql的执行计划在大部分情况下是没问题的,但是随着数据的不断积累修改,会逐渐出现mysql所不了解的细节,影响优化器的正常判断。这个时候如果能对表做一下重建也能让事情回到正轨,但是很多时候没有这个权限或者条件去做(比如你不是DBA,没有这种操作权限;或者表太大,没有完整的时间段可以操作)。那么强制索引使用就成了开发人员一个低成本的解决方案。
过多的join
在mysql中,需要join的表如果太多,会对性能造成很显著的下降。同样,举例说明。
首先生成一个表(表名 test
),这个表只有60条记录,6个字段,其中第一个字段为主键:
pk | c1 | c2 | c3 | c4 | c5 |
---|---|---|---|---|---|
1 | 11 | 21 | 31 | 41 | 51 |
2 | 12 | 22 | 32 | 42 | 52 |
3 | 13 | 23 | 33 | 43 | 53 |
4 | 14 | 24 | 34 | 44 | 54 |
…… | …… | …… | …… | …… | …… |
然后做一个查询:
select count(*) |
也就是说让test表跟自己关联。计算的结果显然是60,而且几乎不费时间。
但是如果是这样的查询(十个 test
表关联),会花费多少时间?
select count(*) |
答案是:肯定超过5分钟。因为做了实际测试,5分钟还没有出结果。
那么mysql到底在干什么呢?用show processlist去看一下运行时情况:
ID | …… | COMMAND | TIME | STATE | INFO |
---|---|---|---|---|---|
121 | …… | QUERY | 302 | statistics | select count(*) from test a1 …… |
原来是处在 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顺序基本上没什么要求。所以适当的把这个值调低,对于性能应该说没有影响。
列存储引擎Infobright
Infobright是基于mysql的存储引擎,具有列存储/列压缩和知识网格等特性,比较适合数据仓库的计算。使用起来也不需要考虑索引之类的问题,非常方便。不过经过一段时间的运用,也发现了个别需要注意的问题。
一个问题和myisam类似,不要取不需要的数据。这里说的不需要的数据,包括不需要的列(Infobright的使用常识。当然行存储也要注意,只不过影响相对比较小,所以没有专门提到),和不需要的行(行数是可以扩展的,行存储一行基本上都能存在一个存储单元中,但是列存储一列明显不可能存在一个存储单元中)。
第二个问题,就是Infobright在长字符检索的时候并不给力。一般来说,网站的访问日志中会有URL字段用来标识访问的具体地址。这样就有查找特定URL的需求。比如我要在一个日志表中查找某种类型的url的访问次数:
select count(*) from log where url like '%mysql%'; |
类似这样在一个长字符串里面检索子串的需求,Infobright的执行时间测试下来是myisam的1.5-3倍。
至于速度慢的原因,这里给出一个简要的解释:Infobright作为列式数据库使用了列存储的常用特性,就是压缩(列式数据库的压缩率一般要能做到10%以内,Infobright也不例外)。另外为了加快查找速度,它还使用了一种叫知识网格检索方式,一般情况下能够极大的减少需要读取的数据量。关于知识网格的原理已经超出了本篇文章的讨论篇幅,可以看 这里了解。但是在查询url的时候,知识网格的优点无法体现出来,但是使用知识网格本身带来的检索代价和解压长字符串的代价却仍然存在,而且比查询一般的数字类字段要来的大的多。
解决办法有几种,比如 官方的方案是把长字符串MD5成一个数字,查询的时候加上数字作为补充查询条件。而 这条微博给出的方法是进行分词然后再整数化。这些方案相对来说比较复杂,而我尝试过一种简单的解决方案(不过也有相当的局限性),就是根据这个长字段排序后再导入。这样一来按照该字段查询时,通过知识网格就能够屏蔽掉比较多的“数据包”(Infobright的数据压缩单元),而未排序的情况下符合条件的数据散布在各个“数据包”中,其解压工作量就大得多了。使用这个方法进行查询,测试下来其执行时间就只有mysql的0.5倍左右了。