多年来,PostgreSQL 一直是驱动 ChatGPT 和 OpenAI API 等核心产品的重要底层数据系统之一。随着我们用户基数的快速增长,对数据库的需求也呈指数级增长。在过去的一年中,我们的 PostgreSQL 负载增长了 10 倍以上,并且仍在快速上升。
我们为推进生产基础设施以维持这一增长所做的努力揭示了一个新的见解:PostgreSQL 可以被扩展以可靠地支持比许多人之前认为可能的更大规模的读密集型工作负载。该系统(最初由加州大学伯克利分校的一组科学家创建)使我们能够通过单个 Azure PostgreSQL 灵活服务器实例和分布在多个全球地区的近 50 个读副本来支持巨大的全球流量。这是我们在 OpenAI 中通过严格的优化和坚实的工程扩展 PostgreSQL 以支持每秒数百万次查询和 8 亿用户的故事;我们还将涵盖我们在过程中学到的关键要点。
我们初始设计的缺陷
ChatGPT 推出后,流量增长速度达到了前所未有的水平。为了应对这一增长,我们迅速在应用程序和 PostgreSQL 数据库层进行了大规模的优化,通过增加实例规模来实现规模扩张,并通过添加更多读副本来实现横向扩展。这种架构长期以来一直为我们服务得很好。随着持续的改进,它将继续为未来的增长提供充足的发展空间。
这听起来可能有些令人惊讶,即单一主架构能够满足 OpenAI 所需的规模要求;然而,在实际操作中实现这一点并非易事。我们已经目睹了由 Postgres 过载引发的多个服务中断事件,这些事件通常遵循相同的模式:上游问题导致数据库负载突然激增,例如由于缓存层故障引起的广泛缓存缺失、昂贵的多路连接激增使 CPU 资源饱和,或者新功能推出时的写入洪流。随着资源利用率的上升,查询延迟增加,请求开始超时。然后重试操作进一步增加了负载,从而触发了一个恶性循环,有可能损害整个 ChatGPT 和 API 服务。
尽管 PostgreSQL 在处理我们以读取为主的任务时具有良好的扩展性,但在高写入流量的时期我们仍会遇到一些问题。这主要是由于 PostgreSQL 的多版本并发控制(MVCC)实现方式所致,这种实现方式对于写入密集型任务来说效率较低。例如,当查询更新一个元组甚至单个字段时,整个行都会被复制以创建一个新的版本。在高写入负载下,这会导致显著的写入放大现象。此外,还会增加读取放大现象,因为查询必须遍历多个元组版本(已失效的元组)以获取最新的版本。MVCC 还带来了其他挑战,如表和索引膨胀、增加的索引维护开销以及复杂的自动清理调优(您可以在我和卡内基梅隆大学的安迪·帕夫洛教授共同撰写的博客《我们最讨厌的 PostgreSQL 部分》(在新窗口中打开)中找到关于这些问题的深入探讨(该博客在 PostgreSQL 的维基页面中被引用))。
将 PostgreSQL 扩展到百万级 QPS
为了克服这些限制并减轻写入压力,我们已经并将继续将可分片(即可以水平分区的)高写入量的工作负载迁移到诸如 Azure Cosmos DB 这样的分片系统中,并优化应用程序逻辑以尽量减少不必要的写入操作。我们也不再允许在当前的 PostgreSQL 部署中添加新的表。新的工作负载默认使用分片系统。
尽管我们的基础设施不断发展,但 PostgreSQL 仍保持未分片的状态,只有一个主实例负责处理所有写入操作。其主要原因是,对现有应用程序工作负载进行分片会非常复杂且耗时,需要对数百个应用程序端点进行更改,并且可能需要数月甚至数年的时间。由于我们的工作负载主要是读取密集型的,并且我们已经实施了大量优化措施,因此当前的架构仍然有足够的余地来支持持续的流量增长。虽然我们不排除将来对 PostgreSQL 进行分片,但鉴于我们目前和未来增长所需的足够发展空间,分片并非短期内的优先事项。
在接下来的几个部分中,我们将深入探讨我们所面临的挑战以及我们所实施的大量优化措施,这些措施旨在解决这些问题并防止未来出现服务中断,同时将 PostgreSQL 推到极限,并使其能够处理每秒数百万次的查询(QPS)。
减轻主节点负载
挑战:只有一个写入者时,单主节点配置无法扩展写入操作。大量的写入峰值会迅速使主节点过载,并影响 ChatGPT 和我们的 API 等服务。
解决方案:我们尽可能减少对主服务器的负载(包括读取和写入操作),以确保其有足够的容量来应对写入峰值。读取流量尽可能地转移到副本服务器上。然而,有些读取查询必须留在主服务器上,因为它们是写入事务的一部分。对于这些查询,我们着重确保其高效运行,并避免慢速查询。对于写入流量,我们将可分片、写入密集型的工作负载迁移到诸如 Azure CosmosDB 这样的分片系统中。那些更难以分片但仍会产生大量写入量的工作负载迁移过程则较为漫长,且仍在进行中。我们还大力优化我们的应用程序以减少写入负载;例如,我们修复了导致冗余写入的应用程序错误,并在适当的情况下引入了延迟写入,以平滑流量峰值。此外,在填充表字段时,我们实施严格的速率限制以防止过大的写入压力。
查询优化
挑战:我们在 PostgreSQL 中识别出几个昂贵的查询。在过去,这些查询的突发量会消耗大量 CPU,从而减慢 ChatGPT 和 API 请求的速度。
解决方案:一些昂贵的查询,例如那些连接多个表的查询,可能会显著降低性能甚至导致整个服务崩溃。我们需要持续优化 PostgreSQL 查询,确保它们高效并避免常见的在线事务处理(OLTP)反模式。例如,我们曾经发现一个连接了 12 个表的极其昂贵的查询,该查询的峰值过去曾导致高严重性的 SEVs。我们应该尽量避免复杂的跨表连接。如果必须进行连接,我们学会了考虑将查询分解,并将复杂的连接逻辑移至应用层。许多这些有问题的查询是由对象关系映射框架(ORMs)生成的,因此仔细审查它们生成的 SQL 并确保其按预期行为非常重要。在 PostgreSQL 中还常见长时间处于空闲状态的查询。配置像 idle_in_transaction_session_timeout 这样的超时设置对于防止它们阻塞自动清理(autovacuum)至关重要。
消除单点故障
挑战:如果只读副本宕机,流量仍然可以路由到其他副本。然而,依赖单个写入者意味着存在单点故障——如果它宕机,整个服务都会受到影响。
解决方案:大多数关键请求仅涉及读取查询。为了缓解主服务器中的单点故障问题,我们将这些读取操作从写入器转移到了副本服务器上,确保即使主服务器出现故障,这些请求仍能继续提供服务。虽然写入操作仍会失败,但影响有所减轻;不再属于严重错误情况(SEV0),因为读取操作仍然可用。
为了减少主要故障的发生,我们让主服务器处于高可用性(HA)模式下,并配备一个热备服务器,即一个持续同步的副本,它始终准备接管处理流量的工作。如果主服务器出现故障或需要下线进行维护,我们可以迅速将备用服务器提升为主服务器,以最大程度减少停机时间。Azure PostgreSQL 团队已经做了大量工作,以确保这些故障转移即使在非常高的负载情况下也能保持安全和可靠。为了处理读副本故障,我们在每个区域部署多个副本,并留有足够的容量余量,以确保单个副本故障不会导致整个区域的中断。
工作负载隔离
挑战:我们经常会遇到这样的情况:某些请求在 PostgreSQL 实例上消耗了过多的资源。这可能会导致在同一实例上运行的其他工作负载的性能下降。例如,新功能的推出可能会引入一些效率低下的查询,这些查询会大量占用 PostgreSQL 的 CPU 资源,从而减慢其他关键功能的请求处理速度。
解决方案:为了缓解“邻居干扰”问题,我们将工作负载隔离到专用实例上,以确保资源密集型请求的突发峰值不会影响其他流量。具体来说,我们将请求分为低优先级和高优先级两个等级,并将它们路由到不同的实例。这样,即使低优先级的工作负载变得资源密集型,也不会降低高优先级请求的性能。我们也在不同的产品和服务中应用相同的策略,以确保一个产品中的活动不会影响另一个产品的性能或可靠性。
“邻居干扰”(Noisy Neighbor)问题指的是:某个应用或查询消耗了过多的共享资源(CPU、内存、磁盘 I/O、网络带宽),导致同一数据库或同一服务器上的其他应用性能下降。
连接池
挑战:每个实例都有一个最大连接限制(Azure PostgreSQL 中为 5,000)。很容易耗尽连接或积累过多空闲连接。我们之前曾因连接风暴导致所有可用连接被耗尽而引发事件。
解决方案:我们部署了 PgBouncer 作为代理层来池化数据库连接。以声明式或事务池化模式运行它,使我们能够高效地重用连接,大大减少了活动客户端连接的数量。这也降低了连接建立延迟:在我们的基准测试中,平均连接时间从 50 毫秒(ms)降至 5 ms。跨区域连接和请求可能很昂贵,因此我们将代理、客户端和副本放置在同一区域,以最小化网络开销和连接使用时间。此外,PgBouncer 必须仔细配置。像空闲超时这样的设置对于防止连接耗尽至关重要。
每个读副本都有自己的 Kubernetes 部署,运行多个 PgBouncer 实例。我们在同一个 Kubernetes 服务后面运行多个 Kubernetes Service,这些部署在实例之间进行流量负载均衡。
缓存
挑战:缓存未命中的突然激增会触发 PostgreSQL 数据库的读取量激增,导致 CPU 饱和并减缓用户请求。
解决方案:为了减轻 PostgreSQL 的读取压力,我们使用缓存层来处理大部分的读取流量。然而,当缓存命中率意外下降时,缓存未命中量的突然增加可能会将大量请求直接推送到 PostgreSQL。这种数据库读取量的突然增加会消耗大量资源,从而降低服务速度。为了在缓存未命中风暴期间防止过载,我们实施了缓存锁定(和租赁)机制,以便只有针对特定键未命中缓存的单个读取者从 PostgreSQL 获取数据。当多个请求针对同一缓存键未命中时,只有一个请求获取锁并继续检索数据并重新填充缓存。其他所有请求则等待缓存更新,而不是同时全部访问 PostgreSQL。这显著减少了冗余的数据库读取,并保护系统免受连续的负载峰值冲击。
扩展读副本
挑战:主要流将 Write Ahead Log (WAL) 数据写入每个读副本。随着副本数量的增加,主节点必须将 WAL 发送到更多实例,从而增加了网络带宽和 CPU 的压力。这导致副本延迟更高且更不稳定,使系统更难可靠地扩展。
解决方案:我们在多个地理区域运行近 50 个读副本以最小化延迟。然而,在当前架构下,主副本必须将 WAL 流式传输到每个副本。尽管目前它能够很好地扩展到非常大的实例类型和高网络带宽,但我们不能无限期地增加副本而不最终使主副本过载。为此,我们正在与 Azure PostgreSQL 团队合作开发级联复制,其中中间副本将 WAL 转发到下游副本。这种方法使我们能够扩展到可能超过一百个副本,而不会使主副本过载。然而,它也引入了额外的运维复杂性,特别是在故障转移管理方面。该功能仍在测试中;我们将确保它在投入生产之前是稳健的,并且能够安全地进行故障转移。
速率限制
挑战:特定端点的突发流量、昂贵查询的激增或重试风暴会迅速耗尽 CPU、I/O 和连接等关键资源,从而导致服务全面退化。
解决方案:我们在多个层级(应用层、连接池器、代理层和查询层)实施了速率限制,以防止突发流量峰值压垮数据库实例并引发级联故障。同时,避免过短的重试间隔也非常关键,因为这可能引发重试风暴。我们还增强了 ORM 层,以支持速率限制,并在必要时完全阻止特定的查询摘要。这种有针对性的负载卸载方式能够从昂贵查询的突发中快速恢复。
Schema 管理
挑战:即使是微小的模式更改,例如修改列类型,也可能触发整个表的重新写入。因此,我们谨慎地应用模式更改——仅限于轻量级操作,并避免任何会重写整个表的更改。
解决方案:仅允许进行轻量级的模式更改,例如添加或删除不会触发完整表重写的某些列。我们对模式更改实施严格的 5 秒超时限制。允许并发创建和删除索引。模式更改仅限于现有表。如果新功能需要额外的表,它们必须在替代的分区系统(如 Azure CosmosDB)中,而不是在 PostgreSQL 中。在填充表字段时,我们应用严格的速率限制以防止写入峰值。尽管这个过程有时需要超过一周,但它确保了稳定性并避免了任何生产影响。
结果与未来的道路
这项工作证明,通过正确的设计和优化,Azure PostgreSQL 可以扩展以处理最大的生产工作负载。PostgreSQL 为读密集型工作负载处理数百万的 QPS,为 OpenAI 最关键的产品(如 ChatGPT 和 API 平台)提供支持。我们增加了近 50 个读副本,同时保持复制延迟接近零,在地理分布的区域中维持低延迟读取,并构建了足够的容量余量以支持未来的增长。
这种扩展方式在最小化延迟和提高可靠性的同时仍然有效。我们始终在生产环境中提供低个位数毫秒级的 p99 客户端延迟和五个九的可用性。在过去 12 个月中,我们仅发生了一次 SEV-0 PostgreSQL 事件(该事件发生在 ChatGPT ImageGen 病毒式发布期间,当时写入流量突然激增超过 10 倍,一周内超过 1 亿新用户注册)。
虽然我们对 PostgreSQL 带来的成果感到满意,但我们仍在不断挑战其极限,以确保我们有足够的运行空间来支持未来的增长。我们已经将可分片的写入密集型工作负载迁移到我们的分片系统,如 CosmosDB。剩余的写入密集型工作负载分片更具挑战性——我们也在积极地将这些工作负载迁移,以进一步将写入负载从 PostgreSQL 主节点卸载。我们还与 Azure 合作,以启用级联复制,以便我们可以安全地扩展到更多的读副本。
展望未来,我们将继续探索更多方法以进一步扩展,包括分片的 PostgreSQL 或其他分布式系统,随着我们基础设施需求的不断增长。