继上篇简单高效 RPC 框架实现指南(一)分享了 RPC 框架的使用,我将和大家好好聊聊在实现 RpcLite 框架过程中我的一些心得体会,希望大家能够此过程中少爬点坑。
1NIO与BIO
在为 RpcLite 框架选择通信模块时候首先进入脑海的是我最熟悉的 BIO 方式。这是 Java 最早支持的方式,也一直被开发者所病诟的方式。

BIO:同步阻塞式 IO,服务器为每个客户端请求连接时开启一个线程并建立通信套接字进行通信。此时不能再接收其他客户端的连接请求。如果这个连接不做任何事会造成不必要的开销。如果需要的连接数比较少而通信频繁的情况下,BIO 效率是非常高的。

NIO:同步非阻塞式 IO,客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有 IO 请求时才启动一个线程进行处理。NIO 本身是基于事件驱动的思想来完成的,解决了 BIO 的并发量问题。
然而当尝试着用 NIO 方式写代码时我发现,自己去维护一套 NIO 通信模块的复杂度远远超出想象,而且可能还潜伏着“不可预知”的问题。于是果断的选择了高性能的,久经考验的,社区活跃的 Netty 框架,极大的减轻了工作量。
2序列化的选择
Java 内建序列化与反序列化的主要缺点是效率低并且比较占用空间。优点也很显著,就是使用起来非常简单方便。也接触过像 Protobuf,Thrift 这样效率非常高,并且还可以跨语言的序列化方式。缺点是要多维护一份 IDL 文件,且对于 Java 开发来说,如:BigDecimal,BigInteger 等类型不支持,学习成本也很高,用起来麻烦。所以在设计 RpcLite 这个项目时我把易用性排在第一位,在网上搜索各种序列化技术时候我找到了 Kryo。

Kryo 是一个高效快速的 Java 序列化框架,提供快速,高效,易用的 API。无论文件、数据库或网络数据,Kryo 都可以随时完成序列化。Kryo 还可以执行自动深拷贝、浅拷贝,即对象到对象的直接拷贝。用 Kryo 框架做序列化对象,不用对对象做任何改动,非常简单易用。当然序列化本身已经是一门非常庞大,复杂的知识体系,各类序列化工具也是层出不穷。这里不做深入的比较与讨论。
3Java 动态代理的选择
Java 的动态代理有两种,jdk 动态代理和 cglib 动态代理。
jdk 动态代理:是由 Java 内部的反射机制来实现的,反射机制在生成代理类的过程中比较高效。网上说 jdk 的动态代理依靠接口实现,如果有些类并没有实现接口,则不能使用。这是不对的!因为是接口,所以 Object 类原生方法如:hashCode()、 toString()等并没有实现,需要特别处理。处理好这个问题,我们使用起来完全没有问题。
cglib 动态代理:底层是借助 asm 来生成代理类的,生成代理类之后相关的执行比较高效。它是对指定的类生成一个子类,所以使用时候不要将类或者方法声明为 final。
值得注意的是,Spring 框架集成了上面两种方式,Spring 框架会根据目标对象的有无实现接口的情况动态地选取使用其中一种代理。当然 Spring 也提供了方法可以强制指定使用 cglib 方式。这里还需要关注一下项目使用的 asm 版本与 Spring 框架依赖的 asm 版本是否兼容的问题。使用 jdk 动态代理的方式不会出现版本兼容的问题,也少引入了第三方 jar 包。
4服务注册与服务发现
RpcLite 框架选择 Zookeeper 作为服务注册与服务发现的底层服务。Zookeeper 是 Yahoo 贡献给 Apache 基金会的一个顶级开源项目,它基于 Paxos 算法,并且十分成熟,多用于分布式应用提供高性能的协同服务,也可用于命名、配置管理、分布式锁等场景。
值得一提的是,Zookeeper 提供的原生的客户端 API 并不是特别易用,在维持长连接状态与监听节点变化方面稍显复杂。这里我选择 Curator 框架,Curator 的抽象层次更高,简化了 Zookeeper 客户端开发工作。
服务注册与发现的思想非常简单,本质上就是一个观察者模式(又称:发布/订阅模式)。在 Zookeeper 之外还可以选择 Redis 实现,这也是非常容易的。
5让运行线程“等待”的方式
Thread.sleep(n),sleep 主要用于线程控制,使正在执行的线程主动让出 CPU,但不会释放同步资源锁。
在 RpcLite 框架中,消息发送之后需要让线程”等待“一段时间,因为结果是通过另一个线程返回的。这个返回可能很快,也可能很慢,这样 sleep 的时间是不可控的。所以这里使用 sleep 不是不可以,是不合适,应该选取线程间通信的方式。线程间通信最常见于“生产者-消费者模式”,实现用 Object 中 wait(n) 与 notify() 或 notifyAll() 方法配合使用。wait(n) 方法相对于 sleep(n) 方法会放弃同步资源锁。值得注意的一点是,Java1.5 后出现了 Condition.java,Condition 中的 await(n) 与 signal() 或 signalAll() 配合使用使得线程间的协作更加安全和高效,对于同一个 Lock 可以创建多个 Condition,即创建多个监视器,这样可以更加细粒度地控制多线程。
6消息里为什么要带有 RequestID?
多个线程同时进行远程方法调用,建立在 Client 与 Server 之间的 socketChannel 连接上会有很多双方发送的消息传递,前后顺序也可能是随机的,Server 处理完结果后,将结果消息发送给 Client,Client 收到很多消息,如何判断将消息结果返回给正确的调用线程?

