@pockry
2017-10-27T12:00:18.000000Z
字数 6258
阅读 1738
架构
作者:段维思(Uber Senior Software Engineer II)
我在Uber这几年,做了很多系统稳定性及可扩展性的工作, 也包括很多快速迭代试错的产品,另外还做了一些移动开发的工作,因此我对Uber的端到端的技术栈还比较熟悉。在这里以我的经历为例跟大家分享一下如何以Uber的方式快速稳定的做一个端到端的大型应用。
我刚加入Uber时,Uber正处于飞速成长期。这样的情况对之前工程师设计的简单系统造成了极大的压力。下面我谈谈实战中的系统设计的经验。
系统设计包括若干个层面。先说顶层的系统设计原则, 如REST,SOA。由于Uber之前一直是算一个创业公司,所以开发速度至关重要,由于微服务 能够极大的促进不同组件的平行开发,SOA成为了Uber的选择。
在这种选择下,我们需要先按功能设计出不同责任的Service,每一个Service作为这个责任的唯一真实信息源。在开发新的功能时,只需要先设计好不同Service之间的合约, 就可以按照合约平行开发了。在实际工作中,这点被证明非常有效。
第二点是不同Service之间的合约和依赖。一个Service的合约决定了它跟上游Service之间的关系,如果这个合约设计的不好,那就会给上游Service上的开发带来各种不方便和重复工作。
比如说如果一个节点可以被设计成幂等(多次操作均返回相同结果)但却没有这么做,那就会导致上游Service在使用这个节点时,失败处理逻辑会复杂很多--如果是幂等, 上游只需要重新调用就可以了;但是如果不是幂等, 上游就需要跟据出错信息来判断依赖系统的状态 (有时甚至很难判断,比如在下游系统状态更新后网络出错) ,然后再根据状态来选择不同的处理方式。
在有些情况下(比如下游系统挂掉了),上游系统甚至需要记录下游系统的状态,这样在backfill的时候才可以直接做正确的处理;而在幂等的情况下,我们只需要无脑调用下游的Service就好。举个例子,很久以前Uber有次分单系统坏了,导致之后要重新backfill,由于依赖 Service设计的是幂等, 该次backfill就一个简单script跑完即可。当然,现在Uber的分单系统还是非常稳定的。
同时,我们也要考虑RPC semantics是at least once, 还是at most once。具体的应用情境下有不同的适用。比如说如果是要做一个付钱的有状态更新的api, 那我们就应该保持at most once的使用,当调用 api 出错时,我们不能贸然再次调用该api。At least once和at most once在大部分情况下对应于幂等 和非幂等的操作。另外,我们在实现系统时也要考虑已有系统提供的接口,比如说一个已有的接单系统已经提供了一个at least once的消息队列,而我们需要做的是跟据累积的交易数来做一些行为,在这样的情况下,我们就需要我们的系统能够消重,或者保证我们要做的行为是幂等的。
第二个层面是Service之间交互可能发生的问题,在设计一定要考虑周全,比如通信可能发生的failure case。我们要假定在线上各种奇怪的情况都会发生。比如我们曾经有上下游Service之间通信时使用的kakfa ingester一直不是非常稳定,导致不时发生下游Service 无法拿到数据来计算,最后我们干脆把kafka换成了http polling, 再也没有问题了。
第三个层面是Service内部的故障, 比如缓存, 数据库断了,或者依赖的第三方Service挂掉了,我们需要根据情况进行处理,做好日志和监控。
如果一个Service是无状态的,那往往它做的事情是根据请求把下游各个Service的返回结果加工一下然后返回。我们可以见到很多这样的Service, 比如各种gateway,各种只读的Service。
服务无状态的情况下往往只需要缓存(如Redis),而不需要持久化存储。对于持久化存储, 我们需要考虑它的数据模型、对ACID的支持、稳定程度、可维护性、内部员工对它的熟练程度、跨数据中心复本的支持程度,等等。到底选择哪一种取决于实际应用情景,我们对各个指标要不同的需要,比如说Uber对于跨数据中心复本的要求就很高,因为Uber每一个请求的用户的期待值都很高,如果因为存储系统坏了,或存储系统阻挡failover,那用户体验会非常差。
另外关于可维护性和内部员工的熟练程度,我们也有血淋淋的例子,比如说一个非常重要的系统在订单最多的一天挂掉了,原因是当时使用的PostgreSQL数据库不知为什么原因而锁死了,不能读也不能写,而公司又没有专业到能够深入解析PostgreSQL的人,这样的情况就很糟糕,最好是换成一个更易维护的数据库。
这两点是系统在扩张过程中需要保证的,为了保证系统的QPS和可响应性,有时甚至会牺牲一些其它的指标,如数据一致性。
支持这两点,我们需要考虑几件事情。
第一是后端框架的选择,通常实时响应系统都是IO密集型的,所以选择能够non-blocking的处理请求的框架就很大好处,既可以降低延迟,因为可以并行调用下游多个系统;又可以增加QPS,因为以前阻塞在IO上的时间可以被用来处理其它的请求。
比较流行的Go,是用后台线程池来支持异步处理,由于是Google支持的,所以比较稳定,当然由于是新语言,设计上也有一些新的略奇怪的地方,如”Why is my nil error value not equal to nil?”;以前的Node.js和Tornado都是用主线程的io-loop来处理。
关于Node.js, 我自己也做过一些benchmarking, 在仅仅链接缓存的情况下,在同样的延迟下,可以达到Python Flask 3倍的QPS。关于Tornado, 由于是使用exception来实现coroutine, 所以略为别扭,也容易出问题,比如Uber在使用过程中发现了一些内存泄露的bug,所以不是特别推荐。
第二是加缓存, 当流量大了以后,可以加缓存的地方,尽量加缓存。当然,缓存本身也会引入一个可能导致故障的点,所以如果不是很稳定,不加为好。因为通常cache connection的timeout都不会设的非常小,所以如果缓存挂掉了,那请求可能要在缓存上阻塞一阵子,导致高延迟。很久以前Uber的溢价系统就曾经因为这个出过一次问题,不过好在通常Redis都比较稳定,且修复很快。
第三点是做负载测试, 这个是个必要步骤。
这点跟前面几点都有重叠的地方,而且对系统至关重要。failure处理有几个层面需要考虑,首先是Service之间的隔离保护,不是一定要放在一起的功能,尽量不要放在一个Service里。比如把运算量很大的溢价计算和serving放在一个Service中,那当流量突然增大时,serving和溢价计算都会受影响,而如果他们是两个Service,那如果serving受到压力,我们只需要解决serving的问题就好,不用担心溢价计算的问题。
又比如我们很久之前的一个事故是当运营分析系统大量读取溢价时,给serving造成了很大压力。这个事故的出错原因固然很低级(数据库读取不合理),但是从大的角度出发,这也引出了第二个要点,Service之间的SLA中应包含该Service的优先级,当出现问题要牺牲Service时,应该先牺牲优先级低的Service,把注意力放在保证优先级高的Service不挂掉。假设我们有一个专门针对内部服务的Service,那我们就可以牺牲该Service,从而有效避免该事故的发生。
由于优先级高的Service通常极其重要,因而往往具有不可替代性,获得的维护资源也多,所以在依赖该Service时往往可以认为它是不会挂掉的,因为它挂掉了调用者Service也没什么用了。而对于优先级低的Service, 我们通常要做好准备它是有可能挂掉的,所以我们要避免这样的Service成为单点故障中的那个点,并且积极寻找当它不可用时的备用方案。
Service之间保护的第三个要点是除了两个Service之间本身的保护,我们还需要关注它们的依赖之间的保护。如果他们的依赖没有很好的隔离, 那么它们的保护并没有到位。比如让不同的Service共享同一个MySQL集群, 于是当一个Service里有不恰当的代码,使劲写入该集群时,其他一些共享该集群的Service也会受到影响。通常会共享这种集群的Service的优先级都不会太高,在资源有限的情况下共享是无奈的选择,但是我们要知道危险性。
我在用户增长组主要聚焦在产品工程,即如何用最少的资源,最快的速度,来实现非常具有可扩展性的解决方案,因为迭代速度越快,代价越小,对竞争对手的优势就越大。同时要和产品经理保持默契,适应不断变化的需求。另外还要和其它组的产品经理和工程师保持沟通,尽量减少和消除产品远景规划上的冲突。
具体的说,为了实现最具可扩展性的方案,我们需要了解我们所能覆盖的使用情景,然后抽象出我们系统的行为。有了行为以后,我们可以在看看还有没有其它的使用情境,也可以用这样的行为所支持,如果可以,我们就达到了用最少的工作来达到最大产出的的结果。
当我们抽象出来这个系统的行为后,我们发现我们要处理的是由注册开始的一系列事件,并且根据这些事件和运营人员设置的规则来做各种处理。在这样的情况下,不仅司机推荐司机奖励,其它的各种司机奖励(比如老司机奖励),和其它的各种推荐活动,也可以用这个系统来处理。
所以我们只需要把这系统的主线架构(事件激发机制)写好,当有需要要加新的奖励规则时,我们只需要让工程师写针对该规则的模块插入即可。同时,我们会对主线架构上的代码进行严格审查,并对插入模块进行出错隔离,这样如果插入的模块有问题,只会影响该模块本身,而不会搞挂掉整个系统。
做产品工程,顾名思义,产品是自变量,工程是因变量。跟产品经理保持好的默契,跟别的组的产品经理和工程师保持好的沟通,至关重要。关于这点要展开说就是另一篇文章了。
我在不同时期也做了很多移动开发的工作。这里我简单谈谈Uber的移动技术栈和App框架Presidio。我将以Android为例。
Presidio是一个组织UI组件和非UI task的框架。先来看看Uber以前的App架构,一般来说,每个UI界面都是按MVVM来写的,在Android的情况下,往往每个界面会对应于一个Activity, View, Controller, Data Manager, 同时该Activity会包括这些View, Controller等等。这种结构往往会导致一个非常大的Controller, 里面有很多不同组的人的代码相互作用,这非常容易给App带来bug,也会延缓试验新功能的速度。
Presidio吸取了这个教训,在组织代码时粒度更小,比如把Controller的功能切分成了Builder,Router,Interactor等等,有点类似VIPER。在这个体系中,一个组件,官方名称为Riblet,包括Router, Interactor, Builder, Component, View, Presenter。而在实现中,我们只有一个Activity, 而在Activity上插了一个以Riblet为节点的树,每个Riblet在被插拔时管理自己的lifecycle。这样也避免了在Activity中使用易出bug的Fragment的lifecycle。
在Presidio中,Builder的主要任务是根据父级传入的参数创建整个Riblet和下层Riblet的Builder。Router是根据lifecycle和Interactor的指令对下层Riblet进行插拔。Interactor是真正的业务逻辑,会根据用户事件或其它事件来做各种决定,并通过Presenter来控制View显示各种信息。
Presidio的另一个优点是不同优先级的模块间的保护(这点是四海皆准)。Presidio主干结构和关键功能上的代码会被严格审查而保证不会有错,而产品工程师为了做实验而开发的Riblet会有默认的flag来关闭,如果实验feature里有bug,最坏的情况是关掉这些非主干功能,从而保证主要功能仍然可以工作。
除了UI,移动端上还有很多其它功能,如各种组件之间通信的和网络通讯。我先说说组件间的通信,一般来说EventBus是一个常用的方式,但是它的不好的一点是所有的组件间的通信都通过一个渠道,这样就缺乏组件间的保护,也不好debug,因为每个激发事件的点都可能是出错点。而RxJava这点就好很多,因为不同的通信是用不同的Observable,所以无关组件间不会相互影响。另外,在代码的组织上,我们可以很干净很容易的把一些列的事件激发和处理串起来,而Event Bus就要繁琐很多。
再说网络通信,通常都是使用Retrofit, 由于它的执行是异步的,所以配合上RxJava就可以把要对返回结果要做的操作串起来。
通常如果客户端的信息有时效性的话,我们需要及时的把信息发给后台,那么我们就需要隔一段时间发些信息回后台,具体的间隔和payload,取决于具体的应用情景。
另外如果我们有后台的消息要发给移动端,我们就需要Push功能。具体的Push其实还分两种,一种就是大家所熟悉的Google Firebase和Apple Push Notification Service,这种Push是不分Mobile App状态而推送过去的,所以即便在App被杀死的情况下,我们可以用它来唤醒App。另一种是App本身可以实现的,只在App在前台的情况下获得推送的功能,这个功能相对第一种来说更轻便,也不需要过Google或Apple。比如说,我们可以试图跟后端保持一个HTTP长链接,然后不时的让后端喂些数据保持这个长链接即可。如果要实现提示消息数,在线提示等功能,这个方案就足够了。
关于网络,我们还需要关注客户端的故障恢复机制。比如在和App通信的数据中心断电了,我们需要让客户端自动跳转到其它备用的数据中心。这就需要我们在移动端事先写好所有的备用选择,并配置各种的降级机制,比如在主数据中心 3次没有响应后跳转到其它数据中心。或者是接到后端的指令后跳转到其它数据中心。
最后关于网络,我们还需要让网络调用的Data Model非常严格,比如把网络调用的interface定义成严格的Protocol Buffer,然后编译成移动端和后端使用的代码,这样就可以防止比较随意的后端payload改动搞坏App。
最后一点是关于monorepo,Uber的移动端代码有很多library,散落在不同的代码仓库之中,这对于并行开发有些好处,但是对于维护就不太方便,比如要改一个annotation可能要改很多代码库并且升级版本等等,最后还是决定合成一个repo, 然后工程师build代码时只需要build相关的代码,这点使用buck可以实现。
希望这些对大家有些帮助,欢迎讨论!