这段时间一直在折腾一个老服务的高可用改造,整个过程最让我头疼的不是业务逻辑重构,而是流量一上来,数据库先扛不住。系统本身是一个任务处理平台,每天要消化几十万个短任务,单机 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 COL
