在线AI教程 / AI入门
[OpenAI] 快速扩展在线存储,服务超过 10 亿 ChatGPT 用户(第二部分)
2026 年 10 月 23 日 工程 快速扩展在线存储,服务超过 10 亿 ChatGPT 用户(第二部分) 我们如何在应用存储平台 Habitat 中优化 Azure Cosm
2026 年 10 月 23 日
工程
快速扩展在线存储,服务超过 10 亿 ChatGPT 用户(第二部分)
我们如何在应用存储平台 Habitat 中优化 Azure Cosmos DB,以应对前所未有的增长。
作者:Jon Lee、Hrachya Hakobyan、Chaomin Yu,均为 Member of Technical Staff
所有 OpenAI 产品都需要快速、可靠地访问数据,而数据由我们的存储平台 Habitat 提供。它必须在毫秒级响应分布在 40 个地理区域的超过 10 亿用户。我们的基础设施曾面临一项非同寻常的挑战:在支撑爆发式增长和新用例的同时,持续改善可靠性、性能以及产品开发者的迭代速度。要解决这一挑战,就必须重新思考在线存储平台的设计与运营方式。我们不断评估各种取舍,遵循这样的路径:先做到对关键任务产品流量足够可靠,再做到对全球用户足够快,最后做到能在大规模下从容运营。
这个分为两部分的系列文章,讲述我们如何把 Habitat 从一个简单的客户端库和数据库,发展为服务超过 500 PB 数据的全球分布式存储平台。第一部分 聚焦于 Habitat 从 Python 客户端演进为一项服务的过程,也介绍了让我们得以把基于 Python 的服务推进到远超许多人认为可行之程度的技术手段。
在本文(第二部分)中,我们将梳理如何扩展底层存储组件:Azure Cosmos DB。通过与 Azure 团队的紧密合作,我们找到了一条可行的多租户路径——共同管理容器(co-managed containers),并在大规模、全球化场景下取得了出色的读取性能。
图 01 · 什么是 Habitat?
在线存储平台
Habitat 是我们构建的在线存储平台,让 OpenAI 产品能够快速、可靠地访问所需信息。
请求 响应 变更(CDC)
客户端
在线存储平台
存储资源
ChatGPT API Codex 内部服务 以及更多
Habitat
缓存(Caches) 授权(ACL policies) 放置与数据驻留(Data residency) 加密(数据安全) 隔离(多租户) 限流(请求整形) 路由(Schema lookup · 数据驻留)
Azure Cosmos DB(在线存储) Nanobase(在线存储) Valkey(缓存) Blob storage(存储资源)
CDC 服务(Change Data Capture)
Databricks Rockset Kafka 以及更多
让多租户在大规模下可靠
随着公司规模扩大,基础设施团队往往会从运行单租户系统演进到多租户系统。在单租户模式下,每个产品界面团队各自维护自己的系统,这能缩小故障影响范围(blast radius),并提供清晰的所有权边界和工作负载隔离。但随着团队与产品界面数量增长,这种模式的运行成本会变得高昂。单租户使每个产品团队都要负责容量规划,并常常导致严重的过度预置。
反过来,多租户把控制权交给一个集中的数据库团队,他们具备更好的资源预置知识与经验。但多租户也有自身的问题,例如吵闹的邻居(noisy-neighbor)问题:某个租户消耗了异常大份额的资源,从而损害其他租户。
在 Habitat 中,我们必须权衡所有这些取舍,才能设计出在大规模下可靠、高效的存储系统。今天,Habitat 是为许多 OpenAI 产品服务的多租户数据库层,但我们走过了一段循环往复的旅程:从使用一个多租户 Azure Cosmos DB 容器,到预置单租户的后端容器,再到默认回归多租户容器。我们在 Habitat 上的多租户之路仍处于很早的阶段:我们支撑了大量用例,但一直靠着尽可能复用 Azure Cosmos DB 的内置原语(primitive),把这块投资往后推。
Azure Cosmos DB 容器简化入门
在梳理我们的 Azure Cosmos DB 历程之前,先交代一些基本背景:
Azure Cosmos DB 账户定义了一组容器以及一套复制策略 每个容器都会预置一定量的吞吐量,单位为每秒请求单位(RU/s) 每个容器内的数据按典型的"以指定逻辑分区键分片"的方式存储,且每个逻辑分区键的存储与吞吐量上限都有明确文档规定 Azure Cosmos DB 会把逻辑分区键分片到更少的底层物理分区上;当存储超过阈值,或容器需要更多物理分区来维持预置吞吐量时,这些物理分区会被拆分成多个
第一阶段:依赖 Azure Cosmos DB 的原语
最初,我们对任何客户端负载都放行,依靠每个逻辑分区键标注的 10K RU/s 上限来提供背压。起步时,我们在每个账户里也只使用一个默认的 Azure Cosmos DB 容器。这勉强够用,但后来证明存在几个问题:
逻辑分区键限制
由于早期 API 的设计选择,我们把逻辑分区键的选择直接暴露给了客户端。结果,在不同产品用例之间,常用的分区键(例如 user_id 或 workspace_id)频繁发生冲突。由于这些用例共享 Azure Cosmos DB 在同一逻辑分区键上的配额,这一设计限制了单个用户可获得的总吞吐量。
在迁移到 Habitat 和 Azure Cosmos DB 之前,我们扩展 Postgres 时常遇到一个问题:来自某个极热组织或热键的大量请求——某个租户产生了不成比例的高负载——足以拖垮整个数据库。我们投入了大量集体精力去缓存这些键,并在缓存未命中时引入租约(lease),好让由此产生的缓存未命中负载不会踩垮底层数据库。
图 02a · 逻辑分区键限制
不同的客户端,相同的分区键
吵闹的邻居
虽然文档中规定的数据大小与吞吐量上限是针对逻辑分区键的,但真正执行限制的其实是物理分区。在实践中,这意味着逻辑分区无法达到 10,000 RU/s;相反,它们要与同一物理分区上共置的所有其他逻辑分区键共享该物理分区的配额。
这使得吵闹的邻居能够影响许多本不相关的逻辑分区键——运行小众产品功能数据的工作负载,会打乱核心的登录与注册流程。
图 02b · 吵闹的邻居
不同的用户,同一个物理分区
弱分区
单个物理分区的吞吐量上限也不是固定的,它取决于分配给该 Azure Cosmos DB 容器的总吞吐量。容器的预置吞吐量会大致均匀地分摊到该容器的各个物理分区上。
随着数据量增长,物理分区会分裂,分区总数增加,而预置总吞吐量保持不变。结果是:数据规模越大,每个分区的有效吞吐量上限就越小。
对于我们那些存储量最大、类似 blob 的用例,这会形成一种故障模式:即使请求速率和使用量保持稳定,仅仅是存储增长就会降低活跃工作负载可用的吞吐量。
图 02C · 弱分区
限制降到需求之下
分区在达到存储上限时会发生分裂。每个子分区获得一半的吞吐量容量。
拆分前:10K RU/s 容量
拆分后:5K RU/s 容量 × 2 个分区
第二阶段:尝试工作负载隔离与单租户
独立的容器能部分解决这些分区键限制和吵闹的邻居问题。使用独立容器后,每个容器都可以拥有自己独立的物理分区,只有该容器自身那些波动的工作负载才可能成为吵闹的邻居,或在分区键空间上相互重叠。不利之处在于,预置新容器存在非零开销——每个容器都需要单独做容量规划——所以我们希望只在有意义的服务或用例边界上创建容器。
我们开始为那些需要强隔离的最关键新用例使用独立的 Azure Cosmos DB 容器。再配合一个通过让每个容器预置的总吞吐量与其物理分区数保持同步来管理弱分区的脚本,我们基本避开了当时面临的所有问题。
然而,由于我们最初是从一个默认容器起步的,许多遗留的关键用例在没有做数据迁移的情况下,不幸被留在了那里。为了保护仍然依赖共享默认容器中数据的关键服务,我们常常建议团队为新服务或重大产品功能创建独立容器。随着规模扩大,默认容器承受的压力越来越大,生产事故也越来越多。为了不让默认容器承受更多负载,我们开始为任何新服务或大型产品功能都预置容器。
这让我们避免了许多全站级故障,把影响范围限制在功能或服务降级这类故障上。但预置新容器的开销最终还是找上了我们:
缺乏稳定的总量增长
在旧的共享默认容器里,任何一个新用例从 0 QPS 涨到 100 万 QPS,都不太可能显著改变总的聚合使用量。但在一个隔离容器上,新用例从 0 涨到 100 万 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 团队紧密合作,以共享租户(shared-tenancy)的设计,从工程上修掉了我们最初的多租户问题:
热键限流
优先对最热的键限流,可以防止吵闹的邻居耗尽某个物理分区的全部可用吞吐量。这削弱了吵闹的邻居效应——单个热键就能造成全站范围的影响。
服务质量(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 账户存储着数十 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 拥有直接的工程对接渠道,甚至打通了彼此直接呼叫(page)而无需经过支持层级的途径。
过去一年中我们开展了大量联合工作,以改善 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 中集成了一个分布式旁路(lookaside)缓存层。它把最近访问过的对象和边(edge)的副本存放在 Valkey 服务器中,这些服务器部署在我们基础设施里靠近用户请求处理位置的地方。通常,这类缓存使用 TTL,在命中率与陈旧度之间做取舍。较短的 TTL 降低陈旧度,但会削弱缓存的正面效果,因为条目会很快从缓存中过期,必须更频繁地重新获取。较长的 TTL 提高命中率,但会在较长时间内返回陈旧数据,导致糟糕的用户体验和不可预测的产品行为。
另一种做法是:当应用对同一份底层数据执行写操作时,执行显式的失效(invalidation)操作。这能显著提升缓存数据的新鲜度,但很难做对。它仍然会受到许多边缘情况的影响,例如部分失败会让缓存停留在陈旧状态。多区域部署会让这些问题叠加:跨区域网络不那么可靠,缓存还会与底层存储的复制过程相互竞争。再用 TTL 加以补充可以给最大陈旧度设一个上界,但仍然受制于上面讨论的同一个 TTL 底层取舍。
我们在失效处理上采取了更先进的方案,沿袭了 Meta 的 TAO 等系统的思路。Habitat 的集成缓存依赖通过 Azure Cosmos DB 变更源(change feed)实现的变更数据捕获(CDC),在数秒内把失效操作传播到全球,从而在保持低陈旧度的同时实现高命中率。我们基础设施中的一个服务消费该变更源,并在底层数据更新时对本地缓存执行失效。这会让下次读取该数据时发生未命中,随后缓存会被填充为最新版本的数据。
用 Rust 降低代理开销
我们还通过用 Rust 重写代码消除了 Habitat 服务自身的开销,从而显著降低了延迟——Rust 比原来的 Python 性能更好。
这些改动合在一起,把我们的整体全球读取 p50 从约 120 ms 降到约 10 ms,自今年早些时候开始专注性能以来提升了 12 倍。
图 05 · 在全球范围内提升性能
读取的 p50 延迟
三项重大设计变更改善了读取延迟
约 120 ms → 约 10 ms
提升 12 倍
关键数据复制到附近的地理区域 区域内缓存上线 Rust 重写上线
虽然缓存已大幅降低中位延迟,但最慢的请求仍然需要从底层存储获取未缓存的数据,而且往往是跨遥远区域。随着更多容量上线、存储效率提升,我们计划解决一个更根本的挑战:从一开始就把数据放在更靠近访问发生地的地方。
扩展变更数据捕获
可靠且可扩展的 CDC——把数据库变更传播到下游系统——其重要性远不止于 Habitat 的缓存策略。每秒数百万条捕获的变更,横跨数千个容器和数百万个容器分区,经一百多个 Kafka topic 流转,同时还支撑最终一致的二级索引、数据移动管道、离线分析以及其他实时产品功能。随着我们的底层基础设施在全球铺开,监听行变更变得越来越复杂。如果某个客户端想注册一个回调,在它那种类型的行发生变更时运行,那么除其他事项外,它必须先确定哪些 Cosmos 账户和容器存放着它的数据、哪些 CDC 服务部署正运行在相应区域并监听那些容器,还要正确扩展 CDC 服务的工作进程集群、Kafka 以及它自己的消费者。我们的 CDC 平台旨在把这一切从产品开发者那里抽象掉,转而提供一个简单的接口:客户端注册一个希望在被变更的行上调用的函数,其余的管道与扩展都由我们处理。
一个内部控制平面持续确保所有必需的容器都在被捕获,选择从哪些 Cosmos 区域读取,并在跨多个地理区域的数千个工作进程之间高效分配工作。使用副本区域和较低优先级的源读取,可以保护对延迟敏感的 Habitat 流量免受后台捕获工作负载的影响。在如此庞大的集群中,工作进程崩溃、容器问题、租约存储错误、无效配置和目的地故障会不断发生。我们加固了 CDC 工作进程的生命周期与租约处理,隔离了不同流之间的故障,并为配置变更和上线构建了防护措施。我们利用死信队列(dead letter queue)来防止永久性投递失败造成的队头阻塞。我们通过把读取方切换到备用 Cosmos 区域来容忍区域故障。在我们的规模下,我们甚至不得不修改 Azure 的 CDC SDK,通过在内存中批量累积检查点、仅定期刷写,使其检查点实现更高效。
尽管 Habitat 内部对 Azure Cosmos DB 的使用相当复杂,但它的价值主张始终是为产品开发者抽象掉基础设施细节,以提升产品开发速度——我们的 CDC 平台也不例外。
展望未来
一支小团队之所以能支撑一个快速增长的存储平台,靠的是刻意保持存储 API 的狭窄边界、持续扩展 Python 服务,以及优先处理最紧迫的工程挑战。我们首先关注关键任务产品流量的可靠性,然后关注全球用户的读取性能,最后关注大规模下的运营效率。这一做法在支撑快速增长和新用例的同时,改善了可靠性、性能和产品开发速度。
为了给下一个十亿用户扩展 Habitat 和会话存储,我们需要在更大规模上解决跨基础设施复制数据的难题。为提前应对,我们一直在构建一个自研的 EB 级在线存储系统,针对 Habitat 中体积庞大的数据类型做了优化。这个仍在开发中的系统把来自 Rockset 的 RocksDB Cloud 内核与基于 Raft 的复制结合起来。它实现与 Azure Cosmos DB 相同的文档模型,但采用分离式存储(disaggregated storage)与物理复制,以减少所需的闪存容量,代价是更低的峰值 qps。
如果你有兴趣在前沿规模上从事这类数据基础设施工作,欢迎查看我们的空缺职位-seattle/)。
作者