前言
最近遇到6000条的数据在没有索引的查询下,执行时间来到了 9s,上一篇文章【6000条数据执行时间(一)】已经分析了主要引发这个问题原因 ,就是数据本身只有16MB左右,但是却占用了磁盘1.8GB的空间,全表扫描的时候遍历这1.8GB的空间就非常的慢了。
就是PG库MVCC机制会引发空间利用问题: 每次
UPDATE或DELETE不会立即物理删除旧数据,而是产生一个死元组(dead tuple),这个死会背数据自动清理机制打标记,标记改空间可重用,但是不会删除数据回收空间。
上一篇文章分析到,标记任务数据库是正常执行的,死元组(dead tuple) 基本在20%左右,所以自动清理机制(打标)是没有问题,问题就在于这些标记可重用的空间没有被重新利用起来。
✔解决表膨胀的方式有多种,我们采取的是在半夜,执行vacuum full 命令,来达到重建表的效果。
16M 的MB的数据为什么会膨胀到 1.8G
虽然PG的MVCC机制会产生垃圾空间,但是数据库本身的机制会把这些空间标记为可重用。显然我们这个表标记可重用的空间没有被利用起来。
✅下面看看DeepSeek对空间重用的说明吧,大的意思就是小于8Kb的数据能够更好的利用历史版本数据占用的空间,如果行的数据比较大则很难去利用free的空间。所以引发了空间重用利用率极低,数据又在不断的更新(占用新的空间)
空间重用的两种主要方式
1. 普通插入/更新:基于 FSM 的跨页面重用
- 流程 :
INSERT新行 → 数据库查询 FSM → 找到第一个空闲空间足够容纳该行的页面 → 将新行写入该页面的空闲位置 → 如果该页面剩余空间变成0,FSM 条目更新。 - 效果 :新行可以"填进"之前被删除行留下的空洞中,无需扩展文件。
- 限制:查找 FSM 有轻微开销;对于大行(超过 8KB),无法直接放入标准页面,必须使用 TOAST 存储,此时 FSM 针对的是 TOAST 表。
2. HOT 更新(堆内元组更新):同一页面内的就地重用
当 UPDATE 操作满足以下条件时,会触发 HOT 更新:
- 被更新的索引列未改变(避免更新索引)。
- 新行版本可以放入旧行所在的同一页面的空闲空间(通常页面内有碎片或已删除其他行)。
在这种情况下,数据库:
- 将旧行标记为"死"状态。
- 在同一页面的空闲位置写入新行。
- 将新行的行指针指向旧行的位置(实现元组链)。
- 好处:不产生新页面,不更新索引,性能极佳;页面内部的空间被高效复用。
你的场景:空间重用为何失效?
根据之前的分析,你的表平均每行 282KB,远大于一个页面(8KB)。这意味着:
- 每行跨越多个页面 。一个 282KB 的行需要占用约
282KB / 8KB ≈ 36个页面。这些页面物理上连续(因为行存储为"块")。 - 更新任何字段(即使很小)都无法进行 HOT 更新 。因为新行版本与旧行版本一样大,同一页面内没有足够的连续空间再放一整行。数据库只能在新位置分配一整块连续页面(36个页面)来放置新版本,旧版本所在的36个页面变成死元组。
- FSM 无法有效重组这些大块空间 。FSM 记录的是每个页面的空闲字节数,但对于跨越36页的大行,FSM 只是看到分散的"小碎片"(每个8KB页面可能有一些空闲字节)。这些碎片不足以容纳另一个282KB的大行(需要36页连续空间),因此数据库永远无法将这些碎片"拼凑"起来重用,只能继续分配新页面。
- 结果 :每次更新都导致表文件扩展
36 页 × 8KB = 288KB。几十次更新后,就会多出几十个 288KB 的旧版本数据块。即使VACUUM运行,FSM 也被"无效碎片"填满,真正的重用几乎不发生 → 表膨胀从 16MB 发展到 1.8GB。
改善空间重用效率的方法
1. 减小单行大小(根本)
- 将大字段拆分成子表(1:1 关联),让主表行变小(例如 < 8KB)。
- 这样每次更新主表的小字段,就可以触发 HOT 更新,空间重用高效。
2. 主动整理碎片
VACUUM FULL:重建表,消除所有碎片,把有效数据压缩到文件头部。代价:锁表。pg_repack(或sys_squeeze):在线重建,不锁表。
🚀测试是大字段引发的空间无法重用问题
对A表进行更新操作,对比A表有大字段频繁更新和没有大字段频繁更新的前后大小。
批量更新1000次 :
- 没有大字段的情况(text类型的字段为null):全表更新1000次,表空间变成从 64KB ---> 3008KB 表空间膨胀50倍
java
public void testA(@RequestParam String num){
List<UnionType> types = typeService.list();
for (int i = 0; i < Integer.parseInt(num); i++) {
typeService.updateBatchById(types);
}
}
- 没有大字段的情况(text类型的字段为null):全表更新1000次,使用单条缓慢更新,表空间从64KB ----》768K 表空间膨胀10倍
java
public void testA(@RequestParam String num) throws InterruptedException {
List<UnionType> types = typeService.list();
for (int i = 0; i < Integer.parseInt(num); i++) {
log.info("--------------:{}",i);
for (UnionType type : types) {
Thread.sleep(1);
typeService.updateById(type);
}
}
}
-
将一个字段变成大字段(text类型的字段存了文本):全表更新1000次,使用单条缓慢更新,表空间从64KB----->448KB 表空间膨胀不到10倍
-
将一个字段变成大字段(text类型的字段存了文本):全表更新1000次,使用批量更新,表空间从64KB----->9728 kB 表空间膨胀 150多倍
✔测试结论:测试有一定的局限性,批量更新导致表膨胀的可能性最大,特别是表存在大字段的时候。
总结
在使用PG类型库的时候,还是要注意控制单条数据的大小。特别是某些表字段非常多,存在大字段的时候,批量更新之后一定要检查表占的空间大小。
数据库还是得定期维护呀🤞