上周帮一位朋友排查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 INDEXALTER 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 datnameorderdb;物理文件路径就是 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 10SELECT 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;注意relkindr只统计普通表。如果你还想看索引占用把relkind换成i或者用pg_indexes去join。很多空间异常不是表大而是索引臃肿大表TOP10之外最好再跑一把索引TOP10SELECT 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到物理文件目录对应关系实战查询逻辑对象只是第一步排障时经常要把逻辑对象定位到物理文件尤其在做数据目录迁移、文件级备份、清理垃圾文件时。完整的定位链路是这样第一步查出数据库的OIDSELECT oid, datname FROM pg_database WHERE datnamemydb;第二步查出目标表对应的relfilenodeSELECT 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手里最实用的一张地图。