我在 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 的分单系统还是非常稳定的。
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 的消息队列,而我们需要做的是跟据累积的交易数来做一些行为,在这样的情况下,我们就需要我们的系统能够消重,或者保证我们要做的行为是幂等的。
第二个层面是 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 的问题就好,不用担心溢价计算的问题。