OpenAI来源发布时间

快速扩展在线存储以服务超过 10 亿 ChatGPT 用户(第二部分)

OpenAI 的所有产品都需要快速、可靠的数据访问,这由我们的存储平台 Habitat 提供。它必须以毫秒级响应服务横跨 40 个地理区域的超过 10 亿用户。

OpenAI 的所有产品都需要快速、可靠的数据访问,这由我们的存储平台 Habitat 提供。它必须以毫秒级响应服务横跨 40 个地理区域的超过 10 亿用户。我们的基础设施面临一项非同寻常的挑战:在持续提升可靠性、性能和产品开发速度的同时,支持爆炸式增长和新用例。应对这一挑战需要我们重新思考在线存储平台的设计和运营方式。我们不断权衡取舍,遵循这样的模式:先达到足以支撑关键任务产品流量的可靠性,再达到足以服务全球用户的速度,最后实现在超大规模下的灵活运营。

这个分为两部分的系列文章将讲述我们如何将 Habitat 从一个简单的客户端库和数据库发展为服务超过 500 PB 数据的全球分布式存储平台。 第一部分 聚焦 Habitat 从 Python 客户端到服务的演进过程,并介绍了让我们把基于 Python 的服务推送到远超许多人认为实际可行范围之外的技术。

在这篇文章(第二部分)中,我们将介绍如何扩展底层存储组件:Azure Cosmos DB。通过与 Azure 团队的紧密合作,我们找到了一种可行的共管容器多租户方案,在全球超大规模下实现了出色的读取性能。

图 01 · 什么是 Habitat?

在线存储平台

Habitat 是我们构建的在线存储平台,使 OpenAI 产品能够快速、可靠地访问所需信息。

  • 请求
  • 响应
  • 变更(CDC)

客户端

在线存储平台

存储资源

  • ChatGPT
  • API
  • Codex
  • 内部服务
  • 以及更多

Habitat

  • 缓存缓存
  • ACL 策略授权
  • 放置与数据驻留数据驻留
  • 加密数据安全
  • 隔离多租户
  • 限流请求整形
  • 路由模式查找 · 数据驻留
  • Azure Cosmos DB在线存储
  • Nanobase在线存储
  • ValkeyCaches
  • Blob 存储存储资源
CDC 服务更改数据捕获
  • Databricks
  • Rockset
  • Kafka
  • 以及更多

让多租户在规模化下保持可靠

随着公司规模的扩大,基础设施团队通常会从运行单租户系统演进到多租户系统。单租户是指每个产品界面团队维护自己的系统,这可以减少影响范围,并提供清晰的所有权边界和工作负载隔离。随着团队和产品界面的增长,这种模式的运行成本变得高昂。单租户使每个产品团队都要负责容量规划,并且常常导致严重的过度配置。

相反,多租户将控制权交给一个集中式数据库团队,该团队在资源配置方面拥有更好的知识和经验。但多租户也会产生自身的问题,例如嘈杂邻居问题,即某个租户占用了异常大的资源份额,从而损害其他租户的利益。

在 Habitat 中,我们必须权衡所有这些取舍,以设计一个可靠、高效、可扩展的存储系统。如今,Habitat 是许多 OpenAI 产品的多租户数据库层,但我们经历了一个循环的历程:从使用一个多租户的 Azure Cosmos DB 容器,到为每个租户配置单租户的后备容器,再到默认恢复使用多租户容器。我们在 Habitat 上的多租户之路仍处于非常早期的阶段:我们支持大量用例,但通过尽可能利用 Azure Cosmos DB 的内置原语,我们得以推迟在这一领域的投入。

Azure Cosmos DB 容器简介

在介绍我们的 Azure Cosmos DB 历程之前,先提供一些基本背景:

  • 一个 Azure Cosmos DB 账户定义了一组容器和一个复制策略

  • 每个容器按请求单位/秒(RU/s)配置一定的吞吐量

  • 每个容器内的数据以典型的按指定逻辑分区键分片的方式存储,并对每个逻辑分区键的存储和吞吐量有明确的限制

  • Azure Cosmos DB 负责将逻辑分区键分片到较少的后备物理分区上,当存储超过阈值或容器需要更多物理分区以维持已配置的吞吐量时,会将它们拆分为多个物理分区

阶段 I:依赖 Azure Cosmos DB 原语

我们最初允许任何客户端负载通过,依赖每个逻辑分区键 10K RU/s 的既定限制来提供背压。最初起步时,我们在每个账户中也只使用一个默认的 Azure Cosmos DB 容器。这运行得还算不错,但由于以下几个原因被证明存在问题:

逻辑分区键限制

由于早期 API 的设计选择,我们将逻辑分区键的选择直接暴露给客户端。因此,在不同产品用例中,我们经常在常用的分区键上发生冲突,例如 user_id 或 workspace_id。由于这些用例共享相同逻辑分区键上的 Azure Cosmos DB 限制,该设计限制了给定用户的整体吞吐量。

在我们迁移到 Habitat 和 Azure Cosmos DB 之前,扩展 Postgres 时遇到的一个常见问题是,来自一个非常热门的组织或热门键(即某个租户产生了不成比例的高负载)的大量请求可能会拖垮整个数据库。我们花了大量的集体精力来缓存这些键,并在缓存未命中时加入租约机制,以防止由此产生的缓存未命中负载冲击底层数据库。

图 02a · 逻辑分区键限制

不同的客户端,相同的分区键

嘈杂邻居

虽然文档中记载的数据大小和吞吐量限制适用于逻辑分区键,但限制实际上是在物理分区上执行的。在实践中,这意味着逻辑分区无法达到 10,000 RU/s。相反,它们与位于同一物理分区上的所有其他逻辑分区键共享该物理分区的限制。

这使得嘈杂邻居能够影响许多不相关的逻辑分区键,例如针对小众产品功能的数据进行操作的工作负载会干扰核心的登录和注册流程。

图 02b · 嘈杂邻居

不同的用户,相同的物理分区

弱分区

给定物理分区的吞吐量限制也是可变的,取决于分配给 Azure Cosmos DB 容器的总吞吐量。容器已配置的吞吐量大致均匀地分布在该容器的各个物理分区上。

随着数据量的增长,物理分区会拆分,在总配置吞吐量保持不变的情况下增加总分区数。其结果是,每个分区的有效吞吐量限制随着数据规模的增大而缩小。

对于我们存储最密集、类似 blob 的使用场景,这会造成一种失效模式:即便请求速率和使用量保持稳定,仅存储增长本身就会降低活跃工作负载的吞吐能力。

图 02C · 弱分区

限额低于需求

当分区达到其存储上限时会分裂。每个子分区获得一半的吞吐量容量。

拆分前:10K RU/s 容量

拆分后:5K RU/s 容量 × 2 个分区

第二阶段:尝试工作负载隔离与单租户

独立容器有助于部分解决这些分区键限制和嘈杂邻居问题。通过使用独立容器,每个容器都可以分配到各自独立的物理分区,只有该容器本身上变化的工作负载才可能成为嘈杂邻居或在分区键空间中重叠。缺点是,预配新容器存在非零开销——每个容器都需要单独的容量规划——因此我们只在服务或有意义的用例边界处创建容器。

我们开始为需要强隔离的最关键新用例使用独立的 Azure Cosmos DB 容器。结合一个通过将每个容器预配的总吞吐量与其物理分区数同步来管理弱分区的脚本,我们基本避免了之前面临的所有问题。

然而,由于我们最初使用的是默认容器,许多遗留的关键用例在没有任何数据迁移的情况下不幸被困在其中。为了保护那些仍然依赖共享默认容器中数据的关键服务,我们常常建议团队为新的服务或主要产品功能创建单独的容器。随着规模扩大,默认容器的压力越来越大,生产事故也随之增多。我们开始为任何新服务或大型产品功能配置容器,只是为了保护默认容器免受更多负载。

