作者:潘吉祥
不知不觉毕业工作已经4个多月,因为工作的原因,挺长时间没有写一些技术性的东西了。在这中间其实我是有动过的,但是每次写了之后自己看着不太满意,就作废了。
我总是希望自己写的东西大概能实实在在地帮到或者说对点开看的读者一些启发,毕竟每个人的时间都是宝贵的。废话不多说,马上开启正题!
首先是背景简介,我所在的是一家大宗物流电商创业公司,我刚到公司接手项目的时候,整个项目其实上线没多久。整个都是一期工程,其它的不提,在SQL查询方面,前人实在是赶工赶得厉害,加上表的设计经验不足,导致项目中大量使用了多表查询。
我这里提到的多表查询是足以触碰我编程规范底线的那种,3到4张表的联查随处可见,5到6张表的联查也有,我见过最多的是8张。震惊归震惊,作为一名初来者,我也没有在这一方面讨论过什么,毕竟不论如何项目都正常运行了几个月了。
直到我入职第二个月中旬,凌晨1点的时候被客服的电话催醒,服务崩了。头儿的第一反应是我把库改坏了,因为那天恰好因为一些需求需要再原来的几个表中新增字段。我虽然不知道是什么原因,但是我能确定这不是我的问题,因为改动我是严格测试过的。
后来,头儿把Tomcat的日志发给我,让我找原因,果然是接口查询报错,就是多表联查的原因。数据量上来之后就,原来的多表联查语句直接临时表溢出,由于事发突然,那天的临时解决方法只是将临时表的容量设置调大。
第二天,我和头儿说那块要改造,头儿也说列入计划,但是由于开发任务多,那个事渐渐又被淡忘,好像以前什么事没发生过。事实上,程序怎么会骗人呢,就想农民种庄稼一样,你不浇水,不施肥,土地不会欺骗性地长出好庄稼。
果然没出半个月,凌晨5点钟的时候,我又开始了一模一样的堵坑任务,有了第一次的经验,我没有给头儿打电话。只不过这次不能再依靠上调固定值了,思考了几分钟,我在原来的查询mapper中多添加了几个约束条件,筛掉了部分范围不需要的数据,算是暂时抗过去了。
第二天上午,我跟头儿说稍微我稍微停一下手头的事情,改造一下那个老出问题的接口,头儿说行。我把原来的select * 改成了单个的字段查询,事实上,接口需要的字段只有12个,而那六张表全字段联查有100多个字段。
这一阶段,我还没有从真正意义上改造掉那个接口,不过那块儿倒是没再出过问题,一直到现在也没有。
但是我知道它迟早有一天会出问题,绝对的,只是时间问题。于是在最近业务稍微稳定的时候,我抽空开始了类似的多表查询改造的思考和实践。
这里我还是说一下,我个人认为这样的项目,前人是有开发经验的,作为电商平台的一种,它不应该存在大量不小于4张表的联查。这很大程度上归因于项目表结构的设计,最重要的订单表,没有遵循订单表设计的常规原则:
分支信息应该保存快照信息,而不是字段关联查询。
举个例子,拿我们都知道的购物订单表,创建者的信息和商品属性等一些具体的信息应该以快照(完全副本)的形式保存在订单表中,而不应该通过相关唯一字段再去查询。
一方面,这样的信息是有可能变化的,如果以关联的方式记录,信息一旦更改,我们可能丢失当时的订单分支信息,这是不合理的;另外,关联的记录保存方式会无限增加查询的复杂度。
而我们公司的订单信息分散更加严重,分支信息很多,因此导致了多至6到7张表的查询。
因此,就电商平台而言,我所在的公司面临的多表联查可能有些极端。但是即使按照规范来设计表,我们还是不可避免碰到3到4张表的联查,只是多少的问题,那么这就是需要解决的问题了。
首先,就互联网企业而言,超过两张表的联查就不应当再SQL语法层面来处理了,因为性能会随着数据量的增加急速下降。这样的多表联查最经典的解决方法就是单表查询,然后再业务层面进行VO数据拼装。
这么说可能有些方法论的味道,那么这样的优化方式具体是如何实现呢,或者说有什么注意的点和通用方法呢?重点来了!
1.我们需要确定页面需要显示的信息字段,并定义对应的VO实体。
2.根据我们需要的显示字段,从多张表中区分出主表和从表。
这里的主从是相对于业务而言的。举个例子,就订单详情页面而言,需要显示的信息80%的字段都能够从订单表中获得,剩下的一些展示信息可能要从用户表、货源表和结算表中获取,那么订单表就是主表,剩下的都是从表。
3.确定主表之后,我们就开始单表查询主表,以下是伪代码。
Select xx,xx,xx from order_bill where xx 获取订单列表
4.此时我们已经获取了页面80%或者更多的信息,我们还需要找出VO中剩下的字段。
Arraylist voList = new arrayList<xxVO>();
for (int i = 0; i < OrderBills.size(); i++) {
OrderBill orderBill = OrderBills.get(i);
xxVO xxVo = new xxVO();
User user = userService.selevtOne(orderBill .用户唯一标识);
Goods goods = goodsService.selectOne(orderBill.货源唯一标识);
Xxx....(同理)
//我们需要把pojo中的属性赋值到vo中,因为属性名都是一样的,我们可以借助工具类来实现属性拷贝,例如beanUtilos或者beanUtil
BeanUtils.copyProperties(vo,pojo);
voList.add(xxVo );
}
5将vo返回页面。
经测试,改造之前,后台管理页面查询所有范围的数据,每页100条,单次查询需要45秒左右,改造后只需要15秒。
What?15秒?你在逗我?
确实,与原来的45秒相比,15秒已经快了不少了,但是对于后台的使用者来说还是不能够承受,15秒什么概念?1/4分钟emming
这样看来上面的代码中规中矩,貌似已经没有可改动的地方了,真的吗?
我给个提示,重点在for循环,这里读者可以先自己思考一下,再往下看。
我们可以看到,在for循环中,我们做了不少处理,而这整个过程是串行的,如果能把for循环改成并行遍历并且处理,那不是会快一些吗?这让我想到了之前学过的一个jdk并行任务处理框架fork-join,它的基本原理就是将一个大任务递归分裂成不能再分的小任务,每个小任务处理完之后,再将结果汇总到一起,这个过程是并行的(并行程度等于CPU核数),有兴趣的读者可以深入理解。
由于原生的fork-join写起来稍显繁琐,这里给大家介绍一个1.8的封装API:stream。这个应该很多人都见过,属于jdk1.8新特性,相信有不少读者都轻视了它的作用,它不仅简化了集合操作,其中厉害的一点是对fork-join的极简封装。
通过cllection.parallelStream()可以得到一个并行处理的stream(注意如果直接.stream()获取的是一个串行的stream,它的效率和for循环基本是一样的),伪代码如下:
ArrayList<OrderListVo> orderListVos = new ArrayList<>();
orderBills.parallelStream().forEach(orderBill -> {
goods goods = goodsService.getOne(orderBill.货源唯一标识);
User user = userService.getOne(orderBill.用户唯一标识);
OrderListVo orderListVo = new OrderListVo();
BeanUtils.copyProperties(orderListVo,orderBill);
BeanUtils.copyProperties(orderListVo,goods);
BeanUtils.copyProperties(orderListVo,user);
orderListVos.add(orderListVo);
});
就这样?没错,别小看这简单的一个改造,经测试,改造的接接口查询从15秒降至2秒以下!
这样,我们就把原来的耗时查询降到了零头的查询级别!系统再也不怕大联查了!
文章写到这里就结束了,以上均来自实际项目、真实数据接口优化,有需要的读者可参考直接应用到自己的实际项目中哈。
感觉有所帮助,记得点个在看!
【推荐阅读】
腾讯推出高性能 RPC 开发框架
Windows 给力!可以扔掉 Linux 虚拟机了!
为什么建议大家使用 Linux 开发?爽(外加七个感叹号)
求求你不要在用!=null判空了