背景
Helios 系统要处理的数据量比较大,尤其是查询所有服务一天的评分数据时要返回每日 1440 分钟的所有应用的评分,总计有几十万个数据点,接口有时延迟会达到数秒。本文记录如何利用 Arthas ,将接口从几百几千 ms,优化到几十 ms。
链路:
从链路上看,线上获取一整天的数据时大概 300 多 ms,而查询数据库只有 11ms,说明大部分时间都是程序组装数据时消耗的,于是动起了优化代码的念头。
优化过程
温馨提示:代码可以不用看,没有上下文的情况下很难明白函数什么意思。主要看 Arthas Trace 的结果与优化思路。
初始未优化版本
代码
1 | Java复制代码 private HeliosGetScoreResponse queryScores(HeliosGetScoreRequest request) { |
Arthas Trace
1 | Java复制代码`---ts=2021-08-17 16:28:00;thread_name=http-nio-8080-exec-10;id=81;is_daemon=true;priority=5;TCCL=org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader@20864cd1 |
分析
Arthas 显示总共花了 4 秒,但实际上在链路上看大概是 350~450ms 左右。其他多出来的时间是 Arthas 每一次执行统计的消耗,因为方法里的循环比较多。这也告诉我们,不要用 trace 去看循环很多的方法。会对性能有非常严重的影响。
可以看出整个函数有 3 个循环,第一层循环的数量为 appId 的数量约为 140,第二层是查出来的数据条数,一天的数据已经归并了所以这里应该是 1,第三层是时间区间的分钟数,一天的话就是 1440 个。
Trace 中可以看到消耗最多的是封装的一个 SimpleDateFormat.formatDate()
。
第一次优化
优化方向
- 遍历每个时间点的思路改变,把合并过的大对象拆分成一个个小对象直接遍历,改成先合并起来,通过时间点逻辑上遍历。这样会减少创建几十万个对象。
- 将时间点集合
Set<String> dateSet
改为Set<Date>
,这样减少反复 formatDate() 的开销。 - 优化字符串转数字的过程,减少
Integer.parseInt
方法调用,改为用Map<String, Integer>
提前创建出 0~100 的字符串数字字典。(后来经过 JMH 测试,还是Integer.parseInt
最快)
代码
1 | Java复制代码private HeliosGetScoreResponse queryScores(HeliosGetScoreRequest request) { |
Arthas Trace
1 | Java复制代码---ts=2021-08-17 14:44:11;thread_name=http-nio-8080-exec-10;id=ab;is_daemon=true;priority=5;TCCL=org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader@16ea0f22 |
分析
这一步实际上执行时间优化了 50ms 左右。
从 Trace 中看耗时时间最长的是 Date 的 compareTo,也就是代码中的 if (splitHeliosScore.getTimeFrom().compareTo(request.getStartTime()) < 0)
而比较意外的是从对象中 get 属性居然也是有开销的。
第二次优化
优化方向
结合上一次 Arthas Trace 的结果,在以下几个方向进行优化:
- 将
Date
对象的换成long
型时间戳进行比较 - 将
Date
对象反复 getTime、setTime,改为long
型时间戳+= 60_000
实现,得到结果后只 setTime 一次。 - 每次填充数据都往
Set<String> dateSet
放入数据,改为通过标识判断只放入一次。 - 存放分数的 ArrayList 在第一次循环之后,可以确认大小,之后循环创建 ArrayList 时直接填入固定的大小,减少内存创建。
代码
1 | Java复制代码 private HeliosGetScoreResponse queryScores(HeliosGetScoreRequest request) { |
Arthas Trace
1 | Java复制代码`---ts=2021-08-17 15:20:41;thread_name=http-nio-8080-exec-7;id=aa;is_daemon=true;priority=5;TCCL=org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader@14be750c |
分析
这一步将执行时间又优化了 80ms 左右。现在还剩是 160ms 了。
从 Trace 中看耗时时间最长的是三个方法:
getScores
。直接 get 了属性啥也没干,但是积少成多list.size()
list.get(index)
也就是说虽然这几个函数里也没干什么东西,但是函数调用、指针寻址本身也是有开销的。
第三次优化
优化方向
- 减少 list 属性的调用
- 一次次 list.add 方法改成 subList 一次性放入
也就是说循环中不做任何耗时操作,不做任何指针/引用。
代码
1 | Java复制代码private HeliosGetScoreResponse queryScores(HeliosGetScoreRequest request) { |
Arthas Trace
1 | Java复制代码`---ts=2021-08-17 15:33:40;thread_name=http-nio-8080-exec-11;id=f1;is_daemon=true;priority=5;TCCL=org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader@d1c5cf2 |
分析
这一步又优化了 100ms 左右,现在还剩 60ms。
现在从 trace 上看耗时操作只有三个了:
- 查数据库
- 合并数据
- 拆分得分字符串 “100,100,100” 为 int 数组 [100,100,100]
第四次优化
优化方向
- 查数据库发现由于 SQL 判断不准确,每次会多查出来一条数据,在后边循环的时候会多循环一倍
- 合并数据时发现可以针对单条数据的情况直接过滤,减少开销。
代码
- 改了 SQL 并验证,减少查询出来的数据量
- 单条数据时不再处理合并逻辑
Arthas Trace
1 | Java复制代码`---ts=2021-08-17 16:03:24;thread_name=http-nio-8080-exec-13;id=f1;is_daemon=true;priority=5;TCCL=org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader@69e2fe3b |
分析
可以看到现在最大耗时的地方终于是数据库查询了。现在查询一整天的数据,也只需要 25~40ms 左右。
结果
链路:
链路上看程序代码还是要处理个十几 ms,主要是字符串转 int[] 时的开销,这一步可以再想办法继续优化。
结论
从这次优化我们可以得到一些结论:
- 尽量少创建对象
SimpleDateFormat
的开销很大Date.compare
的开销不低- 哪怕最简单的操作如
list.size()
list.add
次数多了开销也很可观 - 对于性能分析和优化一定要有合适工具,才能得出有用的结论并针对性优化。一开始我以为减少对象创建就万事大吉,但实际上性能消耗的大头并不在这里。还是得借助 Arthas 的 Trace 才能真正针对性地优化。
强烈推荐 动态追踪技术漫谈,每个希望提升的工程师都应该深入了解动态追踪技术。
本文转载自: 掘金