苏宁的系统间交互最初使用中心化 ESB 架构,但随着系统拆分工作的展开及业务量的迅速攀升,系统间调用规模越来越大,ESB 中心化架构带来的诸如中心资源隔离、中心容量动态评估、问题排查难度、中心化扩展能力瓶颈等问题迅速显现。并且,随着自研系统逐步替换商用系统,需要进行协议转换等工作逐步弱化,因此苏宁亟待一个更轻量化的去中心化的跨系统服务调用方案。
苏宁远程服务框架(RSF)致力于解决系统间的服务调用问题,提供一种透明的、高性能的 RPC 服务调用方案。目前应用于苏宁 1000+ 系统,每天的服务调用次数在 200 亿左右,是苏宁使用最广泛的技术组件。
开源世界里成熟的 RPC 比较多,简单的如 spring remoting,应用广泛,短短几行代码及配置就可以实现跨系统方法调用,但是都只是止步于调通服务。对于一个由上千个系统协同交互构成的复杂电商交易平台来说,只是达到跨系统能调通是远远不够的,需要考虑的问题有很多,比如服务节点的动态注册和发现,生产问题的快速干预,服务治理等等。而在不同的环境、背景下,也会有各自的需求和挑战,这也正是我们选择构建自己的 RPC 框架的核心原因。
本文将重点介绍 RSF 的重点特性及一些我们面临的挑战和相应的解决方案。
重点特性
1. 同步、异步 Future、异步 Callback 三种调用模型
同步调用:是最普遍使用的形式,使用同步调用,当前调用线程会阻塞等待服务调用返回结果或抛出异常。
异步 future:调用立刻返回,当前线程不会阻塞,不会等待服务提供方执行完成。服务提供方的返回结果可以后续通过 Future get 来获得,这种调用形式在某些场景下会特别有用,能实现并行调用服务。Future get 会阻塞当前线程,直到服务方返回结果或有异常抛出。
异步 Callback:调用立刻返回,当前线程不会阻塞,不会等待服务提供方执行完成。当服务方返回结果或抛出异常时,异步执行 callback 中相应的方法。
使用 Future 及 Callback 异步调用在某些场景下非常有用,它能做到调用方的线程不会阻塞等待服务调用结果。结合一些其他的异步技术可以使得整个调用链条异步化。
2. 服务方异步返回调用结果
服务方异步返回调用结果的机制类似 Async Servlet,当前处理调用请求的线程不返回最终结果,而是在其他线程中异步返回结果给消费方,比如服务实现方在实现服务 A 时,需要调用依赖的其他外部服务 B,如果外部服务 B 通过 Http 协议开放服务,则可以通过支持异步的 Http Client 来调用外部服务 B,然后在异步回调中返回 A 的最终调用结果。如果 B 也通过 RSF 开放服务,可以通过异步 Callback 来调用 B,在 callback 中返回 A 的最终调用结果。
处理请求 A 的服务方线程自身不会阻塞等待 B 的调用结果,只是发起了一次异步 Http 或 RSF 调用就结束了。
通过这些异步手段,可以做到整个调用链条异步化,不会有线程阻塞(浪费)在等待服务调用结果上,从而能极大提高整体资源利用率。在线程技术还在主宰着 java 的今天,如何让线程不阻塞、少阻塞是一件很重要的事。
3. 所有服务调用相关配置统一管理,修改后实时生效
比如服务调用的超时时间、重试次数、授权、负载均衡方式、流控、熔断、成员权重、服务路由策略等等服务调用相关的所有配置,在 RSF 都不是写死在应用侧代码或配置文件中的,都是在 RSF 服务管理平台上统一管理的,并且支持修改立刻生效,这一点针对线上问题干预非常重要,可以想象一下,当一个服务出现服务质量等问题时,想修改一个调用相关的配置,还需要发布应用是完全无法接受的。同时,这个能力还可以和监控体系有机结合起来,实现自动调控。
4. 重试及防重
当进行一次服务调用时,如果调用过程出现可重试的异常(如网络异常,调用方资源不足),并且配置是允许重试的话,那么将发起重试。
RSF 的重试和大部分的重试设计相比,稍微复杂。大部分的重试设计都是包含重试的几次请求之间是不交叉的,比如第一次请求已经超时引发了第二次重试请求,在第二次请求过程中,第一次请求结果回来了。大部分的重试设计是忽略第一次请求结果的,因为认为第一次请求的生命周期已经结束了。在 RSF 中则是认为第一次请求返回的结果是有效的,这个设计的目的是尽可能的促使调用成功,但是也引发了一些复杂的并发相关的问题需要处理,太过细节不再展开。
如果服务调用是冪等的,那么不管调用多少次都不会影响系统的状态,重试是安全的。但是,如果服务调用不是冪等的,那么重试就需要考虑防重的问题,RSF 中包含一些扩展点可以由用户来定义自己的防重逻辑,并且也自带了一个基于 redis 的默认防重实现。
5. 服务节点的自动注册和发现
Service discovery 是服务框架中最核心的部件,这个部件的目标很明确,就是服务节点上下线 (包括扩缩容、应用发布、节点宕机等等场景) 引起服务方节点列表变更时,服务消费方能实时、准确的知道。怎么达到则有各种设计,有基于中心化的如 Netflix/eureka,或者基于 ZooKeeper、etcd 的简单一点的方案,也有去中心化的方案,这个部件对数据一致性要求并不高,并不追求数据强一致性,但是如何做到可靠非常关键,试想如果这个部件出问题,导致消费方错误的认为服务方节点全部或者大部分都下线了,会引起什么样的后果,如果是中心化的设计,则会引发全局性的灾难。
RSF 的服务节点发现采用的是中心化的设计,但是我们认为去中心化的思路更优,因为不存在中心化架构下的中心瓶颈,出问题也不会是全局性的灾难,我们也曾基于 gossip 完整设计了一个方案,但是评估后认为实现较为复杂,重点要规避的风险是任何情况下都不会引发 gossip 风暴。
RSF 的服务发现会在本文后半部分稍微深入的展开探讨。
6. 负载均衡
当消费方发起一次服务调用时,RSF 会基于随机策略优先选择当前负载低(Least Pending Requests)的服务提供方节点,选择过程同时也会加入提供方节点权重因子。这种负载均衡方式能基于服务方节点的实时处理能力进行动态调整,能较好的规避短板效应。并且,负载均衡还会优先选择当前和提供方的连接已就绪的(关于 RSF 的连接管理,本文后半部分会稍微深入展开探讨),并且没有被熔断的服务方节点,这些策略目的都是为了为每次请求选择最优的服务方节点。
7. 熔断
RSF 的熔断有两种,一种是服务方法级的熔断,当调用某服务方法出现较高异常比例时,会禁止访问该服务方法一段时间,这段时间过去后,允许少量的请求,如果依然出现较高异常比例时,则继续禁止访问一段时间,否则放开访问限制。合适的设置消费方服务方法熔断,既可以保护服务提供方,避免其已经处于不健康状态下时继续给压。也可以避免消费方应用大量线程因等待服务方结果返回被阻塞(在同步调用下),有效的保护服务消费方自身。从而避免事故级联蔓延。但同时,一旦触发服务方法熔断,后果是严重的,会引起服务方法在一段时间内完全被禁止访问,所以应根据服务自身情况合理的设置触发条件阀值,不应该因为瞬间的服务质量毛刺导致轻易被触发。
RSF 另外一种熔断是针对服务方节点的,当某个服务方节点出现超时、资源繁忙等等异常时,会快速被熔断,负载均衡在选择提供方节点时,会优先选择没有被熔断的服务方节点,以提高调用成功率。
8. 并发流控
RSF 的并发流控有两种,一种是服务提供方侧的流控,一种是服务消费方侧的流控。
服务提供方侧的流控,是为了避免服务请求的并发量超出其设计的承受能力,从而引起各种蔓延以至整个服务方最终被冲垮,应该合理的设置服务方法的并发阀值,超过并发阀值的请求会被快速拒绝,从而有效的保护服务方。 在服务提供方,RSF 维护一个线程池,该线程池负责服务调用的业务代码执行。线程池有一个任务排队队列,一旦排队队列满了,请求将被拒绝。线程池的线程数和排队队列长度都可以在服务配置平台中设置。
为服务设置并发阀值,是将有限宝贵的线程池线程及排队数资源分配给服务。
并发阀值可以分组来设置,也可以为某一个服务方法单独设置。如果使用分组的方式进行设置,那么分组下的所有接口方法将共享一个计数器和阀值。
服务消费方侧的流控,RSF 可以针对某一个服务方法设置某一个服务消费方应用的并发流控阀值。当调用开始时并发计数 +1,当调用结束(调用返回或抛出异常都认为是结束)时并发计数 -1。当计数累计超过指定阀值,则抛出超出并发阀值相关的异常。
合适的设置消费方流控,既可以保护服务提供方,也可以避免消费方大量线程因等待服务方结果返回被阻塞(在同步调用下),有效的保护服务消费方自身。
9. 服务路由
RSF 服务路由是根据调用请求参数列表,调用方机房信息,服务方节点机房部署拓扑等信息,将请求路由到正确的目标服务方节点的过程,比如会员系统可能在多个机房部署相同的服务,并基于会员编号特定的规则将会员数据分布到不同的机房,那么在调用获取会员信息服务时,RSF 需要根据调用参数中的会员编号以及会员服务的机房部署拓扑信息,将请求路由到正确的机房中的服务方节点。
RSF 的服务路由在苏宁多机房多活项目中发挥至关重要的作用,当前支持优先同机房、会员编号分片、主机房、自定义脚本等多种路由策略。
10. 服务治理及监控
RSF 提供全量服务调用统计信息,以帮助服务提供方进行服务质量的持续优化,服务调用的统计信息包括调用次数、失败率、响应时间(平均 /TP90/TP99/TP999)等核心指标,服务相关方可以针对这些核心指标设置安全阀值进行告警。
RSF 还提供了端到端的完整 trace 能力,可以清晰的看到某一次调用的各个时间点明细,如调用方什么时候发起的请求,请求什么时候写到 socket,请求什么时间点到达服务方节点,在服务方线程池中排队等待了多长时间,什么时候开始执行业务代码,业务代码执行了多长时间等等。 这些能力对迅速感知及定位线上问题至关重要。
11. 与非 JAVA 系统的交互
苏宁大部分的系统基于 JAVA,但也存在一些老的系统如 SAP 或一些异构系统如使用 PHP/NODEJS 等,这些系统目前和 RSF 的交互使用 RSF 提供的 SAP Adapter 和 HTTP Adapter 来达到。
一些挑战
下面稍微深入的展开探讨下我们经历过的一些挑战和解决方案。
1. 服务节点注册和发现中心的扩展能力及稳定性
RSF 早期版本的服务节点注册和发现模块基于 ZooKeeper,思路也很简单,就是服务方节点上线的时候向 ZK 写入一个临时节点,当服务方节点主动下线时删除这个临时节点,当服务方节点宕机或其他异常状况时,依赖 ZK 的 session timeout 机制由 ZK server 自动剔除这个临时节点,当发生 session expire 时恢复这个临时节点。当服务方节点列表发生变更时,通过 ZK 的 watch 机制,将最新的服务节点列表下发给订阅的服务消费方节点。这种方案实现简单,但是当 ZK 集群出现故障时,大量服务方节点发生 session timeout,引起大量服务方临时节点下线。如果消费方完全信任 ZK 下发的服务方节点列表就会引发服务不可用的灾难。
另外,ZK 集群因为数据一致性设计的考量,所有的写操作都要经过 leader,包括 session create,session expire,临时节点写入等等都要经过 leader,因此 ZK 集群的写能力是存在单机瓶颈的,就是不管 ZK 集群怎么扩容,写能力就那么多,并且随着加入的 follower 节点越多,写能力越差(其实加入 observer 越多,对写能力也会有影响,毕竟 leader 也要把数据同步给 observer)。
在服务节点注册和发现这个场景下,服务数量 * 每个服务的节点数,这是一个非常巨大的数字,试想当 ZK 集群在最坏情况下要集中处理这么多临时节点数据的写入,还有大量的 session 恢复涉及的数据写入,会造成 leader 处理严重延迟,由此导致的更坏的情况是一个 session 刚恢复了,又因为后续写操作或心跳处理超时导致又 expire,然后又去恢复这种局面,恢复的时间难以估计。
图 1
RSF 目前采用的方案如图 1,服务方节点注册和续约是通过 Redis 来达到的,当服务方节点启动时向 Redis 写入该节点提供的服务列表信息,然后定时发 expire 指令来续约这份信息,当服务方节点主动下线时,从 Redis 删除该数据。当服务方节点宕机或其他异常状况时,依赖 Redis 的 expire 机制来自动删除这份数据。
pump 订阅所有 redis 的 key space,当 redis 的 key space 发生变化时都会通知到 pump,pump 聚合所有的 Redis 数据,将提供方节点 - 服务列表信息的数据转化为服务 - 提供方节点列表的数据结构,写进 ZK。消费方获取及更新服务的服务方节点列表还是通过 ZK 来达到。
在这种设计下,以 Redis 的处理能力,少量的几台 Redis 就可以处理几十万的服务节点注册和续约。ZK 方面,只有 pump 节点向 ZK 写入数据,写入的频率是 pump 侧控制的(不需要每次 redis 数据变动都会写一次 ZK,可以做秒级延迟合并处理),并且数据经过压缩,因此,这部分数据的写入对 ZK 基本没有写压力。
实际测试下来,这种设计经过横向扩展后,可以轻松的处理几万的服务 * 几十万的服务节点的规模,能满足我们未来一段时间的需求。
另外,还有一个值得一提的问题就是如何解决大量消费方的 session 相关操作对 ZK 的压力(虽然没有了临时节点,但是 session create,expire 等还是会都经过 leader),在 ZK 3.5.X 版本中有新的 local session 的概念,session 的生命周期都在 follower 或 subscriber 各自节点本地处理,不会再跟 leader 进行交互。具体细节这里不再展开。
另外,因为存在中心化的设计,所以还是要考虑灾难应对的问题,在 RSF 组件侧我们也提供了灾难应对的能力,即使注册中心出现问题,也能快速在组件侧进行自动修正。
2. 连接数控制的问题
大部分基于 TCP 的 RPC 框架,都是消费方和所有的服务方节点建立长连接,并且通过心跳包机制来保活或检查连接健康情况,针对某些会消费很多服务的应用,或者某些有很多消费方的服务来说,连接数会是一个问题,即使当期没问题,至少存在扩展性方面的问题。而且,这些同时建立的长连接,大部分时间里都是空闲的,存在资源的浪费。
RSF 采用的是和部分服务方建连的方案,就是消费方选择和提供方列表中一部分节点建立连接,在这个思路下,还需要进一步考虑的问题是:
- 采用部分建连的策略,需要考虑连接不均衡的问题。
- 当服务方节点列表发生少量变化时(比如服务方节点宕机),不应导致大面积的连接转移。
- 需要考虑服务之间的连接尽量重用等问题。
- 在有业务路由的场景下,无法预先部分建连,也就是请求的时候,才知道目标提供方,所以没办法预建连。
RSF 最终使用的策略是:
- 和部分服务方节点按需建连,并且当连接超过设定的业务空闲期,就关闭连接。
- 动态调整连接,以尽量做到连接均衡。
- 算法和逻辑保证当消费方或服务方节点部分增减时不引起大面积连接转移。
- 当不同服务的服务方列表有重合时,保证连接重用。
结语
服务调用框架作为最广泛使用的基础技术组件,除了一些基本的共通的能力,每个企业在不同发展阶段,都会或多或少的有自己的特定问题,你遇到的问题换个上下文背景可能问题根本不存在,对应的解决方案也是如此,只有在特定的上下文背景下最合适的方案。
苏宁公共平台组件研发中心所辖产品有:服务调用框架,苏宁 ESB,苏宁消息中间件 WindQ,任务调度平台,配置管理平台、日志采集平台、短信平台、验证码平台、分布式事务等核心基础平台和组件。我们会陆续进行系列纯技术干货分享,开放、探讨、共进,共同寻求突破~ 欢迎大家多多关注、共同交流!
评论 1 条评论