实战系列:(五)一条sql引发的血案

写在前面

       网上很多的文章都是教科书式的说教,缺乏实用价值。这也是笔者想写此系列文章的初衷,希望把实际工作的实战经验分享给大家,帮助大家解决实际问题。后续的一系列文章都是笔者在实际工作遇到的问题,比较具有代表性,从实战的角度进行分析总结,希望能够给大家带来帮助。
关键字:数据库、Mysql、Mybatis、Dubbo

一、问题背景

       其实并没有什么血案,题目只是个噱头而已,吸引大家进来看看,呵呵!既来之则安之,那就看下去吧!定不负厚望,让你有所收获!如有不妥之处,也请批评指正!
       言归正传,哪个码农不和数据库打交道?虽然不要求码农像DBA那样炉火纯青、登峰造极,但至少应该了解基本的sql使用原则与优化方法。
       首先介绍一下项目背景,我们开发的是一个互联网应用,使用Java、Spring Boot、Dubbox、mysql、Mybatis等相关技术实现。采用典型的Dubbo API、Provider和Consumer三层架构,其中,API定义业务服务接口,供Provider和Consumer使用;Provider实现业务逻辑并提供服务,Consumer使用业务服务,这两者打包为独立运行的jar,并部署到Web容器中。
       眼看上线日期节点临近,却突然间蹦出来一个错误,而且困扰了一位开发小兄弟许久,查不到具体原因,这可如何是好?如果解决不了,就真的要有血案发生了。关键时刻还得老将出马!

二、问题分析

       先来看看错误是什么:

2019-05-07 10:45:30,072 [ERROR] [net.xxxxx.job.platform.config.app.job.ExportXxXxxMessageJob_Worker-1] c.d.d.j.e.h.i.DefaultJobExceptionHandler   -  JOb 'net.xxxxx.job.platform.config.app.job.ExportXxXxxMessageJob' exception occure in job processing com.alibaba.dubbo.rpc.RpcException: Failed to invoke the method selectXXXXX in the service net.xxxxxx.xxxx.xxxx.xxxxx.XxXxxxService. Tried 1 times of the providers [10.xx.xxx.213:10200] (1/4) from the registry 10.xx.xxx.209:2181 on the consumer 10.xx.xxx.213 using the dubbo version 2.8.4. Last error is: Failed to invoke remote service: interface net.xxxxxx.xxxx.xxxx.xxxxx.XxXxxxService, method: selectXXXXX, cause: Unable to invoke request
    at com.alibaba.dubbo.rpc.cluster.support.FailoverClusterInvoker.doInvoke(FailoverInvoker.java:108) ~[dubbox-2.8.4.jar!/:2.8.4]
    at com.alibaba.dubbo.rpc.cluster.support.AbstractClusterInvoker.invoke(AbstractClusterInvoker.java:227) ~[dubbox-2.8.4.jar!/:2.8.4]
    at com.alibaba.dubbo.rpc.cluster.support.wrapper.MockClusterInvoker.invoke(MockClusterInvoker.java:72) ~[dubbox-2.8.4.jar!/:2.8.4]
    at com.alibaba.dubbo.rpc.proxy.InvokerInvocationHandler.invoke(InvokerInvocationHandler.java:52) ~[dubbox-2.8.4.jar!/:2.8.4]
    at com.alibaba.dubbo.common.bytecode.proxy5.selectXXXXX(proxy5.java) ~[dubbox-2.8.4.jar!/:2.8.4]
    at xxx.xxx
    ......
Caused by: javax.ws.rs.ProcessingException: Unable to invoke request
    at org.jboss.resteasy.client.jaxrs.engines.ApacheHttpClient4Engine.invoke(ApachHttpClient4Engine.java:287) ~[resteasy-client-3.0.7.Final.jar!/:?]
    at org.jboss.resteasy.client.jaxrs.internal.ClientInvocation.invoke(ClientInvocation.java:407) ~[resteasy-client-3.0.7.Final.jar!/:?]
    at org.jboss.xxx
    ......

       Dubbo中RPC调用时出错,询问了一下具体情况,这个错误在开发环境(Dev Env)上没有出现过,当部署到测试环境(Test Env)的时候就冒了出来。
       这个问题看似很寻常,不就是dubbo远程方法调用失败嘛!一开始确实顺着这个思路去查的,检查了provider的配置,检查了consumer的配置,检查了注入对象,检查了传参,都没毛病啊!确定配置和代码均是正确的,为什么会出现这个错误呢?实际上这个方法是给后台的定时任务Job调用的,那位兄弟一直都是在开发环境上开发调试,一切都很顺利,当部署到测试环境后,突然冒出这个问题,感觉很蹊跷,而且一直认为代码没有问题。使用postman从前端发起调用debug了一下,发现执行到某个操作的时候竟然执行时间超过30秒,而这步操作对应执行了一条sql语句,这肯定不正常啊!到此,问题基本上明了了,开发环境上数据量小,测试环境上数据量大,所以开发环境上没有暴露出问题。
       把对应的sql语句拿出来分析一下,首先在测试数据库上手工执行了一下该条语句,果不其然,执行时间长达59秒之多,这也太恐怖了!问题定位到了,这就不难理解上面报错的原因了,又查看了项目中dubbo的配置。

项目中dubbo的配置如下:

dubbo:  active: true
 application:    
   name: ${spring.application.name}  
 registry:
   protocol: zookeeper    
   client: curator
 protocol:
   name: rest    
   host: 127.0.0.1
 provider:    
   version: 1.0.0    
   timeout: 30000    
   retries: 0    
   loadbalance: random

       这下一切就都清楚了,原来项目中配置的dubbo服务调用超时时间为30秒(provider.timeout: 30000),所以dubbo服务调用失败的原因是超时。好了,那接下来我们就对该条sql语句进行分析和优化。在分析和优化之前,我先把优化的结果贴出来。

优化前执行时间(单位秒) 优化后执行时间(单位秒)
59.010000s 0.017000s

该条sql中涉及到的数据库表如下(真实的表名、字段名、和索引名都做了替换):

序号 表名 数据量
1 table1 9,825,006

下面开始正式讲解分析及优化的过程,该条sql语句如下:

select customer_id, sum(amount) as totalamount, count(1) as totalnum
from table1 where 
  available_time_end BETWEEN date_add(NOW(), interval 24 hour) and date_add(now(), interval 48 hour)
group by customer_id;

       这条sql语句实现的功能其实并不复杂,查询统计table1表中未来24小时至48小时这段时间将要到期的数据。这位兄弟工作时间不长,完全以实现功能为导向,根本没考虑性能问题。不过这样也好,这样才能给老码农以表现的机会,也体现了老码农存在的价值,呵呵!姜还是老的辣嘛!

二话不说,先来EXPLAIN一下,查看Mysql的执行计划:

id select_type table type possible_keys key key_len ref rows Extra
1 SIMPLE table1 index idx_customer_id_order_serial_num_df idx_customer_id_order_serial_num_df 775 (NULL) 9825006 Using where

rows列显示的预计扫描数据量基本就是表table1的全部数据,虽然使用了索引idx_customer_id_order_serial_num_df,但是还是检查了表table1中的所有行,所以效率不高。

再来看一下索引的定义:

Name Fields Index Type Index Method
idx_customer_id_order_serial_num_df customer_id,order_serial_num, df NORMAL BTREE
idx_status_available_time_end status, available_time_end NORMAL BTREE

       看到这里,忽然眼前一亮,计上心头!还有一个索引idx_status_available_time_end可以利用,这是个联合索引,建立在status, available_time_end两个字段之上,而且available_time_end正好又是where的查询条件。但是mysql的联合索引使用最左前缀匹配原则,即最左优先,在检索数据时从联合索引的最左边开始匹配。显然,我们应该充分利用这个索引(idx_status_available_time_end)来提高效率。看了一下status的定义和取值,呵呵!暗自高兴一番,status是个smallint类型,并且只有3个取值:0、1和2。何不把status作为冗余的查询条件,然后再union all呢?对,就这么干!于是把原来的查询改写如下:

