PostgreSQL 表膨胀排查:任务队列表从 24GB 回到 7GB 的复盘

任务队列表慢下来时,我一开始盯着索引看了很久。

这个表每天都会插入新任务,也会频繁更新任务状态。正常情况下,消费者每次拉 100 条待处理任务,接口耗时在 20ms 到 50ms 之间。后来预发环境压测时,同样的查询开始抖到 700ms 以上,偶尔还会超过 1s。

数据库是 PostgreSQL 15.6,表结构做过简化,大概是这样:

CREATE TABLE send_job (
    id           BIGSERIAL PRIMARY KEY,
    tenant_id    BIGINT      NOT NULL,
    status       SMALLINT    NOT NULL,
    run_at       TIMESTAMPTZ NOT NULL,
    retry_count  INT         NOT NULL DEFAULT 0,
    payload      JSONB       NOT NULL,
    locked_at    TIMESTAMPTZ,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

消费者拉任务的 SQL 如下:

SELECT
    id,
    tenant_id,
    payload,
    retry_count
FROM send_job
WHERE tenant_id = 18
  AND status = 0
  AND run_at <= now()
ORDER BY run_at ASC, id ASC
LIMIT 100
FOR UPDATE SKIP LOCKED;

表上已经有针对这个查询的索引:

CREATE INDEX idx_send_job_pending
ON send_job (tenant_id, status, run_at, id);

这个索引从查询形态上看没有明显问题。tenant_idstatus 是等值条件,run_at 接排序和范围,id 用来保证同一时间下顺序稳定。执行计划也没有额外排序。

但慢查询还是出现了。

执行计划看起来没跑偏,但 buffer 很高

先看慢的时候的执行计划。

EXPLAIN (ANALYZE, BUFFERS)
SELECT
    id,
    tenant_id,
    payload,
    retry_count
FROM send_job
WHERE tenant_id = 18
  AND status = 0
  AND run_at <= now()
ORDER BY run_at ASC, id ASC
LIMIT 100
FOR UPDATE SKIP LOCKED;

脱敏后的结果如下:

Limit  (cost=0.43..412.92 rows=100 width=912)
       (actual time=38.214..842.773 rows=100 loops=1)
  Buffers: shared hit=18342 read=6129 dirtied=47
  ->  LockRows  (cost=0.43..283721.50 rows=68812 width=912)
                (actual time=38.212..842.748 rows=100 loops=1)
        Buffers: shared hit=18342 read=6129 dirtied=47
        ->  Index Scan using idx_send_job_pending on send_job
              (cost=0.43..283033.38 rows=68812 width=912)
              (actual time=38.176..840.931 rows=100 loops=1)
              Index Cond: ((tenant_id = 18)
                       AND (status = 0)
                       AND (run_at <= now()))
              Buffers: shared hit=18294 read=6129
Planning Time: 0.318 ms
Execution Time: 842.831 ms

表面看是 Index Scan,索引条件也对上了,返回行数只有 100。问题出在 Buffers 上:为了拿 100 行,读了两万多个 buffer。

这种执行计划很容易误导人。它没有明显的 Seq Scan,也没有 Sort,看起来像是索引用上了。但 PostgreSQL 顺着索引找数据时,仍然可能踩到大量已经失效的行版本和膨胀的数据页。最终表现就是扫描路径没变,I/O 和 CPU 都上去了。

我当时没有继续改索引,而是先看表和索引的体积。

先确认是不是表膨胀

查表大小时,我一般会把表、索引、总大小拆开看。

SELECT
    pg_size_pretty(pg_relation_size('public.send_job'))        AS table_size,
    pg_size_pretty(pg_indexes_size('public.send_job'))         AS indexes_size,
    pg_size_pretty(pg_total_relation_size('public.send_job'))  AS total_size;

当时输出是:

 table_size | indexes_size | total_size
------------+--------------+-----------
 24 GB      | 11 GB        | 35 GB

这个大小明显不正常。任务表的活跃数据只有 120 万行左右,payload 虽然是 JSONB,但大多数任务内容不大。按历史估算,整张表加索引应该在 6GB 到 8GB 之间。

再看统计信息:

SELECT
    relname,
    n_live_tup,
    n_dead_tup,
    last_vacuum,
    last_autovacuum,
    vacuum_count,
    autovacuum_count,
    analyze_count,
    autoanalyze_count
FROM pg_stat_user_tables
WHERE relname = 'send_job';

输出大概是这样:

 relname  | n_live_tup | n_dead_tup | last_vacuum |        last_autovacuum        | vacuum_count | autovacuum_count
----------+------------+------------+-------------+-------------------------------+--------------+------------------
 send_job |    1248310 |    6839204 |             | 2026-01-14 03:18:22.491+08    |            0 |               11

n_dead_tup 是估算值,不适合当成精确数字,但方向已经很明显。死元组数量比活跃行多了几倍,autovacuum 跑过,却没有跟上更新速度。

如果库里可以安装扩展,我还会用 pgstattuple 再确认一次。这个扩展会扫描表,生产环境要避开高峰期。

CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT
    table_len,
    tuple_count,
    tuple_percent,
    dead_tuple_count,
    dead_tuple_percent,
    free_percent
FROM pgstattuple('public.send_job');

结果更直观:

 table_len   | tuple_count | tuple_percent | dead_tuple_count | dead_tuple_percent | free_percent
-------------+-------------+---------------+------------------+--------------------+-------------
 25818447872 |     1270192 |         21.84 |          6924418 |              46.73 |       24.61

这时基本可以判断:查询慢不是因为索引方向错了,而是表和索引里堆了太多过期版本。索引仍然能用,但每次沿着索引走,都会绕进大量无效行版本和分散的数据页。

UPDATE 频繁的表很容易被撑大

PostgreSQL 的 UPDATE 不是在原位置直接覆盖一行。

一条任务从待处理变成处理中,会更新一次:

UPDATE send_job
SET status = 1,
    locked_at = now(),
    updated_at = now()
WHERE id = $1;

处理中变成成功,又会更新一次:

UPDATE send_job
SET status = 2,
    updated_at = now()
WHERE id = $1;

失败重试还会更新 retry_countrun_atstatus

在 MVCC 机制下,每次更新都会产生新的行版本,旧版本要等没有事务再需要它时,才能被 VACUUM 清理。清理之后,空间通常会留在表文件内部,后续插入或更新可以复用。磁盘上的表文件不会因为普通 VACUUM 立刻缩小。

这个机制本身没问题,问题出在任务队列表这种写法上:

insert -> update status -> update retry_count -> update locked_at -> update status -> delete/archive

同一行生命周期里会经历多次更新。任务量上来后,死元组产生速度比 autovacuum 清理速度更快,表文件就会一路涨。

这个表还有一个放大因素:payload 是 JSONB。虽然状态更新没有改 payload,但行版本变多以后,堆表和索引都会承压。任务表越热,这个问题越明显。

autovacuum 为什么没跟上

PostgreSQL 默认的 autovacuum 触发阈值大致可以按这个公式理解:

autovacuum_vacuum_threshold
+
autovacuum_vacuum_scale_factor * 表行数

默认 autovacuum_vacuum_scale_factor 通常是 0.2。对于 100 多万行的表,要积累 20 多万条变更后才容易触发一次 vacuum。对普通业务表可能够用,对任务队列这种高频更新表偏保守。

先看当前表有没有单独配置过 autovacuum 参数:

SELECT
    relname,
    reloptions
FROM pg_class
WHERE oid = 'public.send_job'::regclass;

当时输出是空的:

 relname  | reloptions
----------+------------
 send_job |

也就是完全吃实例默认配置。

再看 autovacuum 进程有没有被长事务拖住。长事务会保留很早之前的快照,VACUUM 即使跑了,也不能清理那些仍可能被旧快照看到的行版本。

SELECT
    pid,
    usename,
    state,
    now() - xact_start AS xact_age,
    wait_event_type,
    wait_event,
    left(query, 160) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start ASC
LIMIT 10;

这次还真查到一个后台导出任务,事务开了两个多小时:

 pid  | usename | state  | xact_age | wait_event_type | query
------+---------+--------+----------+-----------------+-------------------------------
 9182 | report  | idle   | 02:17:43 | Client          | BEGIN; SELECT ... FROM ...

这个连接处于 idle in transaction 状态。它不一定是唯一原因,但会让 VACUUM 清理效果变差。后面我们把报表导出改成了分页短事务,避免长时间挂着一个快照。

先让 VACUUM 追上来

这种问题不要一上来就 VACUUM FULL

VACUUM FULL 会重写整张表,能释放磁盘空间,但需要更强的锁,业务表在线上直接跑风险很高。我的处理顺序是先让普通 VACUUM 把可回收空间标记出来,让后续写入能够复用这些空间,再安排索引重建和表重组。

低峰期先跑一次:

VACUUM (ANALYZE, VERBOSE) public.send_job;

执行时可以观察进度:

SELECT
    pid,
    relid::regclass AS table_name,
    phase,
    heap_blks_total,
    heap_blks_scanned,
    heap_blks_vacuumed,
    index_vacuum_count,
    num_dead_tuples
FROM pg_stat_progress_vacuum
WHERE relid = 'public.send_job'::regclass;

普通 VACUUM 跑完后,磁盘上的 table_size 没有明显下降,这个结果正常。它主要让表内部空间变成可复用状态,同时更新统计信息。真正能立刻感受到的是后续写入不再继续疯狂扩表,查询 buffer 也开始下降。

再查一次统计信息:

SELECT
    n_live_tup,
    n_dead_tup,
    last_vacuum,
    last_autovacuum,
    vacuum_count,
    autovacuum_count
FROM pg_stat_user_tables
WHERE relname = 'send_job';

输出变成:

 n_live_tup | n_dead_tup |          last_vacuum          |        last_autovacuum        | vacuum_count | autovacuum_count
------------+------------+-------------------------------+-------------------------------+--------------+------------------
    1261044 |     318902 | 2026-01-15 01:42:11.029+08    | 2026-01-14 03:18:22.491+08    |            1 |               11

n_dead_tup 降下来了,但这只是止血。要避免几天后复发,还要调整 autovacuum。

给热表单独调 autovacuum

实例级参数不适合为了一个热表改得太激进。这个库里还有不少普通表,如果全局降低 scale factor,autovacuum 会更频繁地扫很多不需要重点照顾的表。

这次直接给 send_job 单独设置存储参数:

ALTER TABLE public.send_job SET (
    autovacuum_vacuum_scale_factor = 0.02,
    autovacuum_vacuum_threshold = 5000,
    autovacuum_analyze_scale_factor = 0.01,
    autovacuum_analyze_threshold = 3000,
    autovacuum_vacuum_cost_limit = 2000,
    autovacuum_vacuum_cost_delay = 2
);

调整后的含义大概是:死元组达到几万级别时就尽早触发,而不是等到几十万甚至更多。ANALYZE 也更积极一些,避免统计信息落后导致执行计划误判。

可以用下面的 SQL 检查是否生效:

SELECT
    relname,
    reloptions
FROM pg_class
WHERE oid = 'public.send_job'::regclass;

输出类似:

 relname  | reloptions
----------+----------------------------------------------------------------------------------------
 send_job | {autovacuum_vacuum_scale_factor=0.02,autovacuum_vacuum_threshold=5000,...}

这个值不能照抄到所有表。写入很少的维表不需要这么激进;超大表可能还要配合分区;更新频率极高的表,还要考虑业务写法能不能减少 UPDATE 次数。

任务队列表比较特殊,它的状态变化频繁,而且查询只关心少量待处理任务。对它来说,宁愿 autovacuum 更早介入,也不要让死元组堆到影响消费者拉取。

索引膨胀也要单独处理

表膨胀处理完后,慢查询有明显改善,但还没有回到最初水平。再看索引大小:

SELECT
    indexrelid::regclass AS index_name,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
    idx_scan,
    idx_tup_read,
    idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relid = 'public.send_job'::regclass
ORDER BY pg_relation_size(indexrelid) DESC;

输出里 idx_send_job_pending 已经涨到了 8GB 多:

 index_name           | index_size | idx_scan | idx_tup_read | idx_tup_fetch
----------------------+------------+----------+--------------+--------------
 idx_send_job_pending | 8192 MB    |  1843921 |    982183411 |    128318122
 send_job_pkey        | 2741 MB    |  2420011 |    241920034 |    241880191

普通 VACUUM 可以清理索引里的无效条目,但索引文件本身也可能变得很松散。这个索引是任务拉取的关键路径,后面单独做了并发重建:

REINDEX INDEX CONCURRENTLY idx_send_job_pending;

这里也要注意,REINDEX ... CONCURRENTLY 不能放在事务块里执行。它比普通 REINDEX 更适合在线业务,但执行时间会更长,也会带来额外 I/O。线上跑之前要确认磁盘空间足够。

重建后再看:

 index_name           | index_size
----------------------+-----------
 idx_send_job_pending | 2380 MB

这一步对任务拉取接口的改善很明显。因为消费者每次都走这个索引,索引层级和页面数量降下来后,buffer 读也跟着下降。

已经涨大的表文件怎么收回来

普通 VACUUM 解决的是复用问题,不负责把表文件压回去。

这次表从 24GB 涨到 24GB,VACUUM 后仍然接近 24GB,只是内部空洞可以继续复用。如果磁盘空间紧张,或者表已经膨胀到影响缓存命中率,就需要安排表重组。

常见选择有三个。

VACUUM FULL public.send_job; 可以直接压缩表文件,但会重写整张表,锁影响比较大。业务高峰期不能随便跑。

pg_repack 可以在线重组表和索引,锁时间短很多,但需要安装扩展,也需要额外磁盘空间。我们这次最终用的是这个方案。

还有一种更土但可控的做法:新建影子表,分批搬迁数据,然后在维护窗口切表。这个方案代码和运维步骤更多,适合对扩展安装比较敏感的环境。

重组前后对比大概是这样:

指标 处理前 VACUUM 后 重组后
表大小 24GB 23GB 5.1GB
索引大小 11GB 10GB 2.4GB
总大小 35GB 33GB 7.5GB
拉取任务耗时 700ms ~ 1s 180ms ~ 260ms 25ms ~ 45ms
单次查询 buffer 2 万多个 5000 左右 200 左右

重组不是每天都要做。真正重要的是前面的 autovacuum 参数和业务写法调整。只要死元组不再长期堆积,后面表文件大小会稳定很多。

业务写法也要一起改

数据库参数只能兜底,任务队列表本身也要减少无意义更新。

原来的消费者每次尝试拉任务时,都会更新 locked_atupdated_at。有些任务被多个消费者竞争时,会产生更多更新。后来把任务领取逻辑改成只更新真正拿到的记录:

WITH picked AS (
    SELECT id
    FROM send_job
    WHERE tenant_id = 18
      AND status = 0
      AND run_at <= now()
    ORDER BY run_at ASC, id ASC
    LIMIT 100
    FOR UPDATE SKIP LOCKED
)
UPDATE send_job j
SET status = 1,
    locked_at = now(),
    updated_at = now()
FROM picked
WHERE j.id = picked.id
RETURNING
    j.id,
    j.tenant_id,
    j.payload,
    j.retry_count;

这段 SQL 把“选择任务”和“锁定任务”放在同一次数据库操作里完成,减少应用层来回调用。它不能消除 UPDATE 带来的新版本,但能减少重复领取和状态抖动。

另外,成功任务不再长时间留在主表里。保留最近 3 天的数据,超过时间后转存到历史表:

CREATE TABLE send_job_history
(LIKE send_job INCLUDING ALL);

归档时分批处理,避免一次性删太多:

WITH moved AS (
    DELETE FROM send_job
    WHERE status = 2
      AND updated_at < now() - interval '3 days'
    ORDER BY id
    LIMIT 5000
    RETURNING *
)
INSERT INTO send_job_history
SELECT *
FROM moved;

PostgreSQL 的 DELETE ... ORDER BY ... LIMIT 不能直接这样写。实际落地时要通过 CTE 先选 ctid 或主键,再删除:

WITH rows AS (
    SELECT id
    FROM send_job
    WHERE status = 2
      AND updated_at < now() - interval '3 days'
    ORDER BY id
    LIMIT 5000
),
moved AS (
    DELETE FROM send_job j
    USING rows r
    WHERE j.id = r.id
    RETURNING j.*
)
INSERT INTO send_job_history
SELECT *
FROM moved;

这个批量大小要按库的写入压力调。5000 只是当时环境下比较稳的值,不能直接当成通用标准。

复盘下来最有用的几个检查点

这类问题以后再遇到,我会按固定顺序查。

先看慢 SQL 的执行计划,重点看 BUFFERS,不要只看有没有走索引。

再看表和索引体积:

SELECT
    pg_size_pretty(pg_relation_size('public.send_job'))       AS table_size,
    pg_size_pretty(pg_indexes_size('public.send_job'))        AS indexes_size,
    pg_size_pretty(pg_total_relation_size('public.send_job')) AS total_size;

接着看死元组和 autovacuum 记录:

SELECT
    n_live_tup,
    n_dead_tup,
    last_autovacuum,
    autovacuum_count
FROM pg_stat_user_tables
WHERE relname = 'send_job';

然后查长事务:

SELECT
    pid,
    state,
    now() - xact_start AS xact_age,
    left(query, 120) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start ASC
LIMIT 10;

如果确认是热表,就给这张表单独调 autovacuum,不要急着改全局参数。

如果索引也膨胀明显,再考虑:

REINDEX INDEX CONCURRENTLY idx_send_job_pending;

如果表文件已经大到必须释放磁盘,再安排 pg_repackVACUUM FULL 或影子表切换。

总结

这次任务队列表变慢,表面上是一个查询耗时问题,实际排查落到了 MVCC、死元组、autovacuum 和索引膨胀上。

PostgreSQL 里的热更新表很容易出现这个问题,尤其是任务队列、订单状态表、消息投递表、库存流水表这类高频更新场景。索引设计正确只能保证查询路径不跑偏,不能自动解决死元组堆积。

我后来给这类表都加了三条约束:状态更新尽量少,完成数据尽快归档,autovacuum 按表单独配置。这样做之后,任务拉取接口重新稳定在几十毫秒,表大小也没有继续失控。

下一篇我会写 PostgreSQL 分页查询优化,重点放在 OFFSET 深分页为什么越来越慢,以及用游标分页、复合排序键和索引顺序把查询稳定下来。

posted @ 2026-05-09 15:44  小雨青年  阅读(9)  评论(0)    收藏  举报