构建Uber端到端技术栈的十条经验(转载)

时间:2023-02-03 17:28:14

好文章就得分享:

一、SOA

系统设计包括若干个层面。先说顶层的系统设计原则,如 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 之间通信时使用的 Kafka 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 在被插拔时管理自己的生命周期。这样也避免了在 Activity 中使用易出 bug 的 Fragment 的生命周期。

在 Presidio 中,Builder 的主要任务是根据父级传入的参数创建整个 Riblet 和下层 Riblet 的 Builder。Router 是根据 lifecycle 和 Interactor 的指令对下层 Riblet 进行插拔。Interactor 是真正的业务逻辑,会根据用户事件或其它事件来做各种决定,并通过 Presenter 来控制 View 显示各种信息。

Presidio 的另一个优点是不同优先级的模块间的保护(这点是四海皆准)。Presidio 主干结构和关键功能上的代码会被严格审查而保证不会有错,而产品工程师为了做实验而开发的 Riblet 会有默认的 flag 来关闭,如果实验 feature 里有 bug,最坏的情况是关掉这些非主干功能,从而保证主要功能仍然可以工作。

十、App 网络与推送的处理

除了 UI,移动端上还有很多其它功能,如各种组件之间通信的和网络通讯。我先说说组件间的通信,一般来说 EventBus 是一个常用的方式,但是它不好的一点是所有的组件间的通信都通过一个渠道,这样就缺乏组件间的保护,也不好 debug,因为每个激发事件的点都可能是出错点。

而 RxJava 这点就好很多,因为不同的通信是用不同的 Observable,所以无关组件间不会相互影响。另外,在代码的组织上,我们可以很干净很容易地把一系列的事件激发和处理串起来,而 EventBus 就要繁琐很多。

再说网络通信,通常都是使用 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 可以实现。