这使我们避免了许多全站范围的宕机,将影响范围限制在功能或服务降级级别的故障。然而,配置新容器的开销最终还是赶上了我们:

缺乏稳定的总增长

在旧的共享默认容器中,任何一个特定的新用例从 0 QPS 增长到 1 million QPS 都不太可能显著改变总体的聚合使用量。然而在隔离容器上,一个从 0 增长到 1 million QPS 的新用例所代表的负载可能是仅由内部 Dogfooding 产生的基线的 1,000 倍。

使用专用容器后,团队现在需要记住在发布前预先配置容器,以达到预期的吞吐量水平。产品团队常常将功能上线到生产环境后却忘记预先扩容,于是我们只能在发布当天坐在SEV会议室里,在最后一刻试图扩容容器。

这是多租户系统混合核心负载的一项优势,而我们为了在这种容器策略下获得更好的隔离性而放弃了它。

图 03 · 缺乏稳定的总量增长

同样的工作负载,不同的余量

负载测试期间 1K RU/s → 发布时 1M RU/s

共享容器

100M RU/s 容量 · 80% 已使用

负载测试

1K20M=

0.005%

的余量

发布

1M20M=

5%

的余量

新建专用容器

10K RU/s 容量 · 无其他工作负载

负载测试

1K10K=

10%

的余量

发布

1M10K=

10,000%

的余量

容量规划

随着时间的推移,监控专用容器的使用情况还需要对流量何时会转移或增长有深入的产品知识。

我们可以提供一个基础的控制平面脚本,根据增长趋势扩展每个容器的预配置吞吐量,但由于缺乏稳定的整体增长,正确地进行这种扩展实际上使 OpenAI 的每个产品团队都不得不参与容量规划。

这在组织上未能扩展,各服务所属团队对其 Azure Cosmos DB 配置的重视程度差异很大。

第三阶段:默认使用多租户,并具备数据迁移能力

随着组织的发展壮大,这些运营问题变得越来越难以扩展。由于没有一个中央团队统一做规划,导致许多团队的规划都很糟糕。因此,我们最终转回了多租户解决方案,并与 Azure 团队密切合作,通过共享租户设计来修复我们最初的多租户问题:

热键限流

首先对最热的键进行限流,可以防止“嘈杂邻居”消耗给定物理分区的全部可用吞吐量。这减少了“嘈杂邻居”效应,即一个热键就可能造成全站范围的影响。

服务质量(QoS)

请求分层 QoS 同样可以防止离线工作负载(如批量插入或数据提取)占用给定物理分区的全部吞吐量。通过为这些工作负载的请求分配较低优先级,它们会首先被限流,从而防止服务于最终用户的高优先级交互式请求被限流。

无服务器模式

Azure Cosmos DB 此前已有一种无服务器模式,本可以解决我们预配模式下的弱分区问题。然而,它有一个重大缺点:它仅根据存储使用量进行伸缩,并将每个物理分区的 RU 使用量限制在 5K RU/s。这能解决我们的弱分区问题,但会阻碍我们扩展 RU 密集型用例。

Azure Cosmos DB 团队为我们构建的新的改进版无服务器模式允许每个物理分区扩展到 10K RU/s,并在基于存储使用量的拆分之外引入了基于 RU 使用量的拆分,使我们不再需要手动监控和扩展容器。

有了这些改进,我们得以重新使用单个共享容器,这极大地简化了产品团队进行前期或持续容量规划的需求,同时保留了工作负载之间的隔离。此后的几个月里,尽管产品开发不断加速,我们再没有发生过任何预配或嘈杂邻居事故。

作为共同托管服务获得更好的可靠性

