写点什么

分布式服务框架之服务化最佳实践

  • 2016-04-06
  • 本文字数:7203 字

    阅读完需:约 24 分钟

编者按:InfoQ 开设栏目“品味书香”,精选技术书籍的精彩章节,以及分享看完书留下的思考和收获,欢迎大家关注。本文节选自李林锋著《分布式服务框架原理与实践》中的章节“服务化最佳实践”,对服务化之后面临的挑战进行分析,并给出解决方案和业务最佳实践。

在服务化之前,业务通常都是本地API 调用,本地方法调用性能损耗较小。服务化之后,服务提供者和消费者之间采用远程网络通信,增加了额外的性能损耗,业务调用的时延将增大,同时由于网络闪断等原因,分布式调用失败的风险也增大。如果服务框架没有足够的容错能力,业务失败率将会大幅提升。

除了性能、可靠性等问题,跨节点的事务一致性问题、分布式调用带来的故障定界困难、海量微服务运维成本增加等也是分布式服务框架必须要解决的难题。本章节我们将对服务化之后面临的挑战进行分析,并给出解决方案和业务最佳实践。

1. 性能和时延问题

在服务化之前,业务通常都是本地 API 调用,本地方法调用性能损耗较小。服务化之后,服务提供者和消费者之间采用远程网络通信,增加了额外的性能损耗:

1) 客户端需要对消息进行序列化,主要占用 CPU 计算资源。

2) 序列化时需要创建二进制数组,耗费 JVM 堆内存或者堆外内存。

3) 客户端需要将序列化之后的二进制数组发送给服务端,占用网络带宽资源。

4) 服务端读取到码流之后,需要将请求数据报反序列化成请求对象,占用 CPU 计算资源。

5) 服务端通过反射的方式调用服务提供者实现类,反射本身对性能影响就比较大。

6) 服务端将响应结果序列化,占用 CPU 计算资源。

7) 服务端将应答码流发送给客户端,占用网络带宽资源。

8) 客户端读取应答码流,反序列化成响应消息,占用 CPU 资源。

通过分析我们发现,一个简单的本地方法调用,切换成远程服务调用之后,额外增加了很多处理流程,不仅占用大量的系统资源,同时增加了时延。一些复杂的应用会拆分成多个服务,形成服务调用链,如果服务化框架的性能比较差、服务调用时延也比较大,业务服务化之后的性能和时延将无法满足业务的性能需求。

1.1 RPC 框架高性能设计

影响 RPC 框架性能的主要因素有三个。

1) I/O 调度模型:同步阻塞 I/O(BIO)还是非阻塞 I/O(NIO)。

2) 序列化框架的选择:文本协议、二进制协议或压缩二进制协议。

3) 线程调度模型:串行调度还是并行调度,锁竞争还是无锁化算法。

1. I/O 调度模型

在 I/O 编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者 I/O 多路复用技术进行处理。I/O 多路复用技术通过把多个 I/O 的阻塞复用到同一个 select 的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。与传统的多线程 / 多进程模型比,I/O 多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降低了系统的维护工作量,节省了系统资源。

JDK1.5_update10 版本使用 epoll 替代了传统的 select/poll,极大地提升了 NIO 通信的性能,它的工作原理如图 1-1 所示。

图 1-1 非阻塞 I/O 工作原理

Netty 是一个开源的高性能 NIO 通信框架:它的 I/O 线程 NioEventLoop 由于聚合了多路复用器 Selector,可以同时并发处理成百上千个客户端 Channel。由于读写操作都是非阻塞的,这就可以充分提升 I/O 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程挂起。另外,由于 Netty 采用了异步通信模式,一个 I/O 线程可以并发处理 _N_ 个客户端连接和读写操作,这从根本上解决了传统同步阻塞 I/O 一连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。

Netty 被精心设计,提供了很多独特的性能提升特性,使它做到了在各种 NIO 框架中性能排名第一,它的性能优化措施总结如下。

1) 零拷贝:(1)Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。(2)Netty 提供了组合 Buffer 对象,可以聚合多个 ByteBuffer 对象,用户可以像操作一个 Buffer 那样方便地对组合 Buffer 进行操作,避免了传统通过内存拷贝的方式将几个小 Buffer 合并成一个大的 Buffer。(3)Netty 的文件传输采用了 transferTo 方法,它可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。

