目录
前言:最近接了一个导出接口的优化需求,原9000+数据导出秒数660+,优化后5S,特此记录一下优化方案以供大家一起讨论学习。本文先通篇列举一下优化的方案,再详细的阐述这次优化的具体细节。
1.接口优化维度个人感觉接口优化分为两个维度,分为接口响应时间(使接口更快的返回数据)和内存占用率(减少接口内存消耗)。
1.1 接口响应时间
这类接口需要优化的原因通常是因为该接口响应时间超过了nginx/网关/feign所配置的超时时间限制。那么我们可以考虑从以下几个方面尝试优化:
1.1.1 缓存相关优化
加缓存的数据分为两种冷数据(很长一段周期不会改变的数据,比如某个月的报表数据/某些车辆的车型数据等)和热点数据(某些查询非常频繁但一旦生成几乎不会改变的数据,比如推出一个卖车活动/或者某些进入抢单池的订单等)。
针对于冷数据可以采取缓存预热的方式,在服务启动的时候提前将相关的缓存数据直接加载到缓存系统。避免用户在请求的时候,先查询数据库,再刷新缓存。缓存预热不需非得用NoSql的数据库,实在没有单表数据量不大的话上个HashMap也行。
热点数据的话比较麻烦,需要一套完善的热点发现机制和更新机制,这里就不展开说明了,感兴趣的同学可以自己查一查(其实自己也不咋精。。。)
1.1.2 SQL相关优化
建立索引:经常查询的字段建立索引,提高查询速度
优化SQL结构:查看哪些sql理论上该走索引实际没走的,修一修
避免返回不必要的数据:别为了省事select *
谨慎使用limit offset,用滚动查询代替:mysql limit offset分页方法当offset值过大时会有严重的效率问题,可以采用 循环+下面的SQL:
SELECT * FROM tableX WHERE id > #{lastBatchMaxId} ORDER BY id [ASC|DESC]LIMIT ${size}
通过不断传入 lastBatchMaxId来进行分段查询最后组装数据。(阿里开发规范第五章3.7)
1.1.3 数据存储位置相关优化
分库分表:按照某一业务维度进行拆分,减少单表数据量,提高查询速度
中间表/冗余字段:这两种方法思路相同,都是在查询前尽量把数据维持在一个统一的地方,减少为了获取数据而查询其他表的次数
搜素引擎:类似中间表方案,区别为将数据统一存放在搜索引擎中,同时搜索引擎支持粒度更细的查询
1.1.4 数据库服务相关配置
读写分离:分离读写任务,提高数据库服务性能上限
优化MySql conf文件配置:修改最大连接数、buffer缓冲区大小等(建议专业DBA来搞)
1.1.5 数据查询次数相关优化
单次换批量:切记循环查库/调用其他服务,能用批量就用批量,大量磁盘IO/HTTP调用会耗费大量资源
1.1.6 非核心流程/耗时操作改异步
一些消息通知,邮件或短信等,这些业务不是主流业务,即使出错也不会影响主流业务的进行,因此把这部分业务采用异步的方式去调用。
注:慎用异步方法,使用不当极容易造成线程阻塞导致服务宕机。
1.1.7 提高日志输出级别
减少非必要日志的打印:日志打印过多会影响服务性能(这条笔者没有实践考证过,部门大佬这么说)
1.2 内存占用率
这类接口需要优化的原因通常是因为调用单次接口的时候出现了频繁GC导致服务挂掉,常见于大数据导出/报表分析接口
1.2.1 检查是否接口内发生GC
笔者遇到过一次是因为数据导出的时候采用一次性把目标数据全部查询出来再写到流中的方式,大量被查询的对象驻留在堆内存中,直接打满了堆内存。解决方案:分批次查询DB,再把结果分批次输入到OutputStream中。
1.3 代码上的一些小技巧(后续持续补充)
仅获取集合中的某个实体对象,考虑用map来代替for循环遍历list:
反例:
List<User> userList = new ArrayList<User>();
for(User user : userList){
if(userId.equals(user.getUserId)){
XXX
}
}
推荐方案:
Map<Long, User> userMap = leads.stream().filter(Objects::nonNull).
collect(Collectors.groupingBy(User::getUserId));
User user = userMap.get(userId);
如果数据量大HashMap初始容量开大一点:HashMap频繁扩容耗费资源
采用内存组装数据来代替数据库的连表查询:内存比磁盘IO快
list去重可以先转linkedHashSet再转回来,也可以直接使用Lamda:
linkedHashSet:
List<Integer> list = new ArrayList<>(Arrays.asList(1, 1, 2, 3));
LinkedHashSet<Integer> temporarySet = new LinkedHashSet<>(list);
ArrayList<Integer> noDuplicateList = new ArrayList<>(temporarySet);
Lamda:
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 3));
List<Integer> noDuplicateList = list.stream().distinct().collect(Collectors.toList());
2.记录一次真实的调优经历
首先拿来接口一看,导出需求在技术实现上为异步,可能是为了前端用户体验(在大数量的情况下不会一直转圈)。打开zipkin想看一下耗时(如果没有可以加计时的日志),发现大量调用某服务的情况(这里猜测循环调用某服务),9000+数据跑了660+S。跟踪日志发现有一个组装数据的方法非常耗时,点进去发现首先查了es,然后发现了下面这行代码:
循环调用服务是大忌,9000条数据9000次http网络请求,果断改成批量。再往下看:
发现一段很可疑的代码,看起来像是为了避免单次接口超时而分批次调用了该接口,跟进这个服务的这个接口:
好家伙,循环查库+调用其他服务,这不慢谁慢。优化思路:for循环干掉,单次换批量;数据库查询改业务层组装数据;HashMap初始化开大点
首先三个批量查询,把所有该查的数据都查完(前提是单次查询不能超时),然后用Lamda组装数据
再次测试该接口,9000+数据接口耗时5S。