线程 A 和线程 B 同时调用 Client ,并通过 socketChannel 发送请求 RequestA和 RequestB,socketChannel 先后将 RequestB 和 RequestA 发送至 Server,而 Server 可能将 ResponseA 先返回,尽管 RequestA 请求到达时间更晚。所以需要在每个 Request 中设置一个表明自己身份的 RequestID,每生成一个Response 时也要把这个 RequestID 带上。通过它来保证 ResponseA 丢给ThreadA,ResponseB 丢给 ThreadB。
7异常处理与重试机制
市面上的很多 RPC 框架不会将 Exception 在网络上传输,尤其是跨语言的 Rpc 框架。跨语言的 RPC 框架不支持 Exception 这很容易理解,因为不方便不同语言之间相互“翻译”。而且有时候 Exception 的 Stack Trace 非常的长,在网络上传输会极大影响效率。作为补偿,这个时候可以模拟 Http 请求返回一个状态码表示本次请求是否成功,以及失败后是否需要重试。
在 RpcLite 中已经选择了纯 Java 实现,不支持跨语言。所以选择在易用性上做得更好一点,RpcLite 支持接口上声明异常(即 Checked Exception)。如果是 Runtime Exception 则会抛出包装之后的 RuntimeException。
在重试方面,如果不指定任何重试次数,RpcLite 会选择默认重试策略,即如果一个请求失败了,会选择重新发送到相邻服务器上。如果方法抛出的方法上声明的异常,则不重试。
值得注意的是:什么时候需要重试?
举个例子,按照 RESTful API 的设计,GET、POST、PUT、PATCH、DELETE 语义上的区分。GET、DELETE 方法一定是天然幂等的;PUT 方法一般会用来更新一个已知资源,除非在创建前;PATCH 方法是新引入的,是对PUT方法的补充,用来对已知资源进行局部更新。这样看来 PUT 和 PATCH 方法也是幂等的。针对于幂等的方法,重试机制会带来极大的便利。而 POST 方法一定不是幂等的,至少不是天然幂等的,所以在 POST 方法处理上一定不能选择自动重试策略,需要显示指出该方法无论如何不需要重试。
在一个网站应用程序中非幂等的接口本身来说是不多的,而且有些非幂等方法也可以通过业务逻辑自身去转化成为幂等接口,如:创建一个用户操作,用户的手机号是唯一的,在数据表中可以设置唯一性约束。如果创建时候发现已经存在的手机号码,则创建失败,这样创建用户的接口就是幂等的了。
所以,为了方便实用,最好能在本身不多的 POST 方法中通过业务逻辑或者设计上保证幂等性。
8客户端与服务端异步起动带来的问题
RpcLite 底层通信框架选择的是 Netty。Netty 是一个高效的纯异步框架,通信方式为异步,启动方式也是异步。这便带来一个问题,如何判断 Server 端与 Client 连接成功开始方法调用呢?
如果只有一个 Server 端,那么 Client 端一定要等待连接成功之后才可以进行通信;如果有多个 Server 端,固定的多个或者是服务注册与发现的方式,此时 Client 端不需要等待与每一个 Server 端连接成功,如果等待与每一个 Server 端成功连接将是非常耗时的。实际上只要“至少一个”连接成功,那么此时判定服务可用,Client 端就可以开始调用服务。可见两种情况下 Client 端的启动过程需要分别处理。
9易用性与效率之间的权衡
之前使用的 RPC 框架一直被开发者抱怨不好用。大致原因有:支持的 Java 类型比较少(如不支持 BigDecimal)、不支持接口声明异常、错误信息不明、框架效率低下等。
实测下,该 RPC 框架效率不低,却不太易用。如在金额传输过程中,跨语言框架不直接支持 BigDecimal 这类 Java 语言中的特有类型,也不支持接口声明异常,方法只能有一个参数。该框架为了追求效率失去了 Java 语言本地调用的语义简洁性,从开发者的角度来看,RPC 框架的使用应该是几乎透明的,与本地调用无异。
10代码简洁,让机器懂更让让人懂
在写 RpcLite 框架之前对比了市面上流行的各类 RPC 框架,发现很多框架都十分庞大,支持的序列化方式有2-3种,服务注册与发现的方式又有2种,并有各种可配置,各种设计模式,更像是一本“炫技”的百科全书。这导致了对着框架代码搜索半天找不到框架入口。
然而使用者其实并不关心底层的序列化方式,通信方式等,只需要框架能保证高效稳定即可。成堆的配置文件对开发者是非常不友好的,即使是“百科全书”式的框架也难满足所有人的需求。
RpcLite 框架就是这样一个梳理最骨干的模块,让开发者更易读懂并能依照自己需求稍做改动的 RPC 框架。RpcLite 框架在实现了 RPC 的常规功能的同时,它的代码也是十分简洁,核心代码共50多个 Java 文件,总大小不到100k。
11What's more?
RpcLite 框架目前只是实现了最基本的功能,若想集成更多功能,大家可以从以下几个方面入手:
目前采用的是 kryo 序列化方式,可以换成更加高效的序列化方式
为框架添加 Health Check 模块或者报警功能
为框架添加 SSL 加密模块
为框架添加 IP 黑名单,白名单功能
12是否真的需要引入 RPC 框架
网上流传着这样一句话:“要搞定微服务架构,先搞定 RPC 框架”。然而我认为恰恰相反,为应用引入 RPC 框架的一个重要前提是要使你的项目是真正的“微服务”。
那么“微服务”应该是什么样子呢?
“微服务”这个概念已经流行了多年,很多公司也争先实践。微服务架构模式 (MicroservicesArchitecture Pattern)的目的是将大型的、复杂的、长期运行的应用程序构建为一组相互配合的服务,每个服务都可以很容易得局部改良或通过水平扩展的方式提高服务的效率。 Micro 这个词意味着每个服务都应该足够小,这里的小不是指代码量的多少,而是在业务逻辑上体现符合 SRP(单一职责原则——SingleResponsible Principle)原则。在业务逻辑或者领域模型足够解耦,各个微服务的专业化程度较高,关键服务间的调用可以在较短的时间内返回的前提下,RPC 框架能够非常容易地使应用得到水平扩展、负载均衡、高容错等的便利。
反之,如果项目本身并不具备“微服务”的特征,引入 RPC 框架并不能使服务效率提高。试想一下一个查询接口 join 了非常多的表,那么瓶颈实际在数据库一侧导致查询很慢,即使再水平扩展部署多少应用也不能提高效率。相反,由于每个请求都比较慢必然导致很多请求在队列里等待,这会出现大量超时问题。如果是写数据的请求情况则更糟糕,因为请求超时不属于成功也不属于失败,而处于中间状态,这时需要人工去处理这些超时的问题。
总之,在选择 RPC 框架时我们应当避免盲从先对自己的业务逻辑进行梳理使它真正成为“微服务”的模型。
最初在查看 RabbitMQ 的官方文档时候,看到一个简单的例子是用 RabbitMQ 去实现RPC 功能,仔细想来 RPC 核心不就是这么一点点内容!对其丰富功能,再次封装,让使用方式更简洁更人性化,这将是非常有趣的事情。
初识 RPC 概念,并没有觉得复杂,但当在每一功能模块的实现中付诸思考和实践时,才明白“纸上得来终觉浅,绝知此事要躬行”其中的深意。希望本文能对想了解 RPC 框架原理的开发者们有所帮助。
代码地址:https://github.com/liuyanfeiyu2012/RpcLite(或点击阅读全文进入)

本文作者:谢星星(点融黑帮),目前就职于点融网架构组Backend Architecture团队,简单务实的程序猿,写代码不给自己挖坑更不能留坑于他人。



