Uber如何重新架构其作业平台?


优步的使命是帮助我们的消费者在全球数千个城市轻松前往任何地方并获得任何东西。在其核心,我们捕捉消费者的意图并通过将其与一组正确的提供者进行匹配来实现它。 
作业履行(Fulfillment )是“向客户提供产品或服务的行为或过程”。优步的作业开发平台协调和管理与数百万活跃参与者进行的订单和用户会话的生命周期。 
Fulfillment Platform 是 Uber 的一项基础功能,可实现新垂直领域的快速扩展。

  • 该平台处理超过一百万个并发用户几十亿人次的每年跨越过万的城市

  • 该平台每天处理数十亿次数据库事务

  • 数百个 Uber 微服务依赖该平台作为准确的行程状态和司机/送货员会话的真实来源 
  • 平台生成的事件用于构建数百个离线数据集以做出关键业务决策
  • 500 多名开发人员使用 API、事件和代码扩展平台,以构建 120 多个独特的履行流程

作业履行平台的最后一次重大改写是在 2014 年,当时 Uber 的规模要小得多,履行场景也更简单。从那时起,优步的业务已经发展到包括移动和交付方面的许多新垂直领域,处理各种不同的履行体验。一些示例包括预先确认司机的预订流程、同时提供多次旅行的批处理流程、机场的虚拟排队机制、Uber Eats 的三边市场、通过 Uber Direct 交付包裹等等。 
两年前,我们做了一个大胆的赌注,开始了从头开始重写 Fulfillment Platform 的旅程,因为 2014 年构建的架构在 Uber 未来十年的增长中无法扩展。 
这是一系列文章中的第一篇,这些文章描述了重新构建 Uber 一个基础业务平台的过程。在 30 多个团队的 100 多名工程师的支持下,我们重建了存储和应用程序架构、域数据建模、API 和事件,并成功地将每个 Uber 产品和城市迁移到了新堆栈。
 
以前的作业架构
整个作业履行模型围绕 2 个实体展开:Trip 和Supply:
  • Trip 代表一个工作单元(例如,将包裹从 A 点运送到 B 点),
  • 而Supply实体代表一个现实世界中能够完成工作单元的人。

rt-demand 服务管理 Trip 实体,rt-supply 服务管理 Supply 实体。
Trip 实体:
Trip 实体至少由 2 个航路点waypoints 组成。航点代表一个位置和可以在该位置执行的一组任务。一个简单的 UberX 旅行通常有一个上车和下车航点,而多目的地旅行有一个上车和下车航点,中间有额外的航点。
Supply实体
Supply 实体为司机/送货人员模拟正在进行的会话的状态。一个Supply实体可以在一次或多次行程中拥有一个或多个航点,以按时间顺序完成。
读/写模式:
在高层次上,服务根据传入的请求执行读-修改-写,并且存在三种读/写模式:

  • 对同一实体的并发读取-修改-写入:例如,一个司机试图下线,一个匹配系统试图将一个新的旅行Trip 提议链接到司机
  • 涉及多个实体的写入:如果司机接受Trip 报价,我们必须修改Trip 实体和Supply实体,并在Supply实体的计划中添加Trip 的航点
  • 涉及多个实体的多个实例的写入:如果司机接受具有多次行程的批量报价,则所有相关实体都需要以全有或全无的方式更新

rt-demand 和 rt-supply 服务是与存储在 Apache Cassandra 和 Redis 键值表中的实体的无共享微服务。在下一节中,我们将描述构成应用程序和存储架构的关键组件。以前的架构优先考虑可用性而不是一致性。有两种形式的缓存:由应用程序管理的 Redis 回退 MSG 和内存缓存以减少 Cassandra 集群上的负载。读取操作主要由内存缓存提供。 
使用 Ringpop 和串行队列的应用层锁定:
Ringpop(归档项目)是一个为分布式应用程序带来协作和协调的库。它在成员协议之上维护一致的哈希环,并提供请求转发作为路由便利。 
Ringpop 为读取-修改-写入周期启用应用程序级序列化,因为每个密钥都有一个唯一的拥有者。Ringpop 以“尽力而为”(可用性高于一致性)将与密钥有关的请求转发给其拥有的工人。每个基于 Ringpop 的服务实例都有一个单线程执行环境(由于 Node.js)、一个根据到达顺序对传入请求进行排序的串行队列,以及一个对象的内存锁。
使用 Saga 的多实体事务 :
Saga提供了一种跨多个服务实现业务事务的模式。我们将这种模式用于跨多程实体和供应实体的交易。它提供了应用层事务语义以支持多数据存储、多服务操作。Saga 协调器首先会在所有参与实体上触发一个建议操作,如果它们都成功,它就会提交;否则,将触发取消操作以执行补偿动作。
 
先前架构的问题
  • 一致性问题

