上周给一个生产MySQL实例排查磁盘告警,数据目录用了92%,innodb_file_per_table开着,我以为能一眼定位到哪张表出了问题,结果导出的Top表清单里最大一张才30GB,目录却占了接近2TB。来回对了几轮才反应过来:统计方式错了。information_schema.tables里的DATA_LENGTH在某些版本和场景下根本不能代表物理文件大小,再加上一堆碎片和膨胀的索引,只看表面数据完全定位不到问题。
后来我把主流数据库挨个过了一遍,把表级磁盘占用的统计口径、查询SQL和实际踩过的坑都整理清楚了。这篇系统聊聊如何查看数据库表的磁盘占用情况,适合DBA、后端开发、以及所有需要维护数据库实例的人参考。
1. 为什么"表磁盘占用"看起来简单,实操中却总对不上
1.1 一张表的空间由哪些部分组成
先说一个基本概念:一张表在磁盘上占的空间,绝不是"行数×平均行宽"这么简单。以InnoDB为例,一张表实际会包含这些部分:
- 聚簇索引(也就是主键索引)本身占用的B+树空间,数据行都存在这里。
- 所有二级索引占用的B+树空间,每个二级索引都是一棵独立的树。
- 大字段(TEXT/BLOB/JSON)可能溢出到额外页面甚至独立文件。
- 页内碎片、预留空闲页、段(segment)管理带来的额外开销。
- 如果有全文索引、空间索引,同样单独占空间。
PostgreSQL更明显一些,一张表不仅有主堆(heap),还有TOAST表(用于存储超长字段)、TOAST索引、空闲空间映射(FSM)、可见性映射(VM)。如果用pg_relation_size只查主堆大小,就会严重低估真实占用。
所以"查看一张表占用多少磁盘"这个需求,首先要明确统计口径:你到底是关心表的裸数据大小、数据加索引大小,还是包含所有辅助结构的总占用。不同口径结果可能差好几倍,这也是很多人排查磁盘问题时数据对不上的根本原因之一。
1.2 数据库统计信息与物理文件之间的差异从哪来
数据库内部其实一直在收集表的统计信息,比如information_schema.tables里的DATA_LENGTH、INDEX_LENGTH,或者pg_class里的relpages,但这些值很多是采样估算出来的,不是实时的物理文件大小。
MySQL的information_schema.tables会在表做ANALYZE TABLE、某些DDL、或者后台统计线程刷新时更新,取的是当前统计时刻的页数乘以页大小。页越碎、空闲空间越多,估算值和实际ibd文件大小差得就越远。
PostgreSQL里pg_class.relpages则是最近一次VACUUM/ANALYZE时统计的页数,如果一张表更新频繁,relpages可能跟实际文件相差很大。这时候需要靠pg_total_relation_size()这类函数去实时读文件元数据,结果才准确。
这也是为什么我强烈建议:表磁盘占用这个事情,不要只看GUI工具里的"表大小"列,要理解它背后的统计口径和误差范围。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 不同数据库查看表占用,查询方式真的不一样
2.1 MySQL:information_schema、sys库和ibd文件三种视角
MySQL查表大小,最常用的就是information_schema.tables:
sql复制SELECT
TABLE_SCHEMA,
TABLE_NAME,
ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) AS size_mb,
TABLE_ROWS
FROM information_schema.tables
WHERE TABLE_SCHEMA NOT IN ('mysql','information_schema','performance_schema','sys')
ORDER BY (DATA_LENGTH + INDEX_LENGTH) DESC
LIMIT 20;
这个SQL能快速排出Top大表,但它只是估算。想拿到更接近真实的数字,可以用sys库的表统计视图,前提是performance_schema开着的:
sql复制SELECT
TABLE_SCHEMA,
TABLE_NAME,
ROUND(total_latency IS NOT NULL, 0), -- 不需要这列,只是提醒你
ROUND((data_length + index_length) / 1024 / 1024, 2) AS size_mb
FROM sys.schema_table_statistics_with_buffer
ORDER BY size_mb DESC
LIMIT 20;
注意sys.schema_table_statistics_with_buffer还带count_star等访问统计信息,对判断"哪些大表值得优化"更有价值,而不仅仅是看尺寸。
最接近物理真相的方式是直接看文件。InnoDB开了innodb_file_per_table时,每个表独立一个ibd文件,可以用操作系统命令直接看:
bash复制ls -lh /var/lib/mysql/数据库名/表名.ibd
du -sh /var/lib/mysql/数据库名/
再看一个实际案例。有一回客户反馈某张订单表在information_schema里显示120GB,但ibd文件却有340GB。原因是这张表经过多年高频写入和删除,页空闲率极高,统计信息里的页数按"有效数据页"算,物理文件却保留了扩展后的尺寸。这种就需要用文件大小作为第一参考。
2.2 PostgreSQL:pg_total_relation_size与TOAST的隐形空间
PostgreSQL提供了一组非常方便的函数,强烈建议直接使用,不要自己去拼pg_class.relpages:
pg_relation_size('表名'):只看主堆大小。pg_table_size('表名'):主堆 + TOAST表 + TOAST索引 + FSM + VM,但不包括普通索引。pg_indexes_size('表名'):所有普通索引的总大小。pg_total_relation_size('表名'):上面所有加起来的完整占用,这是判断"表级磁盘占用"最权威的入口。
一条SQL排出所有表的真实占用:
sql复制SELECT
n.nspname AS schema_name,
c.relname AS table_name,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
pg_size_pretty(pg_relation_size(c.oid)) AS heap_size,
pg_size_pretty(pg_indexes_size(c.oid)) AS index_size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND n.nspname NOT IN ('pg_catalog','information_schema')
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 20;
这里特别要提醒TOAST问题。PostgreSQL对超长字段会压缩后放到TOAST表里,如果一张表有几个大字段(text、bytea、jsonb),TOAST表可能比主堆还大。而pg_relation_size只算主堆,所以你看到主表只有200MB、实际总占用2GB的情况非常常见。用pg_total_relation_size才能把TOAST一起算进去。
2.3 Oracle、SQL Server、达梦数据库的段空间查询
Oracle查表占用,核心是dba_segments视图(普通用户用user_segments),它按段(segment)统计空间,一个表在Oracle里本身就是一个段,索引是单独的段:
sql复制SELECT
owner,
segment_name,
segment_type,
ROUND(bytes / 1024 / 1024, 2) AS size_mb,
tablespace_name
FROM dba_segments
WHERE owner = '应用用户'
ORDER BY bytes DESC
LIMIT 30;
分区表的每个分区是一个独立段,统计时要注意segment_type包括TABLE PARTITION、INDEX PARTITION,要按segment_name汇总才是一个完整表的总大小。
SQL Server这边,最方便的是sp_spaceused存储过程:
sql复制EXEC sp_spaceused 'dbo.orders';
它直接返回行数、保留空间、数据空间、索引空间。如果要批量查所有表,可以配合sys.dm_db_partition_stats和sys.allocation_units写一个汇总查询,但日常用sp_spaceused已经够了。注意SQL Server里reserved是包括数据和索引的,data只是数据页。
达梦数据库因为兼容Oracle语法,同样可以用dba_segments或user_segments来查。我在一个达梦环境里排查过表空间膨胀问题,SQL结构和Oracle几乎一模一样,只是视图里个别字段名位置略有差异,基本是通用的。
2.4 用DataGrip、DBeaver等客户端快速查看
如果你不想敲SQL,也要会用工具。DataGrip左侧数据库树里选中表,右键"Inspect",能看到表的大小统计;DBeaver在表的"Properties"标签页里也能看到行数和空间信息。但这两个工具背后一般也是调用了上面的系统视图和函数,数据刷新时机不一定及时,比如DataGrip默认会缓存元数据,刚做过大批量删除后显示大小可能仍然偏高,需要手动刷新。
所以工具适合快速确认,真正做治理决策时还是要用SQL拿到准确数字。
3. 统计表大小时容易翻车的几个真实场景
3.1 索引比数据还大,问题比想象中隐蔽
我遇到过一个最典型的案例:一张业务流水表,行数不到8000万,主数据有40GB,但第二索引有7个,其中3个覆盖了相同的查询前缀列,索引文件加起来超过130GB。这个表在information_schema里显示接近170GB,物理文件也差不多,但怎么优化都绕不开这3个冗余索引。
这时候光看表总大小还不够,要拆分看数据大小和索引大小:
sql复制SELECT
TABLE_NAME,
ROUND(DATA_LENGTH / 1024 / 1024, 2) AS data_mb,
ROUND(INDEX_LENGTH / 1024 / 1024, 2) AS index_mb,
ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) AS total_mb
FROM information_schema.tables
WHERE TABLE_SCHEMA = '你的库'
ORDER BY (INDEX_LENGTH / DATA_LENGTH) DESC
LIMIT 20;
这个SQL可以帮你快速找出"索引占用比数据还多"的表。发现之后再用sys.schema_redundant_indexes查冗余索引,通常能释放出大量空间。
3.2 DELETE之后空间没降下来:空洞与碎片
"我删了80%的数据,磁盘空间为什么没降?"这是数据库运维里的经典问题。原因在于:
- InnoDB删除数据只是标记行有效位,页面不会物理收缩,磁盘
ibd文件不会变小。 - PostgreSQL删除后产生死元组,需要
VACUUM清理,清理之后空间也只是标记可复用,物理文件依旧是大块头,必须VACUUM FULL才能真正收缩。 - Oracle删除后高水位线(HWM)不会自动下降,需要
ALTER TABLE ... SHRINK SPACE或MOVE。
我自己踩过一次:一张日志表每天写入几百万行,定期删除30天前的数据,总以为删了就干净了,后来磁盘告警才发现日志表ibd文件已经膨胀到600GB,实际上有效数据只有60GB。解决方式是做了一次OPTIMIZE TABLE,但这个过程要非常小心,因为大表重建期间锁表时间长、临时空间也需要足够大,最好在业务低峰期执行,或者用在线DDL工具(gh-ost/pt-online-schema-change)来做。
3.3 information_schema的估算值误差
information_schema.tables的DATA_LENGTH和INDEX_LENGTH本质是InnoDB统计信息的估值,不是实时从文件读取的。我验证过一个极端的例子:连续高频DELETE并INSERT一张表,统计刷新周期没到,DATA_LENGTH可能只有物理文件三分之一不到,或者反过来INDEX_LENGTH虚高。
如果你要拿这个值做容量评估和迁移规划,建议用文件系统层面的数字。MySQL 8.0下可以直接查:
sql复制SELECT
FILE_NAME,
ROUND(TOTAL_EXTENTS * EXTENT_SIZE / 1024 / 1024, 2) AS size_mb
FROM information_schema.FILES
WHERE TABLESPACE_NAME NOT IN ('mysql','information_schema','performance_schema','sys')
ORDER BY TOTAL_EXTENTS * EXTENT_SIZE DESC
LIMIT 20;
这个查询能返回每个.ibd文件的物理大小,比DATA_LENGTH可靠得多。需要说明的是它统计的是表空间文件整体大小,也就是包含已分配但未使用的页面,更接近磁盘真实占用。
3.4 大字段、TOAST、LOB带来的虚假小表
有些表行数不多,但占用很大,问题往往出在大字段上。MySQL里如果一个表有TEXT或BLOB列,大值会存到溢出页,表统计显示的行大小和实际存储差距很大。PostgreSQL的TOAST机制也会把大字段压缩后放到TOAST表里,导致主表和总体占用差异很大。
建议排查时对这类表单独看文件大小或TOAST情况。PostgreSQL可以这样定位:
sql复制SELECT
c.relname,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
pg_size_pretty(pg_relation_size(c.oid)) AS heap_size,
pg_size_pretty(pg_total_relation_size(c.reltoastrelid)) AS toast_size
FROM pg_class c
WHERE c.relkind = 'r'
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 20;
如果toast_size占大头,说明这张表存了大量不适合压缩或者压缩效果不好的长文本、JSON、二进制数据。这时候要考虑是否真的需要把大字段放行内,或者换用对象存储,而不是一股脑加存储。
3.5 长事务和回滚段把磁盘撑爆的排查链路
还有一种情况是:所有表占用加起来都不大,磁盘却满了。这时候不要只盯着表,要查事务和日志。
MySQL里,长事务和未提交事务会导致undo表空间持续增长;PostgreSQL里,长事务会阻止vacuum清理死元组,导致表膨胀;另外pg_wal(WAL日志)如果在长事务或高写入场景下没及时归档,也能轻松吃掉几十GB到几百GB磁盘,对应Redis热搜词里那句"postgresql wal-data占用磁盘过大",本质就是WAL文件堆积。
排查链路建议是:
- 先看整体目录占用:
du -sh /var/lib/mysql或/var/lib/postgresql,区分数据目录、日志目录、临时目录。 - 再看表空间文件汇总,排除表造成的占用。
- 查活跃事务、长事务,比如MySQL的
information_schema.innodb_trx,PostgreSQL的pg_stat_activity里state='idle in transaction'的会话。 - 查日志目录,确认是
binlog、redo、undo还是pg_wal。 - 针对源头处理:杀掉长事务、归档日志、扩容或清理回滚段。
4. 拿到表占用清单后,磁盘空间治理的正确顺序
4.1 先归档、再压缩、最后重建
发现大表后,很多人的第一反应是直接删数据或重建表,但正确顺序应该是归档、压缩、重建三步走。
归档指的是把历史数据移到离线库或冷存储,线上表只保留近期数据。MySQL可以对大表做分区表,按月或按周分区,然后把老分区detach后转存文件;PostgreSQL用分区表加detach partition;Oracle用交换分区。这一步能把有效数据量本身降下来。
第二步是压缩。MySQL 8.0支持InnoDB页面压缩和透明页压缩,PostgreSQL有TOAST压缩和表级压缩方案(比如pg_repack、开源扩展),Oracle有表压缩和索引压缩。压缩对只读或读多写少的表效果尤其明显,有时候能省50%以上空间。
最后才考虑重建。OPTIMIZE TABLE、VACUUM FULL、SHRINK SPACE本质上都是把表重建一遍,能整理碎片、降低高水位线,但代价是额外空间和锁。所以要放在最后,而且必须预判临时空间是否够用,不然重建到一半磁盘又满了,那才叫尴尬。
4.2 利用表大小反查冗余索引
通过表占用拆分,能发现大量冗余索引问题。MySQL里可以查:
sql复制SELECT * FROM sys.schema_redundant_indexes;
这个视图直接给出冗余索引对,比如idx_orders_status和idx_orders_status_created,前者就可以考虑删除。索引删掉之后,ibd文件不一定立刻缩小,但后续写入、更新不会再维护这颗索引树,空间也会逐步释放。
PostgreSQL里可以用pg_stat_user_indexes配合pg_index判断哪些索引很少被使用,比如扫描次数接近0的索引,可以结合业务评估后删除。删除索引前注意先跑一段时间监控,别因为一两次全表扫描的记录就误删了关键索引。
4.3 结合WAL/binlog等实例级占用一起判断
表级占用的目的是定位问题,但治理磁盘空间时不能只看表。数据目录里经常还有一堆容易被忽略的东西:
- MySQL的
binlog,如果expire_logs_days配置过长,会占用大量空间。 - PostgreSQL的
pg_wal,归档失败或wal_keep_size配置不当就会堆积。 - 临时文件、排序文件,来自大查询的
filesort和临时表。 - 慢查询日志、错误日志打开着且没人轮转。
我在一次排查中,把所有表加起来只有300GB,但数据目录占了1.1TB,最后发现binlog日志占了700多GB。所以"查看表的磁盘占用"和"查看实例的磁盘占用"要联动看,别被单一指标误导。
5. 可以直接抄作业的自动化巡检脚本
5.1 MySQL TopN大表查询与重建建议
这个SQL脚本我会在巡检时定时跑,输出Top30大表,并标注"是否需要重建":
sql复制SELECT
table_schema,
table_name,
ROUND(data_length / 1024 / 1024, 2) AS data_mb,
ROUND(index_length / 1024 / 1024, 2) AS index_mb,
ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_mb,
table_rows,
ROUND((data_length + index_length) / 1024 / 1024 / 1024, 2) AS total_gb,
CASE
WHEN ROUND((data_length + index_length) / 1024 / 1024 / 1024, 2) > 100 THEN '需要关注'
ELSE '正常'
END AS suggestion
FROM information_schema.tables
WHERE table_schema NOT IN ('mysql','information_schema','performance_schema','sys')
ORDER BY total_gb DESC
LIMIT 30;
5.2 PostgreSQL大表巡检SQL
PostgreSQL的巡检我通常这样写:
sql复制SELECT
n.nspname AS schema_name,
c.relname AS table_name,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
pg_size_pretty(pg_table_size(c.oid)) AS table_size,
pg_size_pretty(pg_indexes_size(c.oid)) AS index_size,
pg_stat_get_live_tuples(c.oid) AS live_rows,
pg_stat_get_dead_tuples(c.oid) AS dead_rows
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND n.nspname NOT IN ('pg_catalog','information_schema')
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 30;
dead_rows一列对判断膨胀很有用:死元组数量很大且live_rows不多时,基本可以确定需要VACUUM FULL或者排查长事务。
5.3 定时任务与告警触发
光有查询SQL不够,还要接入定时任务。我习惯写一个简单的bash脚本,把上面的SQL结果输出成CSV或文本文件,再用系统crontab每周日凌晨跑一次,跑完通过企业微信或邮件的Webhook把结果发出来,格式类似:
code复制[MySQL磁盘巡检] 2024-06-02
Top1: orders 128.5GB (data 40.2GB, index 88.3GB)
Top2: logs 76.3GB
建议: orders表存在冗余索引,建议评估删除idx_orders_status_created
告警阈值可以根据业务情况定,比如单表超过100GB、索引占比超60%、死元组比例超50%,都值得重点检查。
这里还要补充一个个人经验:巡检脚本一定要记录历史趋势,不要只看单次快照。因为表占用是持续变化的,今天排查出来Top1是订单表,过两周可能变成日志表。有了历史曲线,你才能区分"正常增长"和"异常膨胀",也能在容量规划时预测哪些表下一个季度会冲上存储阈值。我通常在MySQL里建一张table_size_history表,每次巡检把每个表的data_length、index_length、table_rows插进去,一个月后直接查对比,比只看当前快照直观得多。
另外,查看表占用还有一个容易被忽略的实际用途:迁移和备份。无论是做Oracle 11g冷迁移、跨平台数据同步、还是用DataGrip/DBeaver同步表结构,事前都要先摸清每张表的磁盘占用和行数,否则迁移到一半发现目标端磁盘不足,轻则任务失败重则影响业务。表占用巡检这件事,看着基础,但真到关键时刻就是救命的一环。
