欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

构建Uber端到端技术栈的十条经验

程序员文章站 2022-07-14 08:17:10
...
[size=large]我在Uber这几年,做了很多系统稳定性及可扩展性的工作, 也包括很多快速迭代试错的产品,另外还做了一些移动开发的工作,因此我对Uber的端到端的技术栈还比较熟悉。在这里以我的经历为例跟大家分享一下如何以Uber的方式快速稳定的做一个端到端的大型应用。

我刚加入Uber时,Uber正处于飞速成长期。这样的情况对之前工程师设计的简单系统造成了极大的压力。下面我谈谈实战中的系统设计的经验。

一、选择微服务

系统设计包括若干个层面。先说顶层的系统设计原则, 如REST,SOA。由于Uber之前一直是算一个创业公司,所以开发速度至关重要,由于微服务 能够极大的促进不同组件的平行开发,SOA成为了Uber的选择。

在这种选择下,我们需要先按功能设计出不同责任的Service,每一个Service作为这个责任的唯一真实信息源。在开发新的功能时,只需要先设计好不同Service之间的合约, 就可以按照合约平行开发了。在实际工作中,这点被证明非常有效。

二、服务要设计为幂等(idempotent)

第二点是不同Service之间的合约和依赖。一个Service的合约决定了它跟上游Service之间的关系,如果这个合约设计的不好,那就会给上游Service上的开发带来各种不方便和重复工作。

比如说如果一个节点可以被设计成幂等(多次操作均返回相同结果)但却没有这么做,那就会导致上游Service在使用这个节点时,失败处理逻辑会复杂很多--如果是幂等, 上游只需要重新调用就可以了;但是如果不是幂等, 上游就需要跟据出错信息来判断依赖系统的状态 (有时甚至很难判断,比如在下游系统状态更新后网络出错) ,然后再根据状态来选择不同的处理方式。

在有些情况下(比如下游系统挂掉了),上游系统甚至需要记录下游系统的状态,这样在backfill的时候才可以直接做正确的处理;而在幂等的情况下,我们只需要无脑调用下游的Service就好。举个例子,很久以前Uber有次分单系统坏了,导致之后要重新backfill,由于依赖 Service设计的是幂等, 该次backfill就一个简单script跑完即可。当然,现在Uber的分单系统还是非常稳定的。

三、考虑RPC消息的语义(semantics)

同时,我们也要考虑RPC semantics是at least once, 还是at most once。具体的应用情境下有不同的适用。比如说如果是要做一个付钱的有状态更新的api, 那我们就应该保持at most once的使用,当调用 api 出错时,我们不能贸然再次调用该api。At least once和at most once在大部分情况下对应于幂等 和非幂等的操作。另外,我们在实现系统时也要考虑已有系统提供的接口,比如说一个已有的接单系统已经提供了一个at least once的消息队列,而我们需要做的是跟据累积的交易数来做一些行为,在这样的情况下,我们就需要我们的系统能够消重,或者保证我们要做的行为是幂等的。

四、Design for failure

第二个层面是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和可响应性

这两点是系统在扩张过程中需要保证的,为了保证系统的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处理和预防

这点跟前面几点都有重叠的地方,而且对系统至关重要。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 Android App框架Presidio

我在不同时期也做了很多移动开发的工作。这里我简单谈谈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,最坏的情况是关掉这些非主干功能,从而保证主要功能仍然可以工作。

十、App网络与推送的处理

除了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可以实现。
[/size]