2) 内存池:随着 JVM 虚拟机和 JIT 即时编译技术的发展,对象的分配和回收是个非常轻量级的工作。但是对于缓冲区 Buffer,情况却稍有不同,特别是对于堆外直接内存的分配和回收,是一件耗时的操作。为了尽量重用缓冲区,Netty 提供了基于内存池的缓冲区重用机制。性能测试表明,采用内存池的 ByteBuf 相比于朝生夕灭的 ByteBuf,性能高 23 倍左右(性能数据与使用场景强相关)。

3) 无锁化的串行设计:在大多数场景下,并行多线程处理可以提升系统的并发性能。但是,如果对于共享资源的并发访问处理不当,会带来严重的锁竞争,这最终会导致性能的下降。为了尽可能地避免锁竞争带来的性能损耗,可以通过串行化设计,即消息的处理尽可能在同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁。为了尽可能提升性能,Netty 采用了串行无锁化设计,在 I/O 线程内部进行串行操作,避免多线程竞争导致的性能下降。表面上看,串行化设计似乎 CPU 利用率不高,并发程度不够。但是,通过调整 NIO 线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列 - 多个工作线程模型性能更优。

4) 高效的并发编程:volatile 的大量、正确使用;CAS 和原子类的广泛使用;线程安全容器的使用;通过读写锁提升并发性能。

2. 高性能序列化框架

影响序列化性能的关键因素总结如下。

1) 序列化后的码流大小(网络带宽的占用)。

2) 序列化 & 反序列化的性能(CPU 资源占用)。

3) 是否支持跨语言(异构系统的对接和开发语言切换)。

4) 并发调用的性能表现:稳定性、线性增长、偶现的时延毛刺等。

相比于 JSON 等文本协议,二进制序列化框架性能更优异,以 Java 原生序列化和 Protobuf 二进制序列化为例进行性能测试对比,结果如图 1-2 所示。

图 1-2 序列化性能测试对比数据

在序列化框架的技术选型中,如无特殊要求,尽量选择性能更优的二进制序列化框架,码流是否压缩,则需要根据通信内容做灵活选择,对于图片、音频、有大量重复内容的文本文件(例如小说)可以采用码流压缩,常用的压缩算法包括 GZip、Zig-Zag 等。

3. 高性能的 Reactor 线程模型

该模型的特点总结如下。

1) 有专门一个 NIO 线程:Acceptor 线程用于监听服务端,接收客户端的 TCP 连接请求。

2) 网络 I/O 操作:读、写等由一个 NIO 线程池负责,线程池可以采用标准的 JDK 线程池实现,它包含一个任务队列和 _N_ 个可用的线程,由这些 NIO 线程负责消息的读取、解码、编码和发送。

3) 1 个 NIO 线程可以同时处理 _N_ 条链路,但是 1 个链路只对应 1 个 NIO 线程,防止产生并发操作。

由于 Reactor 模式使用的是异步非阻塞 I/O,所有的 I/O 操作都不会导致阻塞,理论上一个线程可以独立处理所有 I/O 相关的操作,因此在绝大多数场景下,Reactor 多线程模型都可以完全满足业务性能需求。

Reactor 线程调度模型的工作原理示意如图 1-3 所示。

图 1-3 高性能的 Reactor 线程调度模型

1.2 业务最佳实践

要保证高性能,单依靠分布式服务框架是不够的,还需要应用的配合,应用服务化高性能实践总结如下:

1) 能异步的尽可能使用异步或者并行服务调用,提升服务的吞吐量,有效降低服务调用时延。

2) 无论是 NIO 通信框架的线程池还是后端业务线程池,线程参数的配置必须合理。如果采用 JDK 默认的线程池,最大线程数建议不超过 20 个。因为 JDK 的线程池默认采用 _N_ 个线程争用 1 个同步阻塞队列方式,当线程数过大时,会导致激烈的锁竞争,此时性能不仅不会提升,反而会下降。

3) 尽量减小要传输的码流大小,提升性能。本地调用时,由于在同一块堆内存中访问,参数大小对性能没有任何影响。跨进程通信时,往往传递的是个复杂对象,如果明确对方只使用其中的某几个字段或者某个对象引用,则不要把整个复杂对象都传递过去。举例,对象 A 持有 8 个基本类型的字段,2 个复杂对象 B 和 C。如果明确服务提供者只需要用到 A 聚合的 C 对象,则请求参数应该是 C,而不是整个对象 A。

