开门见山说一句:很多人看到“Queue Mode(队列模式)”这个词,第一反应是消息队列,第二反应是某个连接池的隐藏开关。但放到 PostgreSQL 高可用部署这个场景里,Queue Mode 其实不是一个独立组件,它更像是一整套“排队机制”的集合:连接层在排队,同步复制在排队,选主竞争也在排队。这些排队是否存在、排得深不深、会不会把业务卡死,直接决定你构建的“高可用”到底是真的可用,还是一个故障就原形毕露的摆设。
这篇文章我基于自己搭过的 Patroni + etcd + HAProxy + PgBouncer 生产级集群来写,从三个层面的 Queue Mode 讲起,把部署步骤、参数取舍、性能调优、故障演练全过一遍。适合已经有 PostgreSQL 基础、正准备上高可用的 DBA、运维和架构师。看完你至少能回答一个问题:当主库开始排队等确认的时候,你的集群到底发生了什么,以及该怎么应对。
1. Queue Mode 到底在排什么队?
1.1 连接层:请求在进数据库之前就排队
先聊最直观的一层排队:业务连接打进来,数据库连接来不及处理,请求只能在中间层等着。
我最早接触这个场景是在做 PgBouncer 连接池压测的时候。PgBouncer 的 pool_mode = transaction 模式,客户端连接建立后,后端 PostgreSQL 连接在事务结束时会归还给连接池。如果后端连接已经被占满,新的客户端请求就要排队等一个空闲连接。这个等待队列在 SHOW POOLS 里可以直接看到,字段叫 client_active、client_waiting、server_active 等等。当 client_waiting 数量持续飙升,说明后端连接池已经顶不住了,要么调大 default_pool_size,要么就是慢查询太多、事务太长把连接都霸占了。
在高可用架构里,HAProxy 那一层也存在排队。当 Patroni 执行主备切换,旧主库还没有完全被摘掉、新主库还没被健康检查确认时,探活的连接会短暂失败,客户端要么重试,要么在 LB 层积压。这个窗口期就是 RTO 的一部分,也是很多人容易忽略的“隐性排队”。
所以连接层的 Queue Mode,本质上是为了削峰和保护后端数据库。但它也是一把双刃剑:排得太深,前端超时;排不进去,后端被打爆。后面第 4 节我详细说监控和参数怎么配。
1.2 复制层:同步提交就是一个确认队列
PostgreSQL 从 9.1 引入同步流复制之后,主库在提交事务时可能会等待备库返回确认。这个“等待确认”的过程,从机制上看就是一个典型的队列:事务按顺序进入 WAL 发送队列,备库每处理完一个 WAL 记录就回报一个确认,主库收到确认后才释放对应事务。
当 synchronous_commit = on 时,每个写事务都必须等到备库把 WAL 刷到磁盘,才能向客户端返回成功。如果备库网络抖动、磁盘慢,或者备库直接挂了,主库上的写事务就会卡在等待状态。你在 pg_stat_activity 里能看到大量会话的 wait_event 是 SyncRep,这个时候的数据库“活着”但“写不进去”,业务侧表现就是数据写入超时、队列暴涨。
这个确认队列的存在,是为了换取 RPO = 0,也就是主备切换时一个事务都不丢。但代价就是:主库写入性能的上限,取决于同步备库的确认速度,而不是主库自己的处理速度。理解这一点,你才能理解为什么同一个数据库,异步模式下 TPS 能冲到几万,而开了同步复制可能只剩三分之一。
1.3 选主层:Patroni 分布式锁的竞争队列
还有一层排队藏得更深,发生在“谁当主库”这个决策过程中。
Patroni 用 etcd 的租约来选主:当前 leader 在 etcd 里持有一个带 TTL 的 key,每过 loop_wait 秒续约一次。leader 故障后,key 过期,所有备库同时尝试创建这个 key,谁创建成功谁就是新的主库,其余备库进入等待状态,等下一次可能的机会。这个“竞争-失败-等待”的过程,从宏观上看也是一个队列。
这层 Queue Mode 对应用透明,但对运维来说非常关键。它决定了故障切换的触发时间(ttl + loop_wait + 健康检查周期)和切换后多久才能恢复写入(备库晋升 + 老主库被隔离)。如果 etcd 本身出了问题,比如三个节点只剩一个能连上,Patroni 可能连选主都没法进行,整个集群进入只读或不可用状态。这就是为什么 etcd 集群的部署质量,直接决定了高可用系统的底限。
三个层面的排队,分别对应连接层、数据层、协调层。它们之间会互相影响:同步复制排队会拉长事务执行时间,事务执行时间长了连接池被占满,连接池排队又导致新请求进不来,最后调度层再一抖动,整个链路就像堵车一样彻底的死锁。下面我直接给出一套能跑通这些机制的部署方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 高可用部署选型:为什么是 Patroni + etcd + HAProxy + PgBouncer
2.1 组件选型及各自职责
PostgreSQL 高可用的方案我见过不少:Keepalived + 流复制、repmgr、Patroni、还有些云厂商自带的只读实例。我最终长期用的是 Patroni,原因很明确:
- Patroni 通过 DCS(我选 etcd)保存集群状态,所有节点对“谁是主”达成共识,避免双主脑裂。
- 它能把
synchronous_standby_names等复制参数自动管理起来,这正好命中 Queue Mode 的核心需求。 - 提供 REST API,HAProxy 可以基于它做精确健康检查,切换时流量自动跟随新主。
- 支持
pg_rewind,老主库重新接入集群时不需要手工重建,恢复速度非常快。
etcd 在方案里负责两件事:一是保存集群拓扑(哪些节点、谁在主、同步状态如何),二是提供 leader lease 锁。它的选主机制天然就是分布式一致性的,这比 Keepalived 那种 IP 漂移方式要可靠很多。
HAProxy 其实可以替换成 F5、LVS 或者云负载均衡,但我在这里选 HAProxy 是因为它配置简单、配合 Patroni 的 /patroni/master 和 /patroni/replica 健康检查接口非常顺滑,一个后端组走写流量,一个后端组走读流量,读写分离和故障转移可以同时完成。
PgBouncer 放在数据库前面,主要解决两个问题:一是 Postgres 每连接一个进程,连接多了内存和上下文切换开销巨大;二是高可用切换后的连接重建需要时间,连接池可以缓存连接,让切换对应用更透明。它的 transaction 模式本身也有队列缓冲作用。
2.2 节点规划与拓扑说明
我建议的最小生产配置是这样的:
| 角色 | 节点数 | 用途 |
|---|---|---|
| etcd | 3 | 分布式协调层,奇数节点保证 quorum |
| PostgreSQL + Patroni | 3 | 一主两备,其中至少一个作为同步备 |
| HAProxy + PgBouncer | 2 | 接入层,可放同机部署,也可独立 |
当然,如果资源有限,早期可以把 etcd 复用部署在数据库节点上,但生产环境我还是建议独立 etcd 节点。原因是 etcd 对磁盘 IO 和网络时延比较敏感,混部在高负载数据库节点上,容易出现 etcd 心跳超时,从而导致不必要的选主抖动。我踩过这个坑,后面第 6 节会详细说。
网络规划上,数据库节点之间建议走独立的内部网段,复制流量不要和业务流量混跑。因为同步复制对网络延迟非常敏感,如果业务大查询和 WAL 流量争抢带宽,写延迟会被明显拉高。这个在 Queue Mode 场景下感受尤其明显。
整体拓扑用一句话描述:应用 -> HAProxy/PgBouncer -> Patroni 集群 -> etcd 提供选主和元数据。应用不需要感知谁是主库,写流量打到 HAProxy 的 master 端口,读流量打到 replica 端口;主库切换后 HAProxy 通过健康检查自动把流量切到新主。
2.3 参数取舍:RPO、RTO 与性能平衡
部署之前,你得先想清楚业务到底要什么。
- RPO = 0(不允许丢数据):必须开同步复制,写性能会受影响,但这是必须付出的代价。
- RTO 尽量短(切换越快越好):可以缩短 etcd 的
ttl和 Patroni 的loop_wait,但太激进的参数会增加误切换风险。 - 性能最大化:可以把
synchronous_commit降级到local或off,但一旦主库瞬间宕机,没来得及传到备库的数据就丢了。
我的默认值是 synchronous_mode: true 加 synchronous_mode_strict: true,这个组合的意思很明确:主库必须存在至少一个同步备库,如果没有同步备库,主库拒绝写事务。很多人不敢开 strict,因为担心备库一挂整个集群不可写。但从高可用角度讲,这个“不可写”恰恰是保护机制:它宁可通过排队保护数据,也不能让你在失去同步保护的情况下写入后悄悄丢数据。
如果业务对写入可用性要求极高,可以保留 synchronous_mode: true,但关闭 strict,并保留至少两个备库。这样当一个同步备库故障时,主库会自动降级为异步模式,继续对外提供服务。缺点是一小段时间内 RPO 不再是零,需要业务接受这个微小的数据丢失窗口。
3. 从零部署:把 Queue Mode 真正跑起来
3.1 etcd 集群初始化:先搭协调层
etcd 是整个集群的“大脑”,必须最先搭好。我的版本用的是 etcd 3.5.x,三台机器分别是 192.168.1.21/22/23。
先在每台机器上安装 etcd,然后写入相同格式的配置。以第一台节点为例:
yaml复制name: etcd1
data-dir: /var/lib/etcd
listen-client-urls: http://0.0.0.0:2379
advertise-client-urls: http://192.168.1.21:2379
listen-peer-urls: http://192.168.1.21:2380
initial-advertise-peer-urls: http://192.168.1.21:2380
initial-cluster: etcd1=http://192.168.1.21:2380,etcd2=http://192.168.1.22:2380,etcd3=http://192.168.1.23:2380
initial-cluster-state: new
initial-cluster-token: pgcluster
第二台和第三台对应替换 etcd2、etcd3 和 IP 地址。配置的关键点是 initial-cluster-state: new,前两次初始化都踩过这个坑:如果某个节点之前已经用旧的 cluster-token 启动过,再启动时就必须改成 existing,否则会报 cluster ID 冲突。
启动后用 etcdctl endpoint health --cluster 检查,三个节点都 healthy 就说明 quorum 没问题。这里提醒一句:etcd 节点千万不要只部署 2 个,因为 2 节点集群只要挂 1 个就没有 quorum,跟单节点没有任何区别。
3.2 Patroni 配置里最容易踩坑的三个参数
Patroni 的配置会分成两部分:本机 patroni.yml 里的进程管理信息,以及通过 DCS 下发的集群级配置。很多参数在第一次启动后通过 patronictl edit-config 修改才生效,直接改 patroni.yml 里对应的位置可能被 DCS 里的值覆盖。这是我一开始最困惑的地方。
先看一个最小可用的 patroni.yml:
yaml复制scope: pgcluster
namespace: /pg
name: pg1
restapi:
listen: 0.0.0.0:8008
connect_address: 192.168.1.21:8008
etcd:
hosts: 192.168.1.21:2379,192.168.1.22:2379,192.168.1.23:2379
bootstrap:
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576
postgresql:
use_pg_rewind: true
use_slots: true
parameters:
wal_level: replica
hot_standby: "on"
max_connections: 200
max_wal_senders: 10
max_replication_slots: 10
hot_standby_feedback: "on"
wal_log_hints: "on"
archive_mode: "on"
archive_command: "true"
recovery_conf:
restore_command: "true"
synchronous_mode: true
synchronous_mode_strict: true
initdb:
- encoding: UTF8
- data-checksums
postgresql:
listen: 0.0.0.0:5432
connect_address: 192.168.1.21:5432
data_dir: /var/lib/postgresql/15/main
bin_dir: /usr/lib/postgresql/15/bin
pgpass: /tmp/pgpass0
authentication:
replication:
username: replicator
password: 'your_repl_password'
rewind:
username: rewind_user
password: 'your_rewind_password'
superuser:
username: postgres
password: 'your_super_password'
parameters:
unix_socket_directories: "/var/run/postgresql"
重点说三个参数:
第一个是 synchronous_mode。设置为 true 后,Patroni 会在选主成功后,把当前同步备库的名称自动写入主库的 synchronous_standby_names。这意味着你不需要手工维护这个复制参数,Patroni 会动态调整,这是它比裸搭建流复制方便太多的地方。
第二个是 synchronous_mode_strict。严格模式下,Patroni 会保证任何时候主库都有一个 sync 备库;一旦同步备库不可用,主库会进入只读或写阻塞。这个参数是我在生产环境必须开的,因为它保证了 Queue Mode 队列的“兜底”是一个可用的备库,而不是一个已经跑飞的备库。
第三个是 maximum_lag_on_failover。这是备库能参与选主的延迟上限,单位是字节。默认值 1048576(1MB)看似宽容,但对繁忙系统来说 1MB WAL 可能意味着几秒钟的数据差距。如果备库延迟超过这个值,它就不会被选中为新主,等于自动过滤掉“追不上日志”的备库。这个值可以根据业务容忍度调整,但我一般不会超过 5MB。
3.3 初始化 PostgreSQL 集群并验证同步状态
三个数据库节点分别安装好 PostgreSQL 15 和 Patroni 后,先在 pg1 上启动 Patroni:
bash复制systemctl start patroni
patronictl -c /etc/patroni/patroni.yml list
第一次启动时 Patroni 会在 pg1 上执行 initdb 初始化数据目录,并把配置写入 etcd。等 pg1 状态变成 running、角色是 Leader 之后,再在 pg2、pg3 上启动 Patroni。它们会从 etcd 读到集群拓扑,然后自动通过流复制追赶主库数据。
启动完成后,看下集群状态:
bash复制patronictl -c /etc/patroni/patroni.yml list pgcluster
应该看到类似这样的输出:
text复制+ Cluster: pgcluster ---------+---------+----+-----------+
| Member | Host | Role | State | Lag |
+--------+--------------------+-----------+--------------+
| pg1 | 192.168.1.21:5432 | Leader | running | 0 |
| pg2 | 192.168.1.22:5432 | Sync | running | 0 |
| pg3 | 192.168.1.23:5432 | Replica | running | 0 |
+--------+--------------------+-----------+--------------+
注意 pg2 的角色是 Sync,这说明同步复制已经生效。再进主库看一眼复制状态:
sql复制SELECT application_name, client_addr, state, sync_state, sync_priority
FROM pg_stat_replication;
正常情况下 sync_state = sync 的节点优先级最高,sync_state = async 是异步备库。如果 synchronous_mode_strict 已经打开,synchronous_standby_names 会类似 FIRST 1 "pg2",表示必须等到 pg2 确认,这正是 Queue Mode 队列生效的标志。
3.4 接入 HAProxy 与 PgBouncer 完成流量入口
接入层的目的,是把“谁在主库”这个动态信息对应用隐藏。HAProxy 通过 Patroni 的 REST API 探活,读写走不同的后端组。
conf复制defaults
mode tcp
timeout connect 5s
timeout client 30s
timeout server 30s
option tcp-check
frontend pg_master
bind *:5000
default_backend pg_master_backend
backend pg_master_backend
option httpchk GET /patroni/master
http-check expect status 200
server pg1 192.168.1.21:5432 check port 8008 inter 5s fall 3 rise 2
server pg2 192.168.1.22:5432 check port 8008 inter 5s fall 3 rise 2
server pg3 192.168.1.23:5432 check port 8008 inter 5s fall 3 rise 2
frontend pg_replica
bind *:5001
default_backend pg_replica_backend
backend pg_replica_backend
option httpchk GET /patroni/replica
http-check expect status 200
server pg1 192.168.1.21:5432 check port 8008 inter 5s fall 3 rise 2
server pg2 192.168.1.22:5432 check port 8008 inter 5s fall 3 rise 2
server pg3 192.168.1.23:5432 check port 8008 inter 5s fall 3 rise 2
健康检查的 inter 5s 和 fall 3 是权衡后的结果。太短会加重 Patroni REST 的压力,太长会导致主备切换后 15 秒内客户端还在连旧主。以 fall 3 计算,最坏情况下需要 15 秒剔除故障节点,这个值我觉得可接受。
PgBouncer 配置示例:
ini复制[databases]
appdb = host=127.0.0.1 port=5000 dbname=appdb
[pgbouncer]
listen_port = 6432
listen_addr = 0.0.0.0
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 30
min_pool_size = 5
server_lifetime = 3600
pool_mode = transaction 意味着每个事务结束,连接就归还给池子,这是 PostgreSQL 连接池最常用的方式,能大幅压减后端连接数。注意 auth_type 不要用明文,要么 md5,要么引入 auth_query 对接数据库用户表,生产环境更安全。
到这里,架构已经通了。但搭起来只是第一步,Queue Mode 最核心的价值在性能和故障场景下才能体现出来。
4. 性能调优:如何让队列不成为瓶颈
4.1 synchronous_commit 五档详解与 TPS 对比
很多人在同步复制上只见过开和关,实际上 synchronous_commit 有五档,每一档对应的等待深度完全不同:
| 参数值 | 主库等待什么 | 数据安全等级 | 性能影响 |
|---|---|---|---|
| off | 不等备库也不等本地刷盘 | 最低,可能丢已提交事务 | 最高 |
| local | 等本地 WAL 刷盘 | 低于默认,备库可能丢 | 高 |
| remote_write | 等备库 WAL 写入但未刷盘 | 略低于 on | 中高 |
| on(默认) | 等备库 WAL 刷盘 | RPO=0 | 中 |
| remote_apply | 等备库 WAL 应用完成 | RPO=0,且备库可立即读 | 最低 |
我实测的大致数据(不同机器差别很大,主要看网络和磁盘):单机异步 off 能到 2 万 TPS 的负载,开 on 掉到 7000 左右,remote_apply 进一步掉到 4000 左右。也就是说,同步复制通常要付出 50%~70% 的写性能代价,这是 Queue Mode 里最直接的“性能税”。
如果你的业务对读一致性要求很高,尤其是有读写分离、备库需要马上读到主库刚提交的数据的场景,remote_apply 是更合适的选择。但要注意,它会让主库等待备库把 WAL replay 到 PostgreSQL 的可见性版本,备库负载变高会直接拖慢主库,这种耦合度需要你评估清楚。
4.2 让排队可观测:监控哪些指标、怎么查
性能能调优的前提,是你得知道“现在在排什么队”。我日常最常用的三组监控查询:
第一组,看同步备库是否健康:
sql复制SELECT application_name,
state,
sync_state,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), flush_lsn)) AS flush_lag,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS replay_lag
FROM pg_stat_replication;
如果 flush_lag 持续增大,说明备库的 WAL 刷盘速度跟不上主库的生成速度,同步队列在堆积。这时候业务还没卡,但你已经在危险的边缘了。
第二组,看主库是否出现了同步等待:
sql复制SELECT pid, datname, usename, state, wait_event_type, wait_event, now() - state_change AS wait_duration
FROM pg_stat_activity
WHERE wait_event = 'SyncRep'
ORDER BY wait_duration DESC;
如果这条 SQL 查出来有持续累积的会话,说明主库的写事务正在排队等备库确认,已经影响业务写入。
第三组,看连接池排队情况。在 PgBouncer 的管理库执行 SHOW POOLS;,关注 client_waiting 列。这个值大于 0 说明新请求已经在连接层排队;如果 client_active 接近 0 而 client_waiting 很大,说明后端连接耗尽,慢查询或者大事务是首要怀疑对象。
4.3 队列溢出时的介入手段
发现队列堆积后,常见的介入手段有几个:
第一个是临时降低同步级别。比如在 Patroni 的 DCS 配置里把 synchronous_commit 从 on 改成 local,让主库不再等待备库确认。这个操作只需几秒,可以快速恢复写入,但代价是切换时可能丢数据,所以我只在抢救场景用,事后一定改回来并做数据一致性检查。
第二个是杀掉拖慢同步队列的慢查询。如果是备库的高负载查询导致 replay 变慢,可以在备库开启 hot_standby_feedback 的同时,把备库上的大查询转移到其他专用分析节点,避免影响复制确认速度。注意 hot_standby_feedback 本身可能造成主库 vacuum 被阻塞,需要兼顾。
第三个是扩容。如果同步复制确实扛不住业务写入,最直接的办法是把写入拆分到多个数据库节点,或者引入应用层分片,而不是妄图让单机同步复制无限提高性能。
5. 故障演练:主库宕机、网络抖动、队列堆积实测
5.1 演练一:主库进程崩溃后的自动切换
真正的高可用,必须演练过才算数。我第一个模拟的是最典型的故障:主库进程崩溃。
在 pg1 上执行:
bash复制kill -9 $(head -1 /var/lib/postgresql/15/main/postmaster.pid)
Patroni 检测到本机 PostgreSQL 异常,会尝试重启。如果重启失败,它会等待 etcd 里的 lease 过期。etcd ttl = 30,加上 Patroni 每 10 秒检查一次,大约 30~40 秒后,pg2 和 pg3 开始竞选。
我实测的切换过程日志显示:pg2 在 etcd 里成功创建 leader key,晋升为主库,pg3 成为新的同步备库,pg1 在恢复后自动以 replica 角色重新加入。整个过程没有人工干预,从主库死掉到新主可写,大概用了 45 秒。
这里有个细节:如果应用端的连接池没有配置连接重建和重试机制,切换期间会有大量报错。所以高可用不光是数据库层面的事,应用连接池的 validation 和重试策略也必须配套。
5.2 演练二:同步备库网络抖动后的写队列堆积
第二个演练是网络抖动,比进程崩溃更难排查。
我用 iptables 把 pg2 的 5432 端口流量全部丢弃,模拟同步备库失联。这时 pg_stat_replication 里 pg2 的 state 从 streaming 变为 catchup 或直接消失,主库上的 synchronous_standby_names 还指向 pg2,于是写事务开始排队:
bash复制iptables -A INPUT -p tcp --dport 5432 -s 192.168.1.22 -j DROP
用 pgbench 跑一个并发写负载,能明显看到 TPS 直线下滑,pg_stat_activity 中 SyncRep 等待的会话数大量增加。更麻烦的是,这个状态下很多监控看板无法直接发现,因为数据库本身 CPU、内存都很正常,只有等待事件在悄悄堆积。
恢复操作是把 iptables 规则删除,pg2 重新连上主库并追平 WAL 后,队列自动释放,写性能恢复。注意,如果 pg2 短暂失联期间主库已经堆积了大量 WAL,pg2 恢复后可能有一个明显的追日志过程,这个阶段主库的确认队列还会继续等,直到 pg2 追到最新 LSN。
5.3 演练三:节点重新加入集群后的数据补齐
第三个演练是节点重新加入。假设 pg3 因为磁盘故障被拉走,修复后重新启动 Patroni,它需要从当前主库补齐故障期间产生的数据。
Patroni 会先判断能否用 pg_rewind 快速找回。只要故障节点的 timeline 没有发生过分叉,pg_rewind 就能把数据目录快速对齐到新主,不需要全量拷贝。但如果数据目录已经损坏,它会自动降级为 pg_basebackup 全量重做,恢复时间取决于数据量大小。
实际上,即便开了 use_pg_rewind,我在演练中也遇到过 rewind 失败的情况,原因大多是老主库没有开启 wal_log_hints 或 data-checksums。这也是我在初始化配置里特意加上 wal_log_hints: on 和 data-checksums 的原因。这个组合是 pg_rewind 能可靠工作的前提。
6. 常见问题与排查技巧实录
6.1 问题速查表
把我在实战里遇到的高频问题整理成了速查表:
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 写事务卡住,wait_event=SyncRep | 唯一同步备库故障 | pg_stat_replication 检查同步节点状态 |
| HAProxy 主库端口无可用后端 | Patroni REST API 返回非 200 | curl http://node:8008/patroni/master |
| Patroni 无法选主 | etcd 丢失 quorum | etcdctl endpoint health --cluster |
| 切换后客户端仍连旧主 | HAProxy 探测周期太长 | 缩短 inter/fall,或手动禁用旧主 |
| 连接池大量 client_waiting | 后端连接耗尽或慢查询占连接 | SHOW POOLS,分析慢查询 |
| 同步备库被误剔除 | 网络瞬间抖动或磁盘 IO 尖刺 | 检查 postgres 日志和 Patroni 日志 |
| 主备切换后数据丢失 | 未开启同步复制 | 启用 synchronous_mode + strict |
6.2 高频故障的定位命令
排查问题的时候,我习惯按“从远到近”的顺序看:
先看 etcd:
bash复制etcdctl endpoint health --cluster
etcdctl member list
再看 Patroni:
bash复制patronictl -c /etc/patroni/patroni.yml list pgcluster
journalctl -u patroni -n 200 --no-pager
然后进 PostgreSQL:
sql复制SELECT pid, state, wait_event_type, wait_event, now()-state_change AS wait_duration
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY wait_duration DESC;
最后看连接池和 HAProxy:
bash复制echo "SHOW POOLS;" | psql -h 127.0.0.1 -p 6432 -U postgres pgbouncer
echo "SHOW STATS;" | psql -h 127.0.0.1 -p 6432 -U postgres pgbouncer
这一套下来,90% 的问题能锁定到具体环节。
6.3 我踩过最深的几个坑
第一个坑是手动修改 synchronous_standby_names。刚开始没用 Patroni 的时候,我是直接在 postgresql.conf 里改的,后来换成 Patroni 后,手动改会被 DCS 里的配置覆盖,而且覆盖时机不可控。正确做法是只通过 patronictl edit-config 调整 DCS 配置。
第二个坑是只配一个同步备库。一主一备听起来是标准的“高可用”,但备库一挂,主库写队列立刻卡死。后来我改成了一主两备,通过 synchronous_standby_names 的 FIRST 1 语义保证主库只等最快的那个同步备库,另一个作为候选同步节点实时待命。这样单个备库故障不会阻塞写,性能也多了一层冗余。
第三个坑是 etcd 和数据库混部。我在实验环境里图省事,把 etcd 放在数据库节点上,结果一次大查询把磁盘 IO 打满,etcd 心跳超时,Patroni 以为 leader 挂了,触发了无意义的切换。从此之后生产环境除了特别小的集群,我坚持独立 etcd 节点。
第四个坑是 PgBouncer 的 transaction 模式下,服务端预处理语句(PREPARE)会带来副作用。因为连接归还后,同一连接上可能残留 PREPARE 语句,不同客户端 session 使用时会冲突。要么应用层统一使用带名字的 prepared statement,要么干脆关闭服务端 PREPARE,在驱动层做本地缓存。
把这三个层面的 Queue Mode 跑顺之后,我对“高可用”的理解比以前实在了很多——所谓高可用,不是系统不会坏,而是坏的时候知道谁在排队、为什么排队、怎么让队列恢复。如果你正准备搭一套 PostgreSQL 高可用集群,先从最小的三节点 etcd 加一主两备开始,把同步复制的排队机制摸透,再考虑要不要上更复杂的读写分离和连接池。这条路走通了,后面再加监控告警、容灾扩展都会顺很多。
