专栏名称: InfoQ
有内容的技术社区媒体。
目录
相关文章推荐
51好读  ›  专栏  ›  InfoQ

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

InfoQ  · 公众号  · 科技媒体  · 2017-10-28 09:23

正文

我在 Uber 这几年,做了很多系统稳定性及可扩展性的工作, 也包括很多快速迭代试错的产品,另外还做了一些移动开发的工作,因此我对 Uber 的端到端的技术栈还比较熟悉。在这里以我的经历为例跟大家分享一下如何以 Uber 的方式快速稳定的做一个端到端的大型应用。

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

1 选择微服务

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

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

2 服务要设计为幂等(idempotent)

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

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

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

3 考虑 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 的消息队列,而我们需要做的是跟据累积的交易数来做一些行为,在这样的情况下,我们就需要我们的系统能够消重,或者保证我们要做的行为是幂等的。

4 Design for failure

第二个层面是 Service 之间交互可能发生的问题,在设计一定要考虑周全,比如通信可能发生的 failure case。我们要假定在线上各种奇怪的情况都会发生。比如我们曾经有上下游 Service 之间通信时使用的 kakfa ingester 一直不是非常稳定,导致不时发生下游 Service 无法拿到数据来计算,最后我们干脆把 kafka 换成了 http polling, 再也没有问题了。

第三个层面是 Service 内部的故障, 比如缓存, 数据库断了,或者依赖的第三方 Service 挂掉了,我们需要根据情况进行处理,做好日志和监控。

5 合理选择存储系统

如果一个 Service 是无状态的,那往往它做的事情是根据请求把下游各个 Service 的返回结果加工一下然后返回。我们可以见到很多这样的 Service, 比如各种 gateway,各种只读的 Service。

服务无状态的情况下往往只需要缓存 (如 Redis),而不需要持久化存储。对于持久化存储, 我们需要考虑它的数据模型、对 ACID 的支持、稳定程度、可维护性、内部员工对它的熟练程度、跨数据中心复本的支持程度,等等。到底选择哪一种取决于实际应用情景,我们对各个指标要不同的需要,比如说 Uber 对于跨数据中心复本的要求就很高,因为 Uber 每一个请求的用户的期待值都很高,如果因为存储系统坏了,或存储系统阻挡 failover,那用户体验会非常差。

另外关于可维护性和内部员工的熟练程度,我们也有血淋淋的例子,比如说一个非常重要的系统在订单最多的一天挂掉了,原因是当时使用的 PostgreSQL 数据库不知为什么原因而锁死了,不能读也不能写,而公司又没有专业到能够深入解析 PostgreSQL 的人,这样的情况就很糟糕,最好是换成一个更易维护的数据库。

6 重视系统的 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 都比较稳定,且修复很快。

第三点是做负载测试, 这个是个必要步骤。

7 Failure 处理和预防

这点跟前面几点都有重叠的地方,而且对系统至关重要。failure 处理有几个层面需要考虑,首先是 Service 之间的隔离保护,不是一定要放在一起的功能,尽量不要放在一个 Service 里。比如把运算量很大的溢价计算和 serving 放在一个 Service 中,那当流量突然增大时,serving 和溢价计算都会受影响,而如果他们是两个 Service,那如果 serving 受到压力,我们只需要解决 serving 的问题就好,不用担心溢价计算的问题。







请到「今天看啥」查看全文