您的位置:首页 > 其它

Your Server as a Function

2015-10-31 19:03 274 查看
http://monkey.org/~marius/funsrv.pdf

概要

建立一个庞大的具有伸缩性的服务软件,这里的系统展示了一个高度的并发性和环境的可变性,这对大多数有经验的编程人员来讲都是一个巨大的挑战。高效,安全和健壮性是最终的目标,它们和传统的模块化,重用性和灵活性是相冲突的。

我们描述了三个抽象,它们合并来展现了一个强大的编程模式——针对安全,模块化和高效率的软件服务:可组合的futrues用来关联并发,异步行为。services和filters被用作专门的函数用以模块化的组合我们复杂的软件服务。

最后,我们讨论了我们的经验——使用这些抽象和技术贯穿Twitter的服务基础设施。

分类和主题描述

1. 介绍

服务在一个庞大可伸缩的设定上需要处理数以万计,如果不是更大的数据并发请求,他们需要处理部分的失败,接受网络的变化,容忍操作的错误。好像这是不够的,利用现成的软件需要满足各种不同的架构————这些不同的架构用于不同的目的。这些目标经常是为了创建模块化和重用的软件。
我们展现了三个抽象,围绕我们在Twitter上组织的服务器软件。它们坚持着函数式编程的风格————强调不可变,函数是一等公民和无副作用————以此组合成一个大的目标,并且以一种灵活,简单,容易理解和健壮的形式。


Futures异步操作的结果,由很多future代表着,这些future组合起来表达着操作之间的依赖。

Services系统的边界由异步的函数services代表。它们提供了一个对称的?和统一的API:相同的抽象代表着客户端和服务器。

Filters应用不可知的概念(如:超时,重试,认证)被filter封装起来,filter组合起来从从多个独立的模块中构建服务。

服务操作(如:进来的RPC行为或者一个超时)被定义成一个声明式的方式,通过future的组合子?关联着一系列的子操作。操作被解析成值传递,鼓励使用不可变的数据结构和简单的推理来提高正确性。
操作描述计算了什么;执行被分开的处理。这对程序员尝试微小的设置————线程,池,队列是大小合适的,并且确保资源是可再生的,这些概念被我们的运行时库所代替——Finagle。放弃这些责任的程序员,在所能掌控的情况下适应是毫不费力的。这里用本地的现成,实现QoS,复杂的网络I/O,而且可以以多线程来追踪元数据。我们已经很成功的部署了它,在一个大的分布式系统中。确实,Finagle和它相关的结构被用在整个Twitter的服务栈中——从前端的网站服务到后端的数据系统。
所有的代码实例使用Scala展示,用Java,虽然这个同样抽象的工作,但并不同样的简洁。


2.Futures

一个Future是一个容器,用来保存异步操作的结果,如:网络RPC调用,超时,磁盘I/O操作。一个future是一个empty——表示结果还不可用,或者是succeed——这个生产者一斤完成并且使用操作的结果填充future;最后或者是failed——生产者失败了,future包含了一个异常。
一个立刻的成功future是被Future.value构造的;一个立刻的失败future被Future.exception构造,一个空的future被一个Promise代表,这是一个可以写的future允许至多一个状态过度,或者是一个非空状态。?Promises与I-structures相似,除了它们本身失败或者是一个成功的计算,它们很少直接的使用。
Future用两种方式组合。第一,一个future可能被定义成其他future的函数,提升数据驱动下编程评估中的依赖图?。第二,独立的future被并发的执行,这是默认的行为——执行时顺序行的仅仅出现在依赖存在的时候。
Futures是函数值,作为第一等成员,这完全由宿主语言来设定。
所有的操作返回future被期望是异步的,尽管这还没有被执行。


独立的成分 这是通常的序列,两个异步的操作由于一个数据的依赖。例如,一个搜索引擎的前端,为了提供个性化的搜索结果,可能咨询一个用户服务去重新写入查询条件。给定:

def search(query: String): Future[Set[Result]]


之后,执行我们的搜索,我们首先需要唤醒rewrite去

检索个性化的查询,这使用了一个参数去搜索。

我们可以解析这个合并的操作作为一个future转换:这个操作评估了这个futrue代表了重写的结果,附加了搜索。这些转换轻微的类似于Unix 管线。使用一个构想来分析,我们可能写了 def psearch(user, query) = rewrite(user, query) | search(); 是一个替代符参数代表了管道中的结果。

flatMap组合子执行各种转换:

