一、问题现象
最近,我们项目的现场运维人员反映,每隔一两个月,网站的页面打开就会变得特别缓慢。而重启后端服务之后,网站的响应速度又能够恢复正常。这种反复出现的性能问题,给用户体验带来了极大的不便,我们有必要彻底分析并解决这个问题。
二、问题定位
2.1 排查过程
为了更好地重现和分析问题,我们要求运维人员下次出现类似情况时,先不要着急重启后端服务。果不其然,过了一段时间后,页面打开变慢的问题再次出现了。
我们首先通过浏览器开发者模式查看网页发起的接口请求信息,发现后端接口的响应速度非常慢,导致整个页面载入延迟严重。接下来,我们找到对应的后端服务进程,在后端服务器上直接调用该接口,测试其性能表现。结果显示,即使是在服务器上直接调用,该接口的响应速度依然很慢。
这种现象就值得高度怀疑了,我们继续观察了服务进程的GC(垃圾回收)情况,发现存在大量的Full GC,堆内存使用率接近99%。种种迹象表明,这很可能是由于内存泄露导致的性能问题。
为了进一步确认,我们使用相关命令将Java堆内存Dump下来,以便后续分析。
2.2 内存分析
拿到堆Dump文件后,我们使用Eclipse Memory Analyzer Tool(MAT)工具对其进行了分析。结果显示,在堆内存中,有一个特别大的ConcurrentHashMap对象,其中存储了大量的数据。通过查看对象的包路径,我们发现这个对象来自于aviator这个第三方依赖包。
查看项目代码,发现我们在使用Aviator表达式引擎时,确实有对表达式求值结果进行了缓存,原本是为了提高表达式执行的性能。不过,有一处代码的实现存在缺陷,导致每次执行表达式时,相关的变量数据也被缓存到了ConcurrentHashMap中,而不是重用缓存数据。
伪代码示意如下:
1 | // 构造Aviator执行环境,这里的for循环是模拟了多次调用 |
由于每次传入的i的值不同,所以表达式在缓存中会被认为是新的entries,并不断占用内存空间。随着时间推移,缓存中的数据持续增长,最终导致内存溢出的问题。
三、解决方案
问题原因查明后,解决方案也就比较明确了。我们需要将变量数据的传入方式改为使用Aviator内置提供的env机制,避免将变量数据缓存进HashMap。伪代码如下:
1 | // 构造Aviator执行环境,这里的for循环是模拟了多次调用 |
这样一来,相同的表达式字符串在缓存中就只会存在一个条目,变量值的改变不会影响缓存的行为。经过这个调整后,我们的缓存使用就合理化了,不会再出现无限增长导致内存溢出的情况。
四、技术小结
通过这次事件,我们可以总结出以下一些经验教训:
- 使用缓存技术时,一定要谨慎设计键值对的构成,避免由于键过于”宽泛”而导致命中率下降、内存占用增长。对于本例,我们就是由于把可变数据也纳入了缓存的Key,才酿成了内存泄漏的隐患。
- 对于开源工具的使用,最好要全面了解其机制和最佳实践,而不是operates半遮面。Aviator表达式引擎为了保证变量的动态作用域,专门提供了env环境机制,我们本应使用这个标准方式。
- 及时有效地监控系统的运行状态、性能指标等,对发现和定位线上问题至关重要。这次问题就是被运维人员的经验性发现而得以解决,如果系统监控做的好,同样可以立即发现异常。
- 学会善用各类工具和框架,高效诊断分析内存、CPU等系统瓶颈和根本原因。本文用到的MAT、GC日志、HeapDump等,都是分析Java应用程序不可或缺的利器。