4) 设置合适的客户端超时时间,防止业务高峰期因为服务端响应慢导致业务线程等应答时被阻塞,进而引起后续其他服务的消息在队列中排队,造成故障扩散。

5) 对于重要的服务,可以单独部署到独立的服务线程池中,与其他非核心服务做隔离,保障核心服务的高效运行。

6) 利用 Docker 等轻量级 OS 容器部署服务,对服务做物理资源层隔离,避免虚拟化之后导致的超过 20% 的性能损耗。

7) 设置合理的服务调度优先级,并根据线上性能监控数据做实时调整。

2. 事务一致性问题

服务化之前,业务采用本地事务,多个本地 SQL 调用可以用一个大的事务块封装起来,如果某一个数据库操作发生异常,就可以将之前的 SQL 操作进行回滚,只有所有 SQL 操作全部成功,才最终提交,这就保证了事务强一致性,如图 2-1 所示。

服务化之后,三个数据库操作可能被拆分到独立的三个数据库访问服务中,此时原来的本地 SQL 调用演变成了远程服务调用,事务一致性无法得到保证,如图 2-2 所示。

图 2-2 服务化之后引入分布式事务问题

假如服务 A 和服务 B 调用成功,则 A 和 B 的 SQL 将会被提交,最后执行服务 C,它的 SQL 操作失败,对于应用 1 消费者而言,服务 A 和服务 B 的相关 SQL 操作已经提交,服务 C 发生了回滚,这就导致事务不一致。从图 2-2 可以得知,服务化之后事务不一致主要是由服务分布式部署导致的,因此也被称为分布式事务问题。

2.1 分布式事务设计方案

通常,分布式事务基于两阶段提交实现,它的工作原理示意图如图 2-3 所示。

图 2-3 两阶段提交原理图

阶段 1:全局事务管理器向所有事务参与者发送准备请求;事务参与者向全局事务管理器回复自己是否准备就绪。

阶段 2:全局事务管理器接收到所有事务参与者的回复之后做判断,如果所有事务参与者都可以提交,则向所有事务提交者发送提交申请,否则进行回滚。事务参与者根据全局事务管理器的指令进行提交或者回滚操作。

分布式事务回滚原理图如图 2-4 所示。

图 2-4 分布式事务回滚原理图

两阶段提交采用的是悲观锁策略,由于各个事务参与者需要等待响应最慢的参与者,因此性能比较差。第一个问题是协议本身的成本:整个协议过程是需要加锁的,比如锁住数据库的某条记录,且需要持久化大量事务状态相关的操作日志。更为麻烦的是,两阶段锁在出现故障时表现出来的脆弱性,比如两阶段锁的致命缺陷:当协调者出现故障,整个事务需要等到协调者恢复后才能继续执行,如果协调者出现类似磁盘故障等错误,该事务将被永久遗弃。

对于分布式服务框架而言,从功能特性上需要支持分布式事务。在实际业务使用过程中,如果能够通过最终一致性解决问题,则不需要做强一致性;如果能够避免分布式事务,则尽量在业务层避免使用分布式事务。

2.2 分布式事务优化

既然分布式事务有诸多缺点,那么为什么我们还在使用呢?有没有更好的解决方案来改进或者替换呢?如果我们只是针对分布式事务去优化的话,发现其实能改进的空间很小,毕竟瓶颈在分布式事务模型本身。

那我们回到问题的根源:为什么我们需要分布式事务?因为我们需要各个资源数据保持一致性,但是对于分布式事务提供的强一致性,所有业务场景真的都需要吗?大多数业务场景都能容忍短暂的不一致,不同的业务对不一致的容忍时间不同。像银行转账业务,中间有几分钟的不一致时间,用户通常都是可以理解和容忍的。

在大多数的业务场景中,我们可以使用最终一致性替代传统的强一致性,尽量避免使用分布式事务。

在实践中常用的最终一致性方案就是使用带有事务功能的 MQ 做中间人角色,它的工作原理如下:在做本地事务之前,先向 MQ 发送一个 prepare 消息,然后执行本地事务,本地事务提交成功的话,向 MQ 发送一个 commit 消息,否则发送一个 rollback 消息,取消之前的消息。MQ 只会在收到 commit 确认才会将消息投递出去,所以这样的形式可以保证在一切正常的情况下,本地事务和 MQ 可以达到一致性。但是分布式调用存在很多异常场景,诸如网络超时、VM 宕机等。假如系统执行了 local_tx() 成功之后,还没来得及将 commit 消息发送给 MQ,或者说发送出去由于网络超时等原因,MQ 没有收到 commit,发生了 commit 消息丢失,那么 MQ 就不会把 prepare 消息投递出去。MQ 会根据策略去尝试询问(回调)发消息的系统(checkCommit)进行检查该消息是否应该投递出去或者丢弃,得到系统的确认之后,MQ 会做投递还是丢弃,这样就完全保证了 MQ 和发消息的系统的一致性,从而保证了接收消息系统的一致性。