select customer_id, sum(amount) as totalamount, count(1) as totalnum
from voucher
where status = 0 and (available_time_end BETWEEN date_add(NOW(), interval 24 hour) and date_add(now(), interval 48 hour))
group by customer_id
                
UNION ALL

select customer_id, sum(amount) as totalamount, count(1) as totalnum
from voucher
where status = 1 and (available_time_end BETWEEN date_add(NOW(), interval 24 hour) and date_add(now(), interval 48 hour))
group by customer_id
                
UNION ALL

select customer_id, sum(amount) as totalamount, count(1) as totalnum
from voucher
where status = 2 and (available_time_end BETWEEN date_add(NOW(), interval 24 hour) and date_add(now(), interval 48 hour))
group by customer_id

看一下执行时间:

OK, Time: 0.017000s

飞起来一样!提升这么明显,看来思路是对的。

EXPLAIN一下看看执行计划:

id select_type table type possible_keys key key_len ref rows Extra
1 PRIMARY table1 range idx_customer_id_order_serial_num_df,idx_status_available_time_end idx_status_available_time_end 8 (NULL) 122 Using index condition; Using temporary; Using filesort
2 UNION table1 range idx_customer_id_order_serial_num_df,idx_status_available_time_end idx_status_available_time_end 8 (NULL) 21 Using index condition; Using temporary; Using filesort
3 UNION table1 range idx_customer_id_order_serial_num_df,idx_status_available_time_end idx_status_available_time_end 8 (NULL) 1 Using index condition; Using temporary; Using filesort
(NULL) UNION RESULT ALL (NULL) (NULL) (NULL) (NULL) (NULL) Using temporary

(EXPLAIN输出说明:ID列表明了该语句所在的层级,如果ID相同从上到下执行,如果ID不同则ID越大的越先执行,其作用类似于执行计划中缩进。)
       跟预想的一样,使用了索引idx_status_available_time_end,并且每次检查的行数为122、21、1,数据量大幅减少,性能提升非常明显!
       顺便插一句,为什么用Union All而不用Union呢?因为Union不仅要做去重(去掉重复行)处理,还要做排序处理,而Union All则不会,在本例的使用场景中,不会存在重复的数据也没有排序的需求,所以使用效率更高的Union All。这也说明了使用什么样的技巧完全取决于使用场景与需求。

三、总结

       因为要培养新人,所以大概给那位小兄弟讲了一下sql优化的基本原则和方法,唠叨半天,总结一下,教学相长嘛!
       首先来讲讲sql语句的执行顺序:
       sql查询语句的执行顺序可不是你所使用的语法顺序。所有的查询语句都是从from开始执行的,在执行过程中,每个步骤都会为下一个步骤生成一个虚拟表,这个虚拟表将作为下一个执行步骤的输入。

(1) from 
(2) join,on 
(3) join 
(4) where 
(5) group by
(6) avg, sum.... 
(7) having 
(8) select 
(9) distinct 
(10) order by
(11) Limit或top

