性能优化实践:一行代码性能提升几十倍?

4f6b2d4c512226ead2c03f972133edc0.jpeg

Part1

问题背景

手头本来有个很重要的性能优化工作,我也一直在提高它的优先级。毕竟按照四象限时间管理法则,重要的事情要先做。

ad175899f4dcaad94bb10487ac55b099.png

问大家一个问题:给你一个「重要不紧急」的事情和「紧急不重要」的事情,你先做哪一个?

在一般的互联网公司,大家都非常忙碌。活儿是永远干不完的。这时候,我建议先做重要的事情。试想:一个人永远都在做「紧急不重要」的事情,他的产出必然是非常低的。这就是为什么「重要不紧急」在第二象限,仅仅排在「重要且紧急」后面。

道理总结起来容易,做起来难。特别是对于性能优化这种工作内容,需要静下心来不被打扰。所以真正开始分析的时候,已经有些晚了。这个要优化的问题,本来是有一个团队在负责的,人家的优化已经进入了尾声。我这时候才开始,如果最终分析结果是要大改,这就是我的责任,没有更早的介入,让大家做了很多的无用功。

所以对于这个性能优化,对我自身提出的硬性要求是:
1、效果要明显
2、改动要小,最好能用上之前同事做优化时添加代码的一些成果

我得知这个性能优化做完之后要给大家做一个分享。前段时间在做本地调试工具kt-connect。技术问题已经告一段落,目前是推广阶段。附加要求是:能否同时做一个本地调试工具的软推广植入?

目标有了,该怎么做呢?

Part 2

性能优化方法


在《性能之巅》这本书中,我印象最深的性能优化方法,包括三种推荐的方法和三种要避免的方法,我称之为:三正三反。

三正

科学法:采用以下框架 问题->假设->预测->实验->分析

USE方法:USE是utilization、saturation和error的首字母,意思是:对于每一个资源,检查使用率、饱和程度和错误情况。它的目的是尽早地进行性能检查,并发现系统瓶颈。

向下挖掘分析法:开始在高级别检查问题,然后依据之前的发现缩小关注的范围,忽视那些无关的部分,更深入发掘那些相关的部分。这种方法经常和5Why分析法配合使用。

三反

街灯讹:用户选择熟悉的观测工具来分析性能,这些工具可能是从互联网上找到的,或者是用户随意选择的,仅仅想看看会有什么结果出现。这样的方法可能命中问题,也可能忽视很多问题。

随机变动讹:用户随机猜测问题可能存在的位置,然后做改动,直到问题消失。这种方法非常耗时而且可能做出的调整不能保持长期有效。例如,一个应用程序的改动规避了一个数据库或者操作系统的bug,其结果是可以提升性能,但是当这个bug被修复后,程序这样的改动就不再有意义,关键是没有人真正了解这件事情。

责怪他人讹:步骤如下
  1、找到一个不是你负责的系统或环境的组件
  2、假定问题是与那个组件相关的
  3、把问题扔给负责那个组件的团队
  4、如果证明错了,返回步骤1

另外,在本地调试的一个极大的好处是可以集成工具,这次使用Intelij+JProfiler来做功能和性能诊断。

实际问题中,怎样运用这些方法和工具呢?

Part 3

分析过程


问题

有一个核心接口,一次调用涉及多次数据库查询,而且查询返回数据量在几百条的量级。网络正常情况下:第一次请求响应时间在1s~3s之间。

8b8d95a4bc11ca72973220d60f1e1513.png

这段时间,负责团队进行了性能优化,在数据库操作的地方加了redis缓存,后面连续请求的话,响应时间在630ms~680ms之间。

f392e8ca1181b92426dfc323d4de8a2b.png

因为这都是本机调试时的测试结果,在服务器上运行耗时要短很多。因为二者的网络架构和经过的网络节点都不同,耗时差异主要在网络延迟。但服务器上运行也要在150ms。对于性能本机与服务器上的结果可以粗略做一个换算:680/150≈4.5。

