这段时间一直在折腾一个老服务的高可用改造,整个过程最让我头疼的不是业务逻辑重构,而是流量一上来,数据库先扛不住。系统本身是一个任务处理平台,每天要消化几十万个短任务,单机 PostgreSQL 跑到 70% 的 CPU 使用率之后,连接数开始争抢,请求排队,告警不断。后来我们把核心链路改成 Queue Mode(队列模式),同时把底层 PostgreSQL 从单机升级成完整的高可用部署,才算真正把性能瓶颈撬开了。这篇文章就围绕这条主线,聊聊我踩过的坑、做的选型、还有那些文档里很少细说的细节。如果你正好在处理高并发写入、任务积压或者数据库单点风险的问题,这篇内容应该能给你一些实打实的参考。
先简单交代一下项目背景,后续再展开设计和实现细节。整个系统的核心功能是接收上游服务推送的批量任务,经过校验、拆单、落库、异步执行、回调结果。早期的架构非常简单:上游直接 HTTP 调用我们的接口,接口内同步写 PostgreSQL,然后异步线程池去跑任务。前半年流量平稳,一切相安无事,等业务方开始大批量接入后,问题一个接一个出现。数据库连接池经常被打满,接口响应时间从 200ms 涨到 2s 以上,任务积压又反过来拖垮了回调服务。这个背景下我们才决定引入 Queue Mode,并且重新设计数据库的高可用架构。
1. 项目背景与性能瓶颈分析
1.1 老架构到底慢在哪
先给老架构画个像。上游服务调用我们的 API 接口,接口内部就是“接收参数 -> 业务校验 -> 写入 PostgreSQL 任务表 -> 返回成功”。与此同时,后端会有一个线程池定时扫描任务表,把状态为待执行的任务拉出来,丢给执行器去跑。听起来没什么毛病,但流量上来之后,有三个环节是硬伤:
第一,同步写库的耗时被用户直接感知。每一次接口调用,必须等 INSERT 事务提交成功才返回。数据库稍有抖动,上游调用方就会超时重试,重试又带来更多的写入压力,形成恶性循环。第二,数据库连接数成了系统的全局锁。连接池默认配置是 50 个连接,高峰期 1000 个并发请求同时进来,线程全在等连接。我们用 HikariCP 监控看到 active 连接数长期打满,等待获取连接的平均耗时超过 800ms。第三,定时扫描任务表的方式是“polling”,轮询间隔设了 2 秒,相当于任务从入库到真正执行,最短也要等 2 秒,任务量大时还会扫描出大量数据,导致每次查询都对数据库产生比较大的压力。
这也暴露了一个最根本的问题:任务执行的速度,完全受制于数据库的查询能力。每次扫描都要遍历整张表或索引,任务一多,数据库的 IO 和 CPU 都被无谓的扫描消耗掉了。我们后续判断,需要把“接收任务”和“执行任务”彻底解耦,引入一个中间缓冲层,这就是 Queue Mode。
1.2 为什么最终选择 Queue Mode 而不是直接上 Kafka
当时也考虑了很多方案,团队里讨论过 Kafka、RabbitMQ、Redis Stream,以及自研的数据库队列表。最终我们选了“数据库队列表 + 队列模式”的轻量方案,没有直接引入独立的 MQ 组件,主要基于三个考虑。
第一,团队的运维体系还不支持复杂的 MQ 集群。引入 Kafka 意味着需要额外的多个 Broker 节点、ZooKeeper 或 KRaft 集群,还要处理消息堆积、分区扩容、消费位点等运维问题。我们的业务体量还没到必须上独立 MQ 的程度,杀鸡用牛刀反而会增加故障面。第二,业务任务需要和数据库保持一致的事务语义。任务入表、更新状态、记录日志,这些操作必须和业务数据在同一个事务里,如果使用外部 MQ,还得处理分布式事务和消息可靠性问题,复杂度陡增。第三,Queue Mode 本质是一种设计思路:通过队列表结构、批量获取、标记状态、延迟重试等手段,把“表”当作一个可扩展的消息队列来使用。它不需要额外部署任何组件,只需要对现有的数据库表和代码结构做调整,就能获得接近 MQ 的效果。
当然,这个方案不是万能的。如果任务量级达到每天几千万甚至上亿,或者需要跨地域、多租户隔离,那还是得考虑独立 MQ。我们目前的体量是每天几十万任务,峰值每秒大概 300 个请求,这个模式下 PostgreSQL 没有任何压力。所以选型一定要匹配业务阶段,不是越复杂越好。
1.3 高可用部署的触发点
在压测过程中还发现,单机 PostgreSQL 一旦发生故障,整个系统会直接瘫痪。过去半年遇到过两次数据库服务器磁盘满,导致所有任务卡死,恢复过程非常痛苦。尽管有定时备份,但从备份恢复到业务可用,至少要一个小时。这让我们意识到,只做性能优化是不够的,如果数据库没有高可用能力,Redis、队列这些上层优化都白搭。于是这次专项改造同时启动了性能突破和基础设施加固两块工作,一块是代码层的队列模式改造,另一块是 PostgreSQL 高可用部署。两者其实是配套的:队列模式把瞬时的写入压力分摊成了平稳的流,高可用部署保证即使数据库节点出问题,写入和消费还能继续走,不会因为单点故障导致整个管道断掉。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 队列模式的核心设计与实现
2.1 队列表结构设计
所谓的 Queue Mode,在关系型数据库里的落地形式就是一张结构合理的队列表,再配上一套严格的“取出-处理-确认”流程。我们原来的任务表字段很多,有业务信息、回调地址、扩展字段、创建时间、重试次数,等等。改造时没有把这张表推倒重来,而是把“待执行任务”单独拆成一张 queue 表,只保留最少量的必要字段,让这张表足够“瘦”,这样扫描、更新、加锁的操作都会非常快。
我设计的具体表结构大致就是下面这样:
sql复制CREATE TABLE task_queue (
id BIGSERIAL PRIMARY KEY,
task_type VARCHAR(64) NOT NULL,
payload JSONB NOT NULL,
status SMALLINT NOT NULL DEFAULT 0,
retry_count SMALLINT NOT NULL DEFAULT 0,
available_time TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_task_queue_status_available ON task_queue (status, available_time);
CREATE INDEX idx_task_queue_id_status ON task_queue (id, status) WHERE status = 0;
关键点在于两个索引。第一个索引 (status, available_time) 用于快速定位哪些任务可以被取出,第二个是部分索引,只索引 status=0 的记录,也就是待处理状态。由于待处理任务永远是少数,这个部分索引的体积非常小,扫描起来特别快。与之前把整个任务表扫一遍相比,性能差距不是一个数量级。
2.2 任务入队的幂等设计
任务入队时,需要有一个“业务ID”或者消息ID来做幂等。上游调用方可能因为网络超时重复发送同一个任务,如果服务端不去重,任务就会重复执行。我们在队列表中增加了一个业务去重字段,通过唯一索引保证同一业务来源、同一业务ID只能入队一次。
sql复制ALTER TABLE task_queue ADD COLUMN biz_id VARCHAR(128);
ALTER TABLE task_queue ADD COLUMN biz_source VARCHAR(32);
CREATE UNIQUE INDEX uk_task_queue_biz ON task_queue (biz_source, biz_id) WHERE status != 2;
这里用了部分唯一索引,只对未完成的任务生效。如果任务已经进入终态,比如成功或者永久失败,那这个去重约束就不应该再拦截同样业务ID的新任务,因为那可能是业务方发起的下一轮任务。这个设计踩过坑,一开始用全表唯一约束,结果同业务ID的后续任务永远入不了队,只能不断更新原记录,语义变得很怪。
入队操作本身必须和上游业务写在同一个事务里,如果事务回滚,任务也不能入队。这样保证了“业务数据一致性和消息不丢失”的强一致。对于没有开启事务的调用方,我们提供了单独 API,内部会开启事务执行入队。
2.3 取出任务的方式:SKIP LOCKED 是关键
Queue Mode 最核心的机制是“并发安全地取出任务”。多个 worker 同时执行 SELECT FOR UPDATE 时,如果没有特殊处理,同一批记录可能会被多个 worker 同时锁定,导致锁等待甚至死锁。PG 从 9.5 开始提供了 SKIP LOCKED,直接跳过已经被其他事务锁定的行,让每个 worker 拿到的都是互不冲突的任务。
具体取任务 SQL 我封装成类似这样:
sql复制WITH batch AS (
SELECT id
FROM task_queue
WHERE status = 0
AND available_time <= now()
ORDER BY available_time
LIMIT 50
FOR UPDATE SKIP LOCKED
)
UPDATE task_queue t
SET status = 1,
updated_at = now()
FROM batch b
WHERE t.id = b.id
RETURNING t.id, t.task_type, t.payload;
这段 SQL 在一个语句里完成了“选任务”和“标记为处理中”两个动作。WITH batch 子查询里拿到最多 50 个可用任务,FOR UPDATE SKIP LOCKED 保证多个 worker 并发时不会互相干扰,拿到任务后立刻改状态。如果任务执行失败,worker 再把 status 改回 0,同时增加 retry_count,设置 available_time = now() + retry delay。这样即使进程崩溃,任务也只会停留在处理中状态,不会丢失,最多会有一定的延迟再被处理。
这里有人会问,为什么不直接 UPDATE ... WHERE status = 0 LIMIT 50?因为 PG 不支持 UPDATE 直接带 LIMIT,而且不通过 FOR UPDATE 锁定的话,两个并发事务可能读到同一批 id,造成重复处理。SKIP LOCKED 的好处是让并发 worker 各拿各的任务,不会等待彼此。不过要注意,这个 SQL 也不是银弹,如果 worker 拿到任务之后执行时间太长,其他 worker 会拿不到锁,一直跳过,造成暂时性的“饿死”,所以 worker 的处理超时要有兜底逻辑。
2.4 Worker 并发模型与优雅停机
Worker 端我们用的是固定线程池。线程数一开始配了 CPU 核心数的两倍,跑下来发现任务里大多有 IO 操作,比如调用外部接口、读写文件,于是慢慢上调到 4 倍。核心配置大致是:
yaml复制queue:
worker:
core-pool-size: 32
max-pool-size: 64
queue-capacity: 512
poll-timeout-seconds: 2
batch-size: 50
max-retry-count: 5
retry-base-delay-seconds: 5
每一个 worker 线程会循环执行“取一批任务 -> 逐个处理 -> 确认成功或失败”。队列空了就阻塞在 poll-timeout-seconds 的时间上,避免空转。这里有个比较重要的点:循环取任务的时候,batch-size 不能设置太大,否则一次拿 200 个任务,如果前 10 个任务处理很慢,后面 190 个任务都会在内存里堆着,一旦进程宕机,这些任务虽然状态还是“处理中”,但实际没人管了。所谓“in-flight”任务会变成孤儿任务,需要靠超时重置机制才能重新被取走。我们的 batch-size 控制在 50,单任务平均耗时 200ms,一批任务最多让 worker 忙碌 10 秒,这在可接受范围内。
优雅停机我们也做了处理。Spring Boot 项目里自定义了一个 QueueWorkerContainer,实现了 SmartLifecycle。收到停机信号后,先停止取新任务,然后等待当前批次执行完毕,最多等 30 秒;如果 30 秒还没结束,就强制打断并回滚状态,让任务重新可见。这个机制在发布新版本时特别有用,不会因为重启导致任务丢失。
2.5 延迟重试与死信队列
任务执行失败时,不能无限重试。第一次失败,可能是暂时性网络抖动,重试有效;但连续失败五次以上,大概率是业务逻辑问题或者外部接口永久性报错,继续重试只会浪费资源。我们设置了一个最大重试次数,超过后把任务标记为 status=3,也就是死信状态。然后提供管理接口,让运维或开发人员查看死信任务的具体报错信息,手动操作重新入队。
延迟重试的实现方式是在更新失败任务时设置 available_time。统一用一个指数退避策略:
java复制long delaySeconds = retryBaseDelaySeconds * Math.pow(2, retryCount);
比如基础延时 5 秒,第一次重试等 10 秒,第二次 20 秒。这样能避免失败任务反复被取出来,把资源腾给正常任务。这个策略在日志系统里很好用,可以直观看到每个任务的重试轨迹。
3. 队列模式性能突破的细节验证
3.1 压测数据对比
改造完成后,我们用同一套压测脚本对三种形态做了对比:改造前的同步写库、只引入队列表但没有 SKIP LOCKED、完整 Queue Mode。压测场景是模拟 500 个并发请求持续 10 分钟,每个请求携带一个任务,观察写入接口的平均响应时间、P99 耗时、数据库 CPU 以及任务从入队到执行完成的整体延迟。
结果数据非常有说服力:
| 压测形态 | 平均响应时间 | P99 | DB CPU | 任务完成延迟 |
|---|---|---|---|---|
| 改造前同步写库 | 1420ms | 3900ms | 95% | 2s+ |
| 队列表但无 SKIP LOCKED | 350ms | 1200ms | 70% | 4.5s |
| 完整 Queue Mode | 45ms | 120ms | 22% | 1.2s |
完整队列模式的成绩,主要归功于两件事:一是接口入队只做一次轻量 INSERT,不会长时间持有数据库事务,所以并发写入能力大幅上升;二是取出任务时通过 SKIP LOCKED 避免了锁冲突,worker 消费能力稳定,整体吞吐量提升了将近三倍。压测期间我们还观察到一个细节:完整 Queue Mode 下 PostgreSQL 的 CPU 占比稳定在 20% 左右,而同步写库模式下数据库经常冲到 95% 以上。这背后的核心差异就是数据库不再被高频的扫描、锁定、业务更新所拖累,只负责队列表的轻量读写,压力自然下来了。
3.2 事务粒度与批量处理的平衡
一个容易被忽略的细节是,取出任务的 UPDATE 语句和后续业务处理的事务边界怎么划分。我们最初的设计是,取出任务和消费任务放在同一个事务里,也就是说,UPDATE task_queue SET status=1 和后面的业务操作都使用同一个数据库事务。如果业务操作很耗时,这个事务会被拖得很久,导致数据库连接长时间被占用,连接池被耗尽。后来我们把事务拆成了两段:先开一个短事务取任务,提交事务,释放连接,然后执行业务逻辑,最后再开一个短事务确认任务完成或失败。
这样做会把任务处理时间内的数据库连接彻底释放出来,连接池的容量需求会降低很多。代价是如果进程在业务逻辑执行过程中崩溃,任务会停留在处理中状态,需要一套超时回收机制。我们通过一个独立的“孤儿任务回收”定时器来处理:每 5 分钟扫描一次状态为处理中但 updated_at 超过 10 分钟的记录,把它们的 status 改回 0,并记录一条回收日志。这样既保证了事务粒度小,又不丢任务。
3.3 批量入队的优化空间
如果上游一次性推来几千个任务,逐个 INSERT 的效率很低。我们提供了一个批量入队接口,使用 PostgreSQL 的 COPY 协议,或者简单的 INSERT INTO ... VALUES (...), (...) ...。实测下来,一次插入 1000 行的效率比起单条循环插入提升了 10 倍以上。
批量入队时需要注意一个坑:如果一批任务里有重复的业务 ID,唯一索引会直接报错,导致整批插入回滚。我们的做法是先在应用层做一次去重,把同批次重复的 biz_id 只保留一条,然后插入前先查一遍已有任务,过滤掉已存在的 ID。考虑到并发场景,这里无法做到 100% 精确,所以 SQL 里用 ON CONFLICT DO NOTHING,让数据库帮忙兜底,既不影响性能,也不会因为单条冲突导致全批失败。
3.4 索引与 Vacuum 策略
队列表的常见问题是频繁更新状态,会产生大量死元组。PG 的 MVCC 机制下,每更新一行就会留下一个旧版本,如果不及时清理,表会迅速膨胀,查询性能持续下降。我们给宽表设置了合理的 autovacuum 参数:
sql复制ALTER TABLE task_queue SET (autovacuum_vacuum_scale_factor = 0.05);
ALTER TABLE task_queue SET (autovacuum_analyze_scale_factor = 0.02);
同时每周做一次定期巡检。如果需要做一些大批量的重推操作,例如把死信任务重新放回队列,我们会先确认表膨胀情况,再决定是否需要 VACUUM FULL。日常运行中,这张表的数据量基本维持在几万行左右,因为有终态任务会被定期删除或归档。我们每天晚上会把 status=2 和 status=3 的终态记录转移到历史表,队列表只保留最近两天的数据,这样索引体积小,查询自然快。
4. PostgreSQL 高可用部署方案
4.1 方案选型:Patroni vs 自研脚本 vs 云数据库
队列模式解决了性能和弹性问题,但如果数据库还是单点,一切归零。当时我们评估了几个高可用方案:自研脚本加 keepalived、使用 repmgr、使用 Patroni、或者直接迁移到云数据库托管服务。
自研脚本的方案最快,但是故障切换逻辑非常脆弱。比如需要自己检测主库故障、提升备库、修改应用连接地址,整个流程要做到自动化、不丢数据,难度远超想象。repmgr 是很多团队在用的开源方案,它也能做自动故障转移,但配置和运维相对繁琐,而且对 etcd/Consul 这类分布式一致性基础设施没有内置支持。Patroni 是目前社区比较主流的选择,它把 PostgreSQL 主从复制、自动故障转移、动态配置管理都整合在一起,依赖 etcd 或 Consul 做分布式锁和节点状态协调,还有一个 REST API 可以查询当前主库是谁。只要 etcd 集群本身是高可用的,Patroni 就能在主库挂掉后快速选出一个新的主库,并且把虚拟 IP 或负载均衡器的流量切换过去。
最终我们选择 Patroni + etcd + HAProxy + keepalived 的组合。这个组合的好处是每个组件职责单一:Patroni 负责数据库高可用,etcd 负责选主和状态存储,HAProxy 负责把读写流量分发到当前主库,keepalived 提供虚拟 IP,应用连接虚拟 IP,切换时无需修改应用配置。架构示意图我不用画太复杂,理解成“应用 -> 虚拟IP -> HAProxy -> 当前主库”这样一条链路即可。只读流量则走备库,由 HAProxy 的另一个后端组负责。
4.2 集群规划与部署步骤
我们准备了三个 PostgreSQL 节点,一个主、两个备,三个节点上都部署 Patroni。etcd 也是三节点,可以和 PG 同机部署,也可以独立部署。考虑到生产环境建议独立部署,以免资源争抢,但为了节约成本,我们初期是把 etcd 和 Patroni 放在一起的,只要机器规格够大,问题也不大。
部署过程我尽量按步骤拆解,方便你直接参考。
第一步,三台机器都安装 PostgreSQL 15,初始化数据目录,但不需要启动服务,因为 Patroni 会接管启动流程。需要保证三台机器的 data 目录为空或者不存在,Patroni 初始化时会自己创建主库、克隆备库。
第二步,安装和配置 etcd。CentOS/RHEL 系统直接用二进制包或 systemd 管理。etcd 的配置很简单,重点是三个节点之间能互通,并且设置相同集群 token 和节点发现地址。启动之后用 etcdctl member list 确认集群健康。
第三步,编写 Patroni 配置文件。我用的配置文件大概是这样的:
yaml复制scope: test-cluster
namespace: /pg
name: pg-01
restapi:
listen: 0.0.0.0:8008
connect_address: 192.168.1.11:8008
etcd:
hosts: 192.168.1.11:2379,192.168.1.12:2379,192.168.1.13:2379
bootstrap:
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576
postgresql:
use_pg_rewind: true
parameters:
wal_level: replica
hot_standby: "on"
max_connections: 500
max_worker_processes: 16
max_wal_senders: 10
max_replication_slots: 10
hot_standby_feedback: "on"
wal_keep_size: 1024
max_standby_streaming_delay: 30s
initdb:
- encoding: UTF8
- data-checksums
pg_hba:
- host replication replicator 192.168.1.0/24 md5
- host all all 192.168.1.0/24 md5
postgresql:
listen: 0.0.0.0:5432
connect_address: 192.168.1.11:5432
data_dir: /data/pgdata
bin_dir: /usr/lib/postgresql/15/bin
authentication:
replication:
username: replicator
password: "repl_pass"
superuser:
username: postgres
password: "pg_pass"
rewind:
username: postgres
password: "pg_pass"
parameters:
unix_socket_directories: "/var/run/postgresql"
watchdog:
mode: off
每个节点的配置类似,唯一不同的是 name、restapi.connect_address、postgresql.connect_address。启动顺序上,第一个节点启动时会自动初始化主库,另外两个节点启动后会从 etcd 获取集群信息,克隆主库数据作为备库。这一步特别强调一下:data-checksums 必须要启用,否则以后 pg_rewind 没法用。maximum_lag_on_failover 的含义是主备延迟超过多少字节就拒绝该备库成为新主,这个值需要根据业务容忍度来设置。
第四步,启动 Patroni。每个节点执行:
bash复制systemctl start patroni
systemctl enable patroni
启动后可以通过 Patroni REST API 查看集群状态:
bash复制curl http://192.168.1.11:8008/patroni
如果输出里面显示 "role": "leader" 和 "state": "running",说明主库切换成功。
第五步,配置 HAProxy。HAProxy 的作用有两个:对外提供一个稳定的入口地址,以及根据 Patroni 的 REST API 探测自动把流量指向当前主库。我们添加了一段类似这样的配置:
code复制listen pg_cluster
bind *:5000
mode tcp
balance roundrobin
option httpchk GET /primary
http-check expect status 200
default-server inter 3s fall 3 rise 2
server pg-01 192.168.1.11:5432 check port 8008
server pg-02 192.168.1.12:5432 check port 8008
server pg-03 192.168.1.13:5432 check port 8008
option httpchk GET /primary 是关键。Patroni 的 REST API 在 8008 端口,如果是主库,/primary 返回 200,备库返回 503。HAProxy 通过这个健康检查,就能保证只有当前主库会被分发到写流量。
第六步,配置 keepalived。三台 HAProxy 节点(可以和 PG 节点混用)之间通过 keepalived 绑一个虚拟 IP,比如 192.168.1.100。应用连接串统一指向这个 VIP。当一台 HAProxy 挂了,VIP 自动漂移到另一台正常的 HAProxy 上,保证接入层的可用性。keepalived 的配置就不贴了,核心是 virtual_ipaddress 声明虚拟 IP,vrrp_instance 里配置优先级,正常情况下优先级高的节点持有多 VIP。
4.3 高可用切换的验证
部署完成后,我们做了一系列故障演练。最典型的场景是主库机器网络中断。模拟方式是把主库所在的机器直接关机,然后观察 Patroni 在 etcd 租约到期后多久触发选举。实测下来,loop_wait=10、ttl=30 的参数组合下,从主库失联到新主库对外提供服务,大概用了 35 秒。这 35 秒里,HAProxy 健康检查发现原主库不可用,自动把流量切到新主库,应用侧连接池会报一些连接错误,但重试机制生效后恢复正常。
这里要注意,切换时间并不等于零,业务侧必须有重试机制,尤其是写请求。如果应用没有配置连接池重试,故障切换期间会报大量 connection refused。所以我们在应用层配置了 HikariCP 的 connection-timeout 和 validation-timeout,同时业务代码里对数据库操作加了重试模板(例如 Spring 的 @Retryable),确保短时故障不影响核心链路。
还有一个需要验证的场景是备库落后主库太多时,强制切换是否会造成数据丢失。我们在 Patroni 里设置了 maximum_lag_on_failover,如果某个备库复制延迟超过阈值,Patroni 会拒绝提升该备库,宁可让集群不可写,也不允许丢数据。这个策略是保守的,适合数据一致性要求高的业务。
4.4 读写分离与连接池管理
高可用部署之后,备库不能闲着。我们的业务里有不少报表查询、后台管理查询,这些查询对实时性要求不高,完全可以走只读备库。在 HAProxy 里再配置一组只读后端,使用 Patroni 的 API 做只读状态检查:
code复制listen pg_replica
bind *:5001
mode tcp
balance roundrobin
option httpchk GET /replica
http-check expect status 200
server pg-02 192.168.1.12:5432 check port 8008
server pg-03 192.168.1.13:5432 check port 8008
应用层再配置两个数据源,一个为主库,一个为只读库,使用 @Transactional(readOnly = true) 标记的方法自动路由到只读数据源。这里要小心事务边界,如果一个方法里有写操作同时又有只读标记,会造成主从数据不一致。我们通过拦截器强制校验,readOnly 事务中不允许执行写 SQL。
连接池方面,主库连接池控制在 50 个以内,备库连接池控制在 20 个,因为备库读多写少,连接释放很快。连接池的 maximum-pool-size 不是越大越好,数据库活跃连接太多反而会引入上下文切换开销。关于连接数计算公式,可以粗略按“每秒事务数 x 每个事务占用连接时间”来估算。比如 500 TPS,每个事务平均 20ms,那么理想的连接数是 500 * 0.02 = 10,再加一点缓冲,20 到 30 个就足够。我们一开始配了 200 个,结果压测时数据库经常出现 CPU 飚高但吞吐量不升反降,后来才发现是连接数过多导致。
5. 常见问题与排查技巧实录
5.1 队列模式相关的坑
这里把队列模式上线后遇到的一些高频问题整理成表,方便大家直接对号入座。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 任务执行重复 | worker 事务提交前状态未更新为处理中,另一个 worker 扫描到旧状态 | 用 UPDATE ... RETURNING 原子地取出任务,确认状态字段更新成功后再执行业务 |
| 任务迟迟不执行 | available_time 设置到了未来,或者 worker 线程数太小 |
检查延迟重试时间,确认 poll-timeout-seconds 和线程池大小配置合理 |
| 孤儿任务堆积 | worker 进程在取任务后崩溃 | 增加回收定时器,定期把超时处理中任务重置为待执行 |
| 队列表膨胀 | 更新频繁,死元组未及时清理 | 优化 autovacuum 参数,定期归档终态任务 |
| 多 worker 取到同一批任务 | FOR UPDATE 缺 SKIP LOCKED |
使用 FOR UPDATE SKIP LOCKED,确认 PG 版本大于等于 9.5 |
还有一个常见误区:有人会把 LIMIT 放在 FOR UPDATE SKIP LOCKED 后面,比如 FOR UPDATE SKIP LOCKED LIMIT 50,这在语义上也没问题,但要注意 PG 的优化器可能先扫描所有满足条件的行再去锁定,如果数据量大,性能会偏低。更推荐用 CTE 加 LIMIT 的方式,避免不必要的行锁。
5.2 PostgreSQL 高可用部署的坑
第一个坑是 pg_rewind 权限配置错误。故障切换后,旧主库重新加入集群需要执行 pg_rewind,这要求旧主库的 postgres 用户密码能被 Patroni 访问,并且在 pg_hba.conf 里允许本机回环连接。如果权限不足,旧主库会一直处于未知状态,无法同步新主库的数据。所以配置文件里 rewind 认证信息必须和 superuser 一致。
第二个坑是 etcd 集群节点数必须是奇数。文档里写了至少 3 个,生产环境建议 5 个,但我们初期图省事只搭了 1 个 etcd 节点,虽然能用,但一旦这个节点挂了,整个 PostgreSQL 集群连主库都选不出来,等于把单点问题从 PG 转移到了 etcd。后来老老实实补到了 3 个节点。
第三个坑是 HAProxy 的健康检查方式。有些教程会用 TCP 检查端口通不通,但这样做在主备切换时会有时间差,因为 PG 端口在备库上也照样开放。必须用 HTTP 检查 Patroni API。我们实际配置时遇到过一个问题:Patroni 的 REST API 默认监听在 8008,但我们没在 HTTP 检查里加端口,HAProxy 以为备库也是健康的,结果写流量被分发到了备库,直接报 cannot execute INSERT in a read-only transaction。这个问题排查了很久,最后用 curl 分别请求各节点 /primary 才发现备库返回的是 503,而 HAProxy 配置里却放在了主库组里。
5.3 从压测中发现的连接风暴问题
高可用部署上线后,一次例行压测中我们发现一个新现象:主库切换的瞬间,应用连接池里所有连接被重置,大量线程同时尝试重新连接数据库,导致新主库瞬间收到上千个连接请求,CPU 飙升,连 Patroni 的心跳都差点延迟。这个就是所谓的“连接风暴”。
解决办法是在连接池层面增加初始化连接数、最小空闲连接数以及预热策略。HikariCP 支持在启动时执行一条轻量 SQL 来预热连接,但还不够,我们结合了应用启动时的数据库连通性检查。更关键的是,在 HAProxy 层面启用慢启动:server pg-01 ... slowstart 30s,让新主库可以逐步接收流量而不是瞬间被打满。同时调整了应用连接池的 maximum-pool-size,通过合理的队列深度和超时时间,避免大量线程同时等待连接。
5.4 备份与恢复的联动
高可用部署解决的是一台机器故障时的自动切换,但并不意味着可以放弃备份。如果整个集群被误删了数据,比如执行了错误的 DELETE 而没有 WHERE,高可用集群里的备库也会被同步删除,这时候只能靠备份来恢复。所以我们仍然保留了每天一次的 pg_dump 逻辑备份,以及基于 pg_basebackup 的物理备份。物理备份归档到对象存储,保留最近 7 天。这里的补充做法是启用了 archive_mode = on 和 archive_command,把 WAL 日志持续归档到同一存储,支持任意时间点恢复(PITR)。
有个实操细节:归档命令如果用的网络存储,要注意写入延迟。比如使用 NFS 或者对象存储命令行工具,一次归档可能耗时几百毫秒。如果 PG 每产生一个 WAL 段都要等归档完成才继续,会形成写入瓶颈。实际配置时建议不设 archive_timeout=0,让归档异步进行,同时监控归档堆积情况即可。
5.5 监控告警的补充建议
无论队列模式还是高可用部署,没有监控告警就等于裸奔。我们为这套系统配置了以下几项关键指标监控:
- PostgreSQL:活跃连接数、事务提交/回滚数、锁等待、复制延迟、WAL 生成速率、队列表大小。
- Patroni:集群状态、leader 节点是否发生变化、REST API 健康状态。
- 队列:队列表 status=0 的数量、处理中数量、死信数量、任务平均取出到完成耗时。
- 应用:接口 P99、线程池活跃度、连接池等待时间。
其中最重要的是“复制延迟”和“死信数量”。复制延迟一旦超过 10 秒,需要立即检查备库机器 IO 或网络;死信数量快速上涨,说明业务方某个外部接口出问题了。我们用了 Prometheus + Grafana 做展示,Alertmanager 负责告警通知。告警规则尽量配置为“5 分钟内持续超过阈值”才触发,避免偶发抖动造成骚扰。
6. 本次改造的经验总结与个人体会
从性能突破的角度看,Queue Mode 让我们把数据库的压力降了一个量级。原来数据库是系统的唯一真相和唯一瓶颈,现在任务接收变得非常轻,数据库只做队列状态管理,业务处理完全异步化。这个模式虽然不像使用 Kafka 那样看起来“高大上”,但在现有技术栈下解决实际问题非常有效。如果团队已经有 RabbitMQ 或者 Kafka 的成熟运维经验,直接上 MQ 当然可以,但我们这种既要兼顾事务一致性、又不想引入额外组件的场景,数据库队列表加 SKIP LOCKED 的轻量方案,确实是性价比最高的选择。
高可用部署方面,Patroni 这套组合方案已经是目前 PostgreSQL 高可用社区的事实标准。它最大的价值不是“自动切换主库”这一个动作,而是把“节点发现、选主、配置管理、健康检查、故障恢复”都收敛到一套标准化的流程里。上了 Patroni 之后,我们再也不需要人工登录机器去执行 pg_ctl promote 或者修改 recovery.conf,运维效率提升非常明显。
最后再说一个我个人的经验教训:无论队列模式还是高可用架构,都不能等到出问题才想起来做。性能瓶颈出现时,业务往往已经被影响了很久,而高可用改造又涉及到底层基础设施,周期比较长,所以更应该在业务平稳期提前规划。这次改造从设计到落地,前前后后用了三周。其中队列模式的代码改造只花了五天,剩下的时间基本都花在 PostgreSQL 高可用部署、压测验证和故障演练上。如果你所在的团队也面临类似的压力,我建议把这两件事当成一个整体来推进,性能优化负责“跑得快”,高可用负责“不能挂”,两者配合起来,系统的健壮性才能有质的提升。