3. 研发团队协作问题

服务化之后,特别是采用微服务架构以后。研发团队会被拆分成多个服务化小组,例如 AWS 的 Two Pizza Team,每个团队由 2~3 名研发负责服务的开发、测试、部署上线、运维和运营等。

随着服务数的膨胀,研发团队的增多,跨团队的协同配合将会成为一个制约研发效率提升的因素。

3.1 共用服务注册中心

为了方便开发测试,经常会在线下共用一个所有服务共享的服务注册中心,这时,一个正在开发中的服务发布到服务注册中心,可能会导致一些消费者不可用。

解决方案:可以让服务提供者开发方,只订阅服务(开发的服务可能依赖其他服务),而不注册正在开发的服务,通过直连测试正在开发的服务。

它的工作原理如图 3-1 所示。

图 3-1 只订阅,不发布

3.2 直连提供者

在开发和测试环境下,如果公共的服务注册中心没有搭建,消费者将无法获取服务提供者的地址列表,只能做本地单元测试或使用模拟桩测试。

还有一种场景就是在实际测试中,服务提供者往往多实例部署,如果服务提供者存在 Bug,就需要做远程断点调试,这会带来两个问题:

1) 服务提供者多实例部署,远程调试地址无法确定,调试效率低下。

2) 多个消费者可能共用一套测试联调环境,断点调试过程中可能被其他消费者意外打断。

解决策略:绕过注册中心,只测试指定服务提供者,这时候可能需要点对点直连,点对点直联方式将以服务接口为单位,忽略注册中心的提供者列表。

3.3 多团队进度协同

假如前端 Web 门户依赖后台 A、B、C 和 D 4 个服务,分别由 4 个不同的研发团队负责,门户要求新特性 2 周内上线。A 和 B 内部需求优先级排序将门户的优先级排的比较高,可以满足交付时间点。但是 C 和 D 服务所在团队由于同时需要开发其他优先级更高的服务,因此把优先级排的相对较低,无法满足 2 周交付。

在 C 和 D 提供版本之前,门户只能先通过打测试桩的方式完成 Mock 测试,但是由于并没有真实的测试过 C 和 D 服务,因此需求无法按期交付。

应用依赖的服务越多,特性交付效率就越低下,交付的速度取决于依赖的最迟交付的那个服务。假如 Web 门户依赖后台的 100 个服务,只要 1 个核心服务没有按期交付,则整个进度就会延迟。

解决方案:调用链可以将应用、服务和中间件之间的依赖关系串接并展示出来,基于调用链首入口的交付日期作为输入,利用依赖管理工具,可以自动计算出调用链上各个服务的最迟交付时间点。通过调用链分析和标准化的依赖计算工具,可以避免人为需求排序失误导致的需求延期。

3.4 服务降级和 Mock 测试

在实际项目开发中,由于小组之间、个人开发者之间开发节奏不一致,经常会出现消费者等待依赖的服务提供者提供联调版本的情况,相互等待会降低项目的研发进度。

解决方案:服务提供者首先将接口定下来并提供给消费者,消费者可以将服务降级同 Mock 测试结合起来,在 Mock 测试代码中实现容错降级的业务逻辑(业务放通),这样既完成了 Mock 测试,又实现了服务降级的业务逻辑开发,一举两得。

3.5 协同调试问题

在实际项目开发过程中,各研发团队进度不一致很正常。如果消费者坐等服务提供者按时提供版本,往往会造成人力资源浪费,影响项目进度。

解决方案:分布式服务框架提供 Mock 桩管理框架,当周边服务提供者尚未完成开发时,将路由切换到模拟测试模式,自动调用 Mock 桩;业务集成测试和上线时,则要能够自动切换到真实的服务提供者上,可以结合服务降级功能实现。

3.6 接口前向兼容性

由于线上的 Bug 修复、内部重构和需求变更,服务提供者会经常修改内部实现,包括但不限于:接口参数变化、参数字段变化、业务逻辑变化和数据表结构变化。