对于核心接口,我们TP90的目标是100ms,在我本机执行至少要低于450ms。怎样进一步优化响应耗时呢?

假设

之前和负责团队聊过,负责团队分析认为:之前的性能瓶颈主要在数据库。所以采用Redis访问代替数据库查询的优化方案。从效果上看响应耗时降低到了原来的1/3~1/4。通过DBA的给力配合,在DEV环境,把慢查询日志从之前记录1s以上的慢查询修改为30ms以上的慢查询。通过以下SQL可以查询到慢查询日志情况:

select DATE_ADD(start_time, INTERVAL 8 HOUR)as start_time,db,user_host,query_time,lock_time,rows_sent,rows_examined,CONVERT(sql_text USING utf8mb4) AS slow_log from mysql.slow_log where db='XXX' order by start_time DESC  limit 1000

值得注意的是默认时间记录的是UTC时间,与北京时间相差8小时,所以SQL中包含了对显示时间的转换。

da3864db60c9b4308262715e4713217c.png

从截图可以看出,慢查询的SQL耗时在50ms左右,都是一条SQL,请求中包含的其他SQL耗时都在30ms以下。

从程序端来看:对这条50ms的SQL调用记录时间,发现第一次请求走数据库时耗时在500ms左右,后续走redis缓存时调用耗时在200ms左右。换算成服务器的耗时执行数据库SQL耗时约为110ms,redis缓存耗时约为45ms。我之前见过缓存中间件做的好的,从Redis里取数据加上网络开销也能维持在1ms之内。这个耗时太长了,我怀疑开销花在网络上。

预测

为了降低网络开销,可以将使用Redis集中式缓存改成使用本地内存缓存。通过观察日志计算,抛去从数据库和缓存取数据的开销,其他时间开销在20多毫秒。据此预测,如果用内存缓存响应延迟可维持在35ms以下。

实验

程序中用了Spring的Cacheable做缓存。

@Cacheable(value = "get#300#30",
            key = "",
            cacheManager = "autoRefreshRedisCacheManager")

修改时只需要修改cacheManager的实现即可。

原来的实现方式是使用redis缓存:

public RedisCacheManager autoRefreshRedisCacheManager(
            @Qualifier("cacheRedisTemplate") RedisTemplate<String, Object> redisTemplate) {
    RedisCacheWriter redisCacheWriter =
            RedisCacheWriter.nonLockingRedisCacheWriter(redisTemplate.getConnectionFactory());
    RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
            .serializeValuesWith(
                    RedisSerializationContext.SerializationPair.fromSerializer(redisTemplate.getValueSerializer()));
    return new AutoRefreshRedisCacheManager(redisTemplate, redisCacheWriter, redisCacheConfiguration);
}

这里就涉及到「面向对象设计原则」中的「里氏替换原则」了。这里返回值不建议直接用实现类,而应该返回接口,方便进行替换:

public CacheManager autoRefreshRedisCacheManager() {
    return new ConcurrentMapCacheManager();
}

运行看效果:

d90307c3fa66af02878599680f910491.png

从效果上看响应时间从几百毫秒降低至几十毫秒,有数量级上的质变,效果还是很显著的。

分析

  • 本地缓存效果这么好,为什么业界偏向于用集中式缓存呢?

集中式缓存可以提供更好的数据一致性,因为所有应用程序实例都访问同一个缓存服务器,减少了数据不一致的可能性;它还通常支持数据的持久化,可以将数据存储在硬盘上,即使缓存服务器重启也能恢复数据;如果数据量大,或者一直不断增长,本地缓存就不合适,甚至可能会服务的运行造成影响,这种情况下,集中式缓存可以通过专用的缓存服务器来处理大量数据,并具有更好的扩展性和性能。

  • 本次的场景是否需要较强的数据一致性和持久化存储呢?