trait Future[T] {
def flatap[U](f: T => Future[U]: Future[U]
...
}


(类型T => Future[U] 是一个一元函数,它·的参数是类型T 结果是类型Future[U])因此,flatMap的参数是一个函数,当future成功了的时候,它会被唤起,生产出相关的future。flatMap,和左右的future组合子,是异步的:它使用future立刻返回,代表一个复合的操作。

个性化的搜索是flatMap很直接的应用:

def psearch(user: String, query: String) =
rewrite(user, query) flatMap{ pquery =>
search(pquery)
}


“finagle”发出个性化的搜索,结果是future代表了复合操作的结果。

在这个例子中,flatMap被用在解决数据依赖上——在搜索和rewrite之间。psearch很少表达这个,没有特定的一个执行策略。

掌控错误 flatMap可以种植计算——当外部的future失败了:返回的future是一个failed没有唤醒设定的函数去产生一个独立的future。确实,类型见证了它的语义:当一个Future失败了,它没有一个值去呈现这个方法去生产这个相关的future。这些错误的语义与惯例相似,基于栈的异常语义。

这是通常有用的,对于恢复像这样的错误,例如:为了回复一个操作,或者提供一个反馈值。rescue组合子提供了这个。然而flatMap操作控制成功的结果,rescue操作控制失败。

trait Future[T] {



def rescue(

f: PartialFunction[Throwable, Future[B]]

): Future[B]

}

({ case … 是scala中的一个偏函数文法,它可能包含多个case闭包,内部是一个方法在Future中,它返回一个新的Future,这个新的Future既不是在给定的时间内完成,也不会是一个超时错误的fails)如果这个future从rewrite(..).within(..)返回错误。部分函数用于定义特定的错误——这个错误通过添加这个原始查询值。因为每一个组合子返回一个future,代表组合操作的结果。我们可以在这个例子中声明它们在一块。这是惯用的风格。

[b]组合多种依赖
服务通常执行“scatter-gather,” 或者“fan-out”操作,这些涉及向多个下游服务发出请求,之后合并它们的结果。例如,Twitter的搜索引擎,Earlybird,分离数组通过多个片段?,一个前端的服务应答查询,通过发出请求去复制每一个片段,之后合并结果。

集合的组合子解决了多个依赖。对于一些值类型A,集合转换了一系列的future到一个A类型的future序列。

def collect[A](fs: Seq[Future[A]]): Future[Seq[A]]

(Seq 是Scala的集合的容器)一个对scatter-gather的操作如下。给序列片段一个方法,

def querySegment(id: Int, query: String): Future[Set[Result]]

我们使用collect去关联多个依赖,search方法来自之前的例子,可以def search(query: String): Future[Set[Result]] = {

val queries: Seq[Future[Result]] =

for (id <- 0 until NumSegments) yield {

querySegment(id, query)

}

collect(queries) flatMap { results: Seq[Set[Result]] =>

Future.value(results.flatten.toSet)

}

}Map,错误在任何future被传送作为一个参数去立即收集到组合起来的future:如果所有的future都被querySegment fail返回,则被收集的future将会立刻返回fail。

在组合中,由一系列的子操作唤醒psearch,返回一个future。这个结果的数据流图是被描绘成图1.


递归组合递归的使用Future组合子是很平常的事。继续我们之前的例子,我们可以迭代的搜索直到我们有一个必要的结果,也许置换我们查询在一些方式。

def permute(query: String): String

def rsearch(user:String, query: String, results: Set[Results], n: Int): Future[Set[Result]] =

if (results.size >= n)

Future.value(results)

else {

val nextQuery = permute(query)

psearch(user, nextQuery) flatMap { newResults =>

if (newResults.size > 0)

rsearch(user, nextQuery,results ++ newResults, n)

else

Future.value(results)

}

}

掌控错误的future语义,这样,rsearch将会短路和失败,它的所有成分都会处理失败。

flatMap合并了future们,实施了一个尾递归消除的形式:上面的例子不会产生任何空间泄漏。这是安全的去定义一个不定长度的递归关系。

3. Services和Filters

一个service是一个异步的方法,典型的代表时一些远端RPC调度,这将被区分出来从一个正则表达式中在Scala中,通过强制返回一个代表Future的值。

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

Services代表一对客户端和服务器,它们被Finagle使用着。Services对Finagle分配进来的请求实施了服务。Finagle提供了包含Service实例的客户端,可能是虚拟的也可能是具体的远端服务。

例如,HTTP服务分配了一个请求到Twitter的网站,返回一个future代表最终的回复。

val client: Service[HttpReq, HttpRep] = Http.newService(“twitter.com:80”)

val f: Future[HttpRep] = client(HttpReq(“/”))

一个Http回应服务可能被这样实现:

Http.serve(“:80”, { req: HttpReq =>

Future.value(HttpRep(Status.OK, req.body))

})

放置客户端和服务器整齐的在一起来描绘对称的服务抽象:跟着是最初的HTTP代理,转发HTTp传输从本地的8080端口到twitter.com

Http.serve(“:8080”, Http.newService(“twitter.com:80”))

Services被用来代表逻辑应用组件,像一个HTTP服务代表Twitter公共API的一部分,一个Thrift RPC服务提供给用户一个认证服务,一个内存缓冲的客户端代表了一个持续的哈希平衡簇等。然而,当建立服务软件时,大量的不可预测的部分也随之提升了;这些包括超时,回复政策,服务统计和认证。

type Filter[Req, Rep] =

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

这就是,一个filter收到一个请求和一个服务,它们是被组合起来的。函数产生了一个future。它跟着一个特定的filter简单的延迟了一个给定的服务:

val identityFilter =

{ (req, service) => service(req) }

一个filter执行请求超时,通过以下进行实施:

def timeoutFilter(d: Duration) =

{ (req, service) => service(req).within(d) }

Filters提供了一个组合子,andThen,这是被用做合并filters——产生了组合的filters——或者和services一起——产生一个新的service,它们的行为通过filter被修改。

val httpClient: Service[HttpReq, HttpRep] = ...
val httpClientWithTimeout: Service[HttpReq, HttpRep] =
timeoutFilter(10.seconds) andThen httpClient


因为services是对称的,filters可能被同时应用到客户和服务。
Filters也可以转换请求和响应,使编程人员用静态类型的设施去强制确认担保。例如,一个HTTP服务可能使用一个分离的请求类型去表明认证请求def authReq(req: HttpReq): Future[AuthHttpReq]证被给定请求通过一个认证服务,返回一个“upgraded“请求在成功,失败或者其他。这个filter:


val auth: (HttpReq, Service[AuthHttpReq, HttpRes])
=> Future[HttpRep] = {
(req, service) =>
authReq(req) flatMap { authReq =>
service(authReq)
}
}


一个需要认证的服务组合,返回一个新的服务和没有认证的请求可能被调度。

val authedService: Service[AuthHttpReq, HttpRep] = …

val service: Service[HttpReq, HttpRep] =

auth andThen authedService

因此,我们可以在静态类型中表达认证的需求。这次轮到编译器出场了,这样减少authReq的错误,在处理认证的组件和认证的请求时,提供了一个高效的,类型安全的防火墙。

4.讨论

4.1 使用future创作声明式的程序

被future鼓励的声明式的程序,强迫编程人员以一系列的组建构造他的系统,他的数据依赖被各种future组合子连接起来。这是描述系统的一类方式,强迫操作的语义性(semantics),这些语义由编程人员描述,执行的细节由finagle掌控。

这里有很大的好处,让编程人员从单调乏味的线程 队列 资源池 资源回收中解脱出来,从而专注于应用程序的语义上。(ps:但这不代表我们不需要继续学习这些“单调乏味”的东西)

这样就能因为程序的执行细节和语义上的分离而达到模块化(ps:模块化是接近我们需求的东西)。在Finagle中我们把精力放在我们最高效的行为上,确实,针对不同的类型的服务需要不同的执行策略。例如,Finagle可以实现线程的亲密关系,所以所有的I/O从属于一个逻辑上的服务请求,这些请求在一个单一的操作线程中执行,减少了线程上下文切换的开销。这里有一点令我们好奇:我们如何使用运行时的信息去提高执行的策略?

因为Finagle实现了运行时,我们能够增加一些特性,就像Dapper风格的RPC追踪,而不改变API,或者修改其他地方的被正在使用的代码。另外,这种风格鼓励编程者思考数据控制上的数据流,被future的组合子表达出来。在数据流上的重要性能够鼓励编程者把他的软件中的用不变量的方式构造出来,而不是一系列可变的共享数据。我们相信这会让数据共享变的更加简单,尤其是呈现一个并发的环境中时。这也许就是基于Future并发的原则。

另一方面,令我们惊奇的是,Future的类型如此的具有感染力——任何从future中驱动的值必须被他自己用future包装——异步的行为在静态类型中表现出来。一个编程者可以简单的说明一个方法的意义,而不必说明这到底要多少的资源消耗(ps:确实,我们平常总是在意一个操作花费了多少时间和空间资源。因为异步,我们不必再耿耿于怀——过一会总会告诉我结果,我只需要做等待时的处理就行了。)

Future在构造和稳定性上都是廉价的。我们当前实现了一个从中心数据库中分配16字节的方案,我们的运行时库的交叉操作少数的操作系统线程的延迟,这使用了有效的数据结构(为了超时的行为),和操作系统的I/O多重服务(为了I/O的行为)。

而大多数的工程师发现编程模型不同寻常,他们赞同并接受了这种价值观。

4.2 Future的练习

Futures,就像它呈现的,是只读的“纯粹”的结构:一个值的生产者从消费者中被分离出来。者加强了模块化,让编程变的更加容易,然而实际时,杂乱的分布式系统必然在处理着任务。

让我们烤炉一个简单的例子:超时。想象一个HTTP客户端被一个服务代表,这个服务是我们应用了一个超时filter:

val httpClient: Service[HttpReq, HttpRep] =
Http.newService("twitter.com:80")
val timeoutClient: Service[HttpReq, HttpRep] =
timeoutFilter(1.second) andThen httpClient
val result: Future[HttpRep] =
timeoutClient(HttpReq("/"))


如果一个请求失败,因为它超过了1秒所以失败了。然而,future是只读的:延时的操作,作为初始化过的被HTTP客户端,延时操作不是终端。这变的有些问题,当连接数不够,或者远端服务器被隔离了的时候。

这个只读的数据流语义,让future看起来是让使用future的人对它的产生没有任何认知。这是一个好的抽象,但是我们可以看到,它可能产生资源泄漏。(这个不像懒计算语义的语言,如Haskell,可能介绍空间的泄漏。)

我们介绍一个终端interrupt机制,来填补这个空缺。Interrupt可以让future的使用者关注异步操作的职责,以更好的控制它,典型的是因为结果不再需要了。Interrupt流与future传送数据的方向相反,它们是建议性质的。Interrupt不会立即改变future的状态,但是一个生产者可能会在它上面执行一个行为。我们加入了一个中断掌控到一个最底端的网络客户端的部分。例如,在我们控制代码最基础的地方——像超时过滤器——这里我们会修改而产生一个中断。

中断也允许我们在所有的服务中实现一个end-to-end的取消操作。第一,我们加入一个控制的消息到我们的RPC协议,去命令服务取消一个进行中的请求。一个客户端,当它被中断时,发布这个控制信号。当一个取消信号被服务器收到时,它产生一个中断,这个中断实施在进行中的future上(作为服务端提供的服务被返回)。他允许我们中断一个请求,这个请求来自于前端的HTTP服务;也可以取消所有正在执行的工作,这个工作贯穿在我们分布式系统中。作为我们的追踪系统,他被实施在没有API修改或者没有任何改变的用户代码上。

当中断违反了纯粹的future代表的数据流模型时,future的使用者仍然没有察觉到他们的产生。中断时建议性的,不会直接影响future的状态。

中断不是没有问题。他们介入一个新的语义冲突:组合子应该传播所有的future吗?或者只是让其中一部分产生效果?如果一个future被多个用户使用者呢?我们没有更多的答案在这些问题上,但是一个中断的实践是很少被使用的,而且他们只是在Finagle中;我们没有遇到任何问题——在语义或者着实施的冲突上。

4.3Filters

Filter兑现了它的诺言——提供干净,正交和应用独立的函数式风格。他们用的很普遍:Finagle重用了filter,我们的前端web服务——相反的HTTP代理——他们所有的服务流——使用一大堆filter到不同层次的任务上进行实施。这是一个配置的片段(ps:看起来有点吓人):

recordHandletime andThen

traceRequest andThen

collectJvmStats andThen

parseRequest andThen

logRequest andThen

recordClientStats andThen

sanitize andThen

respondToHealthCheck andThen

applyTrafficControl andThen

virtualHostServer

Filter备用在任何需要日志的地方,为了去请求一个sanitization(消毒?),交通控制,等。Filter帮助加强了模块化和重用性,他们也提供了测试的可用行。这是相当的简单对于一个单元测试——因为每个filter都是独立的——他们的接口是简单和独一无二的——没有任何设定,而且是最小可笑的失败。更进一步,他们鼓励编程者分离出一个功能性的东西到一个单独的模块中——边界是很干净的——结果是更好的设计和重用。

我们也使用filter扩展到底层的概念的地方。例如,我们可以实现一个更精确的备份请求机制,使用一个简单的filter,大概是40行代码(见附录A)。Tumblr的工程师,他们也用Finagle,说使用一个底层的filter取双向的绑定数据流请求。

4.4 抽象的花费

高层的编程语言和结构不是免费的,Future组合子分配了一个新的future在垃圾收集堆中;闭包,当然,需要在空间中分配一定空间,因为他们的请求时延迟的。我们已经集中精力减少分配的话费——确实需要创建很多工具为了分配空间的分析—它需要进一步的具体实施。

到最后大多的服务被小的垃圾收集器处理。在独立的时候,这个应用在一个小的服务。然而我们fan-out系统会被放大这种延迟,最慢的组件会放大这种延迟——在垃圾收回过程中产生了请求是正常的。Dean和Barroso在Google中表述了相似的经历。

频繁的资源在不经意间的垃圾回收是没有压力的——就像之前的闭包的捕捉而引起的空间泄漏。这会被长时间存在的操作所放大,例如,闭包包含了一个长时间存在的连接,而不是一个请求。Miller e t’al.’s Spores 想要减轻这些泄漏的类型,通过给定编程者更细的控制,在闭包的环境中捕捉。

在大多数服务中,主要的集合是稀少的。这些产生了另一种空间泄漏:如果一个Promise被保护在一个主要的堆中(例如,因为这个操作代表一个不可预测的长时间),这是引用类型,即使它的有用的时间只是很少,它会一直幸存下来直到下一个重要的垃圾回收。

自律的开发是一个重要的缓和的因素。为了保证回归没有引入,我们已经开发了一个工具,JVMGCPROF,它运行在我们测试的常规中,提供了一个报道——在每一个请求分配空间的比率和时间线上。

这是一个仍然需要努力的,在很多好奇心的驱使下。因为Finagle控制本地到无力线程的交织和请求的边界,它可以按需求分配。这开启了一个可能性——在JVM延迟操作的协作下,我们可能使用特定的区域分配技术(region allocation techniques )。

4.5 Twitter中的Futures, Services和 Filters

这些技术被一块应用在RPC系统中,贯穿我们的运行时系统的运转。大多数服务模型软件通过这种方式实现,包括我们前端的服务系统,应用服务,网站爬行(crawling)系统,数据库系统,fan-out和数据维持(maintenance)系统。这个系统被一个多数据中心的主要机制所使用。

我们已经发现这些技术精确在中间件的服务,这些接口是一个服务,而且它们依赖于其他的服务。这样的中间件减少到高效的大的future转换:在一个声明式的风格的中表达是很通常的事情。

这些不变的扩展风格的优点属于一个服务软件。统计,追踪数据,其他的运行时信息呗一系列的filter所观察着。这种普遍的运行时信息可以让我们监控和诊断我们的系统——在没有特定知识的情况下。

我们线下的数据处理系统还没有抛弃这些技术作为我们通常的项目建设,像Hadoop框架。

5.相关的工作

Lwt是一个辅助的线程库尾了Ocaml,这些主要的抽象,共享线程,和Future是相似的。

数据驱动的编程语言也强调计算的依赖关系,执行一个并发图减少了他的运行时。然而,数据流的并发需要确定的,在其他事情之间,在依赖和自由之间从未确定的错误中(大多数的语言需要自由的任何错误)。因此在它的原始格式中,数据流的语言时不适合系统编程的。Royet.al.想要引入一个不确定的输出端去分离一个数据流的编程到一个纯粹(确定)的部分中,呗没有确定的部分所连接。在这个猜测中,但是在系统编程中,很少所有的并发时未确定的。(确实,确定的并发时更好的并发描述)

HasKell和Go提供一个廉价的使用线程,减少基于线程并发的消耗。这些运行时的管理线程作为一个廉价的资源,让编程者从资源 线程管理中解放出来。然而,它们和Future的区别有两点。第一,它们没有提供一个干净的数据流模型——它们的线程没有组合起来作为一个自然的future。第二,线程的管理是建立在它们的运行时,因此限制了让第三方的库,如Finagle进行订制。

6 结论

我们已经描述了达到一个结构性的服务软件系统通过future service 和filter。Future是我们基本的抽象对于表达并发的关系,异步的操作。service和filter被用来结构化服务器和客户端,它们是语义性的——在一个模块化的风格中。

把它们放在一起,这些交织的抽象来自一个基础抽象——我们基于需求环境来提供构建的服务。还有一点,很好的思考我们的业务可以有一个很好的方案:让软件更加简单,让每一块由小的部分所组成。这让我们的软件有一个小的,正交的可重用的组件系统所构成,并且成为一个软件工具集合。让我们的模块化更加清晰。

最后,因为高级的抽象化——future filter和service编程的语义能够脱离具体的执行环境:一个分离的运行时在RPC系统中实现,Finagle,允许开发者集中它们的精力在应用逻辑中。分离运行时,加强了模块化的代码而不依赖于具体的执行策略。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: