扩展 PostgreSQL 以支持 8 亿 ChatGPT 用户

多年来,PostgreSQL 一直是支撑 ChatGPT 和 OpenAI API 等核心产品的关键底层数据系统之一。随着用户群的快速增长,数据库的需求也呈指数级增长。过去一年,PostgreSQL 的负载增长了十倍以上,并且仍在快速攀升。

为了应对这种增长,我们不断改进生产基础设施,并在此过程中发现了一个新的问题:PostgreSQL 可以扩展,可靠地支持比许多人之前想象的要大得多的读取密集型工作负载。该系统(最初由加州大学伯克利分校的一个科学家团队创建)使我们能够仅使用一个主 Azure PostgreSQL 灵活服务器实例和分布在全球多个区域的近 50 个只读副本,就支持海量的全球流量。本文将讲述 OpenAI 如何通过严格的优化和扎实的工程设计,扩展 PostgreSQL 以支持每秒数百万次的查询,服务于 8 亿用户;我们还将分享在此过程中获得的关键经验。

我们最初设计存在的缺陷

ChatGPT 上线后,流量以前所未有的速度增长。为了应对这一增长,我们迅速在应用层和 PostgreSQL 数据库层进行了广泛的优化,通过增加实例规模实现了向上扩展,并通过增加只读副本实现了向外扩展。这种架构长期以来运行良好,并且随着持续改进,它将继续为未来的增长提供充足的空间。

单主架构能够满足 OpenAI 如此庞大的规模需求,这听起来或许令人惊讶;然而,实际操作起来却并非易事。我们已经目睹了多个由 Postgres 过载引发的严重事件 (SEV),它们通常遵循相同的模式:上游问题导致数据库负载突然飙升,例如缓存层故障造成的大范围缓存未命中、大量耗时的多路连接操作导致 CPU 饱和,或是新功能发布引发的写入风暴。随着资源利用率的攀升,查询延迟增加,请求开始超时。重试操作会进一步加剧负载,从而引发恶性循环,并有可能导致整个 ChatGPT 和 API 服务的性能下降。

Scaling load diagram

尽管 PostgreSQL 在我们以读取为主的负载下扩展性表现良好,但在高写入流量期间我们仍然面临挑战。这在很大程度上源于 PostgreSQL 的多版本并发控制(MVCC)实现机制,使其在写入密集型工作负载下效率较低。例如,当一个查询更新某一行(tuple)或甚至只是其中单个字段时,系统都会复制整行数据以创建新版本。在高写入负载下,这会导致显著的写放大(write amplification)。同时,它还会加剧读放大(read amplification),因为查询必须扫描多个元组版本(即“死元组”)才能找到最新版本。

MVCC 还带来了其他一系列挑战,例如表和索引膨胀(bloat)、索引维护开销增加,以及复杂的 autovacuum 调优问题。

将 PostgreSQL 扩展到数百万 QPS

为了缓解这些限制并降低写入压力,我们已经将可分片(即可以水平分区)的写入密集型工作负载迁移到 Azure Cosmos DB 等分片系统,并持续进行迁移,同时优化应用程序逻辑以最大限度地减少不必要的写入。此外,我们不再允许向当前的 PostgreSQL 部署中添加新表。新的工作负载默认部署在分片系统中。

即使我们的基础设施不断发展,PostgreSQL 仍然保持未分片状态,所有写入操作都由单个主实例处理。主要原因是,对现有应用程序工作负载进行分片将非常复杂且耗时,需要修改数百个应用程序端点,可能需要数月甚至数年时间。由于我们的工作负载主要以读取为主,并且我们已经实施了大量的优化,因此当前的架构仍然有足够的空间来支持持续的流量增长。虽然我们并未排除未来对 PostgreSQL 进行分片的可能性,但鉴于我们目前和未来的增长空间充足,这并非近期优先事项。

在接下来的章节中,我们将深入探讨我们面临的挑战以及我们为解决这些挑战和防止未来中断而实施的大量优化措施,将 PostgreSQL 推向极限,并将其扩展到每秒数百万次查询 (QPS)。

降低主负载

挑战:由于只有一个写入器,单主节点配置无法扩展写入能力。写入高峰会迅速导致主节点过载,并影响 ChatGPT 和我们的 API 等服务。

解决方案:我们尽可能降低主节点的负载(包括读取和写入),以确保其拥有足够的容量来应对写入高峰。读取流量尽可能卸载到副本。但是,某些读取查询必须保留在主节点上,因为它们是写入事务的一部分。对于这些查询,我们专注于确保其高效运行,避免出现慢查询。对于写入流量,我们已将可分片的、写入密集型工作负载迁移到 Azure Cosmos DB 等分片系统。难以分片但写入量仍然很高的工作负载需要更长时间才能迁移,并且该过程仍在进行中。我们还积极优化应用程序以降低写入负载;例如,我们修复了导致冗余写入的应用程序错误,并在适当情况下引入延迟写入,以平滑流量高峰。此外,在回填表字段时,我们实施严格的速率限制,以防止过大的写入压力。

查询优化

挑战:我们发现 PostgreSQL 中存在一些开销较大的查询。过去,这些查询的请求量突然激增会消耗大量 CPU 资源,导致 ChatGPT 和 API 请求速度下降。

解决方案:一些开销巨大的查询,例如连接多个表的查询,可能会显著降低服务性能,甚至导致整个服务崩溃。我们需要持续优化 PostgreSQL 查询,以确保其高效运行,并避免常见的在线事务处理 (OLTP) 反模式。例如,我们曾经发现一个开销极大的查询,它连接了 12 个表,而该查询的峰值正是导致过去高危 SEV 事件的原因。我们应该尽可能避免复杂的多表连接。如果必须使用连接,我们学会了考虑拆分查询,并将复杂的连接逻辑移至应用层。许多这类问题查询是由对象关系映射框架 (ORM) 生成的,因此仔细审查它们生成的 SQL 并确保其行为符合预期至关重要。在 PostgreSQL 中,长时间运行的空闲查询也很常见。配置诸如 idle_in_transaction_session_timeout 之类的超时机制对于防止它们阻塞自动清理 (autovacuum) 至关重要。

单点故障缓解

挑战:如果只读副本发生故障,流量仍然可以路由到其他副本。但是,依赖单个写入器意味着存在单点故障——如果它发生故障,整个服务都会受到影响。

解决方案:大多数关键请求仅涉及读取查询。为了降低主节点单点故障的风险,我们将这些读取操作从写入节点转移到副本节点,从而确保即使主节点宕机,这些请求也能继续得到处理。虽然写入操作仍然会失败,但影响已经降低;由于读取操作仍然可用,因此不再属于 SEV0 级别。

为了降低主服务器故障的风险,我们采用高可用性 (HA) 模式运行主服务器,并配备热备服务器。热备服务器是一个持续同步的副本,随时准备接管流量服务。如果主服务器宕机或需要离线维护,我们可以快速将备用服务器升级,最大限度地减少停机时间。Azure PostgreSQL 团队投入了大量精力,确保即使在高负载下,这些故障转移机制也能安全可靠地运行。为了应对只读副本故障,我们在每个区域部署了多个副本,并预留了足够的容量,确保单个副本故障不会导致区域性服务中断。

工作负载隔离

挑战:我们经常会遇到某些请求会消耗 PostgreSQL 实例上不成比例的资源的情况。这会导致运行在同一实例上的其他工作负载性能下降。例如,新功能的推出可能会引入低效的查询,这些查询会大量消耗 PostgreSQL 的 CPU 资源,从而减慢其他关键功能的请求速度。

解决方案:为了缓解“嘈杂邻居”问题,我们将工作负载隔离到专用实例上,以确保资源密集型请求的突然激增不会影响其他流量。具体来说,我们将请求分为低优先级和高优先级两层,并分别路由到不同的实例。这样,即使低优先级工作负载资源消耗过大,也不会降低高优先级请求的性能。我们在不同的产品和服务中也应用了相同的策略,从而确保一个产品的活动不会影响其他产品的性能或可靠性。

连接池

挑战:每个实例都有最大连接数限制(Azure PostgreSQL 为 5,000)。很容易出现连接耗尽或闲置连接过多的情况。我们之前就遇到过连接风暴导致所有可用连接都被耗尽的事件。

解决方案:我们部署了 PgBouncer 作为代理层来池化数据库连接。以语句池或事务池模式运行 PgBouncer,可以高效地重用连接,从而大幅减少活跃客户端连接数。这也有助于降低连接建立延迟:在我们的基准测试中,平均连接时间从 50 毫秒 (ms) 降至 5 毫秒。跨区域连接和请求的开销可能很大,因此我们将代理、客户端和副本部署在同一区域,以最大限度地减少网络开销和连接使用时间。此外,PgBouncer 的配置必须非常谨慎。诸如空闲超时之类的设置对于防止连接耗尽至关重要。

postgreSQL proxy diagram

每个读副本都有其独立的 Kubernetes Deployment,运行着多个 PgBouncer Pod。我们将多个 Kubernetes Deployment 放在同一个 Kubernetes Service 之后,由该 Service 在各个 Pod 之间进行流量负载均衡。

缓存

挑战:缓存未命中率的突然飙升可能会引发 PostgreSQL 数据库读取操作的激增,导致 CPU 饱和,从而减慢用户请求速度。