整个架构建立在我们应该权衡可用性和延迟的一致性的前提下,所以一致性只能通过尽力而为的机制来实现。缺乏原子性意味着我们必须在第二次操作失败时进行协调。在脑裂情况(发生在部署期间、区域故障转移期间)的情况下,由于并发写入可能会发生不一致,这可能最终会相互覆盖,因为 Cassandra 表现出最后写入获胜的语义。
  • 多实体写入

如果一个操作需要跨多个实体写入,那么应用层会在任意的基于 RPC 的机制中处理这种协调,并不断验证预期和当前状态以修复任何不匹配。在形成逻辑事务的操作之间,系统处于内部不一致的状态。随着我们使用 Saga 模式构建更复杂的写入流,跨多个实体和服务调试问题变得更加困难。
  • 可扩展性问题

城市被分成一个可用的 Pod,Pod 的大小取决于 Ringpop 集群的最大环大小。鉴于协议的点对点性质,Ringpop 受到物理限制。这意味着如果任何城市超过并发旅行的阈值,则扩展 pod 存在垂直限制。
  • 弃用的语言和框架

2018 年,Uber 不再推荐 Node.js 和 HTTP/JSON 框架。新工程师被迫了解遗留应用程序框架、不同的编程语言和雪花 HTTP/JSON 协议,以便在堆栈中进行更改,从而大大增加了入门成本。
  • 数据不连贯

以前的架构采用分层的数据存储方法。分片的内存缓存缓冲区提供了第一层,其次是 Redis 和 MSG(及其镜像的 Cassandra 集群)作为第二层。分层方法以保持缓存一致性为代价是高性能和冗余的。缓冲本地缓存中的数据更改在发生时进一步复杂化了缓存不连贯性。 
  • 没有明确的可扩展性模式

在过去几年中,超过 400 名工程师修改了核心履行平台。没有任何明确的扩展模型和开发人员模式,新工程师很难对整个流程进行推理并自信和安全地进行更改。 
 
新的作业履行架构
我们花了 6 个月的时间仔细审核堆栈中的每个产品,从利益相关者团队收集了 200 多页的需求,广泛讨论了具有数十个评估标准的架构选项、基准数据库选择和原型应用程序框架选项。经过几个关键的决定,我们提出了整体架构,以满足我们新十年的需求。本节提供了新架构的高级概述。
新架构的要求
  • 可用性:确保任何单个区域、区域或间歇性基础设施故障对应用程序可用性的影响最小,同时保证至少 99.99% 遵守 SLA
  • 一致性:跨地域的单行、多行、多行多表事务强一致性
  • 可扩展性:提供一个具有清晰抽象和新产品功能简单编程的框架
  • 数据丢失单区域、区域或间歇性基础设施故障不应导致数据丢失
  • 数据库要求:支持二级索引、变更数据捕获和交易的 ACID 合规性
  • 延迟为所有履行操作提供更好或相同的延迟
  • 效率:提供通过规范化的业务和应用程序指标跟踪服务效率的能力
  • 弹性:跨整个堆栈的水平可扩展性,可根据业务增长自动扩展,没有任何可扩展性瓶颈
  • 低运营开销:添加新实体/城市/区域的运营开销最小,无需停机模式升级,自动分片管理。

我们的存储抽象解决方案侧重于 3 种方法:
  • 增量更新现有的基于 NoSQL 的架构
    • 在 Ringpop 中利用 Apache Helix 和 Apache Zookeeper 进行集中分片管理,而不是分散的对等分片管理
    • 使用串行队列确保在任何时间点只执行一个事务,或利用内部集中式锁定解决方案在事务进行时锁定实体
    • 继续对任何多实体跨分片事务使用基于 Saga 的模式
  • 切换到使用内部基于 MySQL 的存储
    • 建立一个机制来分片跨多个 MySQL 集群的所有履行数据 
    • 构建一致架构升级、跨区域复制和轻松将辅助节点提升为主节点的解决方案 
    • 构建解决方案来处理区域故障转移之间的停机时间并确保不缺乏一致性
  • 探索全新的基于 NewSQL 的存储
    • 利用 NewSQL 数据库提供事务原语,同时保持水平可扩展性
    • 评估基于本地的解决方案(如 CockroachDB 或 FoundationDB)或托管解决方案(如 Google Cloud Spanner)
  •  

为了满足事务一致性、水平可扩展性和低运营开销的要求,我们决定利用 NewSQL 架构。在此项目之前,Uber 没有利用基于 NewSQL 的存储的先例。在对可用性 SLA、运营开销、事务能力、模式管理、分片管理、自动扩展和水平可扩展性等维度进行彻底的基准测试和仔细评估后,我们决定使用 Google Cloud Spanner 作为主要存储引擎。
 
