并发在58二手车列表的应用

导语

并发编程,在提高我们程序性能的同时,也会带来种种不可未知的问题,如何优雅地在高并发场景下解决实际业务问题呢?本文从五八二手车真实业务出发,逐步刨析,希望能给读者一些启示。

背景

随着二手车市场的不断扩增,58二手车作为全网的重要入口之一,经历着越来越多的流量考验,业务需求的快速迭代,需要我们在列表页承载更多类型的信息,这使我们面临着更加严峻的性能考验。
获取数据列表的服务接口是我们这块耗时最为严重的接口,曾一度达到200ms+,如何优化此接口的耗时是我们要解决的核心问题。

情景描述

1、数据查询

二手车列表页最多包含近10种信息类型需要查询,其中这些信息可以分为两大类,一类是无依赖的信息,一类是信息间存在补足策略的有依赖信息列表。这里的依赖指的是是否需要查询count接口,来确定下面的信息是否需要再次查询。每种信息都是从第三方服务中拉取列表,不同的服务方接口响应也是差别很大的。

2、数据转换

拉取信息列表后,要经过合并排序去重,然后在对每种信息进行打标转换,以便输出统一的实体模型,转换期间也有可能会调用第三方服务,来处理不同的实体字段。

问题分析

通过阿里的arthas 工具可以监控到列表服务接口getListResult 中两个比较耗时的操作:数据查询 search (150ms)和数据转换 transfer(50ms),接下来我们对此进行分析。

1、数据查询

针对已有的业务逻辑,我们线上优化前采用的是比较便于理解的方法,来处理这块并发,把有依赖的信息列表和无依赖的信息列表区分开,创建不同的线程去执行。下面是执行流程,以及我们监控的每一块的耗时情况。

上面A、B、C三种类型的信息没有任何依赖,我们采用三个线程并发执行,由于它们只需要获取Result(R)列表数据,且取决于耗时最长的执行子线程,平均耗时40ms左右。
下面的七种信息存在着依赖,首先获取D信息的Result(R)列表数据(此调用接口也会同时返回总量Count),根据其总量和页长可以计算该信息最多可以展示的页数,最后一页不足时,由E信息补足到指定的页长,这期间的页码需要获取E信息的Count(C)列表总量接口,以便用于分页的计算,其他的信息依次和E信息一样进行补足策略的处理。由于存在依赖,这里是一个子线程循环执行多种信息,基于页长的考虑,大部分的列表数据查询Result(R)会执行到 F信息,平均耗时取决于前三种信息的Result(R)的耗时和之后信息Count(C)的耗时:(40+10+60)+(5+15+20) = 150ms左右。
上下两类信息查询之间是并发执行的,所以数据查询执行耗时大概150ms左右。

2、数据转换

对于合并去重后的信息列表, 由于实体转换时存在对绝对位置的要求,线上优化前这一块没有进行并发处理。页面平均转换50条数据,由于每条数据转换时可能需要调用第三方的服务,平均每条转换需要1ms左右,所以数据转换执行耗时大概50ms左右。

优化方案

1、优化一:数据查询

重新设计信息间的补足策略,将数据的 count 和 result 查询彻底分开,执行流程如下。

由上图可知,将count(C)和result(R)完全分开,Count中多个子线程并发执行,耗时取决于最高的是50ms,然后执行统一的补足策略,来确定 result 查询的集合,最后多个子线程并发执行,耗时取决于最高的是60ms,平均耗时:50+60=110ms。
此优化应用于线上后, 接口耗时有40ms左右的性能提升。

2、优化二:数据转换

数据转换这块重新考虑并发处理,借助中间实体对象,保留合并排序去重后的绝对位置。将信息列表进行集合拆分,并发转换后再次放入原来排序后的位置。

拆分集合的方式如下:

在现有线程池资源的范围内,灵活合理的设置单个线程执行的子任务数 handlerCount 和允许创建的最大线程数MaxThreadSize。鉴于我们的系统场景,我们设置了每个线程执行5条信息转换,平均耗时5ms左右。
此优化应用于线上后,接口耗时又有40ms左右的性能提升。

3、 优化三:线程池调参:

涉及线程池,势必要考虑线程池参数问题,要想正确合理的设置线程池的大小,必须综合考虑计算环境、资源预算和任务的特性。《Java并发编程实战》的作者Doug Lea给我们介绍了关于计算密集型和IO密集型线程池设置的基本思路,如下:

然后实际生产环境中,一个系统中既存在计算型任务,又存在IO型任务,此时我们该如何合理的设置呢?

针对于我们的应用场景,线程池中存在三个并发场景,如下图所示:

场景描述:单台机器的 QPS大概15~20,一个请求进入需要执行count、result、transfer 三次并发调度,每次都开启了10个线程,任务数计算为:(15~20) * (10+10+10) ~500个任务,考虑流量翻倍情况,任务数最大约1000,再计算每个任务的平均耗时:(40+60+10)/ 3 = 40ms 即为0.04秒,系统预设每个任务最大容忍耗时300ms,即为0.3s(任务执行await超时时间)。
线程计算:单线程时,每个任务平均耗时0.04s,则每秒可以执行(1/0.04)= 25个任务,500个任务则需要(500/25)= 20秒,则20个线程可一秒内执行500个任务,
即corePoolSize可设为20;1秒内执行500个任务,任务最大容忍耗时0.3s,则 workQueue 可设为 (500 * 0.3)= 150个任务;此时每秒可以执行650个任务不会有抛弃,当任务达到1000个时,剩下的(1000-650)= 350 个任务,需要重新创建线程,同上述计算一样需要创建(350/25)= 14个线程,即 maxPoolSize 为(20+14)= 34 个线程。
基于以上计算方式,总结公式如下:

针对本次优化,我们上线前对单机50 QPS 进行 TcpCopy 和 压测处理,数据如下:

最大线程数

CPU负载

队列情况

耗时情况

300

88%

无等待

183ms

180

54%

无等待

128ms

50

28%

无等待

125ms

34

21%

无等待

120ms

24

18%

有等待

135ms

12

18%

有等待

148ms

多次调整线程数,耗时情况如下图所示:

可见,线程数过大会导致CPU负载高,影响耗时情况,过小会导致队列存在等待,也会使耗时增高,针对不同的业务场景,合理的计算线程数 + 实验 Copy压测,才是最佳的实践选择。

总结规划

列表服务并发优化后,提升了80ms以上,性能提升的同时,也带来了开发调试的困难,多场景的并发引入,线程池的参设对于合理利用资源显的尤为重要,后续我们考虑动态设置线程池参数,以便最优化的利用系统资源,提高整体机器的利用率。

参考文献

1.  Arthas:阿里Java 诊断工具,https://alibaba.github.io/arthas/

2.  《Java并发编程实战》作者:Doug Lea,童云兰(译)

作者简介

王杰,58同城ABG资深研发工程师,负责58二手车主站业务架构设计,包括ToC业务架构、业务数据服务、业务监控平台。

杨俊南,58同城ABG资深开发工程师,负责58二手车列表详情系统、二手车用户画像设计与开发。