当公司发展到 OpenAI 这样的规模时,托管服务就变得不那么“托管”了。许多工作负载会成为云服务商基础设施中的异常值。随着 OpenAI 成为 Azure Cosmos DB 最大的用户之一,成功运营它需要一种不同的模式:共享测试、共享仪表板、直接的工程沟通渠道,以及对只在我们这种规模下才会出现的故障模式的共同所有权。如今,我们最大的 Azure Cosmos DB 账户存储了 10s PB 的数据,并服务超过 20M QPS。据我们所知,我们是唯一一家在此规模上部署 Azure Cosmos DB 的公司。

2024 年末,一次区域性中断清楚地说明了这种协作的必要性。当时,一次电力中断袭击了我们最大的一个 Azure Cosmos DB 账户的主 Azure 区域。在随后的故障转移演练中,我们意识到我们最大的账户需要数小时才能故障转移到次要区域,这是我们团队和 Azure Cosmos DB 团队都没有在我们这种规模下测试过的。

大约一个月后,在将这些账户故障转移回原始区域时,我们触发了另一次严重中断。涌向原始区域的连接风暴使原始区域的 Azure Cosmos DB 网关不堪重负。网关模式的 Azure Cosmos DB 客户端处理故障转移时的一个特性导致所有客户端立即建立连接并向原始区域发送请求,尽管当时只有一部分物理分区已移回该区域。

在每次中断中,OpenAI 和 Azure 都执行了各自的事件管理流程,确定了多个根本原因并部署了修复措施。但这些中断是我们团队与 Azure Cosmos DB 团队在运营和测试方式之间更大系统性问题的症状。起初,利用托管服务让我们能够快速行动并专注于业务目标。而到了一定规模,托管服务变得不再那么“托管”,需要在从设计到运营的各个层面与服务提供商深度集成。

如今,Habitat 和 Azure 工程团队共同监控 Habitat 的每一层,从我们的服务层一直到 Azure Cosmos DB 的物理存储层。我们针对一个生产规模但非生产的 Azure Cosmos DB 账户运营和测试变更。我们共同测试和迭代 SDK,并确定我们如何扩展和设定 Azure Cosmos DB 账户大小的最佳实践。我们通过 Teams 拥有直接的工程联系渠道,甚至开发了无需经过支持层级即可直接呼叫对方的方式。

过去一年进行了大量联合工作,以提升 Azure Cosmos DB 控制平面和网关的可扩展性,包括添加负载保护并在其网关中缓存各种 Azure Cosmos DB 元数据以保护 Azure Cosmos DB 元数据分区,通过连接池和更高的连接复用来减少我们庞大的 Python habitat-service 进程群带来的压力,在可能的情况下共置工作负载,以及通过不同的 VPC 将关键工作负载与离线工作负载隔离。

这种程度的协同工程对我们过去一年 Azure Cosmos DB 的稳定性至关重要。任何足够庞大或关键的系统都需要这种团队合作:打破所有权壁垒,跨越组织边界进行运营。

在全球规模上提升性能

正如第一部分所述,我们的核心理念是:先让它能用,再让它够快,再让它够便宜。先保证可靠性,再追求性能,然后是效率。Habitat 已成为 OpenAI 的关键基础设施,因此首要任务是让它可靠。随着系统在 2025 年趋于稳定,以及 OpenAI 在全球范围内扩张,延迟成为用户和产品团队面临的下一个制约因素。

计算与存储容量通常位于不同的区域,使跨区域请求在所难免。

图 04 · 在全球范围内提升性能

全球流量

计算与存储容量通常位于不同的区域,使跨区域请求在所难免。

计算存储图示路由 · 路程越长,耗时越多

计算与存储的地理位置仅作示意。

延迟因数据存储位置和处理请求的位置不同而有很大差异。为了让 Habitat 请求具备良好性能,产品开发者不得不依赖额外的客户端缓存、请求批处理、尽量减少数据库读写,以及尽可能将工作移出关键路径。这固然催生了一些对数据库问题更具韧性的有趣产品设计,但也让产品更复杂、拖慢了产品迭代:这与 Habitat 鼓励快速产品开发、无需理解数据库的目标背道而驰。

