单机吞吐提升 100%,响应时间降低 50%:去哪儿网酒店高性能业务网关优化实践
作者介绍
田文琦
2021年9月加入去哪儿网机票目的地事业群,担任软件研发工程师,现负责国内酒店主站技术团队。主要关注高并发、高性能、高可用相关技术和系统架构。主导的酒店业务网关优化项目,荣获22年去哪儿网技术中心TC项目三等奖。
一、背景
近来,Qunar 酒店的整体技术架构在基于 DDD 指导思想下,一直在进行调整。其中最主要的一个调整就是包含核心领域的团队交出各自的“应用层”,统一交给下游网关团队,组成统一的应用层。这种由多个网关合并成大前台(酒店业务网关)的融合,带来的好处是核心系统边界清晰了,但是对酒店业务网关来说,也带来了不小的困扰,系统面临的压力主要来自两方面:首先,一次性新增了几十万行大量硬编码、临时兼容、聚合业务规则的复杂代码且代码风格迥异,有些甚至是跨语言的代码迁移。其次,后续的复杂多变的应用层业务需求,之前分散在各个子网关中,现在在源源不断地汇总叠加到酒店业务网关。这就导致了一系列的问题:
- 业务网关吞吐性能变差
应对流量尖峰时期的单机最大吞吐量与合并之前相比,下降了20%
- 内部业务逻辑处理速度变差
主流程业务逻辑的处理时间与合并之前相比,上涨了10%
- 代码难以维护、开发效率低
主站内部各个模块之间严重耦合,边界不清,修改扩散问题非常明显,给后续的迭代增加了维护成本,开发新需求的效率也不高。
酒店业务网关作为直接面对用户的系统,出现任何问题都会被放大百倍,上述这些问题亟待解决。
二、现状分析
1、吞吐量下降分析
现有系统虽然业务处理部分是异步化的,但是并不是全链路异步化,如图所示:
同步 servlet 容器,servlet 线程与业务逻辑线程是同一个,高峰期流量上涨或者尤其是遇到流量尖峰的时候,servlet 容器线程被阻塞的时候,我们服务的吞吐量就会明显下降。
业务处理虽然使用了线程池确实能实现异步调用的效果,也能压缩同步等待的时间,但是也有一些缺陷:
- CPU 资源大量浪费在阻塞等待上,导致 CPU 资源利用率低。
- 为了增加并发度,会引入更多额外的线程池,随着 CPU 调度线程数的增加,会导致更严重的资源争用,上下文切换占用 CPU 资源。
- 线程池中的线程都是阻塞的,硬件资源无法充分利用,系统吞吐量容易达到瓶颈。
2、响应时间上涨分析
前期为了快速落地酒店 DDD 架构,合并大前台的重构中,并没有做到一步到位的设计。为了保证项目质量,将整个过程切分为了迁移+重构两个步骤。迁移之后,整个酒店业务网关的内部代码结构是割裂、混乱的。总结如下:
我们最核心的一个接口会调用70多个上游接口,上述问题:边界不清、不内聚、各种重复调用、依赖阻塞等问题导致了核心接口的响应时间有明显上涨。
三、解决方案
1、全流程异步化提升吞吐量
全流程异步化方案,我们主要采用的是 Spring WebFlux。
1.1 选择的理由
响应式编程模型:Spring WebFlux 基于响应式编程模型,使用异步非阻塞式 I/O,可以更高效地处理并发请求,提高应用程序的吞吐量和响应速度。同时,响应式编程模型能够更好地处理高负载情况下的请求,降低系统的资源消耗。
高性能:Spring WebFlux 使用 Reactor 库实现响应式编程模型,可以处理大量的并发请求,具有出色的性能表现。与传统的 Spring MVC 框架相比,Spring WebFlux 可以更好地利用多核 CPU 和内存资源,以实现更高的性能和吞吐量。
可扩展性:Spring WebFlux 不仅可以使用 Tomcat、Jetty 等常规 Web 服务器,还可以使用 Netty 或 Undertow 等基于 NIO 的 Web 服务器实现,与其它非阻塞式 I/O 的框架结合使用,可以更容易地构建可扩展的应用程序。
支持函数式编程:Spring WebFlux 支持函数式编程,使用函数式编程可以更好地处理复杂的业务逻辑,并提高代码的可读性和可维护性。
与 Spring 生态系统无缝集成:Spring WebFlux 可以与 Spring Boot、Spring Security、Spring Data 等 Spring 生态系统的组件无缝集成,提供了完整的 Web 应用程序开发体验。
1.2 实现原理和异步化过程
上图中从下到上每个组件的作用:
Web Server:适配各种 Web 服务, 监听客户端请求,并将其转发到 HttpHandler 处理。
HttpHandler:以非阻塞的方式处理响应式 http 请求的最底层处理器,不同的处理器处理的请求都会归一到 httpHandler 来处理,并返回响应。
DispatcherHandler:调度程序处理程序用于异步处理 HTTP 请求和响应,封装了HandlerMapping、HandlerAdapter、HandlerResultHandler 的调用,实际实现了HttpHandler的处理逻辑。
HandlerMapping:根据路由处理函数 (RouterFunction) 将 http 请求路由到相应的handler。WebFlux 中可以有多个 handler,每个 handler 都有自己的路由。
HandlerAdapter:使用给定的 handler 处理 http 请求,必要时还包括使用异常处理handler 处理异常。
HandlerResultHandler:处理返回结果,将 response 写到输出流中。
Reactive Streams:Reactive Streams 是一个规范,用于处理异步数据流。Spring WebFlux 实现了 Reactor 库,该库基于响应式流规范,处理异步数据流。
在整个过程中 Spring WebFlux 实现了响应式编程模型,构建了高吞吐量、高并发的 Web 应用程序,同时也具有响应快速、可扩展性好、资源利用率高等优点。
下面我们来看下 webFlux 是如何将 Servlet 请求异步化的:
ServletHttpHandlerAdapter 展示了使用 Servlet 异步支持和 Servlet 3.1非阻塞I/O,将 HttpHandler 适配为 HttpServlet。
第10行,request.startAsync()开启异步模式,然后将原始 request 和 response 封装成 ServletServerHttpRequest 和 ServletServerHttpResponse。
第36行,httpHandler.handle(httpRequest, httpResponse) 返回一个 Mono 对象(即Publisher),对 Request 和 Response 的所有具体处理都在 Mono 对象中定义。
所有的操作只有在 subscribe 订阅的那一刻才开始进行,HandlerResultSubscriber 是 Reactive Streams 规范中标准的 subscriber,在它的 onComplete 事件触发时,会结束 servlet 的异步模式。
对 Servlet 返回结果的异步写入,以 DispatcherHandler 为例说明:
第2行,exchange 是对 ServletServerHttpRequest 和 ServletServerHttpResponse 的封装。
第10-15行,在系统预加载的 handlerMappings 中根据 exchange 找到对应的 handler,然后利用 handler 处理 exchange 执行相关业务逻辑,最终结果由 result 将 ServletServerHttpResponse 写入到输出流中。
最后,除了 Servlet 的异步化,作为业务网关,要实现全链路异步化还需要在远程调用方面要支持异步化。在 RPC 调用方式下,我们采用的异步 Dubbo,在 HTTP 调用方式下,我们采用的是 WebClient。
WebClient 默认使用的是 Netty 的 IO 线程进行发送请求,调用线程通过订阅一些事件例如:doOnRequest、doOnResponse 等进行回调处理。异步化的客户端,避免了业务线程池的阻塞,提高了系统的吞吐量。
在使用 WebClient 这种异步 http 客户端的时候,我们也遇到了一些问题:首先,为了避免默认的 NettyIO 线程池可能会执行比较耗时的 IO 操作导致 Channel 阻塞,建议替换成其他线程池,替换方法是 Mono.publishOn(reactor.core.scheduler.Schedulers.newParallel("biz_scheduler", 300))。
其次,因为线程发生了切换,无法兼容 Qtracer (Qunar内部的分布式全链路跟踪系统),所以在初始化 WebClient 客户端的时候,需要在 filter 里插入对 Request 的修改,记录前一个线程保存的 Qtracer 的上下文。WebClient.Builder wcb = WebClient.builder().filter(new QTraceRequestFilter());
2、服务编排降低响应时间
Spring WebFlux 并不是银弹,它并不能保证一定能降低接口响应时间,除了全流程异步化,我们还利用 Spring WebFlux 提供的响应式编程模型,对业务流程进行服务编排,降低依赖之间的阻塞。
2.1 服务编排解决方案
在介绍服务编排之前,我们先来了解一下 Spring WebFlux 提供的响应式编程模型 Reactor。它有最重要的两个响应式类 Flux 和 Mono,一个 Flux 对象表明一个包含0..N 个元素的响应式序列,而一个 Mono 对象表明一个包含零或者一个(0..1)元素的结果。
不管是 Flux 还是 Mono,它的处理过程分三步:首先声明整个执行过程(operator);然后连通主过程,触发执行;最后执行主过程,触发并执行子过程、生成结果。
每个执行过程连通输入流和输出流,子过程之间可以是并行的,也可以是串行的这个取决于实际的业务逻辑。我们的服务编排就是完成输入和输出流的编排,即在第一步声明执行过程(包括子过程),第二步和第三步完全交给 Reactor。下面是我们服务编排的总体设计:
service:是最小的业务编排单元,对 invoker 和 handler 进行了封装,并将结果写回到上下文中。主流程中,一般是由多个 service 进行并行/串行地编排。
Invoker:是对第三方的异步非阻塞调用,对返回结果作 format,不包含业务逻辑。相当于子过程,一个 service 内部根据实际业务场景可以编排0个或多个 Invoker。
handler:纯内存计算,封装共用和内聚的业务逻辑。在实际的业务开发过程中,对上下文中的任一变量,只有一个 handler 有写权限,避免了修改扩散问题。也相当于子过程,根据实际需要编排进 service 中。
上下文:为每个接口都设计了独立的请求/处理/响应上下文,方便监控定位每个模块的处理正确性。
上下文设计举例:
在复杂的 service 中我们会根据实际业务需求组装 invoker 和 handler,例如:日历房售卖信息展示 service 组装了酒店报价、辅营权益等第三方调用 invoker,优惠明细计算、过滤报价规则等共用的逻辑处理 handler。
在实际优化过程中我们抽象了100多个 service,180多个 invoker,120多个 handler。他们都是小而独立的类,一般都不会超过200行,减轻了开发同学尤其是新同学对代码的认知负担。边界清晰,逻辑内聚,代码的不可知问题也得到了解决。
每个 service 都是由一个或多个 Invoker、handler 组装编排的业务单元,内部处理都是全异步并行处理的。如下图所示,ListPreAsyncReqService 中编排了多个 invoker,在基类 MonoGroupInvokeService 中,会通过 Mono.zip(list, s -> this.getClass() + " succ")将多个流合并成为一个流输出。
在 controller 层就负责处理一件事,即对 service 进行编排。如下图所示,
我们利用 flatMap 方法可以方便地将多个 service 按照业务逻辑要求,进行多次地并行/串行编排。
并行编排示例:第12、14行是两个并行处理的输入流 afterAdapterValidMono、preRankSecMono ,二者并行执行各自 service 的处理。
并行处理后的流合并:第16行,搜索结果流 rankMono 和不依赖搜索的其他结果流preRankAsyncMono,使用 Mono.zip 操作将两者合并为一个输出流 afterRankMergeMono。
串行编排举例:第16、20、22行,afterRankMergeMono 结果流作为输入流执行 service14 后转换成 resultAdaptMono,又串行执行 service15 后,输出流 cacheResolveMono。
以上是酒店业务网关的整体服务编排设计。
2.2 编排示例
下面来介绍一下,我们是如何进行流程编排,发挥网关优势,在系统内和系统间达到响应时间全局最优的。
- 系统内:
上图示例中的左侧方案总耗时是300ms。这300ms 来自最长路径 Service1的200ms 加上 Service3 的100ms:
Service1 包含2个并行 invoker 分别耗时100ms、200ms,最长路径200ms。
Service3 包含2个并行invoker 分别耗时50ms、100ms,最长路径100ms。
而右图是将 Service1 的200ms 的 invoker 迁移至与 Service1 并行的 Service0 里。此时,整个处理的最长路径就变成了200ms:
Service0 的最长路径是200ms
Service1+service3 的最长路径是100ms+100ms=200ms
通过系统内 invoker 的最优编排,整体接口的响应时间就会从300ms 降低到200ms
- 系统间:
举例来说,优化前业务网关会并行调用 UGC 点评(接口耗时100ms)和 HCS 住客秀(接口耗时50ms)两个接口,在 UGC 点评系统内部还会串行重复调用 HCS 住客秀接口(接口耗时50ms)。
发挥业务网关优势,UGC 无需再串行调用 HCS 接口,所需业务聚合处理(这里的业务聚合处理是纯内存操作,耗时可以忽略)移至业务网关中操作,这样 UGC 接口的耗时就会降下来。对全局来说,整体接口的耗时就会从原来的100ms 降为50ms。
还有一种情况,假设业务网关是串行调用 UGC 点评接口和 HCS 住客秀接口的话,那么也可以在业务网关调用 HCS 住客秀接口后,将结果通过入参在调用 UGC 点评接口的时候传递过去,也可以省去 UGC 点评调用 HCS 住客秀接口的耗时。
基于对整个酒店主流程业务调用链路充分且清晰的了解基础之上,我们才能找到系统间的最优解决方案。
四、优化效果
1、页面打开速度明显加快
优化后最直接的效果就是在用户体感上,页面的打开速度明显加快了(以详情页为例):
2、接口响应时间下降50%
列表、详情、订单等主流程各个核心接口的P50响应时间都有明显的降幅,平均下降了50%:
以详情页的 A、B 两个接口为例,A接口在优化前的 P50 为366ms:
A 接口优化后的 P50 为36ms:
B 接口的 P50 响应时间,从660ms 降到了410ms:
3、单机吞吐量性能上限提升100%,资源成本下降一半
单机可支持 QPS 上限从100提升至200,吞吐量性能上限提升100%,平稳应对七节两月等常规流量高峰。
在考试、演出、临时政策变化、竞对故障等异常突发事件情况下,会产生瞬时的流量尖峰。在某次实战的情况下,瞬时流量高峰达到过二十万 QPS 以上,酒店业务网关系统经受住了考验,能够轻松应对。
单机性能的提升,我们的机器资源成本也下降了一半。
4、圈复杂度降低38%,研发效率提升30%
优化后酒店业务网关的有效代码行数减少了6万行;代码圈复杂度从19518减少至12084,降低了38%;网关优化后,业务模块更加内聚、边界清晰,日常需求的开发、联调时间均有明显减少,研发效率也提升了30%。
五、小结与下一步规划
1、通过采用 Spring WebFlux 架构和系统内/系统间的服务编排,本次酒店业务网关的优化取得了不错的效果,单机吞吐量提升了100%,整体接口的响应时间下降了50%,为同类型业务网关提供一套行之有效的优化方案。
2、在此基础上,为了保持优化后的效果,我们除了建立监控日常做好预警外,还开发了接口响应时长变化的归因工具,自动分析变化的原因,可以高效排查问题作好持续优化。
3、当前我们在服务编排的时候,只能根据上游接口在稳定期的响应时间,来做到最优编排。当某些上游接口响应时间存在波动较大的情况时,目前的编排功能还无法做到动态自动最优,这部分是我们未来需要优化的方向。