凌晨四点被空间告警叫醒这件事,干过数据库运维的人基本都经历过。那次值班,我登录GaussDB实例时,数据目录所在分区使用率已经冲到97%,监控面板上一片红,可业务侧反馈只是有一条常规批处理在跑,没有导入导出任务,也没有人删过表。我第一反应是“脏数据太多”,但真去排查时才发现,空间问题从来不是一条delete就能解释的。
说句实在话,GaussDB数据库空间排查这件事,最忌讳的就是上来就清数据。磁盘满了,你删几张表、VACUUM一下,当时数字确实掉下来了,但过两天告警再响,你又要重复一遍。真正需要做的,是把空间拆开看:数据库对象占了多少、WAL日志占了多少、临时文件有多少、死元组和长事务又拖住了多少可回收空间,每一块都盘清楚,才能对症下药。这篇文章我就按实际排查的顺序,把GaussDB空间排查的完整思路、常用SQL、容易漏掉的黑洞和一次实战复盘整理出来,给同样被空间问题折腾过的同学一个可以直接参考的手册。
1. 报警之后先别急着清数据:建立空间画像
很多人接到磁盘告警的第一反应是跑进库里查大表,然后truncate。方向没错,但顺序反了。你先要搞清楚磁盘空间是被“数据库文件”占的,还是被“非数据库文件”占的,否则你在库里忙活半天,回头发现占用大头是日志归档目录,就尴尬了。
1.1 先从操作系统层确认挂载与目录分布
不管GaussDB是集中式还是分布式形态,数据文件最终都落在操作系统的某个目录下。第一步永远是先看分区挂载情况:
bash复制df -h
这条命令能告诉你哪个分区满了、总容量多大、已经用了多少。紧接着要看GaussDB的数据目录到底在哪个挂载点上。确认数据目录位置最直接的办法是连上实例执行:
sql复制SHOW data_directory;
如果实例已经因为空间不足起不来,或者连上去很卡,那就用最土但最有效的办法:读配置文件。GaussDB安装时通常会在数据目录下放postgresql.conf,里面data_directory配置项直接写明了路径。不同发行版、不同安装方式的路径差异很大,不要凭经验猜。
拿到数据目录之后,用du按目录从大到小扫一遍:
bash复制du -sh /gaussdb/data/* 2>/dev/null | sort -hr | head -20
这一步能快速看到空间开销集中在base、pg_wal、pg_xlog这些子目录,还是另有乾坤。注意一点,老版本PG内核里WAL目录叫pg_xlog,新版本叫pg_wal,GaussDB早期一些分支沿用了老命名,看到哪个都不必意外。如果你看到某个超大目录既不在base下,也不是日志目录,那大概率就是临时文件或归档残留,这种非表空间占用在数据库内部是查不到的,只能靠操作系统层发现。
1.2 区分“数据库视角的大小”和“操作系统视角的大小”
很多DBA在这里会有一个误解:数据库里查出来表有100GB,为什么du看到的目录只有80GB?或者反过来,库里某张表只有30GB,磁盘却因为一个陈旧文件被占满了。
原因在于,数据库内部统计的是“逻辑大小”,比如表的数据量、索引占用的页面数;而操作系统看到的是“物理文件大小”,包括文件系统块、日志、临时文件、以及已经删除但还被进程占用的文件空间。举个最常见的例子:你用rm删掉了一个大日志文件,但持有该文件句柄的进程还在运行,df看空间一点没释放,du却已经看不到这个文件了。这在Linux上非常经典,排查GaussDB空间时同样可能遇到。
所以我的建议是,先同时跑df -h和du -sh,对比两份结果。如果df显示满、du加出来的总量却对不上,优先考虑文件被删除但未被释放、以及隐藏挂载点这两种情况。lsof +L1可以快速列出被删除但仍被进程占用的文件,看到结果再针对性处理。
1.3 初始化排查底表,避免东一榔头西一棒
空间排查过程中,我习惯先把关键信息记录成一张“底表”:
| 检查项 | 命令/视图 | 判断标准 |
|---|---|---|
| 文件系统空间 | df -h |
使用率超过85%就该关注 |
| 数据目录内部开销 | du -sh /数据目录/* |
找出top级别目录 |
| 数据库整体大小 | pg_database_size() |
确认库级分布 |
| 表空间大小 | pg_tablespace_size() |
确认是否表空间增长 |
| WAL/归档堆积 | pg_wal/ 目录大小 |
单文件16MB,数量异常即为堆积 |
| 临时文件 | pg_stat_database.temp_bytes |
执行大查询后瞬时飙升 |
把这几个指标先过一遍,心里有数了再往下查,才不会在排查时被表象带偏。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 库、Schema、对象级别的空间分布:从大到小锁定目标
操作系统层的画像建好之后,第二步是回到数据库里,按“数据库→Schema→表/索引”三个层级逐层下钻,找到真正吃空间的对象。很多情况下,你不需要一开始就针对所有表做全量统计,成本高而且没必要,用系统函数加排序就能很快聚焦。
2.1 先看库级分布,确认空间是不是被单个库吃掉的
如果实例里同时跑了多个业务库,第一步应该看每个库占多大。连接实例后执行:
sql复制SELECT datname, pg_database_size(datname) AS size_bytes,
pg_size_pretty(pg_database_size(datname)) AS size_pretty
FROM pg_database
ORDER BY pg_database_size(datname) DESC;
也可以用psql或gsql的元命令直接看:
code复制\l+
两种方式效果类似。需要提醒的是,如果你的GaussDB是分布式部署,CN节点上的pg_database_size通常只反映元数据,真实数据分布在各个DN上。这时候需要分别登录各个DN执行统计再汇总,或者利用管理平台提供的集群级空间视图,否则会严重低估实际使用量。我见过有人只看CN就以为空间没涨,结果某个DN磁盘已经写满的案例,务必注意。
2.2 表级大小统计:pg_total_relation_size 是最常用的口径
到了Schema层,核心函数是pg_total_relation_size。这个函数返回的是表本身、表的索引、以及表的TOAST表和TOAST索引加在一起的总大小,比pg_relation_size更贴近实际占用。查询某个Schema下最大的20张表,可以写成这样:
sql复制SELECT schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size,
pg_total_relation_size(schemaname||'.'||tablename) AS total_size_bytes
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC
LIMIT 20;
如果你手上的GaussDB版本支持pg_class,更精细的写法是:
sql复制SELECT n.nspname AS schema_name,
c.relname AS table_name,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'p', 'm')
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 20;
relkind里'r'是普通表,'p'是分区表,'m'是物化视图。把三种都带上是比较稳妥的做法。
2.3 表大不代表索引不大:拆开看表和索引
pg_total_relation_size给出的是一口价,但你要知道空间到底是数据占的还是索引占的,因为处理方式完全不同。数据膨胀可以VACUUM FULL,索引膨胀有时候重建一下比vacuum更有效。拆开看的写法是:
sql复制SELECT c.relname,
pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
pg_size_pretty(pg_indexes_size(c.oid)) AS indexes_size,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
ORDER BY pg_indexes_size(c.oid) DESC
LIMIT 10;
如果一张表的表体只有5GB,索引却占了20GB,说明索引写放大极其严重,通常和频繁UPDATE、大批量删除后未重建有关。这种表做一次索引重建或VACUUM FULL,回收效果会非常明显。另外注意TOAST表,大字段多的情况下TOAST也可能变成一个隐形空间大户,表级统计时别漏。
2.4 分区表统计时的坑:主表大小不代表真实数据
GaussDB对分区表的支持很常见,但分区表的大小统计有个容易踩的坑:pg_total_relation_size查分区主表时,返回的往往不是所有分区加在一起的总大小,因为数据实际落在各个分区子表里。如果你业务里大量使用了分区表,只查主表会被严重误导。
要统计分区表的完整大小,得把下属所有分区都查一遍,或者用递归思路汇总。实践里可以这样做:
sql复制SELECT parent.relname AS parent_table,
sum(pg_total_relation_size(child.oid)) AS total_size_bytes,
pg_size_pretty(sum(pg_total_relation_size(child.oid))) AS total_size
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
WHERE parent.relname = '你的分区表名'
GROUP BY parent.relname;
如果分区数量特别多,这个SQL可能有点慢,但空间排查不是高频操作,慢一点可以接受。只看主表大小然后拍脑袋做扩容,往往会把排查方向带偏。
3. 死元组、长事务与复制槽:空间膨胀真正的元凶
如果第2章节查出来的结果是某张核心业务表特别大,而且这张表平时有频繁的UPDATE和DELETE,那基本可以判断空间问题不只是“数据多”,而是“死元组堆积”导致的膨胀。这一节是整个排查里最关键的部分,因为很多人卡在这里:明明数据量没涨,DELETE也执行了,磁盘空间却不降反升。
3.1 为什么DELETE了数据,空间反而没有释放
GaussDB的MVCC机制和PostgreSQL系内核一脉相承,UPDATE和DELETE不会立刻从物理文件中抹掉旧数据,而是生成一个新版本,把旧版本标记为“死元组”。这些死元组要等到没有任何活跃事务能看到它们之后,才能被清理进程回收。如果数据库长期没有执行VACUUM,或者有长事务挡着,死元组就会越积越多,表文件越撑越大。
判断一张表的膨胀健康度,最直接的字段是pg_stat_all_tables里的n_dead_tup和last_vacuum:
sql复制SELECT schemaname,
relname,
n_live_tup,
n_dead_tup,
last_vacuum,
last_autovacuum,
last_analyze
FROM pg_stat_all_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC
LIMIT 20;
n_dead_tup长期维持在高位,说明清理线程没跟上,或者有事务在阻塞清理。注意这个数字是估算值,不是精确值,但用来判断趋势和相对规模足够了。
3.2 长事务和老快照是空间不回收的头号帮凶
死元组的回收条件是“对该元组可见的事务都已经结束”。换句话说,只要库里存在一个长时间不提交的事务,或者一个长查询持有老快照,它之前的旧版本数据全都要保留,VACUUM跑了也白跑。
排查长事务的SQL要记住:
sql复制SELECT pid,
state,
backend_xmin,
backend_xid,
now() - xact_start AS xact_duration,
now() - query_start AS query_duration,
wait_event_type,
wait_event,
substring(query, 1, 100) AS query_text
FROM pg_stat_activity
WHERE state <> 'idle'
AND backend_xmin IS NOT NULL
ORDER BY xact_start ASC;
backend_xmin是当前会话持有的最小活跃事务快照,这个值越老,意味着死元组被保护得越久。你在VACUUM之前,必须先处理掉这些长事务,否则垃圾回收根本不会动那些旧版本。实际运维中,我遇到过很多次“昨晚一个报表任务跑了8个小时没结束,今天表膨胀了1倍”的情况,真凶就是这个。
3.3 复制槽延迟:另一个被忽略的“空间钉子户”
比长事务更隐蔽的是复制槽。物理或逻辑复制场景下,下游备机或同步工具消费不及时,主库的WAL日志和部分老版本数据会一直保留。复制槽不推进,VACUUM想清理旧版本也动不了,因为下游可能还需要那些数据。
检查复制槽状态:
sql复制SELECT slot_name,
slot_type,
active,
restart_lsn,
confirmed_flush_lsn,
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS lag_bytes
FROM pg_replication_slots;
如果lag_bytes持续增大,或者restart_lsn长时间不动,说明下游消费卡住了。处理手段是修复下游,而不是直接删复制槽——直接删槽虽然能立即释放空间,但也可能让下游数据断层,属于高风险操作。如果确认下游已经不需要同步了,再考虑删除复制槽,并且删之前要和业务确认。
3.4 VACUUM到底有没有用:普通VACUUM和VACUUM FULL的区别
很多初接触GaussDB的DBA会问一个问题:我执行了VACUUM,为什么磁盘空间没降?这就要说清楚两种清理方式的本质差别了。
普通VACUUM做的事情是:把死元组标记为可复用空间,更新统计信息,让后续的INSERT能重新利用这些空位。但它不会把空间还给操作系统,表文件在磁盘上的大小基本不变。只有执行VACUUM FULL,表才会被重写一遍,未使用的页被剔除掉,物理文件才会缩小。
所以结论是:
| 场景 | 推荐操作 | 锁情况 | 效果 |
|---|---|---|---|
| 日常清理死元组,防止膨胀 | 普通VACUUM/autovacuum | 不阻塞读写 | 空间不还给OS,但可复用 |
| 表已经严重膨胀,需要缩文件 | VACUUM FULL | 8级锁,阻塞读写 | 物理文件缩小 |
| 清理后需要统计信息准确 | ANALYZE | 轻量锁 | 更新统计信息 |
有一种情况比较特殊:如果DELETE之后马上执行VACUUM FULL,文件大小通常会明显下降,但VACUUM FULL在执行期间会持有表级锁,业务读写会全部堵住。对大表做这个操作前,一定要评估业务窗口,最好挑低峰期。并且VACUUM FULL重写表期间,磁盘还需要额外空间来承载新旧两份文件,如果磁盘已经到98%以上,可能反而会因为空间不足而失败。遇到这种死局,优先扩容或者先删掉一些可重建的临时表释放空间,再执行回收。
4. WAL、归档与临时文件:几个容易漏掉的隐性空间黑洞
表膨胀排查完之后,空间问题往往已经明朗了,但有一类问题会漏,因为它在数据库对象统计里完全看不到,却在磁盘上实打实占着几十GB甚至上百GB。这类问题集中出现在WAL日志、归档目录和临时文件上。
4.1 WAL目录暴涨:谁在阻止检查点清理日志
GaussDB的WAL日志(无论目录叫pg_wal还是pg_xlog)默认单文件16MB。正常情况下,检查点之后旧的WAL会被清理或归档,目录大小能维持在一个平稳水位。但如果你发现WAL目录下有几百上千个文件,就要检查三件事。
第一,是否开启了归档但归档命令持续失败。归档失败时,主库不会清理已经归档过的WAL,因为严格来说它们还没有被安全归档。去归档日志目录翻一下,如果有大量.failed或.ready状态的文件,基本可以确定归档链路出了问题。
bash复制ls -l /gaussdb/archive_status/ | tail -20
第二,是否有备机断连或复制槽不推进。备机离线时间越长,主库需要保留的WAL就越多。第三,检查点是否过于频繁或者wal_keep_size配置过大。你可以用SQL看归档和检查点情况:
sql复制SELECT * FROM pg_stat_archiver;
重点关注failed_count和last_failed_time,如果失败次数在持续增长,空间问题就是归档失败导致的。
4.2 undo段或旧版本保留机制带来的隐性占用
GaussDB在事务回滚和MVCC上对内核做过多轮优化,不同版本对旧版本的管理策略差异挺大。有的版本依赖VACUUM清理死元组,有的版本内部实现了undo链复用机制,类似Oracle的undo表空间,在事务提交后一段时间内旧版本不会立即清掉,而是受类似undo_retention_time这样的参数控制。
遇到这类实例,你在pg_stat_all_tables里可能看到死元组并不多,磁盘占用却居高不下。这时候需要去查实例对应的undo统计视图或参数配置,确认是不是保留窗口设置得太长。原则上,事务保留窗口越长,空间复用越慢,空间增长越快。在业务能接受的前提下,适当缩短保留时间能有效控制这类空间增长。
4.3 临时文件:排序溢出和异常残留
GaussDB执行大批量排序、Hash Join或创建索引时,如果work_mem设置得太小,中间结果会溢写到磁盘上的临时文件。正常情况下临时文件在会话结束后会自动清理,但碰到数据库异常重启、会话被杀、连接断开等情况,残留临时文件会一直躺在临时目录里。
数据库内部可以通过pg_stat_database统计临时文件的使用量:
sql复制SELECT datname,
temp_files,
pg_size_pretty(temp_bytes) AS temp_size
FROM pg_stat_database
ORDER BY temp_bytes DESC;
操作系统层面,到数据目录下找base/pgsql_tmp或者pgsql_tmp目录,具体路径因版本而异,看到.tmp后缀的残留文件,确认对应的会话已经不存在后可以直接删除。
4.4 已删除文件未释放进程句柄
这个在1.2节提过,但实际案例中太常见了,值得再单独强调一下。有些环境会为了避免磁盘写满而定期用脚本删除过期归档文件,但如果删除动作发生在数据库进程仍在写入该文件的瞬间,或者删的是某个正在被打开的日志,文件系统层面空间不会真正释放。
排查手段很简单:
bash复制lsof +L1 | grep -i gauss | head -20
看到(deleted)字样的记录,记下PID,确认进程类型,再判断是重启进程还是干脆什么都不用做等它自然释放。这种文件在du里看不到,但在df里空间就是满的,不熟悉的人很容易在这里绕圈子。
5. 一次磁盘告警的完整排查链路复盘
前面讲的是方法论,这一节我打算把一次实际告警的处理过程完整过一遍。那是一个典型的业务系统,上游在每天凌晨批量更新订单表,下游有数据同步任务通过逻辑复制订阅变更。接到告警时,磁盘使用率已经到93%,而且还在缓慢往上涨。
我没有直接去删数据,而是按顺序做了下面这些事。
5.1 第一阶段:操作系统层扫描
先执行df -h,确认是数据分区告警。紧接着du -sh扫描数据目录,结果让我有点意外,WAL目录只占了不到10GB,base目录占了500多GB,所以空间大头确实在数据库对象上。用lsof +L1排查了已删除文件,也没有异常。说明问题往里走,在数据文件内部。
5.2 第二阶段:库级和表级定位
连上实例后,先查了每个库的大小。其中订单库占了400多GB,占了绝大部分。继续往表级定位时,发现public.order_detail和public.order_log两张表的pg_total_relation_size加一起超过300GB。这时候我多看了一眼pg_indexes_size,发现订单详情表的索引大小甚至超过了表体大小,典型的写放大痕迹。
5.3 第三阶段:确认死元组与阻塞源
再查pg_stat_all_tables,两张表的n_dead_tup都在百万级别,last_autovacuum却是一个星期前。一看就是清理速度跟不上产生速度。同时查活跃事务时发现一条逻辑复制连接长期处于active状态,它的backend_xmin停留在3天前,而且复制槽的restart_lsn也已经落后非常多。
到这里基本就真相大白了:高频UPDATE产生海量死元组,VACUUM尝试清理却因为有复制槽和长事务保底,老版本必须留着,磁盘自然只增不减。
5.4 第四阶段:处理与验证
处理顺序非常重要。我先把下游同步任务暂停,确认业务可以接受短时数据延迟后,删除了那个已经失效的逻辑复制槽,让数据库不再被它拖着。然后找到那条长事务所在的会话,和业务方确认后手工终止。最后再执行:
sql复制VACUUM (ANALYZE, VERBOSE) public.order_detail;
第一轮只做普通VACUUM,确认没有报错,死元组数量开始下降,但物理文件变化不大。随后在凌晨低峰期对最膨胀的几张表执行了VACUUM FULL。因为VACUUM FULL会锁表,我提前和生产确认了窗口,同时确保磁盘还有足够余量容纳重写过程中的临时文件。执行完成后,df -h显示分区使用率从93%降到了61%,效果立竿见影。
5.5 复盘总结:这次问题的关键点
事后复盘,这次告警有三个教训可以分享:告警的根因不是“数据变多了”,而是“回收链路被卡住了”,如果只清数据不处理复制槽和长事务,空间问题一定会复发;VACUUM FULL不是第一选择而是最后手段,先通过解除阻塞让autovacuum恢复工作,往往可以避免长时间锁表;案例里的复制槽如果早配置过期保护或监控告警,这次空间告警根本不会发生。
| 排查阶段 | 关键操作 | 本次发现 |
|---|---|---|
| 操作系统层 | df、du、lsof | 无异常,数据文件占大头 |
| 数据库层 | pg_database_size、pg_total_relation_size | 订单库两张表超大 |
| 阻塞源 | pg_stat_activity、pg_replication_slots | 失效复制槽+长事务 |
| 清理动作 | VACUUM + VACUUM FULL | 使用率93%降61% |
6. 给长期维护提的几个小建议
空间排查手册写到这儿,方法论和案例都讲完了。但以我的经验来看,真正拉开差距的不是出了问题之后谁定位得快,而是谁能提前让问题不出现。所以最后补几点长期维护层面的建议。
6.1 给空间增长建立“正常水位”
每个实例的空间增长都有自己的规律,比如每天WAL产生量、每周表膨胀速率、每月归档总量。建议把pg_database_size、WAL目录大小、pg_stat_database.temp_bytes、pg_replication_slots的落后字节数做成定时采集任务,哪怕只是每天跑一次存到一张性能表里,两周后你就能看到这个实例的“正常水位”是什么样。没有基线,告警阈值就只能拍脑袋,有了基线,任何偏离都能第一时间发现。
6.2 优先治理长事务和复制槽,而不是频繁调VACUUM参数
遇到膨胀问题,很多人的第一反应是调autovacuum参数,比如调低阈值、调高并发。但如果根因是长事务或复制槽,调参不会有任何效果。我的经验是:每隔几小时扫一次pg_stat_activity,对超过30分钟的长事务告警;对复制槽的restart_lsn落后量设置独立监控,比如落后超过20GB就告警。把这些“阻塞类”指标纳入日常监控,很多空间问题都能消除在萌芽阶段。
6.3 VACUUM FULL之前要想清楚目的
操作级的提醒:执行VACUUM FULL前,先做一次普通的VACUUM,确认死元组确实能被清理,再评估是否需要做FULL。如果普通VACUUM都清不掉,说明有阻塞源,这时候做FULL同样是白做。另外FULL之后建议跟着执行ANALYZE,否则统计信息可能不准确,优化器容易选错执行计划。
6.4 备份脚本里别漏了空间检查
很多环境的数据备份都是全量加归档,如果备份文件和数据文件在同一个挂载点,备份动作本身就是空间告警的触发器。建议在备份脚本执行前后各做一次空间检查,如果剩余空间低于某个安全阈值,直接中止备份并告警。数据库空间排查不只是处理数据库本身,周边链路的关联检查同样不能省。