在实际项目中经常会发生服务提供者修改了接口或者数据结构,但是并没有及时知会到所有消费者,导致服务调用失败。

解决方案:

1) 制定并严格执行《服务前向兼容性规范》,避免发生不兼容修改或者私自修改不通知周边的情况。

2) 接口兼容性技术保障:例如 Thrift 的 IDL,支持新增、修改和删除字段,字段定义位置无关性,码流支持乱序等。

4. 总结

服务化之后,无论是服务化框架,还是业务服务,都面临诸多挑战,本章摘取了其中一些比较重要的问题,并给出解决方案和最佳实践。对于本章节没有列出的问题,则需要服务框架开发者和使用者在实践中探索,找出一条适合自己产品的服务化最佳实践。

书籍介绍

《分布式服务框架原理与实践》作者具有丰富的分布式服务框架、平台中间件的架构设计和实践经验,主导设计的华为分布式服务框架已经在全球数十个国家成功商用。《分布式服务框架原理与实践》依托工作实践,从分布式服务框架的架构设计原理到实践经验总结,涵盖了服务化架构演进、订阅发布、路由策略、集群容错和服务治理等多个专题,全方位剖析服务框架的设计原则和原理,结合大量实践案例与读者分享作者对分布式服务框架设计和运维的体会。同时,对基于 Docker 部署微服务以及基于微服务架构开发、部署和运维业务系统进行了详细介绍。

2016-04-06 12:0715814
用户头像

发布了 25 篇内容, 共 69.7 次阅读, 收获喜欢 228 次。

关注

评论

发布
暂无评论
发现更多内容

HTTP/2 总结

guoguo 👻

实验室里的AI激情:腾讯优图的升级修炼之路

脑极体

微服务架构下分布式事务解决方案

Axe

Java集合总结,从源码到并发一路狂飙

给你买橘子

Java 编程 算法 集合

【写作群星榜】6.27~7.10 写作平台优秀作者 & 文章排名

InfoQ写作社区官方

写作平台 排行榜 热门活动

肖风:数据要素市场与分布式AI平台

CECBC

DDD实施过程中的点滴思考

冯文辉

领域驱动设计 DDD

漫画通信:一图看懂通信发展史

阿里云Edge Plus

积极支持EdgeX发展,英特尔为2020 EdgeX中国挑战赛获奖队伍创造广阔合作空间

最新动态

最大的 String 字符长度是多少?

武培轩

Java 源码 后端 JVM

区块链+高考,让世界再无冒名顶替

CECBC

SpringBoot入门:01 - 配置数据源

封不羁

Java spring springboot

Git 常用操作汇总-cheat sheet

多选参数

git GitHub gitlab gitee

编程能力 —— 异步编程

wendraw

Java 大前端 编程能力

流水账

zack

521我发誓读完本文,再也不会担心Spring配置类问题了

YourBatman

spring springboot @Configuration Spring配置类

16种设计思想 - Design for failure

Man

Java 微服务 设计原则

终于有人把Elasticsearch架构原理讲明白了,感觉之前看的都是渣

爱嘤嘤嘤斯坦

Java elasticsearch 编程 架构

一个爱不释手的Apifox,让我扔掉 Postman的想法

给你买橘子

Java 编程 程序员 开发 Postman

领域驱动设计(DDD)实践之路(一)

vivo互联网技术

架构 领域驱动设计 DDD

5分钟上手部署!!!

清风

Java Spring Boot

Java 后端博客系统文章系统——No2

猿灯塔

数据结构与算法知识点总结

烟雨濛濛

编程能力 —— 解析表达式

wendraw

Java 大前端 编程能力

亚马逊:让创新科技成为重启世界的新动能

爱极客侠

Docker基础修炼3--Docker容器及常用命令

黑马腾云

Docker Linux 容器 命令

【Java虚拟机】垃圾收集器与内存分配

烫烫烫个喵啊

Java Java虚拟机

创业使人成长系列 (2)- 散伙协议

石云升

创业 股权 合伙人 散伙协议

编程能力 —— 寻路问题

wendraw

Java 大前端 编程能力

啃碎并发(八):深入分析wait&notify原理 猿码架构

猿灯塔

利用 Python 爬取了 13966 条运维招聘信息,我得出了哪些结论?

JackTian

Python Linux 运维 数据分析 招聘

分布式服务框架之服务化最佳实践_架构_李林锋_InfoQ精选文章