当数据库从支撑数万用户跃升至数百万甚至 800 万量级时,单一数据库实例的处理能力终将成为整个系统的瓶颈。PostgreSQL 凭借其成熟的生态和可靠的事务机制,成为众多高增长应用的首选数据库,但如何将其扩展能力推向极限,需要系统性地规划水平扩展路径。本文将从读写分离架构出发,深入分析分片策略的适用场景与工程代价,并给出规模化过程中的锁管理实践。
读写分离架构:从单节点到近零延迟复制
读写分离是 PostgreSQL 水平扩展的第一步,也是最符合直觉的扩容路径。其核心思想是将读请求分流到只读副本,而写请求仍然路由到主节点。这种架构在读多写少的场景下尤为有效,理论上可以通过增加只读副本的数量实现近乎无限的水平扩展能力。
在实际工程中,读写分离的关键挑战在于复制延迟的控制。异步复制模式下,主节点将事务日志(WAL)发送给副本节点存在固有的时间差,这个延迟从毫秒级到秒级不等,取决于网络条件和副本节点的负载状态。对于大多数业务场景,极短的复制延迟(如 100 毫秒以内)是可以接受的,但某些强一致性场景(如金融交易、库存扣减)则需要额外的补偿机制。同步复制虽然能够保证主从数据的一致性,但会显著增加事务提交延迟,因为主节点必须等待至少一个副本确认写入后才能返回。因此,在选择复制模式时,需要在数据一致性和系统性能之间做出明确的权衡。
OpenAI 在支持 800 万 ChatGPT 用户时采用了近 50 个只读副本的架构,并将复制延迟控制在接近零的水平。这一数据表明,通过合理的工程优化,PostgreSQL 的读写分离架构能够支撑大规模的只读请求压力。实现这一目标的关键在于副本节点的资源配置、网络拓扑的优化,以及对复制链路的持续监控。当任何一个副本节点因为 CPU 或内存使用率激增而出现性能下降时,它就成为了落后节点(stragglers),可能反过来拖慢主节点的 WAL 发送效率。因此,监控落后节点并及时将其从负载均衡池中移除,是保障整体系统稳定性的重要实践。
分片策略:何时拆分以及如何拆分
当只读扩展无法满足业务需求,或者写入压力成为系统瓶颈时,分片(Sharding)就成为了必须面对的选项。分片的核心是将数据按照某种规则(如用户 ID 哈希、地理位置、时间范围)分布到多个独立的数据库节点上,每个节点只负责一部分数据的读写。理论上,分片可以线性地扩展系统的写入能力和存储容量,但同时也带来了显著的工程复杂度。
在决定是否进行分片之前,必须充分评估分片的必要性和成本。根据 OpenAI 工程团队的公开讨论,他们选择避免对现有工作负载进行分片,主要原因是分片对现有应用代码的侵入性太强。从逻辑模型到物理模型的转变需要修改大量的 SQL 查询语句、事务边界设定以及应用层的路由逻辑。更重要的是,分片后的跨分片查询(如 JOIN、聚合)性能会急剧下降,很多在单节点上简单的操作在分片环境下需要多次查询应用层进行结果合并,这不仅增加了延迟,还大幅提升了系统复杂度。
如果业务场景确实需要分片,那么分片键(Shard Key)的选择是首要考虑的因素。理想的分片键应该满足两个条件:数据分布均匀,避免热点;查询路由单一,大部分查询能够定位到单个分片而无需跨分片操作。常见的分片策略包括范围分片(按时间区间划分)、哈希分片(按 ID 哈希值取模)和目录分片(通过查找表映射)。PostgreSQL 原生支持的表分区功能可以看作是轻量级的分片实现,它允许在单节点内部分区数据,改善大表的查询性能。对于跨节点的分片需求,通常需要借助中间件(如 Citus、pgEdge)或者应用层路由来实现。
Schema 变更的锁管理:可用性与变更效率的平衡
随着数据库规模的扩大,Schema 变更成为了一个高风险操作。在大表上添加索引、修改列类型或者增加约束,都可能需要获取激进的锁(AccessExclusiveLock),这些锁会阻塞所有的读写请求,导致服务中断数秒甚至数分钟。如何在保障可用性的同时高效地完成 Schema 变更,是规模化团队必须解决的工程问题。
一个有效的策略是在 Schema 变更期间主动取消冲突的工作负载,而不是被动等待锁竞争。具体而言,可以部署一个并行的监控脚本,当检测到 Schema 变更语句正在等待获取锁时,主动终止占用锁的事务(通过 pg_terminate_backend)。这种方法的代价是少数长事务会被中断重来,但换取的是 Schema 变更的快速完成。对于生产环境,建议在业务低峰期执行 Schema 变更,并提前通知可能受影响的用户。另一个值得考虑的做法是使用 PostgreSQL 的并发索引创建功能(CREATE INDEX CONCURRENTLY),它允许在不阻塞写入的情况下创建索引,虽然耗时更长,但对线上服务的影响更小。
规模化环境下的 Schema 变更还需要考虑回滚策略。在应用新版本之前,应该准备好能够回滚到旧 Schema 的方案,包括保留旧版本代码、准备回滚脚本,以及在数据库中记录 Schema 版本号。对于关键的表结构变更,建议先在测试环境验证变更对现有查询的影响,特别是那些依赖隐式列顺序或数据类型的查询。某些 ORM 框架对 Schema 变更的兼容性较好,而另一些则可能因为缓存的元数据信息而过时,需要在应用启动时强制刷新。
规模化实践的参数配置建议
将 PostgreSQL 从开发环境迁移到生产级规模时,内存相关参数的配置对性能有着决定性影响。shared_buffers 参数控制 PostgreSQL 用于缓存数据页的内存大小,通常建议设置为系统可用内存的 25% 左右,但需要根据实际工作负载进行调整。对于读密集型场景,可以适当提高这个比例;对于写密集型场景,则需要为 WAL 缓冲和后台进程留出更多空间。
work_mem 参数影响每个排序、哈希连接或哈希聚合操作的内存分配。这个参数设置过低会导致大量磁盘溢出,严重拖慢查询性能;设置过高则可能在高并发场景下耗尽系统内存。一个常用的做法是将 work_mem 设置为单个查询预期的峰值内存除以预期的并发查询数。对于分析型查询,可以考虑在会话级别临时调高这个值;而对于 OLTP 短查询,保持较低的默认值可能更为安全。
在水平扩展场景下,连接池的配置同样关键。PgBouncer 是目前最广泛使用的 PostgreSQL 连接池工具,它能够复用数据库连接,显著降低主节点的连接压力。配置 PgBouncer 时需要注意事务模式与会话模式的权衡:事务模式将连接绑定到单个事务,连接复用效率更高,但不支持会话级别的预处理语句;会话模式保持连接打开更长时间,适合需要使用预处理语句或临时表的场景。对于大多数 Web 应用,事务模式是最合适的选择。
资料来源
本文核心工程实践参考自 Hacker News 上关于「Scaling PostgreSQL to power 800M ChatGPT users」的工程讨论,以及 PostgreSQL 官方文档中关于复制、锁管理和性能调优的技术说明。