Twitter的RPC框架Finagle简介

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处理。

桥接 Netty Finagle

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的更多文档和样例,可以 参考其站点

我来评几句
登录后评论

已发表评论数()