1. 从存储层全景理解openGauss逻辑存储结构
1.1 一次表空间占用排查带来的教训
之前帮一个内部项目排查存储占用,现象很典型:数据目录从200GB一路涨到600GB,但数据库里最大的几张表加起来连一半都不到。A同学第一反应是“日志撑大的”,清理了日志和临时文件,问题依旧。后来通过系统表逐层排查,才确认是一张频繁UPDATE的订单表膨胀得非常离谱,表的逻辑记录数和物理文件大小严重不匹配。这件事让我决定把openGauss的逻辑存储结构从头到尾梳理一遍。如果你也经常被“删了数据空间不释放”“表很大但查不出来大在哪”这类问题困扰,这篇内容大概率能帮你少走弯路。
1.2 逻辑存储与物理存储的边界
在openGauss里,逻辑存储结构描述的是数据库管理员和SQL语句能看见、能操作的对象链条;物理存储结构描述的是数据最终怎么落到磁盘文件。两者并不是一一对应:一个表空间可能对应多个目录和文件,一张逻辑表可能被分区拆成多个物理文件,一个逻辑行在物理上还可能对应多个历史版本。
用生活类比的话,逻辑存储像公司的组织架构,部门和岗位层级清晰;物理存储像办公楼的实际房间分配,一个部门可能分散在几层楼,一个房间也可能被多个团队在不同时段使用。绝大多数运维问题,比如文件越来越大、批量更新后查询变慢,都要先回到逻辑存储层面才能定位。
1.3 五级层次:数据库、表空间、段、区、页
提到逻辑存储结构,很多教材都会从数据库开始往下分成五级:数据库(Database)、表空间(Tablespace)、段(Segment)、区(Extent)、页(Page)。openGauss也不例外,只是每级在实现上有自己的特点。
| 层级 | 逻辑对象 | 主要作用 | 和物理存储的关系 |
|---|---|---|---|
| 1 | 数据库 Database | 逻辑隔离边界,包含系统元数据和用户数据 | 有独立的元数据集合 |
| 2 | 表空间 Tablespace | 决定数据对象存放在哪个磁盘路径 | 映射到一个或多个目录 |
| 3 | 段 Segment | 表、索引或分区对应的逻辑空间单位 | 创建对象时分配,包含若干区 |
| 4 | 区 Extent | 一组连续页面的集合,空间分配的基本单位 | 一段连续磁盘空间 |
| 5 | 页 Page | 默认8KB的存储块,IO读写最小单位 | 对应磁盘上的一个数据块 |
理解这五级之后,再回头看建库、建表、建索引,你就知道每一步实际上是在上面哪一层做文章。比如建表时创建一个段,段在需要扩展时会向存储层申请新区,区再进一步切分成页,数据最终落到页上。这个链路虽然不用在日常SQL里显式操作,但出了问题,绕不开它。
2. 数据库、表空间、模式与表:日常管理最常打交道的四个对象
2.1 数据库是逻辑隔离的第一层边界
openGauss中,一个数据库是一个独立的逻辑存储集合,拥有自己的系统表和用户数据。不同数据库之间的表默认不能互相访问,需要借助跨库访问或者外部数据源能力。因此在多业务共用一套集群时,数据库是第一层逻辑边界,也是权限和备份恢复的基本单元之一。
实操中,很多人在初始化实例后直接使用默认数据库,把所有表都建在同一个库里。短期看没什么问题,长期看会出现两个麻烦:一是权限边界模糊,二是同库内表数量过大后,系统表的统计和清理压力逐渐增大。建议一个独立业务或一个相对独立的应用模块,单独创建数据库。创建时明确指定表空间,是一种很稳妥的做法:
CREATE TABLESPACE fast_tbs LOCATION '/data/fast_tbs'; CREATE DATABASE order_db TABLESPACE = fast_tbs;注意创建表空间前目录必须存在且为空,目录属主应该是运行openGauss的系统用户。集群环境下,每个节点都需要有对应目录,否则后续写入会报错。这个准备工作很基础,但不少人在安装文档没有强调的情况下踩过坑。
2.2 表空间是“放在哪块磁盘”这层决策
表空间的本质是磁盘目录。通过表空间,可以让不同业务的数据分布在不同的物理磁盘上,也可以把一个表空间整体迁移到新路径。它不关心表里的字段,只关心数据文件落在哪个目录。
这里要澄清一个常见误解:表空间不等于数据库。同一个数据库可以使用多个表空间,同一张表也可以指定自己的表空间,甚至表的索引可以放到另一个表空间:
CREATE TABLE t_order (id bigint PRIMARY KEY, amount numeric) TABLESPACE fast_tbs; CREATE INDEX idx_order_amount ON t_order(amount) TABLESPACE slow_tbs;openGauss支持为表和索引指定表空间。如果你希望一张大表独占高性能盘,不一定要新建数据库,新建表空间并把表迁移过去即可。表空间的物理文件路径会变,但库名、schema名、表名都不会变,应用层完全无感知。
补充一点:表空间迁移时要注意空间是否充足,因为openGauss的迁移本质上会复制或重建数据文件,目标目录剩余空间至少要等于源表当前物理大小,否则会在中途耗尽磁盘。
2.3 模式:对象名字和权限的命名空间
模式(Schema)是openGauss中容易被忽略的一层。它是一个命名空间,用来组织表、视图、函数、序列等对象,但不直接对应物理数据文件。模式的物理意义在于对象的完整限定名:schema_name.table_name。
默认public模式承担了所有未指定schema的对象。如果只有一个应用,这没什么问题;但多团队共用一个库时,建议为每个团队创建独立schema,并通过search_path区分。
CREATE SCHEMA app_order; CREATE SCHEMA app_wms; SET search_path TO app_order, public; CREATE TABLE t_order (id bigint PRIMARY KEY, amount numeric);这里最常见的坑是:某个用户执行SELECT时不加schema前缀,结果查询的是public下的同名表,而不是自己schema下的表。原因是search_path顺序不对。解决方法是给角色设置默认搜索路径:
ALTER ROLE order_app SET search_path TO app_order, public;这样角色建立连接后,解析SQL时会优先找app_order。注意:模式权限仍然要单独授予,search_path只是解析名字,不是权限。
2.4 表到行:真正存放业务数据的地方
表是最直接面对业务的对象。openGauss中,行存表按堆表模式组织,每一行的所有列值集中在一条元组里;列存表则按列组织,每一列的数据连续存储。对于OLTP高并发更新场景,行存表是首选;对于OLAP大范围聚合分析,列存表通常更有优势。
从逻辑存储结构看,一张表对应一个段,段再按区和页组织。openGauss会为表分配relfilenode作为物理文件编号;索引、分区也有独立的relfilenode。所以一张大分区表在磁盘上呈现为很多文件,而不是一个巨型文件。这也意味着,不能简单地通过“看文件大小”来确认某张表膨胀,因为同一个文件可能只是某个分区的一部分。
创建表时,建议立即考虑三个问题:这张表会高频更新吗?更新的字段是否在索引中?分区键是什么?这三个答案分别对应fillfactor设置、HOT更新效果和分区表结构设计。如果等到数据量上来后再改,迁移成本会放大很多。
3. 页面内部到底长什么样:元组头、行指针与空闲空间
3.1 默认8KB页的布局
最底层是页。openGauss数据文件默认按8KB的页组织。数据库每次以页为单位读写,而不是按行。查询时先定位到页面,再在页面内定位到元组。页的内部布局可以分成几块:
- 页头:记录页号、校验值、事务相关LSN、空闲空间起始偏移和结束偏移等元数据。
- 行指针数组:每个Item指向一个元组在页内的偏移量,是页内索引的“门牌号”。
- 空闲空间:行指针和元组区域之间未使用的空间。
- 元组区域:实际存放一行数据的地方,从页尾向前增长。
这种行指针向前增长、元组从后往前分配的布局,是为了减少碎片。当一条记录被更新,新版本元组可能放在页内其他地方,行指针被更新为新元组地址,旧元组成为死元组,页内空闲空间就出现碎片。频繁更新时,页面中部会逐渐被各种历史版本占用,但文件总量不会自动缩小。
3.2 一行数据在页内的身份信息
每条元组都有一个元组头,类似邮件的信封。信封里最关键的信息包括:
- t_xmin:插入该元组版本的事务ID。
- t_xmax:删除或锁定该元组版本的事务ID,为0表示未删除。
- t_cid:命令ID,区分同一事务内的多个命令。
- t_ctid:指向当前或更新后的元组位置,格式是(页号,条目号)。
- t_infomask:位标志,记录元组状态。
这些信息是MVCC的基础。事务在读取数据时,通过比较t_xmin、t_xmax与当前快照,判断该行是否可见,而不需要阻塞其他事务。正因如此,UPDATE和DELETE并不会立刻覆盖数据,而是在页面上留下历史版本。这也正是逻辑存储结构直接决定运维行为的地方:如果清理跟不上,旧版本会越积越多。
3.3 UPDATE和DELETE在页面里做了什么
UPDATE时,openGauss通常插入一条新元组,并将旧元组的t_ctid指向新元组位置,旧元组的t_xmax置为当前事务ID。DELETE时,物理记录不会被立刻擦除,只会把t_xmax标记为当前事务ID。已提交的删除操作对应的元组,进入死元组状态,等待后台进程清理。
这就是“删除大量数据之后,磁盘空间看起来一点没少”的根本原因。真正把页内空间空出来的是VACUUM;真正把空间还给操作系统的是VACUUM FULL或表重写。这两个操作的区别在后面会详细展开。
openGauss还提供HOT更新(Heap-Only Tuple)机制。当被更新的列不涉及索引列,且页内有足够空闲空间时,新元组可以放在同一页内,索引仍然指向旧元组,通过旧元组的t_ctid找到新元组。这样可以减少索引维护的写放大。为了让HOT更新生效,通常需要给高频更新表设置小于100的fillfactor,预留页内空间。如果页面填得很满,HOT就难以命中。
4. 索引、分区与膨胀:逻辑存储结构带来的性能因果链
4.1 索引页与堆页的区别
索引也有自己的页面,典型是B-tree索引页。索引条目存储的是键值加上TID(页号,条目号),TID用来定位堆表中的元组。和堆表不同,B-tree索引页要求键值有序,插入新键时经常需要在页面之间搬移键值,随机插入时还会频繁触发页面分裂。
页面分裂的代价不只是多写一个页面,还会让索引物理结构产生更多碎片,后续扫描需要读取更多页面。这就是“索引越多写入越慢”的存储层面原因:每插入一行,除了写堆表,还要向每个索引插入一条目录记录,索引规模大、维护成本急剧上升。
一个常见优化思路是把索引放到单独的快速表空间。但要注意,如果表空间对应的是同一块物理磁盘,优化幅度有限,有时反而增加寻道开销。表空间设计要结合磁盘拓扑,而不是只看SQL。
4.2 分区表:逻辑大表与物理部件的拆分
分区表是理解逻辑存储结构不可回避的例子。同一个逻辑表,在物理上被拆成多个独立分区表,每个分区有独立的relfilenode和数据文件。例如按时间分区:
CREATE TABLE t_order_log ( id bigint, created_at date ) PARTITION BY RANGE (created_at); CREATE TABLE t_order_log_2025_01 PARTITION OF t_order_log FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');这里逻辑上只有一张t_order_log,但查询时优化器会根据where条件做分区裁剪,只扫描匹配的分区。维护时也可以单独truncate旧分区、detach分区,避免对大表执行全量DELETE。删除一个分区时,物理文件会被删除,空间释放比普通表DELETE明显更快。
需要注意分区键的选择。如果分区键是时间字段,最好保证写入时能稳定落在某个分区;如果分区边界设置不当或类型转换导致裁剪失效,SQL会扫描所有分区文件,性能会迅速劣化。理解“一个分区对应一个段、一套文件”之后,这类问题会好排查很多。
4.3 膨胀从哪来,清理要分几步
表膨胀是逻辑存储结构最常引发的运维问题。一切由MVCC留下的旧版本,以及未清理的死元组,都会占用页内空间。若清理进程跟不上写入速度,膨胀会持续累积。表现是活跃行数不算多,但物理文件很大,索引也变大,查询扫描页数变多,缓存命中率下降。
控制膨胀有几个层面:
- 保证自动清理机制处于开启状态,并监控相关统计视图。
- 对高频更新表设置合适的fillfactor,比如85或90,为页内更新预留空间。
- 定期审查大表的膨胀比例,必要时在维护窗口执行VACUUM FULL。
普通VACUUM会把页内可复用的空间标记出来,但不会把空间识别明显归还给操作系统。VACUUM FULL会重写表,压缩文件,但会长时间持有锁。因此生产环境务必安排在维护窗口,并预留足够临时空间。
对于无法锁表的超大表,可以采用“新建表+在线导数据+切换表名”的方式,但需要应用配合或使用视图切换。具体方案要根据业务容忍度来定,没有一刀切的做法。
5. 用系统表观察逻辑存储:常用查询和容易踩的坑
5.1 从pg_database到pg_class的逐层查询
openGauss的元数据自己也存放在表里,可以通过系统表和视图观察。最简单的入门查询:
SELECT datname, oid FROM pg_database; SELECT spcname, oid, pg_tablespace_location(oid) FROM pg_tablespace; SELECT n.nspname, c.relname, c.relkind, c.relfilenode, c.relpages, c.reltuples FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'r' ORDER BY c.relpages DESC LIMIT 50;pg_class中的relfilenode是物理文件编号,relpages是估计页面数,reltuples是估计行数。这些数字来自统计分析,精确度取决于最近执行ANALYZE或VACUUM的时间。当统计信息明显过期时,很多性能分析会失真。
还有一个很实用的函数组合:
SELECT pg_size_pretty(pg_database_size('order_db')); SELECT pg_size_pretty(pg_total_relation_size('app_order.t_order'));前者返回数据库总大小,后者返回表含索引的总大小。排查“哪个对象占了空间”从这里开始最直接。
5.2 一张SQL看全库最大的表和膨胀排名
下面这个查询建议做成固定巡检报表,每天或每周跑一次:
SELECT schemaname, relname, pg_size_pretty(pg_total_relation_size(relid)) AS total_size, n_live_tup, n_dead_tup, CASE WHEN n_live_tup > 0 THEN round(n_dead_tup * 100.0 / n_live_tup, 2) ELSE 0 END AS dead_ratio, last_vacuum, last_autovacuum FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 30;如果dead_ratio长期超过20%,说明清理速度跟不上更新速度。如果last_autovacuum列很久没有更新,就要检查相关参数或后台工作进程是否异常。这个方法同样适用于索引排查:通过pg_stat_user_indexes配合pg_total_relation_size,可以找出索引膨胀。建议把大表、索引膨胀、文件增长作为三个独立巡检项,分别跟踪。
5.3 四个常见认知误区
第一个误区:把表空间当成数据库。实际上一个数据库可以使用多个表空间,一个表空间也可以被多个数据库引用。第二个误区:删除数据后空间会立即返还给操作系统。DELETE只是产生死元组,空间被标记为可复用,但文件大小不会立刻缩小。第三个误区:模式越多越乱。对多团队共用库的场景,独立schema并配合search_path反而安全很多。第四个误区:索引放在独立表空间一定有性能提升。如果表空间对应的物理路径都在同一块磁盘上,性能收益很有限,反而可能增加管理成本。
5.4 最后的一点实操经验
我自己养成的习惯是,建表之前先问自己三个问题:这张表读多还是写多?更新的列会不会被索引覆盖?能不能接受周期性的VACUUM FULL?三个问题的答案几乎能直接给出fillfactor、分区策略和自动清理参数的初步方案。另一个习惯是每个月固定检查一次pg_stat_user_tables中的死元组比例。膨胀不是一天形成的,自动清理也不是万能的,逻辑存储结构决定了空间和性能的底子。把这一层想清楚,日常运维会轻松很多。