本文是一系列文章的第一篇,介绍了 Uber 的基础业务平台的重构的过程。通过对存储和应用架构、域数据建模、API 和事件的重构,在超过 30 个团队的 100 多名工程师的支持下,将 Uber 的每一个产品和城市都成功迁移到新的技术栈。
Uber Fulfillment 简介
Uber 的使命是帮助我们的消费者轻松到达世界上成千上万个城市的任何地方,获取任何东西。关键在于,我们捕捉到了消费者的意图,并通过将其与一组合适的供应商匹配来实现。
Fulfillment 是“向客户提供产品或服务的行为或过程”。Uber 的 Fulfillment 组织开发平台来协调和管理正在进行的订单和用户会话的生命周期,并拥有数百万的活跃参与者。
关键性与规模
Fulfillment Platform 是 Uber 的一项基础能力,它能迅速扩展新的垂直领域。
这个平台每年要处理上百万的并发用户,以及跨越一万个城市的数十亿次旅行。
这个平台每天要处理数十亿次的数据库事务。
数以百计的 Uber 微服务依靠这个平台,作为准确的出行状态和司机/送货员会话的真相来源。
这个平台生成的事件被用来构建数百个离线数据集来作出关键的业务决策。
500 多名开发者使用 API、事件和代码扩展平台来构建超过 120 种不同的 Fulfillment 流。
图 1:Uber 的投资者介绍
对 Fulfillment Platform 的最后一次重大改写是在 2014 年,当时 Uber 的规模要小得多,Flufillment 场景非常简单。Uber 的业务已经扩展到了移动和交付等多个垂直领域,能够满足各种不同的 Flufillment 体验。例如,预先确认司机的预订流程、同时提供多个行程的批处理流、机场的虚拟排队机制、Uber Eats 的三方市场、通过 Uber Direct 交付包裹等等。
两年前,我们大胆下注,开始重写 Fulfillment Platform,因为在 2014 年构建的架构不能适应 Uber 未来十年的发展。
本文是一系列文章的第一篇,介绍了 Uber 的基础业务平台的重构的过程。通过对存储和应用架构、域数据建模、API 和事件的重构,在超过 30 个团队的 100 多名工程师的支持下,将 Uber 的每一个产品和城市都成功迁移到新的技术栈。
Fulfillment 的早期架构
本节介绍了我们在之前 Fulfillment 栈中用于实现简单 UberX 流的数据模型和架构。
图 2:用于简单 UberX 流的高级架构
域建模
整个 Fulfillment 模型围绕着 2 个实体:Trip(行程)和 Supply(供应)。rt-demand 服务管理 Trip 实体,而 rt-supply 服务管理 Supply 实体。Trip 表示工作单元(例如,从 A 点发送一个包裹到 B 点),而 Supply 实体代表了能够完成该工作单元的现实世界的人。
Trip Entity
Trip 实体包括至少两个航路点。一个航路点代表了一个地点以及在该地点可执行的一系列任务。简单的 UberX 行程通常有一个上客点和下客点,而多目的地的行程有一个上客点和下客点,中间还有额外的“途径”航路点。
Supply Entity
Supply 实体对司机/送货员正在进行中的会话的状态进行建模。Supply 实体可以在一次或多次的行程中有一个或多个航路点,并按时间顺序完成。
读写模式
从高的层面来说,基于传入的请求进行读取-修改-写入,服务可以使用三种读/写模式:
并发读取-修改-写入同一实体:例如,一个试图下线的司机和一个想把新的行程报价与他联系起来的匹配系统。涉及多个实体的写入:若司机接受了行程报价,则必须修改 Trip 实体和 Supply 实体,并向 Supply 实体的计划中添加行程的航路点。涉及多个实体的多个实例的写入:如果一个司机接受了一个具有多个行程的批量报价,所有相关的实体都需要以全有或全无的方式更新。
应用架构
rt-demand 和 rt-supply 服务是与存储在 Apache Cassandra® 和 Redis 键值表中的实体无共享的微服务。接下来的小节,我们将介绍构成应用和存储架构的关键组件。之前的架构将可用性置于一致性之上。
图 3:基于 Ringpop 的服务架构剖析
用 Pod 隔离故障
尽管 Uber 的大多数架构运行在 2 个独立区域的 2 个故障域上,但是为了进一步减少爆炸半径,我们创建了一个 Pod 的概念,它包含了一些必要的基础服务来执行 Flufillment 流。每一个地区都有多个 Pod,而城市则根据不同的标准映射到 Pod。
市场存储网关
Pod 内的所有服务都利用了市场存储网关(Marketplace Storage Gateway,MSG)提供的 KV 存储 API。MSG 对底层存储进行抽象,并利用了 Cassandra 集群。为达到更高可用性服务水平的目标,MSG 采用了冗余的存储集群(即,一个应用程序的写入会导致 Cassandra 写入区域内的两个不同的集群)。在每个集群中,都有 3 个数据副本。区域集群支持跨区域的异步复制。
使用 Ringpop 和串行队列的应用层锁定
Ringpop(已归档项目)是一个协作和协调分布式应用的库。它根据成员协议维护一个一致的哈希环,并提供请求转发作为一种路由方式。
Ringpop 在读取-修改-写入周期实现了应用层面的序列化,因为每个键都有一个唯一的工作器。Ringpop 以一种“尽力而为”(可用性高于一致性)的方式,将与某个键有相关的请求转发给其所属的工作器。每一个基于 Ringpop 的服务实例都有一个单线程的执行环境(由于 Node.js),一个根据到达顺序排列传入请求的序列队列,以及一个对象的内存锁。
将 Ringpop 和 Redis 用于缓存
有两种缓存形式:Redis 为应用程序管理的 MSG 提供后备,而内存缓存用于减少 Cassandra 集群的负载。读取操作主要由内存中的缓存提供。
提交后操作和计时器
大部分的事务都需要保证操作在提交之后被执行,并且在适当的时候(比如报价到期)触发调动定时器。这是用 sevnup 框架实现的,这个框架提供了一种方法,当 hashring 中的一个节点宕机时,或另一个节点取得键空间的所有权时,可以移交某些对象的所有权。
使用 Saga 的多实体事务
Saga 提供了跨多个服务实现业务事务的模式。我们利用这种模式实现了跨多 Trip 实体和 Supply 实体的交易。为实现多数据存储、多服务操作,它提供了应用层的事务语义。Saga 协调器将首先触发所有参与的实体的提议操作,如果它们都成功,就会提交;否则,它将触发取消操作来执行补偿操作。
使用独立的 Cassandra 表的二级索引
为查找给定的乘客的行程,rt-demand 维护了一个单独的表,该表将乘客映射到行程标识符的列表中。由于 rt-demand 是用标识符分片的,对于一个给定的行程标识符的所有请求都被转到包含该行程的工作器中,而对于某个给定的乘客标识符的所有请求将被转到拥有该乘客的工作器那里。在创建行程时,该行程首先被保存到乘客索引表中,然后保存到行程表中,并向拥有乘客的工作器发送一条请求,以便使缓存失效。
先前架构的问题
基础设施问题
一致性问题
整个架构是以一致性为交换可用性和延迟为前提的,因此一致性只能通过尽力而为的机制来实现。缺少原子性意味着我们必须在第二个操作失败时协调操作。对于“大脑分裂”(部署过程中的区域故障),不一致可能由于并发的写入操作而发生,这种不一致性可能最终会相互覆盖,因为 Cassandra 显示了最后写入的语义。
多实体写入
如果操作需要跨多个实体写入操作,那么应用层可以在任意的基于 RPC 的机制中处理这种协调,并且持续验证预期的状态和当前的状态,以解决任何不匹配的问题。构成逻辑事务的操作之间,系统处于内部不一致的状态。在使用 Saga 模式构建更复杂的写入操作流时,调试跨越多个实体和服务的问题变得更加困难。
可扩展性问题
城市被分散到一个可用的 Pod 中,Pod 的大小取决于 Ringpop 集群的最大环大小。鉴于协议的点对点性质,Ringpop 有物理限制。这就是说,如果任何一个城市都超过了并发行程的阈值,那么将存在一个垂直的限制,以扩大 Pod 的规模。
应用问题
过时语言和框架
2018 年,Uber 不再推荐 Node.js 和 HTTP/JSON 框架。新工程师被迫了解遗留应用程序框架、不同的编程语言,以及雪花般的 HTTP/JSON 协议,以便在技术栈中进行更改,极大的增加了入职成本。
数据不一致
先前的架构采用分层的数据存储方式。分散式内存缓存缓冲区提供了第一层,Redis 和 MSG(使用镜像的 Cassandra 集群)作为第二层。分层方法具有高性能和冗余,但代价是保证缓存的一致性。在本地缓存中的缓冲数据变化,当缓存不一致时,缓存变得更加复杂。
可扩展性模式不清晰
近几年来,有 400 名工程师对核心 Fulfillment Platform 进行了改造。在没有清晰的扩展模型和开发模式的情况下,一个新的工程师很难理解整个流程,并且自信且安全地进行修改。
Fulfillment 的新架构
我们花了 6 个月的时间仔细审核了技术栈中的每一件产品,收集了利益相关者团队的 200 多页需求,用数十种评估标准广泛辩论了架构选项,对数据库选择进行了基准测试,并对应用框架选项进行了原型设计。在完成了一些关键的决定之后,我们为未来十年的需求提出了一个整体的架构。本节对新的体系结构进行了高级概述。
新架构的要求
可用性:确保任何单一区域、分区或间歇性的基础设施故障对应用程序的可用性的影响最小,同时至少保证 99.99% 的 SLA 遵守率。
一致性:跨区域的单行、多行和多行多表事务的强一致性。
可扩展性:提供一个具有清晰抽象并对新产品功能进行简单编程的框架。
数据丢失:单一区域、分区或间歇性的基础设施故障不应导致数据丢失。
数据库要求:支持二级索引、变更数据捕获和事务的 ACID 遵从性。
延迟性:为所有 Flufillment 操作提供相同或更好的延迟。
效率:通过规范化的业务和应用指标,提供跟踪服务效率的能力。
弹性:全栈横向可扩展性,根据业务增长进行自动扩展,无任何可扩展性瓶颈。
操作开销低:新的实体/城市/地区增加最低操作开销,无停机模式升级,以及自动分片管理。
从 NoSQL 迁移到 NewSQL
存储抽象化解决方案集中在 3 种方法上:
对现有的基于 NoSQL 的架构进行增量更新
利用 Apache Helix 和 Apache Zookeeper 来代替 Ringpop 中分散式的点对点分片管理。
可以使用序列队列来确保在任何时候只有一个事务在执行,或者使用内部集中的锁定解决方案,从而在事务进行时锁定该实体。
在基于 Saga 的模式下继续进行任何多实体的跨分片事务。
切换到使用基于 MySQL 的内部存储
构建一种机制,将多个 MySQL 集群的所有的 Flufillment 数据分割。
建立一种解决方案,以进行一致的模式升级、跨区域复制、以及轻松地向第一台设备推广第二台设备。
建立一种解决方案,处理区域故障切换之间的停机时间,确保一致性不缺失。
探索全新的基于 NewSQL 的存储
使用 NewSQL 数据库来提供事务性基元,同时保持横向可扩展性。
评估基于内部的解决方案(如 CockroachDB 或 FoundationDB)或管理解决方案(如 Google Cloud Spanner)。
为满足事务一致性、横向可扩展性和低操作费用等要求,我们决定采用 NewSQL 架构。Uber 还没有在此项目之前使用过基于 NewSQL 存储的先例。经过全面的基准以及对可用性 SLA、操作开销、事务能力、模式管理、分片管理、自动扩展和横向可扩展性等方面进行彻底的基准测试和仔细评估后,我们决定以 Google Cloud Spanner 为主要的存储引擎。
Spanner 作为事务数据库
我们使用 Spanner 的北美多区域配置 nam3 作为 Flufillment 实体的存储引擎。Fulfillment 服务运行于 Uber 北美运营区,每个事务对部署在 Google Cloud 的 Spanner 进行一系列的网络调用。
Flufillment 依赖于 Spanner 所公开的一些核心能力,例如:
外部一致性:Spanner 提供最严格的事务并发控制保证的外部一致性。
服务器端的事务缓冲:通过使用基于 Spanner DML 的事务,单个模块能够更新其相应的表,而依赖模块可以在正在进行的事务范围内读取更新的版本。
横向可扩展性:Spanner 按主键范围将数据分散到物理服务器上,这就提供了横向可扩展性,前提是不存在热点。
SQL 支持:读取、插入或更新行是通过数据操作语言(Data Manipulation Language)完成。
跨表、跨分片事务:Spanner 支持跨越多行、表和索引的事务。
争用和死锁检测:Spanner 跟踪跨行的锁,并在事务之间发生争用时发出事务中止,以避免潜在的死锁情况。
图 4:新 Flufillment Platform 的存储拓扑
提交后操作
Spanner 的公开版本目前并不支持开箱即用的变更数据捕获。为了给提交后的操作提供至少一次的保证,我们构建了一个名为 Latent Asynchronous Task Execution(LATE,潜在异步任务执行)的组件。所有的提交后操作和计时器都与读写事务一起提交到一个单独的 LATE 操作表中,该表指出了所有要执行的提交后操作。LATE 应用工作器从这个表中扫描并拾取行,并保证至少执行一次。
在本系列的第二篇文章中,我们将介绍如何选择正确的存储、评估需求和在 Uber 内部操作 Spanner。
程序设计模型
由于 Uber 项目的规模越来越大,产品流程也越来越复杂, Fulfillment Platform 的编程设计模型必须提供简单、模块化、可扩展、一致性和正确性,以确保 100 多个工程师可以在此平台上安全构建。
新的编程设计模型从高层次分为三部分:
用来为 Flufillment 实体建模状态图,以确保对行为进行一致性和模块化建模。
业务事务协调器,用于处理多个实体的写入操作,以便每个实体可以模块化,并在不同的产品流程中加以利用。
ORM 层,用于提供数据库抽象、简单性和正确性,这样工程师就无须担心如何使用 ACID 数据库。
图 5:新 Flufillment Platform 的应用架构组件
状态图
我们利用状态图将实体的生命周期表示为一个分层的状态机。我们通过利用与 Protobufs 一致的数据建模方法和建立一个实现状态图的通用 Java 框架,从而正式识别并记录了 Flufillment 实体建模的原则。
什么是状态图?
状态图是一种有限状态机,每一种状态都可以定义它的下级状态机,称为子状态。这些状态可以再次定义子状态。嵌套的状态允许抽象的层次,并提供分层级别,这样就可以放大系统中的特定功能。
一个状态图是由 3 个主要部分组成的。
状态:状态描述了状态机的特定行为。每一个状态都可以指定它所理解的触发器的数量,并且对于每个触发器,如果该触发器发生,可以执行任意数量的转换。状态还描述了进入或退出该状态时要执行的一组动作。
转换:建模状态机如何从一个种状态转换为另一种状态。
触发器:触发器要么是表示用户意图的用户信号,要么是发生事件的系统事件。
当触发器发生时,会通知状态图,然后状态图会告知触发器当前的活动状态。当一个相应的转换被注册到该触发器的状态时,转换将执行,从而导致状态图从当前状态转换为目标状态。
怎样把 Flufillment 实体作为状态图构建?
Flufillment 实体是指一个业务对象,它对物理(如送货人或其车辆)或数字抽象(如运输包裹所需的工作)进行建模,以便使用有限状态机模型对消费者(或多个消费者之间的互动)进行建模。
通过状态图配置静态地定义每个 Flufillment 实体,包括状态、状态间的转换以及在每个状态上注册的触发器。通过明确定义的代码组件(Java 类),这些建模组件(状态图、转换、触发器)在应用层中实现。这些代码组件构成了与建模组件相关的功能业务逻辑。除此之外,触发器作为 RPC 暴露出来,允许外部系统(用户应用、周期性事件、事件管道和其他系统)通过 RPC 接口调用触发器。
事务协调器
单个业务流可能涉及到一个或多个 Flufillment 实体触发器,而与消费者应用程序和其他内部系统交互。举例来说,当捕捉到用户要从餐厅送食物的意图时,我们创建了一个订单实体来捕捉用户的意图,一个工作实体来准备食物,另一个工作实体用于将食物从餐厅送到用户的位置。这样就可以协调多个实体在单一事务范围内的转换,从而为消费者提供业务流中每个 Flufillment 实体的一致性视图。成功完成业务流还可能导致副作用(例如,对其他系统的非事务性更新,写入 Apache Kafka,发送通知),在业务流结束后,这些副作用至少需要执行语义一次。
为在一个或多个实体之间实现事务的一致性协调,我们通过网关提供了高级 API。API 可以是触发器或者查询。触发器允许对一个或多个实体进行事务更新,而查询则允许调用者读取一个或多个实体的状态。
这个网关使用两个主要组件来实现触发器和 API:业务交易协调器和查询计划器。业务交易协调器以实体触发器的有向无环图为输入,并通过图中代表单个实体触发器在单个读写事务范围内协调。查询计划器负责提供实体与他们的关系之间的读取访问,具有不同程度的一致性。
ORM 层
ORM 层提供了一个抽象层,用于事务管理、实体访问和实体-实体关系管理的数据库结构。
本系列的第三篇文章将详细介绍这些组件,它们组成了一个应用框架,以及如何让超过 100 名工程师在一个新架构中构建产品流。
挑战和经验
技术决策:我们必须作出数以百计的复杂程度和影响各异的决策。必须确立正确的决策框架、决策者、评估的维度、以及风险和缓解的策略。
沟通:整个项目历时 2 年,涉及 30 多个团队和数百名开发人员。为利益相关者设计沟通策略,以保持势头非常重要。牵头人每天都会同步处理升级、阻塞和优先级的决定。整个团队每周会面,分享更新、演示和深入研究来保持动力。
作为一个团队工作:在一个稳定的时期,每个团队都会有自己的项目,但通过这个项目,我们让 Flufillment 团队取消了许多项目的优先级,像一个大型虚拟团队一起工作。随着项目需求的变化,我们创建了一些目标和优先级不同的虚拟团队结构。这种敏捷性和一致性帮助我们保持正确的方向。
迁移和兼容性前期设计:新建平台没有迁移现有 API 调用者、离线事件消费者和实时数据的负担。Fulfillment 域拥有数以百计的 API 调用者,数以千计的离线数据消费者,以及超过一百万的在线消费者,随时随地都能进行连续的旅行和会议。制定迁移策略是非常具有挑战性的,包括前向或后向兼容性、架构和工具、影子测试和负载测试。在本系列的第四篇文章中,我们将介绍我们如何实现实时流量迁移。
测试和缓解未知的未知因素:我们为 200 多个产品流建立了新的、端到端的集成测试,并在现有栈上进行了回测,以验证新栈相对于旧栈的正确性。为了比较新旧栈之间的请求和响应,我们构建了一个复杂的基于会话的影子框架。在开发过程中,我们创建了测试城市,以匹配生产城市的配置,进行烟雾测试。所有这些策略都可以确保我们能够尽可能地发现问题,并且减少未知的因素。
作者介绍:
Ashwin Neerabail,Uber 软件工程师/架构师,在设计和开发大型任务关键性平台和基础设施服务方面具有丰富经验。自从加入 Uber 以来,在过去两年中,领导了下一代 Flufillment Platform 的开发。
Ankit Srivastava,Uber 高级工程师,领导并致力于构建可覆盖全球数百万的 Uber 用户的软件开发项目。领导了 Uber Flufillment Platform 的重构。他的兴趣包括构建分布式系统和可扩展性框架,以及为复杂的业务工作流指定测试策略。
Kamran Massoudi,Uber 工程师,为实现 Flufillment Platform 的技术愿景做出了贡献,并领导了多个项目。他是发起和领导 Fulfillment Platform 重构的技术负责人之一。
Madan Thangavelu,Uber 工程总监。在过去 7 年来,见证并促成了 Uber 令人兴奋的高速增长阶段。花了 4 年时间领导 Uber 的 API 网关和流媒体平台团队。目前是 Uber Flufillment Platform 的工程负责人。
Uday Kiran Medisetty,Uber 首席工程师,领导、引导并推动了 Uber 主要的实时平台项目。
原文链接:
https://eng.uber.com/fulfillment-platform-rearchitecture/
关联阅读:
评论