https://mp.weixin.qq.com/s/7-h2w_iXrM5861iGTpftNQ
引言
响应式编程最简单的定义是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<Order> orderFlow = Flowable.fromCallable(() -> queryOrder(orderId));
-
//查商品
-
Flowable<Item> itemFlow = orderFlow.flatMap(order -> Flowable.fromCallable(() -> queryItem(order.getId())));
并发请求 商品详情中往往还会有一些额外的信息,比如浏览量以及留言内容。浏览量和留言它们彼此是互不依赖的,但是都依赖查到的商品信息。
-
Flowable<Detail> detailFlow = itemFlow.flatMap(item -> {
-
//浏览量
-
Flowable<Long> pvFlow = Flowable.fromCallable(() -> queryItemPv(item.getId()));
-
//留言
-
Flowable<Comment> 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,缓存)都是各自维护自己的线程池,理论上这些线程池都可以趋于统一。
微信公众号: 架构师日常笔记 欢迎关注!