23

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

 4 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzI1NDc5MzIxMw%3D%3D&%3Bmid=2247487781&%3Bidx=1&%3Bsn=8a4f0ed1381c3c52e88ca8c64917a322
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

导语

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

背景

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

情景描述

1、数据查询

im6BVbQ.png!web

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

2、数据转换

BZnmq2F.png!web

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

问题分析

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

1、数据查询

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

QBbE7rE.png!web

上面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、数据转换

Y3UNbiq.png!web

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

优化方案

1、优化一:数据查询

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

yQjIJnR.png!web

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

2、优化二:数据转换

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

IFbyEny.png!web

拆分集合的方式如下:

aUFZFrq.png!web

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

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

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

UJJJFrR.png!web

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

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

nia6Vve.png!web

场景描述:单台机器的 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 个线程。
基于以上计算方式,总结公式如下:

Y7FRnuJ.png!web

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

最大线程数

CPU负载

队列情况

耗时情况

300

88%

无等待

183ms

180

54%

无等待

128ms

50

28%

无等待

125ms

34

21%

无等待

120ms

24

18%

有等待

135ms

12

18%

有等待

148ms

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

iA3ENj3.png!web

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

总结规划

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

参考文献

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

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

作者简介

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK