四年来,我见过太多做后端的人天天跟 PostgreSQL 打交道,SELECT * FROM users写得飞起,但你问他pg_class和pg_attribute是什么,多半会愣一下。说实话,这俩名字听起来像某个底层库的配置表,实际上它们是 PostgreSQL 管理自身元数据的“系统目录表”,是整个数据库能够“认识自己”的根基。不管你是想知道一张表有哪些字段、字段是什么类型、表占了多少磁盘空间,还是想理解\d命令背后到底干了什么,最终都要回到这堆目录表上。
这篇文章不打算把概念讲成天书。我的目标很具体:把pg_class、pg_attribute、pg_namespace、pg_type这几张核心目录表拆开,讲清楚它们各自记录了什么、表和表之间怎么关联,然后给你几个能直接抄的 SQL,从“查字段”到“拼 DDL”再到“统计全库体积”,一条龙搞定。适合三类人看:刚接触 PostgreSQL 想往深走的开发、需要批量生成表结构文档的运维、以及想搞懂\d背后原理的较真派。
1. 系统目录表是什么:数据库的“档案室”
1.1 自描述系统:数据库靠“户口档案”管理自己
先建立一个直觉:PostgreSQL 和 MySQL 最大的不同之一,就是 PostgreSQL 的元数据查询方式极其“透明”。MySQL 里你查表结构要么用SHOW CREATE TABLE,要么查information_schema;而在 PostgreSQL 里,连这些信息本身也只是一张张普通表,你甚至可以直接对它们执行SELECT、JOIN,只要不随便UPDATE,你就是安全的。
这些“记录表的信息的表”,就叫系统目录表,英文叫 System Catalog。它们统一存放在pg_catalogschema 下,也叫“系统表空间”。为什么要搞这么一套东西?因为数据库需要“自描述”——它必须知道自己内部有哪些表、每个表有哪些列、列是什么类型、有没有索引和约束。这套描述信息如果不放在表里,而是放在某个二进制文件里,那查询起来反而麻烦,也没法用 SQL 来分析了。
我常用一个生活化的类比:pg_class相当于整个数据库的“户口登记簿”,每个表、索引、视图、序列都有一行;pg_attribute则是“家庭成员登记表”,每一张表的每一列就是一条记录;pg_namespace相当于“街道办”,负责分组管理不同的 schema。你平时用\d users看到的字段清单,本质上就是数据库帮你做了一次pg_class+pg_attribute+pg_type的三表关联。
1.2 常用系统目录表的功能划分
为了让你心里有个完整地图,我先用一张表把最核心的几张目录表摆出来。不需要一次性背下来,但至少要知道“查某类信息应该找谁”。
| 系统目录表 | 记录内容 | 典型用途 |
|---|---|---|
pg_class | 所有表、索引、视图、序列、物化视图、复合类型、TOAST 表 | 查对象是否存在、类型、大小估算、行数估算 |
pg_attribute | 每个表/索引的每一列 | 查字段名、字段类型、是否非空、列的序号 |
pg_namespace | 所有 schema(命名空间) | 区分 public、pg_catalog、用户自定义 schema |
pg_type | 所有数据类型 | 把类型 OID 翻译成 readable 的类型名 |
pg_constraint | 主键、外键、唯一、检查、排他约束 | 查约束定义,辅助重建 DDL |
pg_index | 索引与堆表的关联 | 查主键索引、唯一索引、索引是否有效 |
pg_attrdef | 字段默认值 | 查列的 DEFAULT 定义 |
pg_depend | 对象之间的依赖关系 | 查视图依赖了哪些表、外键依赖等 |
这些表之间靠“OID”互相勾连。OID 是 PostgreSQL 内部的全局对象标识符,pg_class.oid是某张表的唯一 ID,pg_attribute.attrelid指向所属表的 OID,pg_namespace.oid被pg_class.relnamespace引用。你只要把这些 OID 关联起来,就能自由拼出任何你想要的信息。
2. 核心目录表逐个拆解:pg_class 与 pg_namespace
2.1 pg_class:所有表、索引、视图、序列的总登记簿
pg_class是整个系统目录里最重要的一张表,每一条记录代表一个“关系”(relation)。注意这里的 relation 不只是表,还包括索引、视图、序列、物化视图、复合类型、TOAST 表。这就是为什么你查pg_class的时候,一定要看relkind字段,否则会把一堆索引和视图也捞出来。
relkind的取值需要背一下,因为写元数据查询时 90% 的过滤条件都靠它:
| relkind 取值 | 含义 |
|---|---|
r | 普通表(ordinary table) |
i | 索引 |
S | 序列 |
v | 视图 |
m | 物化视图 |
c | 复合类型 |
t | TOAST 表 |
f | 外部表 |
p | 分区表(父表) |
I | 分区索引 |
除了relkind,pg_class里还有几个字段非常常用:relname是对象名,relnamespace是所属 schema 的 OID,relnatts是列数(包括系统列),relpages是表占用的磁盘页数(8KB 一页),reltuples是估算行数,relfilenode是磁盘存储文件的节点号。
举个最典型的例子:你想列出当前库里所有普通用户表,而不是系统表、索引、视图,可以这么写:
SELECT n.nspname AS schema, c.relname AS table_name, c.relkind, c.relnatts 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 n.nspname, c.relname;这个查询我会记一辈子。relkind='r'排除掉索引和视图,nspname NOT IN排除系统自带的两个 schema。日常做巡检、导出表清单、做数据字典,全靠这个底子扩展。
这里有个容易踩的坑:reltuples不是实时精确值,它是上一次ANALYZE或VACUUM时记录的估算值。如果一张表刚导入了 500 万行却还没做ANALYZE,reltuples可能还停留在 0 或旧数值,拿它做行数监控必须小心。
2.2 pg_namespace:schema 分区的“街道办”
pg_namespace非常简单,核心字段就两个:oid和nspname。它存在的意义是给数据库对象做分组。默认安装后你一定见过这几个 schema:pg_catalog(系统表都在这里)、public(用户默认 schema)、information_schema(SQL 标准视图层)。
为什么要专门把 schema 做成一张表?因为 PostgreSQL 的“库 -> schema -> 表”三级结构中,schema 是对象的直接归属单位。两个不同 schema 下可以存在同名表,比如public.users和app.users同时存在,pg_class里就有两条relname='users'的记录,区别就在relnamespace上。
所以几乎所有pg_class查询都要关联pg_namespace,否则同名表会让你怀疑人生。我给你一个实用技巧:可以用'public.users'::regclass这种写法,直接把“schema.表名”转换成对应的 OID,省掉一次关联。例如:
SELECT c.oid, c.relname, c.relkind FROM pg_class c WHERE c.oid = 'public.users'::regclass;注意::regclass在表不存在时会直接报错,如果你要“表不存在就返回 NULL”这种容错场景,用to_regclass('public.users')。
3. 字段与类型的核心目录:pg_attribute 与 pg_type
3.1 pg_attribute:每一列的“身份证档案”
pg_attribute可能是你会查得最频繁的一张表,因为它记录的是“每一张表的每一个字段”。它的核心字段包括:
attrelid:所属表 OID,指向pg_class.oidattname:字段名atttypid:字段类型的 OID,指向pg_type.oidatttypmod:类型修饰符,比如varchar(50)的 50 就存在这里attnotnull:是否非空attnum:字段序号,从 1 开始attisdropped:该列是否已被删除
实战查询一个表的全部字段,最标准写法是:
SELECT a.attname AS column_name, format_type(a.atttypid, a.atttypmod) AS data_type, a.attnotnull AS not_null, a.attnum AS ordinal_position FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relname = 'users' AND n.nspname = 'public' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum;这里有一个几乎所有人都踩过的坑:attnum > 0不是可选的。PostgreSQL 每一张表除了用户定义的列,还自带一组系统列,比如ctid、xmin、xmax、cmin、cmax、tableoid。它们的attnum是负数。如果不加attnum > 0,你会看到字段列表里莫名其妙多出ctid和xmin这类东西,完全不是你想要的表结构。
再一个是attisdropped。你执行ALTER TABLE users DROP COLUMN email之后,这个列并不会从pg_attribute里物理删除,它只是被标记为attisdropped = true,列名会被改成类似........pg.dropped.2........的名字。这就是为什么很多系统表查询必须加AND NOT a.attisdropped,否则导出的字段清单里会出现一堆“幽灵列”。
那么format_type(a.atttypid, a.atttypmod)又是干什么的?atttypid只是类型 OID,比如varchar的 OID 可能是 1043,直接输出给用户看完全没法读。format_type()会把 OID 和类型修饰符组合成可读字符串,比如character varying(50)、timestamp with time zone、numeric(10,2)。如果没有这个函数,你自己做JOIN pg_type拿typname得到的是varchar这种不带长度信息的名字,字段的精度信息全丢了。所以写元数据查询,format_type是标配。
3.2 pg_type:全局类型注册中心
pg_type记录的是整个数据库所有可用的数据类型,不管是内置的int4、text、timestamp,还是你通过CREATE TYPE创建的枚举、复合类型、域,甚至每个表本身在pg_type里也有一条记录(复合类型)。
它的核心字段:
typname:类型名,比如int4、varchar、booltyptype:类型分类标志。b表示基础类型(base type),c表示复合类型(composite),d表示域,e表示枚举,p表示伪类型,r表示范围类型typcategory:类型的功能分类。N数值类型,S字符串类型,A数组类型,T时间类型等typrelid:如果这个类型是复合类型,指向对应的pg_class.oid
为什么pg_type这么重要?因为你查询字段类型时,本质上是把pg_attribute.atttypid关联到pg_type.oid的过程。但实际上上面推荐的format_type()已经把这一步封装了,大多数场景你不需要手动关联pg_type。只有当你判断“这个字段是不是数组”“这个字段是不是枚举”这类语义问题时,才需要直接查pg_type。
举个例子,你想知道某张表里哪些字段是数组类型:
SELECT a.attname, t.typname, t.typcategory FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid JOIN pg_type t ON t.oid = a.atttypid WHERE c.relname = 'users' AND a.attnum > 0 AND NOT a.attisdropped AND t.typcategory = 'A';4. 约束与索引的元数据:pg_constraint 与 pg_index
4.1 pg_constraint:PK、FK、Unique、Check 的台账
光知道字段还不够,要还原一张表的完整结构,必须拿到约束。pg_constraint就是约束的总台账,核心字段:
conname:约束名contype:约束类型。p主键、f外键、u唯一约束、c检查约束、x排他约束conrelid:约束所在表的 OIDconfrelid:外键指向的表 OIDconkey:约束涉及的列在表中的attnum数组,比如主键由第 1、第 3 列组成,这里就是{1,3}confkey:外键引用的被参照表的列attnum数组
查某个表的全部约束,最省事的写法是用pg_get_constraintdef()直接把约束定义转成 DDL 片段:
SELECT conname, contype, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE conrelid = 'public.users'::regclass;输出效果类似:
conname | contype | definition -------------------+---------+--------------------------------------------------- users_pkey | p | PRIMARY KEY (id) users_email_key | u | UNIQUE (email) users_org_fkey | f | FOREIGN KEY (org_id) REFERENCES orgs(id) users_age_check | c | CHECK ((age >= 0))这里我提醒一句:pg_get_constraintdef()不只会读pg_constraint,它在内部还要关联很多别的东西,但你不必操心这些,直接调用就是了。它的好处是输出结果几乎可以直接拼进CREATE TABLE语句里,省去手动拼接的麻烦。
4.2 pg_index:索引与堆表的关联
pg_index记录的是“哪个索引建在哪张表上”。它的核心字段包括:
indexrelid:索引本身的 OID,指向pg_class.oidindrelid:索引所在的堆表 OIDindnatts:索引包含的列数indkey:一个int2vector数组,存放索引列在表中的attnum编号indisprimary:是否主键索引indisunique:是否唯一索引indisvalid:索引是否有效,CONCURRENTLY建索引过程中会短暂为false
查某个表上的所有索引和对应列,这个查询建议直接收藏:
SELECT i.relname AS index_name, ix.indisprimary, ix.indisunique, pg_get_indexdef(ix.indexrelid) AS index_definition FROM pg_index ix JOIN pg_class i ON i.oid = ix.indexrelid JOIN pg_class t ON t.oid = ix.indrelid WHERE t.relname = 'users';这里有个非常容易绕晕的地方:pg_class里每个索引也有自己的一条记录,所以pg_index和pg_class需要关联两次。第一次用indexrelid拿索引名,第二次用indrelid找表名。如果你只关联一次,会搞不清哪个是表哪个是索引。
如果还想精确知道每个索引落在哪几列上,可以使用unnest(ix.indkey)把int2vector展开成行,再去匹配pg_attribute.attnum:
SELECT i.relname AS index_name, a.attname AS column_name FROM pg_index ix JOIN pg_class i ON i.oid = ix.indexrelid JOIN pg_class t ON t.oid = ix.indrelid JOIN unnest(ix.indkey) AS col(attnum) ON true JOIN pg_attribute a ON a.attrelid = ix.indrelid AND a.attnum = col.attnum WHERE t.relname = 'users' ORDER BY i.relname, col.attnum;注意一个边界情况:表达式索引(比如lower(email))在indkey里对应的attnum是 0,unnest展开后匹配不到pg_attribute,所以这类索引的列信息会缺失,需要用pg_get_indexdef()才能完整还原。
5. 实战一:一条 SQL 还原任意表的结构定义
理论知识铺垫完了,下面来点真的。我把前面拆开的表重新组装,给你几条能直接跑出结果的完整 SQL,每一段我都会解释它在干什么。
5.1 生成字段清单(含类型、非空)
假设你要给public.users这张表生成一份简洁的字段清单,输入输出如下:
SELECT a.attname AS column_name, format_type(a.atttypid, a.atttypmod) AS data_type, a.attnotnull AS not_null, COALESCE(d.adbin, '') AS default_expr FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum WHERE c.relname = 'users' AND n.nspname = 'public' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum;输出大概是:
column_name | data_type | not_null | default_expr ------------+---------------------------+----------+------------- id | bigint | t | nextval('users_id_seq'::regclass) name | character varying(50) | f | email | character varying(128) | t | created_at | timestamp with time zone | f | now()我把pg_attrdef也加了进来。这是一个记录字段默认值的目录表,adbin是默认值的内部表达式,直接输出可能有点丑,但用于理解字段是否带默认值已经足够。
这个查询里最容易被忽略的是LEFT JOIN pg_attrdef。如果你用JOIN,没默认值的字段会被过滤掉,整个清单就不完整了。所以凡是“补充信息类”的表,优先用LEFT JOIN。
5.2 拼装建表 DDL:字段 + 约束一次成型
字段清单再进阶一步,就是直接把CREATE TABLE语句拼出来。思路是先用string_agg把所有字段定义拼成一列,再拼接约束定义:
SELECT 'CREATE TABLE ' || n.nspname || '.' || c.relname || ' (' || string_agg( a.attname || ' ' || format_type(a.atttypid, a.atttypmod) || CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END, ', ' ORDER BY a.attnum ) || ');' AS ddl FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace JOIN pg_attribute a ON a.attrelid = c.oid WHERE c.relname = 'users' AND n.nspname = 'public' AND a.attnum > 0 AND NOT a.attisdropped GROUP BY n.nspname, c.relname;执行后输出类似:
CREATE TABLE public.users (id bigint NOT NULL, name character varying(50), email character varying(128) NOT NULL, created_at timestamp with time zone, ...);这就是手工拼接版 DDL。如果想把主键、外键、唯一约束也拼进去,把前面pg_get_constraintdef()的结果用string_agg拼到字段定义后面,再加, PRIMARY KEY (id)之类的片段。完整的自动化 DDL 生成工具通常就是这么干的,只不过外面再包一层编程语言处理分号、逗号的位置。你自己写脚本时,建议先GROUP BY拼字段,再单独查约束,最后在代码里合并,比一条超大 SQL 维护起来舒服得多。
5.3 视图接管:封装成常用查询
这种又长又臭的 SQL,不建议每次用都敲一遍。我自己的习惯是把最常用的几个封装成视图,比如:
CREATE VIEW v_table_columns AS SELECT n.nspname AS table_schema, c.relname AS table_name, a.attname AS column_name, format_type(a.atttypid, a.atttypmod) AS data_type, a.attnotnull AS not_null, a.attnum AS ordinal_position FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace JOIN pg_attribute a ON a.attrelid = c.oid WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND a.attnum > 0 AND NOT a.attisdropped;建完之后,以后查任何表的字段就一句话:
SELECT * FROM v_table_columns WHERE table_schema = 'public' AND table_name = 'users';封装之前可以先在事务里测试一下,确认输出无误再正式建视图,避免把错误的过滤条件长期固化下来。
6. 实战二:查看全库对象清单与磁盘占用排行
6.1 一次性统计所有表、索引、视图、序列
很多运维场景下,你需要对整个库的对象做一次体检。写出下面这条 SQL,全库所有用户对象的清单就出来了:
SELECT n.nspname AS schema, c.relname AS object_name, CASE c.relkind WHEN 'r' THEN '表' WHEN 'i' THEN '索引' WHEN 'S' THEN '序列' WHEN 'v' THEN '视图' WHEN 'm' THEN '物化视图' WHEN 'p' THEN '分区表' WHEN 't' THEN 'TOAST表' ELSE c.relkind::text END AS object_type, c.relnatts AS column_count FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND c.relkind IN ('r', 'i', 'S', 'v', 'm', 'p') ORDER BY n.nspname, c.relname;这个查询的过滤逻辑要单独说明一下:没有过滤relkind的话,TOAST 表和复合类型也会混进来;过滤太严格的话,又可能漏掉物化视图。我上面的集合是日常巡检最常用的六个对象类型,够覆盖绝大多数场景。
6.2 表大小、行数估算与磁盘占用排序
pg_class里的relpages、reltuples只是估算信息,真正要精确算磁盘占用,还是得借助函数。三个最常用的函数:
pg_relation_size(oid):只算表本身数据的大小(不包括索引和 TOAST)pg_total_relation_size(oid):表 + 索引 + TOAST 的总大小pg_table_size(oid):表数据 + TOAST + 可见性映射等信息
实际使用中,我看得最多的是pg_total_relation_size,因为它才是“这张表到底占了多少磁盘”的完整答案。下面这条 SQL 能直接排出全库用户表的大小 Top 20:
SELECT n.nspname AS schema, c.relname AS table_name, pg_total_relation_size(c.oid) / 1024 / 1024 AS total_size_mb, pg_relation_size(c.oid) / 1024 / 1024 AS data_size_mb, c.reltuples::bigint AS estimated_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 20;这里再次强调reltuples的估算性质:它是最近一次ANALYZE或VACUUM留下的值。如果你刚批量导入了大量数据,建议先执行ANALYZE users;再跑这个统计,否则“估算行数”这一列可能严重偏离现实。
另外要注意,分区表(relkind='p' 的父表)的pg_total_relation_size只算父表本身,不包含各分区的数据量。要做分区表的全量统计,需要遍历所有子分区再求和,别被这个数字误导。
7. 常用系统视图与内置函数速查
7.1 更友好的入口:pg_tables、pg_indexes 等系统视图
PostgreSQL 已经内置了不少“半成品视图”,把你上面需要手动 JOIN 的活干了一半。比如:
pg_tables:直接列出所有表,包含 schemaname、tablename、tableowner、hasindexes 等pg_indexes:直接列出所有索引,包含 tablename、indexname、indexdefpg_views:列出所有视图和视图定义pg_stat_user_tables:统计用户表的插入、更新、删除、扫描次数、最近分析时间等
以pg_tables为例,列出所有用户表只需:
SELECT schemaname, tablename FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema');系统视图的好处是查询简单、字段语义明确,适合快速查看。但它也有代价:视图层封装了复杂的 JOIN,灵活性不如直接查pg_class;而且视图隐藏了一些系统字段细节(比如 relfilenode、relkind),当你需要更底层的元数据时,还是得回到pg_class。
我的个人经验是分层使用:日常查看用系统视图,批量导出、性能分析、需要深度过滤时直接查系统目录表。
7.2 实用函数盘点
和系统目录表配套的还有一批金牌函数,熟练掌握能省一半时间:
| 函数 | 作用 | 典型用法 |
|---|---|---|
format_type(type_oid, typmod) | 把类型 OID 转成可读类型名 | format_type(atttypid, atttypmod) |
to_regclass(text) | 把表名转换成 OID,不存在返回 NULL | to_regclass('public.users') |
pg_get_constraintdef(oid) | 获取约束的 DDL 片段 | pg_get_constraintdef(con.oid) |
pg_get_indexdef(oid) | 获取索引的完整 DDL | pg_get_indexdef(ix.indexrelid) |
pg_get_viewdef(oid) | 获取视图定义的 SELECT 语句 | pg_get_viewdef('public.v1'::regclass) |
pg_relation_size(oid) | 表本身数据大小 | pg_relation_size(c.oid) |
pg_total_relation_size(oid) | 表 + 索引 + TOAST 总大小 | pg_total_relation_size(c.oid) |
pg_relation_filepath(oid) | 查看表对应的磁盘文件路径 | pg_relation_filepath(c.oid) |
这里特别推荐pg_get_indexdef()。它不仅能还原索引定义,连表达式索引、部分索引、INCLUDE 列这类复杂情况都能完整输出,是文档生成和数据库迁移的好帮手。你手动拼索引 DDL 总会漏掉某个细节,直接用这个函数最稳。
7.3 pg_catalog 与 information_schema 怎么选
很多新手会问:information_schema.columns也能查字段信息,和pg_attribute有什么区别?
区别主要有三点。第一,information_schema是 SQL 标准定义的视图,它只暴露标准化的信息,PostgreSQL 特有细节如存储参数、分区信息、TOAST 策略它一概不管;第二,查询性能上,information_schema因为要跨多个目录表做兼容转换,通常会比直接查pg_catalog稍慢,在数据量大的库上做全库遍历时差距会更明显;第三,information_schema的输出更“规整”,比如类型名直接就是不带 OID 的可读字符串,但代价是丢失了部分底层语义。
我的建议很直接:你想写可移植的脚本,用information_schema;你想深入理解 PostgreSQL 行为,或者做性能相关分析,直接查pg_catalog。别在information_schema里找relpages、relfilenode这种字段,找不到的,因为标准视图根本不提供这些。
8. 常见问题与避坑实录
8.1 为什么查不到某些表?
这是最常遇到的问题。原因基本有两种:一是权限不够,pg_class只会把当前用户有权限看到的对象显示出来,比如你在一个普通账号下看不到其他 schema 里的表,这是正常的,不代表表不存在;二是过滤条件写错了,比如没排除pg_catalogschema,结果看到一堆pg_class、pg_attribute本身。
排查思路先查pg_tables视图:
SELECT count(*) FROM pg_tables WHERE schemaname = 'public';如果这里能看到表,而你的自定义 SQL 查不到,几乎可以断定是 JOIN 逻辑或过滤条件的问题,重点检查relkind和nspname两个条件。
8.2 表的 relfilenode 为什么是 0?
一部分对象在pg_class.relfilenode里会显示为 0,这代表它没有自己的物理存储文件。典型情况包括视图、复合类型、分区父表。视图本身不存储数据,它的“内容”就是一条 SELECT 语句,存在pg_rewrite里;分区父表只是一个逻辑壳子,实际数据都散落在各子分区中。
如果你要用pg_relation_filepath()去定位表的物理文件,遇到这些对象返回的是空路径,别慌,这不是异常。
8.3 TOAST 表到底是什么?
当你看到pg_class里一堆pg_toast_xxxx名字的表,不要奇怪,它们是 TOAST 表(The Oversized-Attribute Storage Technique)。PostgreSQL 为了存储超长字段(比如超大文本、JSONB),会把行拆成多块存到附属表里。TOAST 表的relkind是t,它们的relname前缀是pg_toast_且隐藏在pg_toastschema 里。
如果你用pg_total_relation_size()看大表的体积,其中的一部分就是 TOAST 贡献的,这完全是正常现象。
8.4 不要在应用里直接 UPDATE 系统表
最后说一个红线:虽然pg_class、pg_attribute看起来就是普通表,你可以SELECT、可以JOIN,但不代表应该直接UPDATE或DELETE。官方明确说过,直接修改系统表是不受支持的,轻则导致系统不一致,重则整个数据库无法启动。你的一切变更需求,都应该走CREATE TABLE、ALTER TABLE、CREATE INDEX这类 DDL 命令。
我见过有人在迁移脚本里顺手UPDATE pg_class SET relname = ...来重命名表,结果触发了各种连锁问题。真要重命名,用ALTER TABLE ... RENAME TO ...,一行 DDL 解决问题,何必去自找麻烦。
8.5 版本差异:系统表字段也在演进
PostgreSQL 每个大版本都会微调系统目录表的字段。比如pg_class在 PG10 增加了relpartition,PG12 增加了relispartition,PG14 的relkind才支持I分区索引。如果你写的元数据脚本要跨版本部署,尽量用视图或函数,避免直接引用某个版本才有的字段。最简单的兼容做法是先在目标环境跑一条只查基础字段的 SQL,再在正式脚本里逐步扩展。
我在实际运维中踩过一次教训:一份查索引的脚本在 PG13 跑得好好的,迁移到 PG15 后发现多出了几类之前没见过的relkind值,导致统计报表里的对象分类突然少了几类。后来我把relkind的 CASE 映射集中到一个视图里管理,版本升级时只改一处,才根治了这个问题。
结尾:一点个人经验
在我自己写元数据工具的过程中,最重要的体会是:先别急着背字段,把pg_class、pg_attribute、pg_namespace这三个表的关系彻底搞熟,后续所有问题都能迎刃而解。pg_class告诉你有什么对象,pg_namespace告诉你对象在哪个 schema,pg_attribute告诉你对象内部长什么样,剩下的pg_type、pg_constraint、pg_index都是锦上添花。
另外一个小建议:不管你用不用得上,都去把你项目的核心表跑一遍字段清单查询,然后在字段清单里挑几个印象深刻的观察一下输出结果。看过一次attnum为负数的系统列,看过一次被删列留下的attisdropped标记,你对 PostgreSQL“表也是数据库自己管理的数据”这句话的理解,会比任何人给你讲十遍原理都深刻。
最后分享一个能长期受益的习惯:把自己常用的系统表查询整理成一套视图或 SQL 模板,放到一个专门的维护文档里。以后不管是做数据字典、生成变更脚本、还是排查奇怪的“找不到表”问题,你都能在十分钟内拿出结论。这套东西的价值,会在你遇到一个完全陌生的环境时体现得淋漓尽致。