快速扩展在线存储以服务超过 10 亿 ChatGPT 用户(第二部分)
了解 OpenAI 如何通过可靠的多租户、全球缓存和 Azure Cosmos DB 扩展 Habitat,为 ChatGPT 提供快 12 倍的读取速度。
所有 OpenAI 产品都需要快速、可靠的数据访问能力,而这由我们的存储平台 Habitat 提供。它必须在毫秒级时间内响应分布在 40 个地理区域的逾 10 亿用户。我们的基础设施面临着前所未有的挑战:既要支持爆发式增长和新的使用场景,又要持续提高可靠性、性能和产品开发速度。要解决这一挑战,我们必须重新思考在线存储平台的设计和运营方式。我们不断评估各种取舍,始终遵循同一个顺序:先做到足够可靠,支撑关键产品流量;再做到足够快,满足全球用户需求;最后实现海量规模下的高效运营。
本系列分为两部分,介绍我们如何将 Habitat 从一个简单的客户端库和数据库,发展为承载超过 500 PB 数据的全球分布式存储平台。第一部分重点介绍了 Habitat 从 Python 客户端演变为服务的过程。其中还介绍了一些技术,让我们能够将基于 Python 的服务推向远超许多人认为可行的规模。
在本文,也就是第二部分中,我们将介绍如何扩展底层存储组件 Azure Cosmos DB。通过与 Azure 团队紧密合作,我们最终形成了一套可行的多租户方案:共同管理容器,在全球海量规模下实现出色的读取性能。
图 01 · 什么是 Habitat?
在线存储平台
Habitat 是我们构建的在线存储平台,让 OpenAI 产品能够快速、可靠地访问所需信息。
- 请求
- 响应
- 变更(CDC)
让多租户架构在大规模下可靠运行
随着公司规模扩大,基础设施团队通常会从运行单租户系统逐步转向多租户系统。在单租户架构中,各产品模块团队分别维护自己的系统。这能缩小故障影响范围,明确职责边界,并轻松实现工作负载隔离。但随着团队和产品模块增多,这种架构的运营成本会变得高昂。单租户架构要求每个产品团队都负责容量规划,往往会导致资源严重超额预配。
相比之下,多租户架构将管理权交给统一的数据库团队,由他们凭借更丰富的专业知识和经验进行资源预配。但多租户架构也有自身的问题,例如噪声邻居问题:某个租户占用异常高比例的资源,损害其他租户的利益。
在设计 Habitat 时,我们必须权衡所有这些因素,才能构建一个可靠、高效且支持大规模运行的存储系统。如今,Habitat 是众多 OpenAI 产品的多租户数据库层。但我们为此走了一段迂回的路:先是使用一个多租户 Azure Cosmos DB 容器,随后为各租户预配独立的后端容器,最后又回到默认使用多租户容器的方式。Habitat 的多租户建设仍处于很早期的阶段:虽然我们支持众多使用场景,但通过尽可能利用 Azure Cosmos DB 的内置原语,我们得以推迟在这一领域投入更多资源。
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 时经常遇到一个问题:流量极高的组织或热点键产生的大量请求,也就是单个租户产生的不成比例的高负载,可能拖垮整个数据库。我们为此投入了大量协作精力,缓存这些键,并在缓存未命中的处理流程中加入租约机制,避免缓存未命中产生的负载冲垮底层数据库。
Figure 02a · Logical partition key limits
Different clients, the same partition key
噪声邻居
虽然文档中规定的数据大小和吞吐量限制针对的是逻辑分区键,但实际执行这些限制的是物理分区。在实践中,这意味着逻辑分区无法达到 10,000 RU/s。它们必须与同一物理分区上的其他所有逻辑分区键共享该物理分区的配额。
因此,噪声邻居可能影响许多互不相关的逻辑分区键:处理小众产品功能数据的工作负载,甚至会干扰核心的登录和注册流程。
Figure 02b · Noisy neighbors
Different users, the same physical partition
弱分区
单个物理分区的吞吐量上限也会变化,取决于分配给 Azure Cosmos DB 容器的总吞吐量。容器的预配吞吐量大致均匀地分布在其各个物理分区上。
随着数据量增长,物理分区会拆分,使分区总数增加,但预配吞吐量总额保持不变。结果是,数据量越大,每个分区的实际吞吐量上限就越低。
对于我们存储需求最大、类似 Blob 存储的使用场景,这会造成一种故障模式:即使请求速率和使用情况保持稳定,仅存储量增长就会降低活跃工作负载可用的吞吐量容量。
Figure 02C · Weak partitions
The limit falls below demand
A partition splits when it reaches its storage limit. Each child receives half the throughput capacity.
第二阶段:尝试工作负载隔离与单租户架构
使用独立容器,有助于部分解决这些分区键限制和噪声邻居问题。每个独立容器都可以分配自己的物理分区,因此,只有同一容器内的不同工作负载才可能互为噪声邻居,或在分区键空间上发生重叠。缺点是,预配新容器并非没有开销:每个容器都需要单独进行容量规划。因此,我们希望仅在合理的服务或使用场景边界上创建容器。
我们开始为需要强隔离的最关键新场景使用独立的 Azure Cosmos DB 容器。同时,我们通过脚本根据各容器的物理分区数量同步调整其预配吞吐量总额,以治理弱分区。结合这两种做法,我们基本避开了当时面临的所有问题。
但由于我们最初使用的是默认容器,许多既有的关键场景在未迁移数据的情况下,不得不继续留在其中。为了保护仍依赖共享默认容器中数据的关键服务,我们经常建议各团队为新服务或重大产品功能创建独立容器。随着规模扩大,默认容器承受的压力不断增加,生产故障也随之增多。我们开始为所有新服务或大型产品功能预配容器,只为避免默认容器承受更多负载。
这让我们避免了许多全站故障,将影响范围限制在个别功能或服务的降级与中断。然而,预配新容器的开销最终还是成为了问题:
总体负载增长不再平稳
在原来的共享默认容器中,某个新场景从 0 QPS 增长到 100 万 QPS,通常不会显著改变总用量。但在独立容器中,新场景从 0 增长到 100 万 QPS,相比仅供内部试用时的基线,负载可能增加到 1,000 倍。
采用专用容器后,团队需要记得在产品上线前,按照预期吞吐量提前预配容器。产品团队经常将功能发布到生产环境,却忘了提前扩容,于是上线当天,我们就得待在严重故障应急室里,临时尝试扩容容器。
平滑总体负载是多租户系统混合承载工作负载的一项核心优势,而这种容器策略为了获得更好的隔离性,牺牲了这一优势。
Figure 03 · Lack of stable aggregate growth
The same workload, different headroom
1K RU/s during load testing → 1M RU/s at launch
容量规划
随着时间推移,要监控专用容器的使用情况,也需要深入了解产品,知道流量何时会转移或增长。
我们可以提供一个基础的控制平面脚本,根据增长趋势提高各容器的预配吞吐量。但由于总体负载增长不再平稳,要正确扩容,实际上需要 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 账户存储着数十 PB 的数据,承载超过 2,000 万 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 元数据,以保护元数据分区;通过连接池和更高的连接复用率,减轻大量 Python habitat-service 进程带来的压力;尽可能将工作负载部署在同一位置;并通过不同的 VPC 将关键工作负载与离线工作负载隔离。
过去一年,这种深度的工程协作对保障我们 Azure Cosmos DB 的稳定性至关重要。任何规模足够大或足够关键的系统,都离不开这种团队协作:打破职责壁垒,跨越组织边界开展工作。
提升全球范围内的性能
正如第一部分所述,我们的核心理念是:先让它能用,再让它快,最后让它省。可靠性优先,其次是性能,最后是效率。Habitat 已成为 OpenAI 的关键基础设施,因此首要任务是确保其可靠性。2025 年,随着系统逐渐稳定、OpenAI 的业务向全球拓展,延迟成为用户和产品团队面临的下一个瓶颈。
计算和存储资源往往位于不同区域,因此跨区域请求不可避免。
Figure 04 · Improving performance at a global scale
Global traffic
Compute and storage capacity were often located in different regions, making cross-region requests unavoidable.
Compute
StorageIllustrative routes · Longer journeys take more time
计算和存储资源的地理位置仅作示意。
数据存储位置和请求处理位置的不同,会导致延迟存在很大差异。为了提高 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 降低代理开销
我们还用性能优于原有 Python 实现的 Rust 重写了代码,消除 Habitat 服务自身的开销,从而显著降低延迟。
这些改进共同将全球整体读取延迟的 p50 从约 120 毫秒降至约 10 毫秒。自今年早些时候开始专注于性能优化以来,读取速度已提升至原来的 12 倍。
Figure 05 · Improving performance at a global scale
p50 latency for reads
Three major design changes improved read latency
虽然缓存大幅降低了延迟中位数,但最慢的请求仍需从底层存储获取未缓存的数据,而且往往要跨越相距遥远的区域。随着更多容量投入使用、存储效率不断提升,我们计划解决一个更根本的问题:从一开始,就将数据放在更靠近访问来源的位置。
扩展变更数据捕获能力
可靠且可扩展的 CDC 能将数据库变更传播到下游系统,其关键作用并不局限于 Habitat 的缓存策略。每秒从数千个容器、数百万个容器分区捕获的数百万条变更,经由一百多个 Kafka 主题传递,还支撑着最终一致的二级索引、数据迁移流水线、离线分析及其他实时产品功能。随着底层基础设施向全球扩展,监听数据行变更也变得越来越复杂。如果客户端希望注册一个回调,在其数据类型的行发生变更时运行,就必须先确定数据位于哪些 Cosmos 账户和容器中、相应区域中哪个 CDC 服务部署正在监听这些容器,还要为 CDC 服务工作进程集群、Kafka 及其消费者正确配置容量。这还只是需要完成的部分工作。我们的 CDC 平台旨在对产品开发者屏蔽这一切,只提供简单的接口:客户端注册一个函数,在对应数据行变更时调用,而所有底层连接和扩容工作都由我们负责。
内部控制平面会持续确保所有必要容器的变更都被捕获,选择要读取的 Cosmos 区域,并在分布于多个地理区域的数千个工作进程之间高效分配任务。通过使用副本区域,并降低更改源读取请求的优先级,我们保护了对延迟敏感的 Habitat 流量,使其免受后台捕获工作负载的影响。在如此庞大的集群中,工作进程崩溃、容器问题、租约存储错误、无效配置和目标端故障会不断发生。我们增强了 CDC 工作进程生命周期和租约处理的稳健性,隔离不同数据流之间的故障,并为配置变更和发布部署建立了防护机制。我们使用死信队列,防止永久性投递失败导致队头阻塞。发生区域故障时,我们通过将读取器切换到其他 Cosmos 区域来维持运行。在我们这样的规模下,甚至还需要修改 Azure 的 CDC SDK:在内存中批量处理检查点,仅定期刷写,以提高检查点机制的效率。
虽然 Habitat 内部使用 Azure Cosmos DB 的方式很复杂,但其核心价值始终是向产品开发者屏蔽基础设施细节,以提升产品开发速度。我们的 CDC 平台也遵循同样的理念。
展望未来
通过有意精简存储 API 的范围、持续扩展 Python 服务,并优先解决最紧迫的工程挑战,一个小团队便得以支撑快速增长的存储平台。我们先保障关键产品流量的可靠性,再优化全球用户的读取性能,最后提升大规模运行下的运营效率。这种方法既支持了快速增长和新场景,也提升了可靠性、性能和产品开发速度。
要扩展 Habitat 和对话存储,为下一个 10 亿用户提供服务,我们需要解决在更大规模的基础设施中复制数据的难题。为提前应对这一挑战,我们正在自研一套定制的 EB 级在线存储系统,专门针对 Habitat 中体积较大的数据类型进行优化。这套仍在开发中的系统,将 Rockset 的 RocksDB Cloud 核心与基于 Raft 的复制机制相结合。它实现了与 Azure Cosmos DB 相同的文档模型,但采用解耦存储和物理复制,以较低的峰值 QPS 为代价,减少所需的闪存容量。
如果你有兴趣在前沿规模下开展这类数据基础设施工作,欢迎查看我们的在招职位。