Twitter 的 RPC 框架 Finagle 简介

阅读数:26746 2014 年 5 月 30 日

话题:Java语言 & 开发架构

Finagle是 Twitter 基于 Netty 开发的支持容错的、协议无关的 RPC 框架,该框架支撑了 Twitter 的核心服务。来自 Twitter 的软件工程师 Jeff Smick撰文详细描述了该框架的工作原理和使用方式

在 Jeff Smick 的博客文章中,介绍了 Twitter 的架构演进历程。Twitter 面向服务的架构是由一个庞大的 Ruby on Rails 应用转化而来的。为了适应这种架构的变化,需要有一个高性能的、支持容错的、协议无关且异步的 RPC 框架。在面向服务的架构之中,服务会将大多数的时间花费在等待上游服务的响应上,因此使用异步的库能够让服务并发地处理请求,从而充分发挥硬件的潜能。Finagle 构建在 Netty 之上,并不是直接在原生 NIO 之上构建的,这是因为 Netty 已经解决了许多 Twitter 所遇到的问题并提供了干净整洁的 API。

Twitter 构建在多个开源协议之上,包括 HTTP、Thrift、Memcached、MySQL 以及 Redis。因此,网络栈需要足够灵活,以保证能与这些协议进行交流并且还要具有足够的可扩展性以支持添加新的协议。Netty 本身没有与任何特定的协议绑定,对其添加协议支持非常简单,只需创建对应的事件处理器(event handler)即可。这种可扩展性产生了众多社区驱动的协议实现,包括SPDY、PostrgreSQL、WebSockets、IRC 以及 AWS。

Netty 的连接管理以及协议无关性为 Finagle 奠定了很好的基础,不过 Twitter 的有些需求是 Netty 没有原生支持的,因为这些需求都是“较高层次的”。比如,客户端需要连接到服务器集群并且要进行负载均衡。所有的服务均需要导出指标信息(如请求率、延迟等),这些指标为调试服务的行为提供了有价值的内部信息。在面向服务架构中,一个请求可能会经过数十个服务,所以如果没有跟踪框架的话,调试性能问题几乎是不可能的。为了解决这些问题,Twitter 构建了 Finagle。简而言之,Finagle 依赖于 Netty 的 IO 多路复用技术(multiplexing),并在 Netty 面向连接的模型之上提供了面向事务(transaction-oriented)的框架。

Finagle的工作原理

Finagle 强调模块化的理念,它会将独立的组件组合在一起。每个组件可以根据配置进行替换。比如,所有的跟踪器(tracer)都实现了相同的接口,这样的话,就可以创建跟踪器将追踪数据存储到本地文件、保持在内存中并暴露为读取端点或者将其写入到网络之中。

在 Finagle 栈的底部是 Transport,它代表了对象的流,这种流可以异步地读取和写入。Transport 实现为 Netty 的 ChannelHandler,并插入到 ChannelPipeline 的最后。当 Finagle 识别到服务已经准备好读取数据时,Netty 会从线路中读取数据并使其穿过 ChannelPipeline,这些数据会被 codec 解析,然后发送到 Finagle 的 Transport。从这里开始,Finagle 将数据发送到自己的栈之中。

对于客户端的连接,Finagle 维持了一个 Transport 的池,通过它来平衡负载。根据所提供的连接池语义,Finagle 可以向 Netty 请求一个新的连接,也可以重用空闲的连接。当请求新的连接时,会基于客户端的 codec 创建一个 Netty ChannelPipeline。一些额外的 ChannelHandler 会添加到 ChannelPipeline 之中,以完成统计(stats)、日志以及 SSL 的功能。如果所有的连接都处于忙碌的状态,那么请求将会按照所配置的排队策略进行排队等候。

在服务端,Netty 通过所提供的 ChannelPipelineFactory 来管理 codec、统计、超时以及日志等功能。在服务端 ChannelPipeline 中,最后一个 ChannelHandler 是 Finagle 桥(bridge)。这个桥会等待新进入的连接并为每个连接创建新的 Transport。Transport 在传递给服务器实现之前会包装一个新的 channel。然后,会从 ChannelPipeline 之中读取信息,并发送到所实现的服务器实例中。

  1. Finagle 客户端位于 Finagle Transport 之上,这个 Transport 为用户抽象了 Netty;
  2. Netty ChannelPipeline 包含了所有的 ChannelHandler 实现,这些实现完成实际的工作;
  3. 对于每一个连接都会创建 Finagle 服务器,并且会为其提供一个 Transport 来进行读取和写入;
  4. ChannelHandler 实现了协议的编码 / 解码逻辑、连接级别的统计以及 SSL 处理。

桥接NettyFinagle

Finagle 客户端使用 ChannelConnector 来桥接 Finagle 与 Netty。ChannelConnector 是一个函数,接受 SocketAddress 并返回 Future Transport。当请求新的 Netty 连接时,Finagle 使用 ChannelConnector 来请求一个新的 Channel,并使用该 Channel 创建 Transport。连接会异步建立,如果连接成功的话,会使用新建立的 Transport 来填充 Future,如果无法建立连接的话,就会产生失败。Finagle 客户端会基于这个 Transport 分发请求。

Finagle 服务器会通过 Listener 绑定一个接口和端口。当新的连接创建时,Listener 创建一个 Transport 并将其传入一个给定的函数。这样,Transport 会传给 Dispatcher,它会根据指定的策略将来自 Transport 的请求分发给 Service。

Finagle的抽象

Finagle 的核心概念是一个简单的函数(在这里函数式编程很关键),这个函数会从 Request 生成包含 Response 的 Future:

