作者 | 喜洲
背 景
高德云图是高德地理信息基础能力的出口,对外提供包含搜索和导航等服务接口数量超 700 个,接入应用达 40 万以上,日均处理请求量超百亿,日均 QPS 峰值过百万。高德云图服务端包含开放平台、苹果地图和多类行业解决方案,服务客户包括个人与企业开发者、企业专有用户,以及手淘、天猫、支付宝、飞猪、Lazada 等阿里经济体团队。
传统服务端架构一般采用同步阻塞模型,这符合常人思维模式,但同步等待浪费系统资源,且通过分配更多线程来支撑更多请求的方式,会导致上下文切换和锁竞争,拉低资源利用率。基于一个请求一个线程的服务模式无法做到动态伸缩,难以应对突发流量。
云图核心目标是稳定高效地输出高德基础能力,为最大化利用服务器资源、提高服务稳定性和优化终端用户体验,云图服务端基于不同业务场景在异步与反应式技术架构上做了一系列探索和实践。本文将介绍在优化升级过程中的探索与思考,希望为有类似需求的团队带来帮助。
异步与反应式
异步任务处理机制可分为两大类,第一类是线程池,第二类是事件驱动。第一类将阻塞式任务从一个线程交由另一个线程处理,避免影响当前线程,但无法解决线程膨胀问题。第二类即基于 epoll 事件驱动和多路复用,在处理 I/O 阻塞任务时可以实现服务内非阻塞响应。
Reactive 反应式的“反应”体现在:1. 对事件立即响应;2. 对失败场景立即响应;3. 对用户请求立即响应。其本质是面向事件流、基于 Reactor 模型、可保证服务在任何场景下都能保持实时响应的架构 (https://www.reactivemanifesto.org/)。
在任务处理角度,它基于事件驱动模型(比如 Java NIO/Selector)提供任务异步处理能力,在编程模型角度,它提供面向事件流的一系列操作组合(Operator),与目前流行的函数式编程、CQRS 和 EventSourcing 相辅相成。
随着 Java 8 和 Lambda 的普及,反应式技术框架大量出现,Java 9 已经提供 Reactive Streams API 实现,Spring 也推出基于 Reactor 的 WebFlux 框架,反应式正在成为互联网服务可靠方案。
轻量业务前置
云图线上服务整体可以抽象为下列四层。
流量接入层主要由用户鉴权、流控等轻量业务组成,特点是网络 I/O 密集,无长耗时逻辑且不依赖复杂中间件。为充分利用 Nginx 事件驱动模型和 Lua 开发效率的优势,我们选择 OpenResty lua-nginx-module 来前置这层业务。
优点: OpenResty 可以使用少量服务器资源支撑极高请求量。
缺点: Lua 语言偏小众,且部分中间件客户端缺乏对 Lua 支持,目前使用场景主要集中在流量接入层和 API 层内轻量业务场景。
从 Servlet 到去 Servlet
云图业务层以 Java 技术栈为主,其中很多 Web 服务基于同步阻塞式 Java Servlet,部分服务基于异步 Servlet。同步模型易于开发和问题追踪,但难以平稳应对高并发场景,虽然 Servlet 3.0 支持在非容器线程中处理请求,帮助尽快释放容器线程池中的线程,但由于其底层 I/O 依旧是阻塞操作(InputStream/OutputStream),在将结果写回响应流时仍会阻塞处理线程。如果业务包含阻塞逻辑,在面对高并发场景时压力只是从容器线程池转移到业务自定义线程池。Servlet 3.1 支持读写 Socket 时不阻塞线程(NIO),但由于围绕着 HTTP 请求响应语义模型来设计,在接口设计上并非纯粹异步,Tomcat 等 Servlet 容器无法最大程度发挥 NIO 优势。
苹果地图请求分发服务
云图苹果地图请求分发服务负责对苹果地图用户请求进行处理、分发和结果聚合,以支持苹果海外搜索与导航,其对并发能力、服务耗时和稳定性有着较高要求。在技术选型时考虑到 Servlet 的约束,以及项目周期、学习曲线和实现成本等因素,最终选择 Vert.x (https://vertx.io/)。
在我们看来,Vert.x 的优点包含:
轻量、高性能。
易读的源码、低学习曲线。
Actor 模型,基于 Verticle 和 EventBus 可以实现服务模块解耦与资源隔离。
由于该服务链路较短,最终没有选择 RxJava 而使用 Java 8 CompletableFuture 来实现异步服务接口定义和任务编排,同时基于 Vert.x Verticle 封装不同业务单元,基于 EventBus 实现业务单元间消息传递。
Verticle 类似于 Akka Actor,每个 Verticle 实例会绑定一个 EventLoopContext 对象,且每个 Context 会被分配一个 EventLoop 线程,Verticle 实例内部逻辑总会由该线程执行,Vert.x 通过这一机制确保 Verticle 内部线程安全。
同时,由于 EventLoopGroup 在 Vertx 实例化时已经完成定义,不论当前有多少个 Verticle 实例,它们都会共享这个固定大小的 EventLoopGroup,通过控制不同业务单元对应的 Verticle 实例数量,可以实现业务单元线程资源隔离,达到模拟服务多租户的目的。
基于苹果请求分发服务,我们将 Vert.x 进行落地,达到凭借少量服务资源来稳定支撑苹果地图入口流量分发的目标。如果服务对 Spring 技术生态没有强需求,推荐使用 Vert.x 来做为反应式技术初体验。
从 Future 到 Reactive
通过 CompletableFuture 和 Lambda 表达式,可以快速实现轻量业务异步封装与编排,与 Callback 相比可以避免方法多层嵌套问题,但面对相对复杂业务逻辑时仍存在以下局限:
难以简单优雅实现多异步任务编排;
难以处理实时流式场景;
难以支持高级异常处理;
不支持任务延迟执行。
使用 Reactive 模型能够解决上述 Future 的局限。假设要实现下面这个需求(例子修改自 Project Reactor Reference Guide):筛选出符合特定条件的一组产品 Id (findIds 方法),再通过 Id 找到对应产品名称(findProductName 方法)与成交均价(findAvgPrice 方法),最终输出统计结果。
基于 Future 实现:
public CompletableFuture<List<String>> getStatisticOfFruits(){
CompletableFuture<List<String>> ids = findIds("fruits");
CompletableFuture<List<String>> result
= ids.thenComposeAsync(l -> {
Stream<CompletableFuture<String>> zip = l.stream().map(i ->{
CompletableFuture<String> nameTask = findProductName(i);
CompletableFuture<Integer> priceTask = findAvgPrice(i);
return nameTask.thenCombineAsync(priceTask,
(name, price)-> "Name: " + name + " - price: " + price);
});
List<CompletableFuture<String>> combinationList
= zip.collect(Collectors.toList());
CompletableFuture<String>[] combinationArray
= combinationList.toArray(
new CompletableFuture[combinationList.size()]);
CompletableFuture<Void> allDone
= CompletableFuture.allOf(combinationArray);
return allDone.thenApply(v -> combinationList.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList()));
});
return result;
}
基于 Reactive(Project Reactor)实现:
Flux<String> ids = rxFindIds("fruits");
Flux<String> combinations =
ids.flatMap(id -> {
Mono<String> nameTask = rxFindProductName(id);
Mono<Integer> priceTask = rxFindAvgPrice(id);
return nameTask.zipWith(priceTask,
(name, price)-> "Name " + name + " - price " + price);
});
return combinations.collectList();
}
从上面简单对比可以看出,相比 Future,基于 Reactive 模型丰富的操作符组合(filter/map/flatMap/zip/onErrorResume 等高阶函数)代码清晰易读,搭配 Lamda 可以轻松实现复杂业务场景任务编排。
大家可以将 Reactive 系统想象成现实中的一条生产线,系统原始输入可以看作是生产线起点端的原始材料(Publisher),原始材料被生产线向下游运输(Push),对于原始输入每个处理步骤可以看作是生产线上运送 / 装卸 / 加工 / 检验等一系列操作(Operator/Processor),生产线加工出的产品则会投递给预定(Subscription)该批产品的用户(Subscriber),通过预定,终端消费者无需反复询问上游产品源(Pull),同时生产线上每一步都可以向前反馈当前处理能力,避免上游投递物料数量超出当前加工能力(Back Pressure),从而保证生产线平稳运行。
开放平台 LBS API 服务
云图开放平台 LBS API 服务负责对外透出搜索和导航等基础 LBS 能力,实现请求校验、结果拼装、协议转换等逻辑。随着业务快速发展,底层业务域服务粒度和复杂度不断增加,虽然提高了整体服务横向扩展能力,但给上层业务聚合增加困难,往往一个服务场景就涉及多次分布式调用。
这里用一个简单业务场景举例。在请求导航服务时可以将地址设置为起终点,如下图所示,请求处理链路包含请求校验、并发调用地理编码(Geocoding)服务获取经纬度坐标、请求导航引擎、基于业务规则信息处理与过滤、协议转换等步骤,其中涉及多数据源聚合操作。
使用传统编程模式来处理类似上述业务场景,往往涉及复杂并发管理和异常处理。使用 Reactive 则能简化业务逻辑组合,提高代码可读性:
public Mono<RoutingResponse> processRoutingRequest(String originalRequest) {
return
validate(originalRequest)
.then(Mono
.zip(getOriginCoordinate(originalRequest),
getDestinationCoordinate(originalRequest))
.flatMap((coordinates) -> callRoutingEngine(
coordinates.getT1(), coordinates.getT2())))
.flatMap(this::filter)
.flatMap(this::convert)
.onErrorResume(this::handleException);
}
我们选择 Project Reactor(https://projectreactor.io/)和 Spring Webflux 来做为 LBS API 服务反应式架构升级基础,从之前 Servlet 技术栈转向 Reactive 技术栈,主要工作包括:
业务逻辑梳理与异步化改造,主要包含业务重构分拆、任务异步化编排与改造、中间件客户端异步化替换等,以及适配与异步化不兼容逻辑,比如使用 Reactor Context 替换 ThreadLocal 来传递上下文;
服务框架重构。提高业务抽象粒度,开发者只需实现具体业务步骤,业务处理链任务编排交由框架层实现。
升级重构之后,服务性能较之前 Servlet 架构得到了明显提升。以短途驾车导航场景为例,压测结果显示在高并发请求压力下新应用 QPS 较之前提升 480%。
通过业务梳理重构与服务异步反应式改造,LBS API 服务解决了早期面临的资源利用率问题,单机可支撑 QPS 较之前有数倍提升, 在系统资源正常情况下,面对流量突增场景服务耗时仍能保持平稳,同时通过任务异步编排替代多任务排队,有效降低了部分复杂业务链路服务耗时。后续规划包括:
当前 API 与行业层各微服务之间缺乏对 Backpressure 的支持,导致很难彻底避免上游请求压垮下游服务的潜在风险。目前我们在调研 RSocket 协议(https://rsocket.io/),希望通过全链路协议反应式改造完成服务整体对 Backpressure 的支持;
行业服务层很多系统,业务复杂度远超其他两层,计划通过领域驱动设计完成业务梳理与反应式改造。
总结与展望
云图服务端通过在异步反应式技术上的尝试与探索,为不同业务场景找到了相匹配的优化方案,解决了原有同步阻塞模型资源利用率低、系统稳定性易波动的问题。但异步反应式并非银弹,相对同步模型,它对开发同学思维模式有着更高要求,且整条服务链路均需进行反应式改造。
在适合的业务场景下,反应式技术架构能够有效提升服务吞吐能力,降低业务编排复杂度,帮助构建云原生时代整体系统快速即时反应能力。希望与对反应式技术感兴趣的同学和团队多多交流。
InfoQ 读者交流群上线啦!各位小伙伴可以扫描下方二维码,添加 InfoQ 小助手,回复关键字“ 进群 ”申请入群。大家可以和 InfoQ 读者一起畅所欲言,和编辑们零距离接触,超值的技术礼包等你领取 ,还有超值活动等你参加,快来加入我们吧!
点个在看少个 bug :point_down: