为什么一条简单的
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)中写入新行,还会:
-
在每一个相关索引中插入一条新的索引条目,指向新行;
-
旧的索引条目(指向 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;
⚠️ 不带
CONCURRENTLY的REINDEX会对表加排他锁,生产环境请务必使用并发模式。
六、总结
一次 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 堆积失控。