type Service[Req, Rep] = Req => Future[Rep]

Future 是一个容器,用来保存异步操作的结果,这样的操作包括网络 RPC、超时或磁盘的 I/O 操作。Future 要么是空——此时尚未有可用的结果,要么成功——生成者已经完成操作并将结果填充到了 Future 之中,要么失败——生产者发生了失败,Future 中包含了结果异常。

这种简单性能够促成很强大的结构。在客户端和服务器端,Service 代表着相同的 API。服务器端实现 Service 接口,这个服务器可以用来进行具体的测试,Finagle 也可以将其在某个网络接口上导出。客户端可以得到 Service 的实现,这个实现可以是虚拟的,也可以是远程服务器的具体实现。

比如说,我们可以通过实现 Service 创建一个简单的 HTTP Server,它接受 HttpReq 并返回代表最终响应的 Future[HttpRep]:

val s: Service[HttpReq, HttpRep] = new Service[HttpReq, HttpRep] {
def apply(req: HttpReq): Future[HttpRep] =
	Future.value(HttpRep(Status.OK, req.body)) 
} 

Http.serve(":80", s)

这个样例在所有接口的 80 端口上暴露该服务器,并且通过 twitter.com 的 80 端口进行使用。但是,我们也可以选择不暴露服务器而是直接使用它:

server(HttpReq("/")) map { rep => transformResponse(rep) }

在这里,客户端代码的行为方式是一样的,但是并不需要网络连接,这就使得客户端和服务器的测试变得很简单直接。

客户端和服务器提供的都是应用特定的功能,但通常也会需要一些与应用本身无关的功能,举例来说认证、超时、统计等等。Filter 为实现应用无关的特性提供了抽象。

Filter 接受一个请求以及要进行组合的 Service:

type Filter[Req, Rep] = (Req, Service[Req, Rep]) => Future[Rep]

在应用到 Service 之前,Filter 可以形成链:

recordHandletime andThen
traceRequest andThen
collectJvmStats
andThen myService

这样的话,就能够很容易地进行逻辑抽象和关注点分离。Finagle 在内部大量使用了 Filter,Filter 有助于促进模块化和可重用性。

Filter 还可以修改请求和响应的数据及类型。下图展现了一个请求穿过过滤器链到达 Service 以及响应反向穿出的过程:

对请求失败的管理

在扩展性的架构中,失败是常见的事情,硬件故障、网络阻塞以及网络连接失败都会导致问题的产生。对于支持高吞吐量和低延迟的库来说,如果它不能处理失败的话,那这样库是没有什么意义的。为了获取更好的失败管理功能,Finagle 在吞吐量和延迟上做了一定的牺牲。

Finagle 可以使用主机集群实现负载的平衡,客户端在本地会跟踪它所知道的每个主机。它是通过计数发送到某个主机上的未完成请求做到这一点的。这样的话,Finagle 就能将新的请求发送给最低负载的主机,同时也就能获得最低的延迟。

如果发生了失败的请求,Finagle 会关闭到失败主机的连接,并将其从负载均衡器中移除。在后台,Finagle 会不断地尝试重新连接,如果 Finagle 能够重新建立连接的话,就会再次将其添加到负载均衡器之中。

服务的组合

Finagle 将服务作为函数的理念能够编写出简单且具有表述性的代码。例如,某个用户对其时间线(timeline)的请求会涉及到多个服务,核心包括认证服务、时间线服务以及 Tweet 服务。它们之间的关系可以很简洁地进行表述:

val timelineSvc = Thrift.newIface[TimelineService](...) // #1  
val tweetSvc = Thrift.newIface[TweetService](...) 
val authSvc = Thrift.newIface[AuthService](...)   
val authFilter = Filter.mk[Req, AuthReq, Res, Res] { (req, svc) => // #2
           authSvc.authenticate(req) flatMap svc(_) 
}   
val apiService = Service.mk[AuthReq, Res] { req =>   
   timelineSvc(req.userId) flatMap {tl =>     
	val tweets = tl map tweetSvc.getById(_)     
	Future.collect(tweets) map tweetsToJson(_) }     
	}    
} //#3  
Http.serve(":80", authFilter andThen apiService) // #4   
// #1 创建每个服务的客户端 
// #2 创建过滤器来认证传入的请求 
// #3 创建服务,将已认证的时间线请求转换为 json 响应  
// #4 使用认证过滤器和服务,在 80 端口启动新的 HTTP 服务器 

在上面的例子中,创建了时间线服务、Tweet 服务以及认证服务的客户端,创建了一个 Filter 来认证原始的请求,最后,实现了服务,将其与认证过滤器组合起来,并暴露在 80 端口上。

当收到请求时,认证过滤器会尝试进行认证,如果失败的话就会立即返回并不会影响到核心服务。认证成功后,AuthReq 将会发送到 API 服务上。服务会使用附带的 userId 借助时间线服务查找该用户的时间线,这里会返回一个 tweet id 的列表,然后对其进行遍历获取关联的 tweet,最终请求的 tweet 列表会收集起来并作为 JSON 返回。在这里我们将并发的事情全部留给了 Finagle 处理,并不用关心线程池以及竞态条件的问题,代码整洁清晰并且很安全。

以上介绍了 Finagle 的基本功能以及简单的用法。Finagle 支撑了 Tweet 巨大的网络传输增长,同时还降低了延迟以及对硬件的需求。目前,Finagle 与 Netty 社区积极合作,在完善产品的同时,也为社区做出了贡献。Finagle 内部会更加模块化,从而为升级到 Netty 4 铺平道路。

关于 Finagle 的更多文档和样例,可以参考其站点