解决方案:为了减轻 PostgreSQL 的读取压力,我们使用了一层缓存来处理大部分读请求。然而,当缓存命中率意外下降时,大量缓存未命中(cache misses)会瞬间将大量请求直接打到 PostgreSQL 上。这种数据库读取量的突发性激增会消耗大量系统资源,导致服务变慢。

为防止在缓存未命中风暴(cache-miss storms)期间数据库过载,我们实现了一种缓存锁(cache locking)机制。具体来说,当多个请求同时未命中同一个缓存键时,只有其中一个请求能够获取锁,并负责从 PostgreSQL 中读取数据、重新填充缓存;其余请求则等待缓存更新完成,而不是全部同时访问数据库。

这一机制显著减少了冗余的数据库读取操作,有效保护系统免受连锁性负载激增的影响。

扩展只读副本

挑战:主库将预写日志(WAL)数据流式传输到每个只读副本。随着副本数量的增加,主库必须向更多实例发送 WAL,这增加了网络带宽和 CPU 的压力。这导致副本延迟更高且更不稳定,使得系统难以可靠地扩展。

解决方案:我们在多个地理区域运营近 50 个只读副本以最小化延迟。然而,根据当前架构,主库必须向每个副本流式传输 WAL。尽管目前使用非常大的实例类型和高网络带宽可以很好地扩展,但我们不能无限地添加副本而不最终使主库过载。为了解决这个问题,我们正在与 Azure PostgreSQL 团队合作开发级联复制功能,其中中间副本将 WAL 转发给下游副本。这种方法使我们能够扩展到可能超过一百个副本而不会使主库不堪重负。然而,这也引入了额外的操作复杂性,特别是在故障转移管理方面。该功能仍在测试中;我们将在推广到生产环境之前确保其稳定并能安全地进行故障转移。

postgreSQL cascading replication diagram

速率限制

挑战:特定端点上的突发流量高峰、昂贵查询的激增或重试风暴可能会迅速耗尽 CPU、I/O 和连接等关键资源,导致大范围的服务降级。

解决方案:我们在多个层面实施了限流——应用层、连接池、代理和查询层面——以防止突发流量高峰压垮数据库实例并引发连锁故障。避免过短的重试间隔也至关重要,因为这可能触发重试风暴。我们还增强了 ORM 层以支持限流,并在必要时完全阻止特定查询摘要。这种有针对性的负载卸载形式能够从昂贵查询的突发激增中快速恢复。

模式管理

挑战:即使是更改列类型这样的小模式变更也可能触发完整的表重写。因此我们谨慎应用模式变更——将其限制为轻量级操作,避免任何需要重写整个表的操作。

解决方案:只允许轻量级模式变更,例如添加或删除不会触发完整表重写的某些列。我们对模式变更执行严格的 5 秒超时限制。允许并发创建和删除索引。模式变更仅限于现有表。如果新功能需要额外的表,它们必须位于其他分片系统中,如 Azure CosmosDB 而非 PostgreSQL。在回填表字段时,我们应用严格的速率限制以防止写入峰值。尽管此过程有时可能需要超过一周时间,但它确保了稳定性并避免了任何生产影响。

结果与未来之路

这项工作表明,通过正确的设计和优化,Azure PostgreSQL 可以扩展以处理最大的生产工作负载。PostgreSQL 为读取密集型工作负载处理数百万 QPS,为 OpenAI 最重要的产品如 ChatGPT 和 API 平台提供支持。我们添加了近 50 个只读副本,同时保持复制延迟接近零,在地理分布区域间维持低延迟读取,并构建了足够的容量余量以支持未来增长。

这种扩展在最小化延迟和提高可靠性的同时发挥作用。我们持续提供客户端侧低两位数毫秒的 p99 延迟和生产环境中的五个九可用性。在过去 12 个月中,我们只有一次 SEV-0 PostgreSQL 事件(发生在 ChatGPT ImageGen 病毒式发布期间,当时由于超过 1 亿新用户在一周内注册,写入流量突然激增超过 10 倍。)

虽然我们对 PostgreSQL 带给我们的发展感到满意,但我们继续推动其极限以确保为未来增长留有足够空间。我们已经将可分片的写入密集型工作负载迁移到了我们的分片系统如 CosmosDB。剩余的写入密集型工作负载更难以分片——我们正在积极迁移这些工作负载,以进一步减轻 PostgreSQL 主库的写入负担。我们还在与 Azure 合作启用级联复制,以便能够安全地扩展到更多只读副本。

展望未来,随着我们的基础设施需求持续增长,我们将继续探索进一步扩展的其他方法,包括分片 PostgreSQL 或替代分布式系统。

参考

本文翻译于 scaling-postgresql

本文阅读量 次, 总访问量 ,总访客数
Built with Hugo .   Theme Stack designed by Jimmy