前言
随着业务量的增长,系统流量也越来越高,历史原因,系统内部使用了大量的线程池,最近发现在流量高峰的情况下,出现了很多线程池拒绝,导致很多系统问题,团队内部统一对线程池的使用进行了分析、优化、解决,这里主要对线程池踩过坑做一个分享。
线程池的应用
线程池其实应用地方很多,一方面是用的管理端或者说运营端,对一些定时任务的处理,或者非实时较大数据量的数据任务处理,这种场景前期设置合适,除了遇到规定时间内必须执行完任务的诉求,基本上没有什么问题。
另外一种,也是笔者这次团队踩坑遇到的问题。目前像京东、淘宝、拼多多等这种电商平台,大家看到他们的复杂页面,这些复杂页面一般都由各个模块组成,一般来讲客户端打开这些复杂页面,这些复杂页面获取的数据,后端会将这些数据分成多个模块去请求,每个模块会是一个线程池去获取相关模块的数据。
如下图笔者打开的京东首页:
其实可见京东首页很多模块的数据,这些数据就可以在服务端后端拆分成多个线程池并行去请求下游,拿取相关数据。
这里笔者,画了一个类似的常见的页面多么块数据的请求的交互图如下:
如图所示,复杂页面各个模块的数据,对应用服务端后台,可能会开多个线程池去获取数据。
好处:
可以并行获取下游的数据,提高了获取数据的响应时间。
各个模块数据之间的获取是线程池隔离的,一个模块的下游出现问题,只会导致这个模块数据没有,对整体数据的获取的没有影响。
坏处:
每个模块都需要启单独的线程池获取,增加了系统的线程数量,浪费了内存,增加了多余的线程切换,浪费了CPU资源。
遇到的问题
刚开始业务量不是很大的时候,上述线程池没有任何问题,线上由多个线程池返回的数据也均正常。随着业务量的增长,在业务高峰期,总是会出现部分数据没有返回的情况,如上面图中所示,有些模块的数据由于线程池资源打满,导致有些请求,该线程池负责获取的模块数据由于拒绝 或者 队列等待原因,导致没有在规定时间内(聚合数据的时候会等待所有模块返回数据,一般会设置等待时间保证整体数据返回)返回,导致某些模块的数据没有返回。
目前系统对线程池队列 和 页面模块 均进行了打点,这种情况下,出现波动,导致某些模块不展示,对整体页面曝光有影响。
如下图所示:
由于线程池4发生了拒绝,导致页面模块4成为空白或者展示兜底图片。
这里补充一下整体页面的后端执行时间:
服务端时间 = max{模块1时间,模块2时间,模块3时间,模块4时间,模块x时间} or 系统等待时间 + 业务处理时间
这里说明一下:
系统最大等待时间 其实是为了防止无限等待,当超过这个时间没有返回的模块数据将不再获取,返回有损的结果,不至于整体超时。
问题的分析与解决
分析
为什么有的模块线程池为什么拿不到数据?这里主要原因是线程池的设置不合理。
线程是重要的资源,JVM 和 机器内存也有一定的限制,线程池也不能无限扩张。
另外,历史原因,这里比较明显的问题是对很多模块使用的线程池设置的核心线程数、最大线程数,还有等待队列的大小没有做任何的区分,从而导致在高流量的情况下出现两个瓶颈点:
1.所有机器同时执行的线程池活跃数量不足以满足页面的流量QPS。
2.线程池队列设置过大,且线程池内任务执行时间过长,导致排队等待的任务出现:
等待调度时间 + 任务执行时间 > 系统等待时间
上述两点,就会导致页面数据缺失的问题。
方案:
对于上述的两点,需要我们关注线程池数量 和 线程池队列大小。
java原生线程池ThreadPoolExecutor调度原理:
线程池初始化后,当有任务提交到线程池中,线程池优先创建线程,直到线程数量达到 coreSize,随后,再有新的任务进行,优先使用队列,当队列满了之后,继续扩大线程数量到 maxSize,如果仍不满足流量容量,执行拒绝策略。
从这里可以看到两个关键点:
1.线程先到coreSize,不论系统是否能够有资源增加线程,都需要放置到队列。
2.任务临时放置到队列,就会等待问题(任务内容占用问题,GC,这个问题里不考虑),具体等待多久,这个跟任务的执行时间有关系。
解决方案:
对于第一点,可以采用Jetty的线程池进行替换,jetty的线程池QueuedThreadPool当线程池没有达到最大线程数的话,有新的任务进来,会优先创建新的线程,当所有线程达到最大线程数的话,才进行队列放置。
这种线程池确实可以解决上面的问题,我们并没有采取这种方案,主要是考虑到加大线程资源,适合于所有线程池都是平等的情况下,比如中间件、网关等,而文中的情况,对于不太重要的模块数据可以允许降级不获取的,而不是无限扩容线程,这样会跟核心模块的线程池争用线程资源,进而会影响核心模块的展示。
关于第二点,这里需要计算线程任务的执行时间,根据执行时间进行推算。
详细推算如下:
任务执行时间: taskExecTime (ms) 每秒执行的任务数:taskExecPerSec = 1000 / taskExecTime (ms) 系统等待时间:sysWaitTime (即每次请求等待线程池返回结果的时间) 我们这里统一用毫秒的单位 ms 核心线程数:coreSize,需要保证常态化流量,一般为页面的常态QPS,为了应对波动可以 是1.2倍 或者 2倍(这个倍数取决于保证系统SLA的常态化容量,比如日常保证双倍容量)。 队列的大小:queueSize = (sysWaitTime / taskExecTime ) * coreSize 即:队列大小 应该是 任务放在队列的时间 + 执行时间 不应该小于系统等待时间,保证数据可以返回。
方案验证:
按照上述方案调整后,需要进行压测,关注模块下发数据监控,以及线程池队列的使用情况,保证推算跟实际设置的误差在允许范围内。
总结
线程池的高效使用,需要针对不同的业务场景进行定制化的设置,为业务服务,在遇到问题后临时调整。
文中的方案,也是在特定的情况下,对线程池高效利用的一种设置,如果业务侧不忍受,也可以增加线程数,对于单机资源不足,通过扩容来解决。
压测过程中,对于C端服务,线程池的使用的关注也是重要的一点。