PostgreSQL Dead Tuple 与索引膨胀深度解析

为什么一条简单的 UPDATE 会让数据库越来越慢?


一、根源:MVCC 机制

PostgreSQL 使用 MVCC(多版本并发控制,Multi-Version Concurrency Control) 实现事务隔离。

其核心思想是:

UPDATE 不是"修改",而是"写一条新记录 + 将旧记录标记为死亡"。

这意味着每一次 UPDATE,磁盘上都会多出一条数据。


二、什么是 Dead Tuple?

执行一条 UPDATE

sql 复制代码
UPDATE orders SET status = 'done' WHERE id = 1;

PostgreSQL 实际发生的事情

复制代码
【旧行】id=1, status='pending'  →  标记为"已删除"(dead tuple,死元组)
【新行】id=1, status='done'     →  插入一条全新的行

旧行(dead tuple)并不会立刻从磁盘中抹除,它依然静静地占据着存储空间,等待后续被清理。

Dead Tuple 的特征

属性 说明
占用真实磁盘空间 存在于数据页(Page)中,不会自动消失
对新事务不可见 旧事务提交后,新事务无法读取到它
依赖 VACUUM 清理 只有执行 VACUUM 操作后,空间才会被回收

为什么不直接删掉?

因为在并发场景下,其他正在进行的事务可能仍然需要读取旧版本的数据(快照隔离)。PostgreSQL 必须保留旧行,直到确认没有任何事务再需要它为止。


三、什么是索引膨胀?

问题的产生

每次 UPDATE,PostgreSQL 除了在堆表(Heap)中写入新行,还会:

  1. 每一个相关索引中插入一条新的索引条目,指向新行;

  2. 旧的索引条目(指向 dead tuple)不会立刻被清理,继续占据索引空间。

    索引结构(B-Tree)示意:
    ├── id=1 → 指向旧行(dead tuple,无效)
    └── id=1 → 指向新行(live tuple)✅

随着大量 UPDATE 的累积,索引中堆满了指向 dead tuple 的无效条目,索引文件体积持续膨胀。

索引膨胀的危害

问题 说明
索引文件变大 占用更多磁盘空间
查询性能下降 扫描索引时需要跳过大量无效条目
内存命中率降低 Buffer Cache 被无效数据"稀释",有效数据被挤出
写入速度变慢 每次写入需要维护更庞大的索引结构

四、大批量 UPDATE 为什么特别慢?

以下面这条 SQL 为例:

sql 复制代码
UPDATE magellan_nkh_inbound
SET version_number = CONCAT(version_number, '_zip')
WHERE version_number = #{olderBatch}
AND NOT EXISTS (
    SELECT 1 FROM magellan_nk_odh_inbound b
    WHERE b.version_number = #{newerBatch}
      AND b.sell_record_id = magellan_nk_odh_inbound.sell_record_id
);

一次性对千万级数据执行 UPDATE,代价如下:

复制代码
每更新 1 行  =  1 个 dead tuple + 1 条新堆行 + 索引条目翻倍(旧dead + 新live)
更新 1000 万行  =  1000 万 dead tuple + 至少 2000 万个索引变动

修改主键字段,代价翻倍

本例中 (version_number, sell_record_id)联合主键 ,而 UPDATE 直接修改了 version_number(主键字段之一),这导致:

  • 主键索引需要全量重写
  • 等同于把整张表的主键索引翻了一遍;
  • 所有依赖该主键的二级索引也需要同步更新。

这是性能极差的根本原因。


五、如何诊断与处理?

查看表的 Dead Tuple 情况

sql 复制代码
SELECT
    schemaname,
    tablename,
    n_live_tup                                                          AS live_tuples,
    n_dead_tup                                                          AS dead_tuples,
    ROUND(n_dead_tup * 100.0 / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio,
    last_autovacuum,
    last_vacuum
FROM pg_stat_user_tables
WHERE tablename = 'magellan_nk_odh_inbound';

查看索引膨胀情况

sql 复制代码
SELECT
    indexname,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
    idx_scan,
    idx_tup_read,
    idx_tup_fetch
FROM pg_stat_user_indexes
WHERE tablename = 'magellan_nk_odh_inbound'
ORDER BY pg_relation_size(indexrelid) DESC;

清理 Dead Tuple

sql 复制代码
-- 普通清理(不回收磁盘空间,但标记空间可复用)
VACUUM magellan_nk_odh_inbound;

-- 彻底清理并回收磁盘空间(会锁表,慎用)
VACUUM FULL magellan_nk_odh_inbound;

重建索引(消除索引膨胀)

sql 复制代码
-- 不锁表重建(推荐生产环境,PostgreSQL 12+)
REINDEX INDEX CONCURRENTLY idx_name;

-- 重建表上所有索引(不锁表)
REINDEX TABLE CONCURRENTLY magellan_nk_odh_inbound;

⚠️ 不带 CONCURRENTLYREINDEX 会对表加排他锁,生产环境请务必使用并发模式。


六、总结

复制代码
一次 UPDATE 的完整代价:

  堆表(Heap)
  ├── 旧行 → [dead tuple,占用空间] ←── VACUUM 清理后才释放
  └── 新行 → [live tuple]

  索引(Index)
  ├── 旧条目 → 指向 dead tuple ←── VACUUM 后才可回收
  └── 新条目 → 指向 live tuple ✅

  结论:
  大量 UPDATE = 大量 dead tuple = 索引膨胀 = 表膨胀 = 查询/写入性能劣化

千万级数据修改的最佳实践

方案 优点 缺点
一次性 UPDATE 代码简单 dead tuple 爆炸,性能极差
分批 DELETE + INSERT dead tuple 可控,性能稳定 实现稍复杂,推荐使用
创建新表 + 数据迁移 最彻底,无碎片 停机时间长,适合离线操作

核心原则 :千万级以上数据若需修改主键字段,应优先考虑分批 DELETE + INSERT ,并在每批次之间留出时间让 autovacuum 跟上清理节奏,避免 dead tuple 堆积失控。

相关推荐
岳麓丹枫00113 小时前
Windows 版 smem_通过服务名获取对应进程树的内存统计
windows·postgresql
岳麓丹枫00114 小时前
Windows版本smem_通过进程名统计对应内存占用
windows·postgresql
liuzhilongDBA15 小时前
当 PostgreSQL 成为 AI 的双手——Bruce Momjian 的 MCP Server 实战
数据库·人工智能·postgresql
qq_4523962315 小时前
第八篇:《Dockerfile 指令精讲(一):FROM、RUN、COPY、ADD》
数据库·docker·postgresql
小当家.1051 天前
PostgreSQL 做向量数据库:pgvector 在 RAG 中的实战与多场景适配
数据库·人工智能·postgresql·rag
倒流时光三十年1 天前
PostgreSQL 部分索引(Partial Index)详解
数据库·postgresql·partial index·部分索引
Gauss松鼠会1 天前
GaussDB(DWS)性能问题处理套路
服务器·数据库·postgresql·性能优化·gaussdb
夜郎king2 天前
PostgreSQL 16 搭配 PgVector:Windows 11 完整安装教程
数据库·windows·postgresql
或与且与或非2 天前
postgresql+rabbitmq集群搭建方案
数据库·postgresql·rabbitmq