响应式编程最简单的定义是Reactive programming is programming with asynchronous data streams。无论是从Spring5中引入的响应式编程框架还是java9中集成的响应式流,都能看到响应式编程的影子。可以说响应式编程代表了未来编程的方向。
响应式编程其天然就是非阻塞的,当数据准备完成后自动触发下一个动作而不是等待数据完成。这种思想再结合异步化编程使得我们在统一线程模型,降低多线程编程成本的同时提升整个系统的吞吐量。
闲鱼当前的业务场景本身足够复杂,然而目前大规模使用的仍然是阻塞式编程,每个业务场景的流程被串行同步执行。它最大的问题在于大量线程等待IO(日志记录,RPC调用,HTTP请求等)完成。有鉴于此,闲鱼引入RxJava 2.0来作为响应式编程框架对应用进行异步化改造。目前app内的鱼塘首页,留言列表以及鱼塘详情已经完成异步化改造并上线。
异步化编程不同于传统的阻塞式编程,他们的区别可以用下面的图来表达,C代表computation任务,I代表IO任务。原来的一个线程中执行完所有的computation和IO任务转变成现在computation和IO任务分离,这样做的好处是CPU只关注computation任务,理论上CPU核数个线程就能满足所有的computation任务,大大减少线程切换开销。
线程模型 RxJava框架背后是线程模型的演进。在引入RxJava前传统Java自带多线程框架(Executor)和异步Future,它们的问题在于
线程池无法统一。开发在自己的场景下都可能去定义线程池。
上下文切换。随着线程数的增多,线程上下文的切换必然增多。
Future异步方式仍然是一种阻塞等待式方法。
RxJava结合全异步编程方式的优势在于
线程利用率大幅提高。当线程需要做阻塞操作时及时切换避免长时间占有线程,整个流程无阻塞。
RxJava统一了线程池模型。我们可以根据不同的场景选择对应的线程调度模型。
极致线程模型变得可能。理论上只需要cpu核数个线程就可以运行所有computation任务,这意味线程切换带来的开销几乎被消除。
响应式全异步编程天生拒绝阻塞,任何阻塞点都可能导致性能的退步。更严重的如果我们控制了线程数,当任务因为阻塞而产生堆积,随着堆积的任务变多应用会gc影响线上服务。目前闲鱼应用中存在4类阻塞点
应用日志。
HTTP请求。
RPC调用。
缓存读写。
闲鱼中的日志不仅仅作为异常输出手段,也是数据统计的一个主要方法。因为日志的性能显得很重要。虽然log4j和logback都提供了异步方式,但是它们本质上还是基于锁来实现。log4j2是新一代的基于LMAX Disruptor的无锁异步日志系统,在多线程程序中,其吞吐量比log4j和logback高10倍左右。
而HTTP,RPC以及缓存的读写都需要改造成纯异步方式:当请求发生时线程被释放,请求完成后继续在新的线程中执行余下业务流程。
对闲鱼中的场景进行一下归纳,可以梳理出三种执行范式,下面会分别简单展示这三种范式。为了简单起见,其中涉及到的IO操作都用一个函数加以抽象。
串行请求 以常用的电商场景为例,查看我买的商品详情。业务流程上要先查询订单详情,然后从订单详情中拿到商品,最后根据商品去查询商品详情。
//查订单
Flowable orderFlow = Flowable.fromCallable(() -> queryOrder(orderId));
//查商品
Flowable- itemFlow = orderFlow.flatMap(order -> Flowable.fromCallable(() -> queryItem(order.getId())));
并发请求 商品详情中往往还会有一些额外的信息,比如浏览量以及留言内容。浏览量和留言它们彼此是互不依赖的,但是都依赖查到的商品信息。
Flowable detailFlow = itemFlow.flatMap(item -> {
//浏览量
Flowable pvFlow = Flowable.fromCallable(() -> queryItemPv(item.getId()));
//留言
Flowable commentFlow = Flowable.fromCallable(() -> queryItemComment(item.getId()));
//浏览,留言,商品共同组成detail
return Flowable.zip(pvFlow,commentFlow,(pv,comment) -> buildItemDetail(item,pv,comment));
});
更新缓存 热门商品详情往往意味高并发访问,我们可以将这些数据缓存来减轻数据库的压力。但是缓存成功与否都不影响本次请求(缓存失败导致下次请求仍然走db,本次请求的数据仍然返回给用户)。更新缓存本质上代表这样一类操作,它们在请求完成后执行一些额外的操作(缓存,通知用户,日志记录等),这些额外操作的成功与否不对主流程造成任何影响。
//更新缓存,doOnNext不会对流的结果造成任何影响,只是触发一个操作
detailFlow.doOnNext(detail -> cache(detail));
需要强调的是上面流中所有的方法都对应着一次IO操作(RPC,缓存读写等),这些IO操作都应该是全异步方式实现(不能等待IO完成,而是IO完成后主动唤醒)。幸运的是已经有一些第三方库来帮我们完成这些IO异步化。
我们对改造完的接口进行了一轮性能测试,分别从接口rt,线程数以及cpu利用率三个方面对阻塞式执行和响应式纯异步方式进行对比
rt 因为我们在异步方式中增加了并行操作, 所以rt降低是必然的,rt下降50%左右。当请求QPS达到650的时候,传统阻塞方式rt飙升,服务开始不可用;响应式纯异步方式rt较为稳定,QPS达到850的时候开始明显上涨。
线程数 阻塞式执行方式因为在IO发生时线程会等待IO完成,而异步方式下线程直接释放,所以异步方式下线程利用效率明显更高。下面的测试结果也表面异步方式的线程数一直较为稳定;阻塞模式下线程在QPS到达650时被耗尽,这意味着新的请求将被直接拒绝。
CPU 由于异步模式下我们增加了并发,因此CPU使用必然会较阻塞模式高,测试结果也说明了这一点。然而当QPS到达650的时候,阻塞模式下服务已经不可用,因此CPU利用率最高只能到达75%左右;而异步模式CPU能够到达97%。
本文介绍了响应式编程在闲鱼的应用现状,我们选用了RxJava作为响应式编程框架,并选取了闲鱼留言列表,鱼塘首页以及鱼塘详情页进行改造并进行了性能测试。测试数据表明闲鱼群聊首页场景下,在rt降低50%的同时整个系统的吞吐量能提升约30%。
目前响应式纯异步编程在闲鱼仍然处于起步阶段,我们的线程池模型仍然没法做到极致(暂时没法完全消除阻塞点)。接下来会朝着极致线程模型进行尝试,可能的改造点包括
阻塞点消除。
cpu核数个线程来调度所有的computation任务。
底层的IO线程模型统一。现在IO操作(RPC,HTTP,缓存)都是各自维护自己的线程池,理论上这些线程池都可以趋于统一。
如果对文本的内容有疑问或指正,欢迎告知我们。
闲鱼技术团队是一只短小精悍的工程技术团队。我们不仅关注于业务问题的有效解决,同时我们在推动打破技术栈分工限制(android/iOS/Html5/Server 编程模型和语言的统一)、计算机视觉技术在移动终端上的前沿实践工作。作为闲鱼技术团队的软件工程师,您有机会去展示您所有的才能和勇气,在整个产品的演进和用户问题解决中证明技术发展是改变生活方式的动力。
简历投递:[email protected]
识别二维码,前瞻技术尽在掌握