改造之前本身也是定时任务来刷新的,业务场景允许一定的数据不一致。如果数据由于重启等原因失效,只需要从数据库重新拉取,不需要持久化存储。

  • 本次的场景有没有什么副作用?比如缓存数据太多,占用大量内存?

为了尽早发现问题,可以使用USE方法,观察系统的指标。针对本次的场景,因为接口相当于返回的配置数据。如果对数据库的数据只缓存全量,总缓存数据量通过计算吃掉的内存不超过1M,问题不大。但是目前的修改方式,会根据入参做不同的缓存。缓存数据是要成笛卡尔积增长的。加上用户输入非法数值。会不会打满内存?还是默认的内存淘汰策略就可以搞定?

对此我进行了测试,打开JProfiler的录制功能。添加一个测试类,类里将各种参数的笛卡尔积都调用一遍,还加了一些随时数作为入参的来模拟用户输入错误的情况。观察内存和响应时间。下面两张图中,绿色线是CPU占用的情况,蓝色是内存占用的情况。

这张图是修改前使用redis缓存:

b39c2ac3b8d94d6d6d457874884daecd.png

这张图是修改后使用本地缓存:

0bee4d45201ef0f2ca8c34a8a476eddc.png

从结果看,对内存并没有很大影响。先运行这个测试类,因为把所有的情况都已经缓存了,所以运行速度也特别快。

但是如果没有运行测试类,改变一个参数,第一次调用因为缓存不存在,还是会慢的。为了减少缓存占用,同时提高用户不同参数输入时的响应时间,建议将这些配置信息不要按照参数每次去取,而是一次性取出并缓存。其他参数使用内存过滤。

  • 还有没有其他可以提高性能的优化点?

程序中还存在着与入参无关的获取数据操作,耗时也不短,可以进行异步获取,提高响应速度。

  • 有没有发现其他问题?

 其中有一步要分成几百个子任务,经同事实验发现,使用异步时,启用6个线程速度最快。在此基础上增加线程效率反而下降,这个需要调查原因,找到竞争的资源。

Part 4

总结

这次性能优化整体采用科学法的框架,结合USE方法排查隐患和向下挖掘分析法进行问题分析。过程中使用了业界常用的分析工具比如慢日志、JProfiler来做数据支撑。很多工作可以本地编写完了直接扔到服务器上测试。但是使用本地调试和扔到服务器上测试,养成的习惯和解决问题的方式,思考问题的缜密程度都截然不同。这些最终都决定了一个人的编程能力。

本篇文章中缓存使用的是ConcurrentMap。这个从性能上要比咱们耳熟能详的本地缓存大咖:Caffeine、Guava Cache要好。但只适用于测试,不建议生产环境使用。因为生产环境要考虑长期运行时的内存管理等问题。ConcurrentMap将存储所有存入的数据(本次测试场景入参是笛卡尔积之后也只有不到1千种情况,所以可以用),如果不引进额外的管理机制会导致OOM等问题。

以下是生产环境常用的本地缓存类库性能对比:

49902025bd2addcfba8bbf491b707de1.png

性能最好的Caffeine,业界是这样评价的:

Caffeine是基于Java 1.8的高性能本地缓存库,由Guava改进而来,而且在Spring5开始的默认缓存实现就将Caffeine代替原来的Google Guava,官方说明指出,其缓存命中率已经接近最优值。实际上Caffeine这样的本地缓存和ConcurrentMap很像,即支持并发,并且支持O(1)时间复杂度的数据存取。二者的主要区别在于:

ConcurrentMap将存储所有存入的数据,直到你显式将其移除;
Caffeine将通过给定的配置,自动移除“不常用”的数据,以保持内存的合理占用。
因此,一种更好的理解方式是:Cache是一种带有存储和移除策略的Map。

即:业界生产使用的最好的本地缓存组件的性能标杆是ConcurrentMap。

acf35c2c5773d904cf344b006871a812.jpeg

  • 29
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值