PostgreSQL扩展实战:从连接池到分库分表支撑8亿用户
发布时间:2026/10/3 3:24:47 锦皓数字建站

1. 先搞清楚 8 亿用户到底意味着什么1.1 把产品指标翻译成数据库指标先说结论8 亿用户这个数字如果直接换算成 PostgreSQL 的“活跃连接数”你会得到一个完全没法用的答案。实际上我们需要拆解的是 DAU、会话频率、消息频率和读写比。拿一个对话类产品做估算8 亿注册/月活用户其中 DAU 假设 30%就是 2.4 亿。每个 DAU 每天平均发起 10 次对话请求每次对话产生 15 次交互包括流式返回、用户追问、工具调用结果记录等。每天总请求量约 36 亿次折合每秒约 42000 QPS。其中 70% 是读拉取历史会话、上下文片段、用户配置30% 是写产生新会话、追加消息、更新用量统计。于是 PostgreSQL 侧的真实压力大约是写 TPS 1.2 万到 1.5 万读 QPS 3 万左右再加上一个非常棘手的问题——每次交互都要写一条或多条“消息记录”写入模式高度顺序化而且还有热点用户。说明一下8 亿不是某个单一集群直接扛的数量级它更多是“容量规划目标”或“压力模型上限”。真正落地的时候没有任何一个理性架构师会让一套 PostgreSQL 去直接接 8 亿用户的全部请求。我们要做的事情是用 PostgreSQL 做核心数据底座把 4 万 QPS 的量级拆到多个实例、多个分片、多个层级里让它看起来像是一个“无限扩展”的数据库。1.2 对话场景的负载特征和传统业务差在哪做电商或 OA 系统的人可能觉得 4 万 QPS 不算什么但如果把这个负载放到 PostgreSQL 上你会发现几个让传统 DBA 头疼的特点第一写放大极其严重。一条用户消息往往要插入多张表会话表、消息表、事实表用于后续训练/评估的日志、用户行为汇总表。一次聊天请求能触发 10 次以上写事务而且必须保证事务一致性。第二读模式不是简单的主键查询。用户打开历史会话通常要按用户 ID 和时间范围拉取几十条消息。如果没有好的索引和分区这条 SQL 能把一个 16 核实例的 CPU 打满。第三流式响应带来的连接时间很长。客户端可能建一条连接后保持几分钟的长连接等待模型流式返回。PG 每个连接有固定内存开销work_mem、临时缓冲区等如果连接数上去内存瞬间就被吃光。所以扩展 PostgreSQL 的第一步不是马上改配置、加索引而是先把“产品负载模型”画出来。连这个都搞不清楚后面所有参数调优都是空中楼阁。我把初期容量估算贴出来具体环境可以按业务调整指标基础值估算公式DAU2.4 亿月活跃 8 亿 × 30%日均请求36 亿DAU × 10 次对话 × 15 次交互/对话峰值 QPS6 万日均 QPS × 1.5 峰值系数峰值写 TPS1.8 万峰值 QPS × 30% 写比例每日新增数据量约 20 TB36 亿请求 × 平均 600 字节关键记录这个 20 TB 看起来很吓人但真正会长期占空间的其实只有消息明细和审计日志。所以后面说到数据模型时核心原则就是把“高频读写数据”和“只增不改的存档数据”彻底分开。2. 第一步扩展连接池和读写分离2.1 别让连接数成为第一个瓶颈PostgreSQL 和 MySQL 不太一样它是进程模型每个连接对应一个 backend process。即使参数调得再好5000 个连接也会消耗大量内存。你可以算一下如果每个 backend 平均占 5 MB 内存实际上加上各类缓存能到 10 MB5000 个连接就是 50 GB。这个账算完任何直接用 max_connections10000 的方案都应该被否掉。标准做法是在前端放 PgBouncer。我建议用 transaction 模式连接只在事务执行期间被占用事务结束就返还给池子10000 个应用并发能压缩到 200~400 个真实 PG 连接在 transaction 模式下不能使用 session 级临时表、SET 局部变量等特性但对话类业务基本用不到这些。我的建议配置假设 16 核 64 GB 的数据库节点[pgbouncer] listen_addr 0.0.0.0 listen_port 6432 auth_type md5 pool_mode transaction max_client_conn 10000 default_pool_size 400 min_pool_size 50 reserve_pool_size 20 reserve_pool_timeout 3 server_idle_timeout 300这里有个容易被忽略的点PgBouncer 的default_pool_size不是越大越好。它代表的是“每个用户/数据库组合”的连接数如果你在 PgBouncer 背后只连一个数据库可以把它当成总连接池大小用如果背后挂了多个库要按库拆分。基于我的经验单机 64 GB 内存跑 PG 16400 个连接是一个比较稳的起点超过 600 时锁竞争就开始显现了。2.2 读写分离先卸掉 70% 的读流量连接池解决的是“连接太多”的问题但 CPU 压力还在主库上。对话场景里 70% 是读操作其中绝大多数历史列表、上下文加载允许秒级延迟完全可以走只读副本。我倾向用 PostgreSQL 内置的流复制streaming replication搭一主两从主库负责所有写事务、强一致读、DDL从库 1负责历史会话、搜索类读请求从库 2负责报表、数据分析和后台任务避免和在线业务抢资源。应用层要做的只有一件事把“读请求”和“写请求”路由到不同的连接地址。对大多数团队来说不需要引入复杂的读写分离中间件直接在 ORM 或 DAO 层做两个数据源即可。流复制配置很简单关键参数如下-- 主库 postgresql.conf wal_level logical max_wal_senders 10 wal_keep_size 4GB max_replication_slots 10 hot_standby on -- 从库 standby.signal primary_conninfo host主库IP port5432 userrepl passwordxxx这里我要提醒一个容易踩的坑很多人在流复制开启后忘了主库还要设置synchronous_commit。如果全链路接受不了任何数据丢失比如用户刚刚发送的那条消息必须立刻出现在历史里可以开synchronous_commit remote_apply并配置至少一个同步从库。代价是每个写事务都要等从库确认峰值写性能会下降 30% 左右。对对话场景来说我建议“关键表强同步、非关键表异步”在 PG 里可以通过ALTER TABLE ... SET LOGGED配合synchronous_standby_names做部分控制复杂归复杂但值得做。3. 分库分表与数据建模扩展能力的真正内核3.1 按什么键拆分用户 ID 还是会话 ID当单实例撑不住时最常见的下一步就是分片。对话类数据最自然的分片键是用户 ID用户的所有会话都落在同一分片天然支持“拉取某个用户历史会话”的查询。如果按会话 ID 分片跨分片查用户会话列表会非常痛苦。分片的两层含义按用户 ID hash 拆库把 8 亿用户分到 64 个逻辑分片再映射到 16 个物理实例按时间拆表把消息表按月分区让旧数据自动脱离在线查询路径。举例用户 ID 为u_12345678可以先计算 hash 值然后hash % 64得到分片号再通过分片号 % 16得到物理实例编号。这个路由规则可以放在应用层也可以放到一个小型路由中间件里。我见过太多团队在没有分片经验的情况下直接引入分布式数据库中间件结果被分布式事务坑得死去活来。如果你的场景主要是“按用户读自己的数据”那应用层 hash 分片 PG 逻辑复制完全够用反而最可控。3.2 分区表把冷热数据分开PostgreSQL 11 开始支持原生声明式分区到 PG 16 已经相当成熟。对 8 亿用户级别的消息表我强烈建议按月分区CREATE TABLE messages ( message_id uuid NOT NULL, user_id text NOT NULL, session_id text NOT NULL, role text NOT NULL, content text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (message_id, user_id, created_at) ) PARTITION BY RANGE (created_at); CREATE TABLE messages_202501 PARTITION OF messages FOR VALUES FROM (2025-01-01) TO (2025-02-01); CREATE TABLE messages_202502 PARTITION OF messages FOR VALUES FROM (2025-02-01) TO (2025-03-01);注意主键必须有分区键created_at。很多人在这地方卡住因为 PG 的分区索引要求唯一约束包含分区键否则建不出来。索引方面最常用的是(user_id, created_at DESC)复合索引支撑“某用户最近会话列表”查询。如果经常按 session_id 查再单独建(session_id, created_at)索引但不要两个索引都无限堆写入会痛。分区最大的好处不只是查询过滤还有运维层面的解放每月初直接把上上个月的分区DETACH归档到冷存储或单独实例在线库体积永远保持在一个可控的范围内。对 8 亿用户来说数据增长是持续的如果没有“归档”机制任何一个数据库都会在半年后被拖垮。3.3 对话内容用 JSONB 还是关系型这是我在社区里被问得最多的问题。我的答案很明确核心消息存关系型字段展开成列展示层和工具调用等杂数据用 JSONB。原因是对话内容需要支持过滤、排序、聚合、按用户维度统计 token 数等。关系型字段让 SQL 简单、索引可控。而一条消息里的元信息模型版本、温度参数、停止原因、测试标记等变化非常快专门建列会被 DDL 搞疯用 JSONB 最合适。CREATE TABLE message_meta ( message_id uuid PRIMARY KEY, meta jsonb NOT NULL DEFAULT {}, updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_message_meta_gin ON message_meta USING gin (meta);JSONB 的 GIN 索引能支持包含查询但别拿 JSONB 字段去做范围查询或排序性能很差。JSONB 是“存储灵活”的手段不是“查询万能”的手段。另外像会话 token 用量这种高频更新的指标不要放在消息表里每次UPDATE。你可以在内存里累计定期批量写入一张session_usage表。任何在线系统只要 UPDATE 频繁就要考虑合并写入PostgreSQL 的 UPDATE 本质是删除旧版本 插入新版本产生 WAL 和 vacuum 压力能避则避。4. 高可用与数据安全能扛得住也得丢得起4.1 Patroni etcd 自动故障转移节点再多如果主库挂了没人接管业务照样中断。PostgreSQL 生态里目前最成熟的高可用方案是 Patroni etcd它通过分布式锁选主能在故障后 30 秒内完成 VIP 漂移和从库提升。部署结构大概是3 个 ETCD 节点组成仲裁集群3 个 PG 节点组成一主两从Patroni 在每个 PG 节点上运行向 ETCD 上报心跳主库故障后Patroni 从从库中选一个提升为新主并把 VIP 漂移到新主应用层通过 VIP 连接无感知切换。配置要点# patroni.yml scope: chat-platform namespace: /pg/ name: pg-0 restapi: listen: 0.0.0.0:8008 connect_address: 10.0.0.1:8008 etcd: hosts: 10.0.0.11:2379,10.0.0.12:2379,10.0.0.13:2379 bootstrap: dcs: ttl: 30 loop_wait: 10 retry_timeout: 10 maximum_lag_on_failover: 33554432 postgresql: use_pg_rewind: true use_slots: true parameters: wal_level: replica hot_standby: on max_connections: 600 max_worker_processes: 48 max_wal_senders: 12 max_replication_slots: 12 hot_standby_feedback: on checkpoint_timeout: 60这里一个特别容易被忽视的参数是maximum_lag_on_failover。它表示从库落后主库多少字节时不允许参加选主。如果忽略这个参数故障切换时可能选出一个数据严重落后的从库导致用户最近消息丢失。对于 8 亿用户级产品这个参数必须认真设置。4.2 备份策略物理备份和逻辑备份分开做故障转移解决的是硬件故障解决不了误删除和逻辑损坏。所以备份必须双轨物理备份用 pgBackRest 做全量 WAL 归档用于恢复到任意时间点。逻辑备份用 pg_dump 做每日快照用于单表恢复和迁移。我推荐的保留策略是物理全量每周一次WAL 归档保留 30 天逻辑备份保留 14 天。每天做一次恢复演练随机挑一个备份恢复到临时实例校验关键表行数。很多团队只做备份不验证。等真正需要恢复时打开备份文件发现审计日志根本没有被归档或者 pgBackRest 的归档路径权限不对那一刻才叫崩溃。所以备份演练不能省而且要写入值班手册每周必做。5. PostgreSQL 16/17 关键参数调优别直接抄默认值5.1 硬参数清单很多人在跑 PostgreSQL 时用的是发行版默认配置默认配置是为 2 GB 内存的小机器准备的对 8 亿用户场景完全是灾难。以下是我在一台 64 GB 内存、32 核 CPU 的主库上常用的参数shared_buffers 16GB effective_cache_size 48GB work_mem 64MB maintenance_work_mem 2GB max_connections 600 max_parallel_workers_per_gather 8 max_parallel_workers 16 wal_level replica synchronous_commit on checkpoint_timeout 15min max_wal_size 32GB min_wal_size 4GB wal_buffers 32MB random_page_cost 1.1 effective_io_concurrency 200 default_statistics_target 500参数背后的思考shared_buffers 16GB差不多内存的 1/4 是 PG 的经典起点。设太低会让缓存命中率下降太高则留给操作系统页缓存的空间不足。work_mem 64MB这个参数要小心。它不是全局内存而是每个排序、哈希操作最多可用的内存。600 个连接同时排序就是 600 × 64MB瞬间可以打爆内存。所以我建议“会话级按需调大全局设置保守”。random_page_cost 1.1SSD 环境不需要按机械硬盘的 4.0 去设置设低一点可以让优化器更大胆地选择索引扫描对在线业务非常有帮助。max_parallel_workers_per_gather 8面向复杂查询但要注意并行度过高会加剧短查询的启动开销。对话类在线查询都是微秒级到毫秒级别把并行度开太高。hot_standby_feedback on防止热备库上的长查询导致主库 vacuum 无法清理死元组从而膨胀。如果你用的是 PostgreSQL 16/17还有一个值得关注的特性在 Linux 上可以通过相关 I/O 参数绕过操作系统页缓存减少双缓存带来的内存浪费实测在高负载场景下对吞吐量有正面影响。不过要确保你充分理解这个参数的副作用先在小流量灰度验证。5.2 需要盯死的监控指标参数调完不等于万事大吉。8 亿用户级别的系统会出现各种怪异问题必须建立监控。我自己的监控清单指标报警阈值说明活跃连接数 池大小 80%连接池将满应用层可能会排队事务提交延迟p99 50ms有锁等待或磁盘瓶颈复制延迟 10s从库追不上主库读新鲜度不可控死元组比例 20%autovacuum 跟不上写入速度WAL 生成速率 30GB/h写入量异常放大检查慢 SQL 或索引过多长事务时长 5min会拖住 vacuum 和复制位点缓存命中率 95%热数据超出内存需要扩容或冷热分离这些指标建议用 Prometheus postgres_exporter 采集配合 Grafana 面板。没有监控的扩展就是裸奔。6. 真实踩坑记录这些故障我全遇过6.1 “刚发的消息刷新就没了”——复制延迟导致的读不到这是读写分离后最经典的问题。用户发送消息后前端写入主库但历史列表读到的是从库从库复制延迟 3 秒于是用户刷新后看不到刚发的消息。用户体验非常糟糕。解决方案是“读己之写”read-your-writes应用层把用户近期写入的 session ID 标记为“热点”这些请求强制路由到主库或者把复制延迟压到 1 秒以内同时在前端做乐观更新先展示本地消息再和服务器拉取结果做合并。我最后选择的是双管齐下消息写入后 10 秒内的读取都走主库10 秒后再走从库。主库读压力增加不大但体验问题彻底解决。6.2 vacuum 风暴与事务 ID 回卷风险某天凌晨主库 CPU 突然飙到 100%pg_stat_activity 里全是 autovacuum worker。原因是业务批量导入历史数据短时间内产生了上亿个死元组autovacuum 同时启动多个 worker疯狂扫表、做索引清理严重抢占正常业务资源。事后我做了三件事把autovacuum_max_workers从 3 提升到 8但限制单库的并发 worker 数不让它一次全冲把大表拆成按月分区让 vacuum 可以更细粒度地做而不是每次都扫几十 TB 的父表给autovacuum_vacuum_cost_delay设定合理值让 autovacuum 的“暴力清扫”变成“柔性清扫”。还有一个必须警惕的问题事务 ID 回卷wraparound。如果一个数据库长期没有执行 vacuum最老的活事务年龄接近 2 亿时PG 会强制进入单用户模式。在 8 亿用户的高吞吐场景里这真不是开玩笑。你必须把vacuum_freeze_min_age和autovacuum_freeze_max_age纳入监控。6.3 连接池被打爆源头竟然是一条慢 SQL一次线上事故里PgBouncer 的 400 个连接全部占满新请求全部排队集群瞬间雪崩。导火索是一条看似无害的查询SELECT * FROM messages WHERE session_id ... ORDER BY created_at DESC LIMIT 100;session_id字段当时没有索引于是这条 SQL 做了全表扫描。本来只是慢但用户量一大慢查询并发堆积把连接池里的连接全占住最终拖垮整个集群。这个教训有两个任何进入生产环境的查询都要走 EXPLAIN确认走索引连接池不是无限资源慢查询会通过连接池反噬全部请求必须在应用层加超时和并发限制。PG 里建议给所有新加字段的查询建立索引并定期用pg_stat_statements找出最耗时的 TOP SQL。对已经发现的全表扫描慢查询先压一下流量然后立刻补索引。6.4 分区表剪枝失效查询还是扫全表还有一个让我印象深刻的坑明明建了分区表某次版本升级后EXPLAIN却发现 SQL 还是扫描了所有分区。原因是查询条件里对created_at做了函数包装比如WHERE date(created_at) 2025-01-01这会导致分区键的表达式无法被识别分区剪枝直接失效。正确写法是范围条件WHERE created_at 2025-01-01 AND created_at 2025-01-02如果确实需要按天统计可以在应用层把时间边界计算好再传入 SQL不要对分区键做任何函数处理。这类问题在高并发场景下尤其致命因为扫全分区等于把热数据查询打成全表扫描。7. 一点个人经验收尾扩展 PostgreSQL 到 8 亿用户级别从来不是靠某一个“大招”解决的。连接池、读写分离、分区、分片、高可用、备份、监控、参数调优每一层都得扎实任何一层出现短板都会在规模放大时变成事故。我个人体会最深的是先想清楚数据归属再谈扩展。如果数据模型一开始就把用户、会话、消息的边界划分得很清楚后面做分片和归档就都顺了如果模型是乱的再牛的中间件也救不了。另外扩展过程中最容易被忽视的不是 CPU、内存而是连接管理和复制延迟这两块看起来不起眼实际上最容易在关键时刻反噬。如果你正在做类似规模的后台架构可以从一个最小闭环开始先搭一主两从 PgBouncer Patroni再用压测工具把负载拉起来最后逐步优化数据模型和查询。这套底座搭稳了后面支撑千万级日活用户才有底气。希望这篇内容能给你一些可参考的路线。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。