这是我们在 2026 年的重点工作之一,我们制定了一套分层策略来优化读取性能——读取占 Habitat 请求量的 90% 以上:

在容量允许时将数据存储在靠近用户的地方

我们在全球不同的 Azure 区域中拥有多个 Azure Cosmos DB 账户,以满足有数据驻留要求的用户。即使对没有严格数据驻留要求的用户,将数据放置在靠近用户的位置也能通过更低延迟改善其体验。

在实践中,除数据驻留场景外,我们无法广泛做到这一点,因为数据放置受物理云容量的约束。由于我们的数据集规模庞大,许多区域无法容纳与其用户流量份额相称的巨大存储占用。

在数据中心容量、电力或散热受限的区域,我们有时面临直接取舍:是将容量留给同地部署的存储以降低 Habitat 延迟,还是将容量用于额外的服务计算并把存储请求路由到更远的地方。

因此,放置决策往往是在以下两者之间做产品取舍:A) 针对不同产品和用户子集优化延迟,以及 B) 原始的存储与计算容量。

仅对最小且对延迟最敏感的数据集进行广泛复制

对于对延迟最关键的工作负载,例如每个请求都需要的身份验证元数据,我们通过使用具有多个次要区域的高复制 Azure Cosmos DB 账户来减少计算与 Habitat 之间的 RTT。一些账户复制到我们提供服务的全部 40 个 Azure 区域;另一些账户则复制到每个大洲至少一个区域。

我们不得不比预期更早地在 Habitat 中构建一个透明的数据迁移与路由层,以便在不同复制拓扑的账户之间移动数据集。

然而,这种方法最终也受容量限制,因此仅限于少数经过手工挑选的数据集。

其余一切依赖集成缓存

Habitat 的工作负载以读取为主,应用程序不断获取设置、状态和其他属性;不成比例的流量来自相对较小的、由频繁访问类型组成的工作集。这使得该系统特别适合分布式缓存。虽然缓存在单区域基础设施中能提升性能,但在跨区域场景下效果会更加显著,可用于“隐藏”跨区域延迟。

我们在 Habitat 中集成了一个分布式旁路缓存层。它将最近访问的对象和边的一份副本存储在 Valkey 服务器上,这些服务器部署在我们的基础设施中靠近处理用户请求的位置。通常,这类缓存使用 TTL 在命中率与陈旧度之间做权衡。较短的 TTL 可以减少陈旧度,但会削弱缓存的正面效果,因为条目很快过期,必须更频繁地重新获取。较长的 TTL 能提高命中率,但会在较长时间内提供陈旧数据,导致糟糕的用户体验和不可预测的产品行为。

另一种方法是在应用程序对同一底层数据执行写操作时执行显式的失效操作。这能显著提高缓存数据的新鲜度,但很难做对。它还会受到许多边缘情况的影响,例如部分失败会导致缓存保持陈旧。多区域部署会加剧这些问题:跨区域网络可靠性更低,而且缓存会与底层存储的复制产生竞争。辅以 TTL 可以限定最大陈旧度,但仍然存在上文讨论的同样的 TTL 权衡。

我们采取了更先进的失效方案,沿袭了 Meta 的 TAO 等系统的脚步。Habitat 的集成缓存依靠通过 Azure Cosmos DB 的更改馈送实现的变更数据捕获(CDC),在数秒内将失效信息传播到全球,在保持低陈旧度的同时实现高命中率。我们基础设施中的一个服务消费这个更改馈送,并在底层数据更新时对本地缓存执行失效操作。这会导致下一次读取该数据时发生未命中,随后缓存被最新版本的数据填充。

用 Rust 降低代理开销

我们还通过用 Rust 重写代码(其性能优于原来的 Python),显著降低了 Habitat 服务本身的开销,从而进一步降低延迟。