Spanner 作为事务数据库
Fulfillment 依赖于 Spanner 公开的一些核心功能,例如:
  • 外部一致性:Spanner 提供外部一致性,这是对事务最严格的并发控制保证
  • 服务器端事务缓冲:我们利用基于 Spanner DML 的事务,以便各个模块可以更新其对应的表,并且依赖模块可以在正在进行的事务范围内读取更新的版本
  • 水平可扩展性:Spanner 按主键范围将数据分片到物理服务器,这提供了水平可扩展性,假设没有热点
  • SQL 支持:通过数据操作语言完成对行的读取、插入或更新
  • Cross Table、Cross Shard 事务:Spanner 支持跨多行、多表、多索引的事务
  • 争用和死锁检测Spanner 跟踪跨行的锁并在事务之间存在争用时发出事务中止并避免潜在的死锁情况
  • 陈旧读取支持时间读取(完全陈旧)和有界陈旧读取

Spanner 的公共版本目前不支持开箱即用的变更数据捕获。为了为提交后操作提供至少一次保证,我们构建了一个名为潜在异步任务执行 (LATE) 的组件。所有提交后操作和计时器都与读写事务一起提交到单独的 LATE 操作表,该表指示要执行的所有提交后操作。LATE 应用程序工作人员扫描并从该表中提取行并保证至少执行一次。
 
编程模型
随着 Uber 工程规模的扩大和我们产品流程的增长,Fulfillment Platform 的编程模型必须提供简单性、模块化、可扩展性、一致性和正确性,以确保 100 多名工程师可以安全地在该平台上构建。
在较高的层次上,新的编程模型有 3 个部分: 
  • 用于建模履行实体的状态图,以确保一致和模块化的行为建模
  • 业务事务协调器,用于处理跨多个实体的写入,以便每个实体都可以模块化并在不同的产品流中使用
  • ORM 层用于提供数据库抽象、简单性和正确性,因此工程师无需担心知道如何使用 ACID 数据库

状态图
我们利用状态图将实体生命周期表示为分层状态机。我们通过利用 Protobufs 的一致数据建模方法并构建用于实现状态图的通用 Java 框架,将实现实体建模的原则正式化并记录在案。
状态图是一个有限状态机,其中每个状态都可以定义自己的从属状态机,称为子状态。这些状态可以再次定义子状态。嵌套状态允许抽象级别并提供分层级别以放大系统内的特定功能能力。
状态图由 3 个主要组件组成:
  • 状态:状态描述的状态机的特定行为。每个状态都可以指定它理解的触发器数量,并且对于每个触发器,如果​​该触发器发生,可以进行任意数量的转换。状态还描述了进入或退出状态时要执行的一组操作。
  • 转换:转换模拟状态机如何从一种状态转换到另一种状态。
  • 触发器:触发​​器是表示用户意图的用户信号或发生某些事情的系统事件。

当触发器发生时,状态图会得到通知,然后状态图会通知有关触发器的当前活动状态。如果存在登记到状态为触发,则执行转换,从而在状态图的相应的过渡转变从当前状态到目标状态。
 
我们如何将作业履行实体构建为状态图?
作业履行实体是指对物理(例如,送货人员或其车辆)或数字抽象(例如,运输包裹所需的工作)进行建模的业务对象,以便对消费者(或多个消费者之间的交互)进行建模) 使用有限状态机模型。 
每个履行实体都是通过状态图配置静态定义的,其中包含状态、状态之间的转换以及在每个状态上注册的触发器。这些建模组件(状态图、转换、触发器)通过显式定义的代码组件(Java 类)在应用程序层中具体化。这些代码组件构成了与建模组件相关联的功能性业务逻辑。除此之外,触发器作为 RPC 公开,以允许外部系统(用户应用程序、周期性事件、事件管道和其他系统)通过 RPC 接口调用触发器。 
 
事务协调员
在与消费者应用程序和其他内部系统交互时,单个业务流可能涉及一个或多个履行实体触发器。例如,当捕获用户从餐厅送餐的意图时,我们创建一个订单实体来捕获用户的意图,一个用于准备食物的作业实体,以及另一个用于从餐厅将食物运送到用户位置的作业实体。这导致在单个事务范围内协调多个实体转换,以便为参与业务流的每个履行实体的消费者提供一致的视图。业务流的成功完成也可能导致副作用(例如,对其他系统的非事务性更新、写入 Apache Kafka)
为了在一个或多个实体之间实现事务一致的协调,我们通过网关提供更高级别的 API。API 可以是触发器或查询。触发器允许一个或多个实体的事务更新,而查询允许调用者读取一个或多个实体的状态。
网关利用 2 个主要组件来实现触发器和 API:业务事务协调器和查询计划器。业务事务协调器将实体触发器的有向无环图作为输入,并通过代表单个实体触发器的节点进行编排,在图中在单个读写事务的范围内。查询计划器负责提供具有跨实体及其关系的可变一致性级别的读取访问。 
ORM层
ORM 层为事务管理、实体访问和实体-实体关系管理的数据库构造提供了一个抽象层。