一、背景
在现今的信息时代,微服务技术已成为一种重要的解决方案,微服务技术可以使系统的规模和功能变的更加灵活,从而获得更高的可扩展性和可用性。然而,微服务调用中出现的超时问题,却也成为系统可用性的一大隐患。超时会导致客户端的性能下降,甚至可能无法正常工作。本文针对超时问题,提出相关的优化手段,降低微服务调用超时的风险。
1.1 误区
当我们遇到超时或执行慢的问题时,我们往往会认为是依赖方出现了问题。
例如:访问 Redis、DB、 RPC 接口变慢、超时,第一时间找依赖方排查问题,对方反馈的结论是,我这边(服务端)没有问题,请检查一下你那边(客户端)是否有问题。
实际上,性能下降是一个非常复杂的问题,它可能涉及多个方面,包括服务端和客户端。例如:代码质量、硬件资源、网络状况等问题都会导致性能下降,从而引发响应慢、超时等问题。因此,我们需要全面地分析问题,找出影响性能的各种因素。
1.2 分享的目的
本文将详细介绍我们在生产环境中遇到的慢执行和超时等问题,并提出相关的优化手段,通过优化长尾性能,降低变慢或超时的风险,提升系统的稳定性。
二、超时的分类
常见的超时一般有两类:
a. 连接超时(ConnectTimeout):指建立网络连接所需要的时间超出了设定的等待时间。
b. Socket 超时(SocketTimeout):指在数据传输过程中,客户端等待服务端响应的时间超出了设定的等待时间。
如下图,①就是连接超时关注的时间,②就是 Socket 超时关注的时间,本文讲解的超时为 Socket 超时。
图1 客户端请求过程
三、超时问题分析与优化
3.1 设置合理的超时时间
根据实际情况设置合理的超时时间,避免因为超时时间设置不合理导致的接口超时。
1)分析
看下客户端设置的超时时间是否合理。比如调用服务端 P99.9 是100ms ,客户端设置的超时时间是 100ms ,就会有 0.1% 的请求会超时。
2)优化方案
我们在设置超时时间需要综合考虑网络延迟、服务响应时间、GC 等情况。
以门票活动查询引擎为例:
- 核心接口:最小值( P99.9*3 ,用户可接受的等待时间),核心会影响到订单,在用户可接受范围内尽可能出结果。
- 非核心接口:最小值( P99.9*1.5,用户可接受的等待时间),非核心不影响订单,不展示也没关系。
3.2 限流
当系统遇到突发流量时,通过限流的方式,控制流量的访问速度,避免系统崩溃或超时。
1)分析
看下超时时间点的请求量是否有突增,比如有某些突然的活动,这个时候应用没有提前扩容,面对突增流量会导致应用负载比较高,从而导致超时问题。
2)优化方案
评估当前应用最大可承载的流量,配置限流,维度可以是单机+集群。
单机限流:在面对突增流量时避免单机崩溃。
集群限流:在有限的资源下提供最大化的服务能力,保证系统稳定性,不会出现崩溃或故障。
3.3 提升缓存命中率
提升缓存命中率,可以提高接口的响应速度,降低接口的响应时间,从而减少超时的发生。
1)分析
分析调用链路,找到慢的地方对其进行优化,提升服务端的响应速度。
如下图所示,很明显可以看到服务端执行时间超过了客户端配置的超时时间 200ms 导致超时。
图2 客户端调用服务端超时链路
继续分析服务端执行链路,发现是因为缓存没有命中导致的。
图3 缓存未命中链路
2)优化方案
对于高并发系统来说,常见的是使用缓存来提升性能。
如下图是之前的缓存架构,这种缓存架构有两个风险。
a. 缓存是固定过期的,会导致某个时间大量 key 失效直接击穿到数据库。
b. 主动刷新机制是删除缓存,监听数据库 binlog 消息删除缓存,如果大批量刷数据会导致大量 key 失效。
图4 固定过期+懒加载模式
针对上面的风险我们优化了缓存架构,固定过期改为主动续期缓存,主动监听消息刷新缓存的方案,如下图所示。
图5 缓存前后架构对比
3)效果
缓存命中率提升到 98% 以上,接口性能(RT)提升 50% 以上。
图6 处理性能提升50%
这个缓存优化方案在我们团队之前写的一篇文章《1分钟售票8万张!门票抢票背后的技术思考》中有详细的介绍,具体细节这个地方不再展开,有兴趣的同学可以自行阅读。
3.4 优化线程池
减少不合理的线程,降低线程切换带来的超时。
1)分析
a. HTTP 线程数
先看下服务端 HTTP 线程数是否有明显增加,且流量没有增长,要确认 HTTP 线程数增加不是因为流量增长导致的。如下面两张图,流量正常的情况下 HTTP 线程数增加,说明是服务端响应变慢导致,可以确认超时是服务端原因。
图7 服务流量平稳
图8 HTTP线程数突增
b. 总线程数
再看下总线程数是否有增加(排除 HTTP 线程数),如果有,说明有使用多线程导致线程数量增加。这个时候需要 Dump 下线程,看下哪些线程使用的比较多。
2)解决方案
a. 统一管理线程池:动态配置参数+监控能力
通过工具类封装统一的线程池,提供动态配置参数和线程池监控能力。
- 效果
线程池具备监控能力,如下图是最小值(核心线程数)、最大值(最大线程数)和当前线程池中线程数量的监控,可以参考这个来调整线程池参数。
图9 线程池水位线监控
b. 异步改同步:小于10ms 的不使用多线程
高并发的场景下线程太多,线程调度时间得不到保障,一次任务需要多个 CPU 时间片,下一次调度的时间无法得到保障。
如下图是一个线程池执行耗时埋点,通过埋点发现 A 在线程池中执行的比较快,平均线和 P95 都在 10ms 以下,没有必要使用线程池,改成同步执行。
图10 优化前执行耗时
- 效果
接口性能提升明显,平均线从 2.7ms 降低到 1.6ms,P99.9 从 23.7ms 降低到 1.7ms。
之前使用多线程,请求量有波动的时候线程增加比较多,导致线程调度时间得不到保障,体现到 P99.9 就很高。
图11 优化前后耗时对比
另外可以明显看到总线程数也相应减少了很多。
图12 异步改同步后总线程数减少
3.5 优化 GC
优化 GC,减少 GC 的停顿时间,提高接口的性能。
1 )分析
首先看超时时间点是否有 Full GC,没有再看下 Yong GC 是否有明显的毛刺,如下图可以看到 3 个明显的毛刺。
图13 Yong GC时间
如果超时的时间点(如下图)可以对应上 GC 毛刺时间点,那可以确认问题是由于Yong GC 导致。
图14 客户端调用服务端超时次数
2)解决方案
a. 通用性 JVM 参数调优
检查 -Xmx -Xms 这两个值设置的是否一样,如果不一样 JVM 在运行时会根据实际情况来动态调整堆大小,这个调整频繁会有性能开销,并且初始化堆较小的话,GC 次数会比较频繁。
- 效果
-Xmx3296m -Xms1977m 改成 -Xmx3296m -Xms3296m 后效果如下图所示,频率和时间都有明显的下降。
图15 通用性JVM参数调优后效果
b. G1 垃圾回收器参数调优
背景:如果没有设置新生代最大值和最小值或者只设置了最大值和最小值中的一个,那么 G1 将根据参数 G1MaxNewSizePercent(默认值为60)和 G1NewSizePercent(默认值为5)占整个堆空间的比例来计算最大值和最小值,会动态平衡来分配新生代空间。
JVM刚启动默认分配新生代空间是总堆的 5%,随着流量的增加,新生代很容易就满了,从而发生 Yong GC,下一次重新分配更多新生代空间,直到从默认的 5% 动态扩容和合适的初始值。这种配置在发布接入流量或者大流量涌入时容易发生频繁的 Yong GC。
针对这类问题,优化方案是调大 G1NewSizePercent,调大初始值,让 GC 更加平稳。这个值需要根据业务场景参考GC日志中Eden初始大小分布来设置,太大可能会导致 Full GC 问题。
以查询引擎为例,根据 GC 日志分析,新生代大小占堆比例在 35% 后相对平稳,设置的参数为:
-XX:+UnlockExperimentalVMOptions -XX:G1NewSizePercent=35
- 效果
优化后效果如下图,可以看到优化之后 GC 次数从 27次/min 降低到 11次/min,GC 时间从 560ms 降低到 250ms。
图16 G1参数调优后效果
3.6 线程异步改成 NIO 异步编程
NIO(非阻塞 IO)可以减少线程数量,提高线程的利用率,从而降低线程切换带来的超时。
1)分析:CPU 指标
分析 CPU 相关指标,如果出现 CPU 利用率正常,CPU Load 高需要重点关注(如果是CPU 利用率高的情况,说明 CPU 本身就很繁忙,那 CPU Load 高也比较正常)。
在分析之前,先介绍几个概念:
a. CPU时间片
CPU 将时间分成若干个时间片,每个时间片分配给一个线程使用。当一个时间片用完后,CPU 会停止当前线程的执行,进行上下文切换到下一个任务,以此类推。
这样可以让多个任务在同一时间内并发执行,提高系统的效率和响应速度。
下图模拟了单核 CPU 执行的过程,需要注意的进行上下文切换是需要开销的,但实际一次上下文切换需要的时间很短(一般是微秒级别)。
图17 CPU执行线程流程
b. CPU利用率
按时间片维度来理解,假设每次时间片都正好被使用完。
c. CPU Load
从上面概念分析,如果出现 CPU 利用率正常,但是 CPU Load 高,那说明 CPU 空闲时间片、等待线程数很多,正在使用的时间片很少,这种情况要减少 CPU Load 需要减少等待线程数。
2)分析:实际案例
我们之前生产遇到过多次 CPU Load 高 CPU 利用率正常的情况。问题出现前后代码没有变动,比较明显的变化是流量有上涨。排查代码发现有使用线程池并发调用接口的地方,调用方式如下图。
图18 线程池执行模型
这种方式在流量较低的情况下看不出什么问题,流量变高会导致需要的线程数量成倍增加。
例如:一次请求 A 需要调用 BCD 3个接口,那100个并发需要的线程数就是100 + 3*100=400(第一个100是A对应的主线程,后面的3*100是 BCD 需要的100个线程)。
3)解决方案
线程池并发调用改成 NIO 异步调用,如下图所示。
和之前对比,100 个并发,需要的线程数也是 100(这个地方不考虑 NIO 本身的线程,这个是全局的,并且是相对固定很少的线程数)。
图19 NIO异步调用执行模型
4)效果
超时问题没再出现,CPU Load 平均下降50%,之前 Load 经常超过2(CPU 核数为2),改造之后 Load 降到 0.5 左右。
图20 CPU Load优化后效果
3.7 启动阶段预热
启动阶段预热可以提前建立链接,减少流量接入时的链接建立,从而降低超时的发生。
1)分析
应用拉入后出现大量超时,并且 CPU Load 高 CPU 利用率正常,说明有很多等待线程,这种是拉入后有大量请求在等待被处理。
之前我们生产遇到过是在等待 Redis 建立链接,建链的过程是同步的,应用刚拉入请求量瞬间涌入就会导致大量请求在等待 Redis 建链完成。
2)解决方案
启动阶段预热提前建立链接,或者是配置 Redis 的最小空闲连接数。其他资源准备也可以通过启动阶段预热完成,比如 DB 链接、本地缓存加载等。
3.8 优化 JIT
JIT(Just-In-Time)编译可以提高程序的运行效率,灰度接入流量将字节码编译成本地机器码,避免对接口性能的影响。
1)JIT 介绍
JIT 是 Just-In-Time 的缩写,意为即时编译。JIT 是一种在程序运行时将字节码编译为本地机器码的技术,可以提高程序的执行效率。
在 Java 中,程序首先被编译为字节码,然后由 JVM 解释执行。但是,解释执行的效率较低,因为每次执行都需要解释一遍字节码。为了提高程序的执行效率,JIT 技术被引入到 Java 中。JIT 会在程序运行时,将频繁执行的代码块编译为本地机器码,然后再执行机器码,这样可以大大提高程序的执行效率。
2)分析
JIT 技术可以根据程序的实际运行情况,动态地优化代码,使得程序的性能更好。但是JIT 编译过程需要一定的时间,因此在程序刚开始运行时,可能会出现一些性能瓶颈。
如下图应用拉入后 JIT 时间很久,那可以确认是 JIT 导致超时。
图21 JIT执行时间
3)解决方案
优化 JIT 一个比较好的方案是开启服务预热(预热功能携程 RPC 框架是支持的)。
原理是让应用拉入后不是立马接入100% 流量,而是随着时间移动来逐渐增加流量,最终接入100% 流量,这种会让小部分流量将热点代码提前编译好。
4)效果
开启服务预热后,如下图所示,应用流量是逐渐增加的,可以看到响应时间随着时间越来越低,这就达到了预热的效果。
图22 服务拉入后请求量和响应时间
3.9 换宿主机
当宿主机负载过高时,可以考虑更换宿主机,避免宿主机负载过高影响容器负载。
1)分析
a. CPU Throttled 指标
看下应用 CPU 节流指标,CPU 节流会导致 CPU 休眠引起服务停顿。如果 CPU 利用率正常还是出现了 CPU 节流,这种大多数都是宿主机问题导致。
图23 CPU节流情况
b. 宿主机指标
排查宿主机 CPU 利用率、CPU Load、磁盘、IO 等指标是否正常,如下图 CPU Load在某个时刻后大于1说明宿主机负载较大。
图24 宿主机CPU Load监控
2)解决方案
重启机器换宿主机。
3.10 优化网络
排查不稳定的网络线路,保证网络的稳定性。
1)分析
网络的重点看下 TCPLostRetransmit(丢失的重传包)指标。比如下图,某个点指标异常,而这个点其他指标都正常,那可以初步怀疑是网络问题导致,最终确认需要找网络相关团队确认。
图25 TCPLostRetransmit指标
2)解决方案
找网络相关团队排查优化。
四、总结
回顾全文,主要讲解遇到超时问题怎么分析、怎么定位、怎么优化,从简单到复杂总结了 10 种常见的优化方法。这些方法不一定能解决其他不同业务场景的超时问题,具体需要结合自己的实际业务场景来验证。
本文总结的方法都是我们在生产中遇到的真实情况,通过不断实践总结出来的,希望这些内容能够给阅读本文的同学带来一定的收获。
4.1 优化注意事项
- 超时时间设置和 GC 调优需要结合自己业务场景来优化。
- NIO 异步编程改造成本、复杂度较高,我们也在探索更简单的方式,例如 JDK19 引入的虚拟线程(类似 Go 协程),可以用同步编程方式来实现异步的效果。