这些更改加在一起,使我们的全球读取 p50 从大约 120 毫秒降低到大约 10 毫秒,自今年早些时候我们开始关注性能以来提升了 12 倍。

图 05 · 在全球范围内提升性能

读取的 p50 延迟

三项重大设计更改改善了读取延迟

大约 120 毫秒 → 大约 10 毫秒

12 倍提升

  1. 关键数据复制到附近的地理区域
  2. 区域内缓存上线
  3. Rust 重写上线

虽然缓存已大幅降低了中位延迟,但最慢的请求仍需要从底层存储获取未缓存的数据,且往往要跨越遥远的区域。随着更多容量上线以及我们提高存储效率,我们计划解决一个更根本的挑战:从一开始就把数据放置在更接近其被访问的位置。

扩展变更数据捕获

可靠且可扩展的 CDC——将数据库变更传播到下游系统——不仅是 Habitat 缓存策略的关键组成部分。每秒数百万条捕获的变更,跨越数千个容器和数百万个容器分区,流经一百多个 Kafka 主题,同时还为最终一致的二级索引、数据移动管道、离线分析以及其他实时产品功能提供支持。随着我们的底层基础设施在全球范围内增长,监听行变更变得越来越复杂。如果客户端想要注册一个回调,在其类型的行发生变更时运行,他们必须首先确定哪些 Cosmos 账户和容器包含他们的数据,各地区哪些 CDC 服务部署正在监听这些容器,并正确扩展 CDC 服务工作节点集群、Kafka 及其消费者。我们的 CDC 平台旨在为产品开发者抽象掉所有这些内容,转而提供一个简单的接口:客户端注册一个希望在其变更行上调用的函数,而我们处理所有的管道和扩展工作。

一个内部控制平面持续确保所有必需的容器都被捕获,选择从哪些 Cosmos 区域读取,并在跨多个地理区域的数千个工作节点之间高效分配工作。使用副本区域和较低优先级的源读取可以保护对延迟敏感的 Habitat 流量免受后台捕获工作负载的影响。在如此庞大的集群中,工作节点崩溃、容器问题、租约存储错误、无效配置和目标故障不断发生。我们强化了 CDC 工作节点生命周期和租约处理,隔离了流之间的故障,并为配置更改和发布构建了安全保障措施。我们利用死信队列来防止永久性投递失败造成队头阻塞。我们通过将读取器切换到备用 Cosmos 区域来容忍区域性中断。在我们的规模下,我们甚至不得不修改 Azure 的 CDC SDK,通过在内存中批量处理检查点并仅定期刷新它们,使其检查点实现更加高效。

虽然 Habitat 对 Azure Cosmos DB 的内部使用很复杂,但其价值主张一直是向产品开发者抽象掉基础设施细节以提高产品开发速度,我们的 CDC 平台也不例外。

展望未来

一个小团队之所以能够支持一个快速增长的存储平台,是因为将存储 API 有意保持得足够窄、持续扩展 Python 服务,并优先处理最紧迫的工程挑战。我们首先专注于关键任务产品流量的可靠性,然后是全球用户的读取性能,最后是大规模下的运营效率。这种方法在提高可靠性、性能和产品开发速度的同时,实现了快速增长和新用例。

为了在下一个十亿用户规模下扩展 Habitat 和对话存储,我们需要解决在更大规模下跨基础设施复制数据的难题。为提前应对,我们一直在构建一个定制的内部 EB 级在线存储系统,专门针对庞大的 Habitat 数据类型进行优化。这个正在开发中的系统结合了来自 Rockset 的 RocksDB Cloud 核心与基于 Raft 的复制。它实现了与 Azure Cosmos DB 相同的文档模型,但使用分离式存储和物理复制来减少所需的闪存容量,作为交换,峰值 qps 会更低。

如果你有兴趣参与这类前沿规模的数据基础设施工作, 请查看我们的开放职位⁠.

原始出处

OpenAI

内容说明

原始发布及相关权利归来源方。

机器翻译 · 请以原文为准