上周帮一位朋友排查openGauss空间告警,他的实例数据目录已经用了接近600GB,可业务库里的实际数据满打满算也就200GB出头。告警从一个月前就开始跳,他一直靠扩磁盘硬扛。等我登上去一看,问题其实不复杂:他从来没理清过openGauss的逻辑存储结构,不知道哪些对象占了空间、表数据落在哪些物理文件上、为什么删了数据空间却降不下来。这类问题我见过太多次,根子都在对基础存储体系缺少整体认知。
这篇内容就是把openGauss的逻辑存储结构完整拆开讲一遍,从表空间、数据库、Schema、表一路讲到8KB的数据块,顺便把排查空间问题、定位物理文件的SQL和一些调优经验一起放出来。适合三类人看:刚接触openGauss、还分不清数据库和Schema到底差在哪的初学者;正在做运维、遇到空间告警只会扩磁盘的DBA;以及要做存储引擎选型、想理解ASTORE/USTORE和行存列存差异的开发同学。
1. 先画一张全景图:openGauss的逻辑存储到底分几层
很多人看官方文档,第一眼看到“表空间、数据库、模式、表、数据块”这些词就晕了,因为它们看起来是嵌套关系,但又说不清具体怎么嵌套。我建议先别管细节,把下面这个层级图记住:
- 一个openGauss实例,可以创建多个数据库(Database)
- 数据库内可以创建多个Schema(模式)
- Schema下可以创建多张表(Table)以及其他对象
- 一张表的数据,落在一个或多个数据文件(Segment/File)里
- 每个数据文件由若干个数据块(Page)组成,默认一个块是8KB
表空间(Tablespace)是这套体系里比较特殊的一层,它不属于某个数据库,而是跨数据库存在。你可以把表空间理解成“物理磁盘区域的容器”,数据库里的表、索引对象都归属于某个表空间,决定它们实际写到磁盘的哪个目录、哪块盘上。
我习惯用一个物流仓库来类比这套结构,比硬记概念好使:
- 整个openGauss实例,就是一座物流园区;
- 表空间是园区里划分出来的区域,有的区域在普通硬盘上,有的区域在高性能SSD上;
- 数据库是园区里的独立仓库,每个仓库的货物互不干扰,一个仓库出问题不影响另一个;
- Schema是仓库内部的货架分区,同一个仓库里按业务类型分成不同货架区;
- 表就是具体的货架,货架上摆什么货物由建表语句决定;
- 数据块是货架上一格一格的8KB标准托盘位,数据最终就码在这些托盘上。
这个类比能帮你解决90%的概念混淆问题。比如有人问“为什么我建了数据库还要建Schema”,对应到仓库逻辑就很好回答:数据库是仓库,仓库里面总要分货架区吧,Schema就是这个货架区。
搞清楚层级后,再看逻辑与物理的对应关系。openGauss实例安装完成后,数据目录下有几个重要目录:base目录存放普通数据库的数据文件,global目录存放全局对象。你在数据库里建一张表,这张表在物理上对应一个relfilenode编号文件,默认路径是数据目录/base/数据库OID/relfilenode。如果这张表被指定到自定义表空间,路径就变成表空间location目录/数据库OID/relfilenode。
这种分层的设计并不是为了好玩,每一层都在解决一个实际问题:
- 表空间解决“数据放哪块盘、怎么规划容量”的问题;
- 数据库解决“业务隔离、备份恢复粒度”的问题;
- Schema解决“命名空间和访问边界”的问题;
- 表和数据块解决“数据怎么组织、怎么高效读写”的问题。
理解了这个全景,后面每一层的细节就不容易迷糊了。
2. 逐层拆解逻辑存储组件:从表空间到数据块
2.1 表空间:给数据规划物理落盘位置
表空间是openGauss里最高层的逻辑存储单元,它和物理目录直接绑定。系统自带两个表空间:pg_default和pg_global。pg_default是默认表空间,大家平时建表不指定,数据就会落在这里;pg_global存放全局系统对象,一般不需要也不应该动它。用户表空间靠CREATE TABLESPACE创建,语法很简单:
CREATE TABLESPACE fast_ssd LOCATION '/data/ssd/tbs1';这里location指向的目录必须真实存在,并且openGauss运行用户要有读写权限。表空间的实际作用,说直白点就两件事:第一,把不同IO特性的数据分散到不同存储介质上,比如把热点业务表放到SSD表空间,把归档历史表放到机械盘表空间;第二,方便做容量规划,不同业务的数据分表空间放,哪块增长快一眼就能看出来。
我在实际运维里特别推荐一个习惯:建核心业务表时显式指定表空间,不要全部扔pg_default里。比如订单表放在fast_ssd,它的索引可以再建一个独立表空间,进一步分散IO。索引和表分离存储,在高并发下能明显减少单个磁盘的读竞争。
创建完表之后,想迁移到另一个表空间,用ALTER TABLE或者ALTER INDEX:
ALTER TABLE order_tbl SET TABLESPACE fast_ssd;这里要说一个常见的误区:表空间不是数据库,更不是Schema。一个表空间可以放多个数据库下的对象,它只是“对象的物理落盘位置标签”,不要试图用它去做业务隔离。
查询表空间信息非常方便:
SELECT spcname, pg_tablespace_size(spcname) AS size FROM pg_tablespace;pg_tablespace_size会直接返回该表空间上所有对象的实际占用字节数,做容量盘点时这是第一步。
2.2 数据库与Schema:两道隔离关卡别搞混
数据库层的作用是业务隔离。一个实例里经常同时跑着交易库、日志库、报表库,彼此之间互不可见,backup恢复也可以按库粒度做。创建数据库时,openGauss是从模板库复制出来的,系统提供了template0、template1两种模板库。日常习惯是不要往template1里塞业务对象,否则会影响后续所有用默认模板创建的数据库。新库默认编码、字符集等特性在创建时就要想好,因为alter database修改字符集这类操作限制很多。
Schema是数据库内部的命名空间,它解决的是“同一个数据库里如何组织对象”的问题。一张表的完整逻辑名是“库名.模式名.表名”,比如:
CREATE TABLE orderdb.public.order_tbl (id int PRIMARY KEY);默认Schema是public,大多数初学者根本感觉不到它的存在,也就容易踩坑。最典型的坑就是search_path。
search_path决定了不写Schema前缀时,SQL去哪个Schema下找对象。默认搜索路径是“$user, public”,意思是先找当前用户名对应的Schema,找不到再找public。我见过有人给业务账号建了独立Schema,但应用连接后create table直接建到了public里,导致权限混乱、对象分散。解决办法很简单,连接后在会话第一行执行:
SET search_path TO myschema, public;权限控制也是Schema的重要职责。库级别权限管的是“谁能连这个库”,Schema级别权限管的是“谁能在里面建表、读写对象”。实际生产环境里,我建议给不同业务模块建独立Schema,再给对应角色只授该Schema的USAGE和CREATE权限,这样比全部堆在public里做表级授权清晰得多。
数据库、Schema、表空间这三者独立存在,可以自由组合。同一个数据库里的不同表可以分布在不同的表空间;同一个表空间的对象也可以来自不同数据库。理解了这种多维度组合,你看系统视图pg_class里的relnamespace、reltablespace字段就不会懵了。
2.3 表、段与数据文件:relfilenode是连接逻辑与物理的钥匙
数据库Schema下面的表,是真正承载业务数据的地方。openGauss默认的行存表是堆表组织方式,数据行按插入顺序追加到数据页面里,不保证物理有序。堆表里每一行都有一个唯一的系统标识ctid,格式是“页面号+行索引号”,比如(0,1)表示第0号页面里的第1行。ctid是SQL查询的隐藏列,也是后来做数据定位、排查数据分布的重要工具。
从物理文件角度看,一张表对应一个relfilenode编号。系统视图pg_class里有个关键字段就叫relfilenode,它把逻辑对象和文件系统里的实际文件串在一起。查法:
SELECT n.nspname, c.relname, c.relfilenode FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relname = 'order_tbl';查到relfilenode后,再去数据目录对应的路径下找文件。默认表空间的位置规则是:
- 数据库OID从pg_database里查:
SELECT oid, datname FROM pg_database WHERE datname='orderdb'; - 物理文件路径就是 base/该OID/relfilenode
如果表被放到自定义表空间,路径变成 表空间location/该OID/relfilenode。
表文件在超限之后会继续扩展新文件,命名规律是relfilenode后面带序号,比如16385.1、16385.2。这和处理超大表的场景有关,单个文件过大对文件系统、备份工具都不友好。
表对应的物理文件不止数据主文件一个。如果表里有varchar/text这类可变长大字段,会额外生成TOAST表和TOAST索引,它们也各有自己的relfilenode,这也是为什么计算一张表总大小要用pg_total_relation_size,它会自动把主表、TOAST、所有索引都算进去。
分区表是个容易出问题的点。分区主表在pg_class里relkind是'p',它本身没有独立的数据文件,relfilenode显示为0,真正数据都在各个分区里。如果只看主表relfilenode去磁盘找文件,什么都找不到。要定位分区表的物理文件,得查各个分区(每个分区相当于一张独立表)。这一点在实际排查里非常容易绕弯路。
2.4 数据块:openGauss IO和缓存的最小逻辑单位
数据块是整个逻辑存储结构的最底层,openGauss默认一个块是8KB。这个数值不是拍脑袋定的,它和主流数据库、操作系统的页大小机制都有关系,同时也兼顾了磁盘随机IO的性能和空间管理的粒度。数据块是缓存、读写、恢复的基本单位,内存里的shared buffer也是以8KB为单位换入换出。
一个数据块的内部布局大概有四部分:
- 页头(PageHeader):记录页的基本信息,比如页面编号、空闲空间起始位置、特殊空间位置等
- 行索引数组(ItemIdData):每个数组项指向一条实际元组的偏移量和长度
- 空闲空间:新插入行和更新行时使用
- 元组数据区:真正存数据的区域
行存表插入一条数据时,系统在空闲空间里找一个位置放下元组,然后把偏移量写入行索引数组。查询时先扫行索引数组,再根据偏移量取元组。更新时副作用很大,如果新版本的元组在同一个页里放不下,就得放到别的页面,产生跨页指针,这也是表会膨胀的根源之一。
想直观看到数据块内部结构,实验环境里可以加载pageinspect扩展:
CREATE EXTENSION pageinspect; SELECT * FROM page_header(get_raw_page('order_tbl', 0));它能读到第0号页面的页头信息、元组统计等,比从文档背概念直观得多。我自己第一次用pageinspect看实际页结构时,对“数据块到底是啥”才彻底有了画面感。
对运维排障来说,数据块这层还有一个实用换算关系:一张表的空间大小,基本等于“页面数量 × 8KB”。pg_class里的relpages字段就是这张表占用的页面总数。如果一个表relpages是65536,那它主文件大约就是512MB。这个换算能力在快速估算大表规模时特别有用。
3. 存储引擎不同,逻辑存储的表现差异很大
3.1 ASTORE与USTORE:更新逻辑差异决定了表的膨胀走势
openGauss的存储引擎设计里,ASTORE和USTORE是最容易让人纠结的两个词。很多人只知道“一个是默认的,另一个写多时用”,但不知道为什么。
ASTORE是追加写模式。一行数据更新时,它不会修改原来的元组,而是在当前页面空闲区(或新页面)插入一个新版本元组,旧版元组就留在原地,通过事务可见性字段标记为“已失效”。高并发更新场景下,这种机制会让表以肉眼可见的速度膨胀,垃圾版本只能靠VACUUM来回收。所以ASTORE适合读多写少的场景,比如配置表、历史订单查询表。
USTORE是原位更新模式,更新时会直接在当前页找到目标元组做修改,旧版本信息写入独立的UNDO记录。这样做的好处是表的堆页面不会因为频繁更新而疯狂增长,膨胀压力大幅减小,VACUUM的工作量也低。代价是UNDO空间本身需要管理,系统开销并不比ASTORE免费。建表时这样指定:
CREATE TABLE order_tbl (id int PRIMARY KEY, info varchar) WITH (STORAGE_TYPE = USTORE);我给的选型建议是:默认ASTORE完全没问题,但如果一张表的高频操作是大量UPDATE、DELETE,比如状态流转表、库存流水表,优先考虑USTORE。转之前先评估UNDO表空间的容量和监控手段,否则省了膨胀又多了UNDO告警,等于按下葫芦浮起瓢。
3.2 行存与列存:OLTP和OLAP的分岔路
行存表的逻辑存储结构,前面讲的堆表、ctid、元组这些都是它的规则。行存的优势是一条记录的所有字段都在同一个页面附近,做点查询、点更新、走主键索引回表时非常快,所以OLTP系统默认就是行存。
列存表则完全不同。它在逻辑上把一列的连续值放在一起存储,同一行的不同字段可能分散在不同的物理文件或远距离页面上。这种组织方式让列存表在只查少数几列、做聚合分析时优势巨大,因为根本不用读取无关列的数据,而且相邻列值高度相似,压缩比可以做得非常漂亮。建列存表的语法是:
CREATE TABLE report_stats ( user_id int, spend_amount numeric ) WITH (ORIENTATION = COLUMN);有一次帮客户做报表优化,一张1.2亿行的明细表,行存查某个月的总消费金额跑了快40秒,改成列存表后只需要6秒,主要差异就在“少读了几个数量级的无用数据”。
但列存不是万能的。它不适合频繁的单行更新——因为一行数据被打散到多个列文件中,更新一条记录要动很多位置。OLTP系统里如果误用了列存表,性能会比行存差很多。选型时记住一条线:行存面向“高频增删改查”,列存面向“大数据量聚合分析”。
3.3 段页式存储:表文件数量爆炸时的另一条路
常规存储方式下,每张表、每个索引都对应独立物理文件。业务如果频繁创建、删除临时表或分区表,文件系统里会积累大量小文件,inode耗尽、目录遍历变慢、备份变慢这些故障就来了。
段页式存储就是针对这个问题设计的。它不再为每张表单独分配文件,而是把表空间的大文件预先划分成固定大小的页面,表对象从这些页面池里分配空间。逻辑上的“段”指向段页式管理的页面,而不是指向某个孤立文件。类比一下:普通方式是每批货都单独开门建仓,段页式是先盖一栋大仓库,里面按隔间编号分配,货柜再多也只是在大仓库里不断分割。
段页式适合的对象类型很明确:大量分区表、频繁创建和销毁的中间表、高并发短连接下大量临时对象。但它不是openGauss的默认配置,需要初始化阶段规划和相关参数配合,生产环境开启前要先做小规模压测,观察页面分配效率和空间回收是否符合预期。普通的稳态业务表用常规存储就够了,没必要为了赶新技术而强行段页式。
4. 落地实操:用SQL把存储结构完全摸透
4.1 一套可以直接抄的体检SQL
逻辑存储结构理解得再透,最后都要落到查询实践上。我整理了一套我每次到现场都会跑的“存储体检”SQL,基本能覆盖日常巡检需求。
先看实例里所有数据库的大小,按大小排序找谁最大:
SELECT datname, pg_database_size(datname) AS size_bytes FROM pg_database ORDER BY size_bytes DESC;再看各个表空间的数据分布:
SELECT spcname, pg_tablespace_size(spcname) AS total_size FROM pg_tablespace ORDER BY total_size DESC;然后进入目标库,按Schema统计对象数量,看逻辑组织是否混乱:
SELECT n.nspname AS schema_name, count(*) AS object_count FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace GROUP BY n.nspname ORDER BY object_count DESC;最常用的大表TOP 10:
SELECT n.nspname AS schemaname, c.relname AS tablename, 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 = 'r' ORDER BY total_size DESC LIMIT 10;注意relkind='r'只统计普通表。如果你还想看索引占用,把relkind换成'i',或者用pg_indexes去join。很多空间异常不是表大,而是索引臃肿,大表TOP10之外最好再跑一把索引TOP10:
SELECT n.nspname AS schemaname, c.relname AS indexname, pg_relation_size(c.oid) AS index_size FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'i' ORDER BY index_size DESC LIMIT 10;运行完这套,基本能在几分钟内定位“实例空间被谁吃掉”的问题。
4.2 从relfilenode到物理文件:目录对应关系实战
查询逻辑对象只是第一步,排障时经常要把逻辑对象定位到物理文件,尤其在做数据目录迁移、文件级备份、清理垃圾文件时。完整的定位链路是这样:
第一步,查出数据库的OID:
SELECT oid, datname FROM pg_database WHERE datname='mydb';第二步,查出目标表对应的relfilenode:
SELECT relname, relfilenode FROM pg_class WHERE relname = 'order_tbl';然后到服务器上执行:
ls -lh $PGDATA/base/<数据库OID>/<relfilenode>如果表放在了自定义表空间,那么路径是:
ls -lh /data/ssd/tbs1/<数据库OID>/<relfilenode>这里要特别说两个容易出问题的地方。第一,如果系统里开启了某些特性或做过表重组操作,看到的relfilenode可能变化了。表被VACUUM FULL重写后relfilenode会变更,不用慌,重新查一次pg_class就能拿到最新的。第二,分区表主表relfilenode是0,真正的数据文件在分区上,定位时必须先查分区定义,再定位具体分区的relfilenode,不能拿主表名字去磁盘找。
还有一个很实用的技巧:用文件大小反推逻辑对象。物理文件大小除以8192就是页面数,再和pg_class里的relpages对比,如果对不上,说明可能存在大量空闲页或者文件扩展异常,这往往是后续空间回收优化的线索。
5. 常见问题与排查技巧实录
5.1 表空间告警:先定位TOP大对象,不要急着扩盘
表空间使用率超过85%时,大部分人第一反应是加磁盘,我建议先跑一遍前文的大表TOP 10和索引TOP 10,判断空间是被真实数据还垃圾版本占用。真实数据大,就走扩容或归档;垃圾版本多,优先VACUUM。扩容不是不行,但它应该排在定位问题之后,而不是代替定位。
处理时有几条心得:
- 确认大对象属于哪个业务模块,能不能归档。比如历史订单表,按时间分区的话直接truncate旧分区最省事
- 能drop的表直接drop,物理空间立刻释放,不用额外操作
- 需要保留数据但表结构可以重写的,VACUUM FULL后空间才会缩回来
- VACUUM FULL会锁表并重写整个表,必须在业务低峰执行,而且需要额外的临时空间。如果磁盘已经满了,先清掉日志、临时文件腾出空间再执行
5.2 删了大表,磁盘空间却没释放,大概率是VACUUM和长事务的问题
DELETE大量数据,或者DROP了表,磁盘空间一时没降下来,很多新手就慌。DELETE只是把行标记为待回收,空间要等VACUUM收拾;DROP表是立刻释放物理文件的,但如果它还在某个长事务的可见性范围内,文件可能暂时无法删除。
遇到空间不释放,我固定查三件事:第一,有没有长事务和空闲事务在跑,查pg_stat_activity按xact_start时间排序,拖得越久对垃圾回收影响越大;第二,复制槽是不是堵塞了,导致提交版本没法清理,检查pg_replication_slots里active和restart_lsn;第三,表或者索引是不是从来没有执行过VACUUM,查pg_stat_all_tables的last_vacuum和last_autovacuum。
autovacuum不是万能的,默认阈值偏向保守。高更新频率下,手工VACUUM或者把autovacuum_vacuum_scale_factor调小、autovacuum_vacuum_threshold调低,都比扩盘更治本。更新特别凶的表,直接评估换USTORE引擎。
5.3 search_path混乱导致“表不见了”和权限异常
这个坑出现的频率远超想象。一个业务库里有多个Schema,某个应用连上去后执行SELECT报“关系不存在”,但表明明就在public里。原因八成是连接的search_path不包含public,或者被设置成了某个不存在的用户同名Schema,系统找了一圈没找到就报错。
解决方式是显式设置搜索路径:
SET search_path TO myschema, public;长期方案是应用层统一规范,连接建立后固定执行search_path设置;开发规范里明确禁止不加Schema前缀的建表和访问。我见过太多因为省这几个字符,最后查出两条同名表、数据写错位置的案例。
还有权限问题。某个角色能连数据库,但select其他Schema的表被拒,提示permission denied for schema,这时要给角色授Schema的USAGE权限:
GRANT USAGE, CREATE ON SCHEMA myschema TO app_user;逻辑存储结构的所有层级,最终都通过权限系统来约束边界,这也是排查异常时容易被忽略的一条暗线。
5.4 索引、序列、TOAST也占用逻辑存储空间
很多空间体检只盯“表”,实际上一个库里还有大量索引、序列、TOAST数据。TOAST表的relfilenode可以在pg_class里查到,它的命名往往和主表有对应关系,数据目录下会有多个文件。序列虽然单个体积小,但如果业务为每行都nextval一次,并且序列缓存调得很大,也可能吃掉不少空间。完整的存储盘点必须把relkind为'r'、'i'、't'、'S'的对象全部覆盖,否则你永远不知道那看不见的50GB藏在哪。
之后再做表级归档时,还要记得把索引、约束一并归档,或者重建。很多DBA只搬主表数据,索引重新建,结果业务高峰期被索引构建压力卡住,这是另外的教训了。
我自己每次接手一个openGauss实例,第一件事不是看配置参数,而是花十分钟把存储“户型图”打出来:表空间大小、库大小、Schema对象分布、TOP20大表和索引,全部存档。后面再遇到空间告警、性能抖动,拿这份基线一对比,很快就能定位异常对象。排查存储问题时,也习惯先通过pg_class拿到relfilenode,再去找物理文件,这样永远知道自己正在看的是哪张表的落盘数据。
最后分享一个小技巧:遇到表占用空间和实际数据量严重不符时,可以先查一下relpages和reltuples的比例,relpages代表页面数,reltuples代表行数。如果页面很多但行数很少,说明表里堆了大量死元组,这就是典型的膨胀信号。这时候别急着调业务,先跑一次VACUUM,再观察relpages有没有下降,很多空间告警就是这么无声无息解决掉的。逻辑存储结构这东西,平时不显山不漏水,真到排障的时候,就是DBA手里最实用的一张地图。