第一步:首先对from子句中的前两个表执行一个笛卡尔乘积,此时生成虚拟表 vt1;
第二步:接下来便是应用on筛选器,on中的逻辑表达式将应用到vt1中的各个行,筛选出满足on逻辑表达式条件的行,生成虚拟表vt2;
第三步:如果是outer join,那么这一步就将添加外部行,left outer join就把左表在第二步中过滤的添加进来,如果是right outer join 那么就将右表在第二步中过滤掉的行添加进来,这样生成虚拟表vt3。如果from子句中的表数目多于两个,那么就将vt3和第三个表连接从而计算笛卡尔乘积,生成虚拟表,重复1-3的步骤,最终得到一个新的虚拟表vt3;
第四步:应用where筛选器,对于上一步产生的虚拟表vt3应用where筛选器,生成虚拟表vt4;
第五步:group by子句将vt4中的相同值组合成为一组,得到虚拟表vt5;
第六步:应用cube或者rollup选项,为vt5生成超组,生成vt6;
第七步:应用having筛选器,生成vt7,只有符合条件的记录才会被插入到虚拟表VT7中;
第八步:处理select子句,将vt7中的在select中出现的列筛选出来,生成vt8;
第九步:应用distinct子句,移除vt8中相同的行,生成vt9;
第十步:应用order by子句。按照排序条件排序vt9,此时返回的是一个游标,而不是虚拟表。排序的成本是很高的,所以order by要慎用;
第十一步:应用limit或者top选项,取出指定行的记录,产生虚拟表VT10, 并将结果返回给客户端。

还有就是查询优化的基本原则和注意事项:

  1. 尽量使用索引;
  2. 尽量避免全表扫描;
  3. 在联表操作时,尽量选择数据量较小的表做基表,这是最基本的优化原则;
  4. 确保被驱动的表被索引,不能确保驱动表被索引的情况下,加大 join_buffer_size 的大小;
  5. 子查询会生成临时表,但是临时表是没有任何索引的,子查询生成的临时表只能进行全表扫描;
  6. 当查询结果超过总数据一定比例的时候,走索引的查询开销反而比全表扫描要大,这时mysql则会放弃索引而选择进行全表扫描。

       每种数据库都会有查询优化器,Mysql也不例外。Mysql的查询优化器会对select查询进行优化,所以要善用EXPLAIN来查看执行计划。要明确的一点是,explain只能解释select语句,所以不要试图执行explain update之类的语句。
       最后,我们结合上面提到的优化原则以及具体到Mysql数据库,看看有哪些具体的优化方法:

  1. 查询语句应该尽量避免全表扫描,首先应该考虑在where子句以及order by子句的字段上建立索引;
  2. 应尽量使用exist和not exist代替in和not in,因为后者很有可能会导致全表扫描而放弃使用索引;
  3. 应尽量避免在where子句中对字段进行null判断,因为null判断会导致全表扫描;
  4. 应尽量避免在where子句中使用or作为连接条件,同样会导致全表扫描;
  5. 应尽量避免在where子句中使用!= 或者<>操作符,同样会导致全表扫描;
  6. 使用like "%abc%" 或者like "%abc"同样也会导致全表扫描,而like "abc%"会使用索引;
  7. 在使用union操作符时,应该考虑是否可以使用Union All来代替,因为Union操作符在进行结果合并时,会对产生的结果进行排序,删除重复记录,在没有该需要的情况使用Union All,后者仅仅是将结果合并返回,能大幅提高性能;
  8. 应尽量避免在where子句中使用表达式操作符,因为会导致全表扫描;
  9. 应尽量避免在where子句中对字段使用函数,同样会导致全表扫描;
  10. select语句中尽量避免使用“*”,因为在sql语句在解析的过程中,会将“*”转换成所有列的列名,而这个工作是通过查询数据字典完成的,有一定的开销;
  11. where子句中,表连接条件应该写在其他条件之前,因为where子句的解析是从后面向前的,所以尽量把能够过滤掉多数记录的限制条件放在where子句的末尾;
  12. 若数据库表上存在诸如index(a,b,c)之类的联合索引,则where 子句中条件字段的出现顺序应该与索引字段的出现顺序一致,否则将无法使用索引;
  13. From子句中表的出现顺序同样会对sql语句的执行性能造成影响,from子句在解析时是从后向前的,即写在末尾的表将被优先处理,应该选择记录数较少的表作为基表放在后面;
  14. 尽量使用>= 操作符替代>操作符;
  15. OR改写成IN:OR的效率是n级别,IN的效率是log(n)级别;

                                                                             2019年9月4日星期三 于团结湖瑞辰国际中心

你可能感兴趣的:(实战系列:(五)一条sql引发的血案)