1. 为什么要读懂KingbaseES的存储结构
有段时间我一直在帮客户排查一个诡异的问题:数据库服务正常运行,慢查询也没有明显变化,但磁盘空间一天掉几个百分点,最终整个实例直接写不进去了。当时接手的人第一反应是"清理日志",可日志文件才几百兆,根本不可能是元凶。后来我登录进系统,按库、按表统计了relfilenode对应的物理文件大小,才定位到一张业务日志表占了将近300GB——而这张表在业务系统里按天归档,理论存量也就几十GB。这张表为什么膨胀到这种程度,答案就藏在KingbaseES的存储结构里。
这个案例不是孤例。很多从Oracle、MySQL迁移过来的DBA,一开始面对人大金仓数据库(KingbaseES)时,最先感到不适的往往不是SQL语法,而是"不知道数据到底存在哪、占了多少空间、为什么删了数据文件却不见小"。原因很简单:KingbaseES虽然兼容MySQL和Oracle的很多使用习惯,但它的内核源自PostgreSQL,存储引擎的组织方式、数据文件的命名规则、WAL日志的保留策略都自成一套。你不懂这些,遇到磁盘暴涨、备份变慢、恢复失败这类问题就会很被动。
这篇文章想把KingbaseES的存储结构从头到尾拆一遍——从安装目录到数据目录,从文件命名规则到页面内部的元组布局,从WAL日志到MVCC带来的空间膨胀,最后落到几个可以直接上手的排查命令和真实案例。无论你是刚接触金仓数据库的新手,还是已经跑了一段时间想深入理解内部机制的运维或开发,这篇内容都能给你一张完整的"地图"。看完之后,至少你遇到存储相关的问题时,知道从哪里下手,而不是靠猜。
2. KingbaseES实例目录:安装结构与参数文件体系
很多人拿到KingbaseES安装包,一路下一步就装完了,然后就开始建库建表,几乎没有认真看过安装目录里那些文件夹到底是干嘛的。真到需要配置参数、调整归档、迁移数据目录的时候,才发现自己连 kingbase.conf 在哪、数据文件在哪个路径下都说不清楚。
2.1 安装目录与数据目录的职责划分
KingbaseES的安装目录和数据目录是两个完全不同的概念,这一点和MySQL有点像,但和Oracle的单实例大目录结构差异很大。
先说安装目录。默认情况下,Linux环境一般装在/opt/Kingbase/ES/V8或用户自定义的路径下,这个目录里主要的子目录包括:
bin:存放可执行文件,包括数据库服务端进程 kingbase、客户端工具 ksql、备份恢复工具 sys_backup、sys_rman 等。这些工具本质上就是PostgreSQL工具链的适配版本,只是名字和内部实现做了国产化改造。lib:存放动态链接库,包括插件库。金仓很多高级特性都是通过插件方式提供的,比如存储过程调试、审计日志、外部数据封装等。share:包含扩展的SQL脚本、默认配置模板、时区数据、字符集数据等。你在数据库里执行CREATE EXTENSION时,实际就是到这里的扩展目录去加载对应的SQL脚本和控制文件。include:开发用的头文件,编写C语言扩展或客户端程序时才会用到。etc:部分版本的配置文件或证书文件可能会放在这里,不过核心配置通常还在数据目录里。
数据目录(data)才是数据库真正"存储"的核心。它的位置由初始化时的-D参数决定,或者由启动时的启动脚本指定。默认情况下,KingbaseES的数据目录里能看到以下几类关键的存储相关对象:
| 路径/文件 | 作用 |
|---|---|
kingbase.conf | 主参数配置文件,相当于PostgreSQL的postgresql.conf,控制内存、日志、WAL归档、连接数等核心参数 |
kingbase.auto.conf | 通过ALTER SYSTEM命令修改的参数会写入这里,启动时自动加载,优先级高于主配置文件 |
pg_hba.conf | 客户端访问认证控制文件,管理哪些IP、哪些用户能连上来 |
base/ | 每个数据库对应的子目录,目录名是数据库的OID |
global/ | 集群级共享系统表目录,包含所有数据库共享的全局对象,比如数据库级权限、内置角色等 |
pg_wal/ | WAL日志目录,存储事务日志(PostgreSQL传统叫pg_xlog,PG10之后改了名,金仓沿用了pg_wal) |
pg_control | 控制文件,记录实例启动状态、最新检查点位置、数据库系统标识符等元信息 |
postmaster.pid | 记录守护进程PID、启动时间、共享内存地址等信息,实例运行时才会出现 |
pg_log/ | 运行日志目录(不同版本名称可能不同,有的叫log,有的叫sys_log) |
pg_replslot/ | 复制槽文件目录,用于逻辑复制或物理流复制场景 |
提示:如果你执行
SHOW data_directory;或者SHOW config_file;,数据库会直接告诉你当前实例的数据目录和配置文件路径。记住这两个命令,比翻安装文档快得多。
2.2 关键参数文件与控制文件的联动关系
存储结构不是死的,它与参数配置有很强的联动。我举几个直接影响"存储"的配置项:
shared_buffers:共享缓冲区大小,决定数据库有多少内存用于缓存数据页。这个值设置过小,会导致频繁从磁盘读页,IO压力增大;设置过大,又可能挤占系统内存,甚至导致操作系统swap。金仓默认值往往偏保守,业务量稍大就需要调整。wal_level:WAL日志的写入级别,可选值为 minimal、replica 或 logical。如果要做逻辑复制或需要时间点恢复(PITR),至少得设成 replica,要做逻辑复制则要设成 logical。这个参数直接影响WAL里记录的信息量,也就直接影响pg_wal/目录的增长速度。archive_mode和archive_command:是否开启WAL归档,以及归档命令。很多存储问题都出在"开了归档但没配置清理策略"上,导致归档目录无限增长。max_wal_size和min_wal_size:控制检查点之间WAL文件的最大/最小保留量。max_wal_size设得很大,可以在高写入场景下减少检查点频率,但代价是pg_wal/目录会占用更多磁盘空间。fsync:是否强制刷盘。生产环境绝对不能关,否则一旦操作系统崩溃,数据页和WAL的一致性会被破坏,可能造成无法恢复的损坏。
控制文件(pg_control)和参数文件的关系也需要理解。pg_control 不是给人读的文本文件,它是二进制格式,记录了数据库的全局状态:系统标识符、最新检查点位置、WAL起始位置、数据库状态(正在启动、恢复中、崩溃恢复等)。KingbaseES在启动时会先读控制文件,校验数据目录是否合法,然后根据控制文件里记录的检查点位置去找对应的WAL文件进行恢复。所以这个文件一旦丢失或损坏,整个实例基本就起不来了。虽然pg_resetwal工具能强行重置WAL产生一个新起点,但这属于最后的"急救手段",往往会导致部分已提交事务丢失。
理解这一层,你就知道为什么我强烈建议在做任何重要变更之前,先完整记录一下SHOW data_directory;、SHOW config_file;的输出,同时确认kingbase.conf里归档目录和日志目录的路径。很多"数据库起不来"的事故,复盘下来就是有人改错了配置文件路径,或者把配置目录和数据目录搞混了。
3. 磁盘上的持久化文件:从堆表到WAL的完整链路
数据最终是落在磁盘上的,理解KingbaseES的磁盘文件组织方式,才能解释很多"看起来奇怪"的现象。比如为什么一张表中删除了大量数据,磁盘空间却不释放?为什么表文件会从16384跳到16384.1、16384.2?为什么WAL目录下会有大量同名文件反复生成?
3.1 数据文件的编号规则与文件扩展逻辑
KingbaseES中,每个数据表(或索引)在磁盘上都有一个对应的物理文件,默认存放在base/数据库OID/表OID这个路径下。普通用户对象(表、索引)的OID并不是随随便便的数字,它是对象创建时由系统自动分配的,你可以通过系统表查出来。举个例子:
-- 查看某个表的OID和对应文件路径 SELECT relname, relfilenode, oid FROM pg_class WHERE relname = 'your_table_name';relfilenode和oid大多数情况下一致,但在执行过某些操作(如VACUUM FULL、CLUSTER)后,relfilenode可能发生变化,因为重建表会生成一个新的物理文件。
物理文件默认以8KB为一个页(block),随着数据增长,文件会按块自动扩展。当大小超过1GB时,系统会创建新的姊妹文件,命名规则是在原文件名基础上加.1、.2这样的序号。所以你会看到16384、16384.1、16384.2这种连续文件,它们其实属于同一个表。这个机制与PostgreSQL完全一致,也是继承自PostgreSQL的segment file管理方式。
很多人会问:为什么要拆成1GB一段?主要原因是文件系统对单个文件大小有限制,同时超过一定大小的文件也不方便备份工具管理。拆段之后,哪怕表大小达到几十TB,底层也只是多个1GB文件的集合,管理起来更灵活。
数据文件的增长不是"按需一次性分配"的,而是攒到一定量才扩展一次。也就是说,一张刚创建的空表,物理文件可能只有0字节或者几KB大小,只有当插入的数据超过一个页面(8KB)后,文件系统里才会真正分配新的页。这带来一个实际影响:查看磁盘占用时,ls -l看到的大小可能和表内统计的行数严重不符——比如一张表里有1000万行,但文件可能还是空的,因为数据可能还没刷盘或者还停在内存缓冲区里。
3.2 WAL日志的工作机制与归档参数
KingbaseES使用Write-Ahead Logging(WAL)机制来保证数据持久性和崩溃恢复。简单说:任何修改操作,先写WAL日志,再写数据文件。这样即便数据文件在崩溃时还没刷盘,数据库重启后也能根据WAL日志重放操作,恢复到崩溃前的一致状态。这个机制与PostgreSQL一致,金仓沿用了整套实现。
WAL文件默认位于数据目录的pg_wal/子目录下,文件名是24位十六进制,每段默认16MB。比如000000010000000000000001这样的命名。目录里的WAL文件数量不是固定的,它受wal_keep_size、checkpoint_completion_target、max_wal_size等参数的影响,在高写入场景下可能积累几十个甚至上百个文件。
归档参数与WAL存储息息相关。如果开启了归档,archive_command指定的命令会在每个WAL段写满后执行一次,把文件复制到归档目录。常见配置示例:
archive_mode = on archive_command = 'test ! -f /backup/archive/%f && cp %p /backup/archive/%f'这段命令的意思是:如果归档目录下不存在同名文件,就把当前WAL段复制过去。很多人只配了archive_command,没配清理策略,于是归档目录以每天几个GB的速度增长。这种问题在初次接触金仓的团队里特别常见。
另外要提醒的是:开启了wal_level = logical时,WAL文件会记录逻辑解码所需的信息,写入量会比replica模式大不少。如果业务并不需要逻辑复制,就别盲目设置成logical,否则纯粹是浪费磁盘和IO。
3.3 控制文件、事务日志的协作时序
每次事务提交时,KingbaseES会把事务状态记录到pg_xact(旧称pg_clog)目录中,同时更新WAL。这个目录存放的是"某事务ID是否已提交/中止"的状态位图,文件不大但非常重要。配合pg_control,数据库在崩溃恢复时才能判断哪些事务是已提交的、哪些需要回滚。
从存储角度看,一次正常事务的落盘时序大致是:
- 事务开始,分配事务ID;
- 修改数据页(在内存中),同时生成对应的WAL记录;
- 事务提交时,将WAL记录刷到磁盘(在
fsync=on的前提下); - 更新
pg_xact中的事务状态; - 检查点发生时,把脏页刷到数据文件,并更新
pg_control中的检查点位置。
有意思的地方在于:第5步往往不是每次事务都发生的,而是周期性触发。所以在两次检查点之间,可能WAL已经写了几百MB,但数据文件还没更新。这也是为什么VACUUM和CHECKPOINT在存储管理中如此重要——它们一个管清理,一个管落盘。
4. 逻辑存储层级:表空间-数据库-数据文件-页面-元组
如果说上一章讲的是"磁盘上有什么文件",这一章要讲的是"数据库内部怎么把逻辑表映射到物理文件"。这块不搞清楚,你就没法解释为什么同一张表放在不同表空间下性能会有差异,也没法理解VACUUM FULL和普通VACUUM在存储上的本质区别。
4.1 五层结构的职责边界
KingbaseES的逻辑存储结构可以分成五层:表空间(Tablespace)→ 数据库(Database)→ 数据文件(Segment/File)→ 页面(Page/Block)→ 元组(Tuple/Row)。
表空间是最顶层的物理存储容器。一个表空间对应文件系统里的一个目录。默认情况下有两个表空间:pg_default和pg_global。前者存放所有用户数据的默认位置,对应数据目录下的base/;后者存放全局系统表,对应global/。你可以在其他磁盘路径下创建自定义表空间,然后把特定表或索引指定到该表空间,这样就能把IO负载分散到不同物理盘。
数据库是表空间之下的逻辑容器。每个数据库在base/目录下有一个对应的子目录,目录名就是数据库的OID。数据库内部的表、索引等对象,物理文件就放在这个子目录里。
数据文件是物理上的"段落"单位,默认1GB分一段。之前已经讲过,不再赘述。
页面是存储的最小物理单位,默认8KB。每次数据库从磁盘读取数据,最少也是读一个页面;每次写入也至少是写一个页面。共享缓冲区shared_buffers就是以页面为单位缓存数据。
元组是最终存放一行数据的地方,一个页面内可以存放多个元组。但要注意:一个元组不能跨页面存储,也就是说单行数据如果超过8KB,就需要使用TOAST(The Oversized-Attribute Storage Technique)机制拆到单独的表里。
4.2 页面的内部布局与行定位方式
理解了页面布局,才算真正理解了存储。一个8KB的页面大致分为几个区域:
- 页头(PageHeaderData):固定长度,记录页面的LSN(最后一次修改对应的WAL位置)、页面空闲空间起始位置、页面中元组数量、校验和等元信息。
- 行指针数组(ItemIdData):每个元组对应一个行指针,记录该元组在页面中的偏移量和长度。行指针从页面头部向后排列。
- 空闲空间(Free Space):行指针数组和元组数据之间的空隙,新插入的元组会优先使用这块空间。
- 元组数据区(Heap Tuple Data):从页面末尾向前填充,每个元组包含行头(HeapTupleHeaderData)和实际列数据。
为什么行指针要从前往后、元组数据要从后往前?因为这种"两头生长"的设计可以有效利用页面剩余空间,减少页面碎片。你可以把它类比成停车场:入口车道在最前方,车位从最后面开始停,中间留一条通道,新来的车在通道里找位置。
当你执行一条SELECT时,数据库会根据索引或顺序扫描找到对应页面,然后通过行指针定位到具体元组。元组头里记录了该元组的 xmin(插入该元组的事务ID)、xmax(删除或锁定该元组的事务ID)等信息,这些是MVCC(多版本并发控制)机制的基石。
4.3 MVCC机制如何影响存储空间的使用
KingbaseES的MVCC实现方式与PostgreSQL一致:更新一行数据时,并不是在原位置上修改,而是插入一条新版本元组,然后让旧版本的 xmax 指向当前事务ID,表示旧版本已失效。删除一行数据时,也不是立即物理删除,而是把该元组的 xmax 设置为删除事务ID,标记为"已删除"。
这就带来一个存储上很重要的推论:你执行了 UPDATE 或 DELETE,磁盘上的空间并不会马上变小,只是标记为"过期"。只有等到后续的VACUUM进程来清理,这些过期元组占用的空间才可能被重新利用或释放。更麻烦的是,如果有长期未提交的事务或长时间运行的查询持有旧快照,VACUUM可能完全无法清理这些过期版本,导致表不断膨胀。
我接手过一个真实案例:某系统每晚批量更新上千万行数据,但批量任务里有一个事务在结束后没有及时提交(代码bug),导致整个表的大量过期版本无法被清理。三周后,一张逻辑上只有5000万行的表,物理文件膨胀到80GB。这种问题不深入理解MVCC和存储结构,根本无从下手。
5. 索引与特殊存储结构:为什么空间会莫名膨胀
讲完堆表(Heap Table),索引的存储也不能忽略。很多人在排查数据库体积暴涨时,只检查了业务表,忘了索引同样会占用大量磁盘空间。更隐蔽的是,KingbaseES中索引页的复用逻辑与堆表不同,一些操作会导致索引实际占用的空间远超预期。
5.1 B+Tree索引的实际存储开销
KingbaseES默认使用B+Tree结构存储索引数据。B+Tree的特点是非叶子节点只存键值和指向子节点的指针,叶子节点才真正存储行指针(ctid)。一个叶子页面通常能容纳成百上千个键值,所以索引本身的体积往往比对应的表小很多。但有几个例外需要注意:
- 索引列较长时,单个键值占用空间大,页面容纳的条目少,索引文件增长更快;
- 组合索引的键值是各列拼接后的结果,长度可能是各列之和,体积也会明显增加;
- 主键索引和有唯一约束的索引,存储开销和表内的行数严格成正比,不会因为某列内容重复而压缩。
一种实用的排查方式:把表中索引的物理大小和表本身的物理大小做一个对比。
SELECT tab.relname AS table_name, pg_size_pretty(pg_total_relation_size(tab.oid)) AS total_size, pg_size_pretty(pg_relation_size(tab.oid)) AS table_size, pg_size_pretty(pg_total_relation_size(tab.oid) - pg_relation_size(tab.oid)) AS index_size FROM pg_class tab WHERE tab.relkind = 'r' AND tab.relname = 'your_table_name';如果你发现索引总大小接近甚至超过表大小,那就要审视一下索引是否有冗余:是否存在多个索引覆盖同一组列?是否在大文本字段上建了不必要的索引?业务上是否真的需要那么多唯一约束?
5.2 膨胀的根源与回收策略
索引膨胀的根源和堆表不太一样。堆表中的过期元组可以通过VACUUM清理并复用空间;但索引页面的复用条件更苛刻。B+Tree在删除键值后,页面不会立即合并,只有当页面完全空的时候,才会被标记为可重用。如果业务对某张表频繁执行插入和删除,索引页面就会被"掏空",形成大量半空的页面,文件自然越撑越大。
针对这种情况,常用的手段有几种:
- 普通
VACUUM:回收堆表中过期元组占用的空间,并更新统计信息,但不能缩小文件体积; VACUUM FULL:重建表文件,把有效数据压缩到新文件中,可以大幅减小物理大小,但会锁表,业务高峰期绝对不能执行;REINDEX:重建索引,可以整理索引页面、消除膨胀,同样会有锁表问题;CLUSTER:按指定索引的顺序重排堆表,副作用是会重写整张表,耗时和空间开销都很大。
从我的经验来看,一个规范的维护窗口很重要:低峰期执行VACUUM,每隔一段时间(比如一个月)在维护窗口内执行VACUUM FULL或REINDEX,同时检查是否有长时间未提交事务阻塞了清理。记住一个原则:VACUUM是常态化动作,VACUUM FULL是"手术",不要天天做,但也不能永远不做。
6. 读存储结构的五个实操姿势
理论讲了不少,但这篇文章如果没有可落地的命令和排查方法,价值就少了一半。这一章直接给操作干货,基本都是我在实际维护金仓数据库时反复用到的查询和命令。
6.1 用系统表反查文件路径
想知道某张表对应的物理文件在哪,最直接的方法是用pg_relation_filepath()函数:
SELECT pg_relation_filepath('your_table_name');返回结果类似base/16384/24650,意思就是数据目录下base/16384/目录里的24650文件。结合数据目录路径,你可以在操作系统层面直接查看文件大小:
ls -lh $DATA_DIR/base/16384/24650*这个函数同样适用于索引:
SELECT pg_relation_filepath('your_index_name');如果你想看一张表所有相关对象(表本身、TOAST表、TOAST索引、普通索引)的完整路径,可以用:
SELECT relname AS object_name, relkind, pg_relation_filepath(oid) AS file_path FROM pg_class WHERE relname = 'your_table_name' OR relname IN (SELECT indexname FROM pg_indexes WHERE tablename = 'your_table_name');注意:relkind字段中r表示普通表,i表示索引,t表示TOAST表,I表示TOAST索引,S表示序列。这些信息在排查存储问题时非常关键。
6.2 表空间管理实操
创建表空间的语法和PostgreSQL保持一致:
CREATE TABLESPACE ts_disk2 LOCATION '/data2/kingbase/tablespace';执行前要注意几个点:
- 目标目录必须存在,且属主必须是运行数据库的操作系统用户(通常为kingbase用户);
- 目录应提前设置好权限,建议
700; - 不能在事务块中执行
CREATE TABLESPACE。
创建之后,可以把表移动过去:
ALTER TABLE your_table_name SET TABLESPACE ts_disk2;也可以把整个数据库的默认表空间切换过去:
ALTER DATABASE your_db_name SET TABLESPACE ts_disk2;查看现有表空间及其使用情况:
SELECT spcname, pg_tablespace_location(oid) AS location, pg_size_pretty(sum(pg_total_relation_size(c.oid))) AS total_size FROM pg_tablespace t LEFT JOIN pg_class c ON c.reltablespace = t.oid GROUP BY spcname, pg_tablespace_location(oid), t.oid;表空间是物理存储与逻辑对象的桥梁,合理使用表空间可以显著改善IO分布。比如把WAL目录放高速SSD,把低频历史数据表放到机械盘或对象存储挂载盘,各有各的适用场景。
6.3 一个完整的排查案例:磁盘被一张"幽灵表"占满
开篇那个案例值得展开说明一下完整的排查过程,因为它几乎涵盖了上面讲的每一个知识点。
某生产系统数据库所在的磁盘使用率达到95%,告警邮件已经发了一整天。最初运维团队怀疑是pg_wal目录膨胀,因为最近数据库发生过一次主备切换。登录后我首先做了几件事:
第一步,确认数据目录位置和整体占用:
df -h du -sh $DATA_DIR du -sh $DATA_DIR/base/* | sort -rh | head -10第二步,定位占用最大的数据库目录,然后进到对应OID目录里看哪些物理文件体积异常:
ls -lhS $DATA_DIR/base/ | head -20第三步,根据大文件反查对象。比如发现24650.3这个文件有80GB,那就查:
SELECT relname, relkind FROM pg_class WHERE relfilenode = 24650 OR oid = 24650;结果发现是一张名为t_log的业务表。但业务方反馈这张表每天归档删除,理论上不应该这么大。
第四步,查表的详细统计信息,确认膨胀率:
SELECT pg_relation_size('t_log') AS table_bytes, pg_total_relation_size('t_log') AS total_bytes, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum FROM pg_stat_user_tables WHERE relname = 't_log';n_dead_tup高达几千万,而last_vacuum显示已经十几天没有成功执行过VACUUM。再加上last_autovacuum也为空,说明自动清理进程要么被关掉了,要么因为某种原因没有触发。
第五步,查是否有长事务阻塞清理:
SELECT pid, xact_start, state, application_name FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY xact_start ASC;果然有一个应用连接的事务已经持续运行了超过20小时。因为它在事务开始后获得了一致的快照,VACUUM无法推进全局的清理点,导致所有过期版本都无法清理。最后,和业务方确认后杀掉该连接并提交或回滚事务,再手动执行VACUUM FULL t_log;,表文件从80GB降到12GB。磁盘警报解除。
这个案例如果不懂存储结构,就只能看到"磁盘满了→删日志→不够→再删"的死循环,永远找不到真正的病灶。
6.4 用拓展视图统计TOP对象
如果你想定期巡检整个实例的存储健康度,可以写一个按对象大小排序的查询脚本。常用的统计视图和函数组合如下:
SELECT n.nspname AS schema_name, c.relname AS object_name, CASE c.relkind WHEN 'r' THEN 'table' WHEN 'i' THEN 'index' WHEN 't' THEN 'toast_table' WHEN 'I' THEN 'toast_index' WHEN 'S' THEN 'sequence' WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized_view' ELSE c.relkind::text END AS object_type, pg_size_pretty(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 IN ('r', 'i', 't', 'I', 'S', 'm') AND n.nspname NOT LIKE 'pg_%' ORDER BY pg_total_relation_size(c.oid) DESC LIMIT 50;我习惯把这张表的结果输出到监控系统,每天定时跑一次,把大小异常的TOP对象自动推给运维群。很多生产问题都能在爆发前一天被提前发现。
7. 从存储结构看备份恢复与迁移的取舍
备份恢复这件事,如果不理解底层存储结构,很容易在"为什么这个备份恢复不了"的问题上卡很久。KingbaseES的备份和PostgreSQL一样,分为物理备份(基于文件系统或WAL归档)和逻辑备份(基于SQL导出)两大类,它们的底层逻辑各不相同。
7.1 物理备份与逻辑备份的本质区别
物理备份的本质是"复制存储结构本身"。金仓提供的sys_backup工具(基于pg_basebackup二次开发)会做两件事:一是把数据目录里所有数据文件做一份基础备份,二是持续归档WAL文件。恢复的时候,先回放基础备份,再按时间点重放WAL,就能把数据库恢复到任意一致的时间点(PITR)。
物理备份的优势是速度快、粒度细,可以完整恢复整个实例,包括所有数据库、表空间、角色权限。但它有一个硬约束:备份文件的版本和平台必须与目标实例兼容,换句话说,你不能拿一套V8R6的备份恢复到V8R3的实例上,也不能随便跨CPU架构(x86到ARM)恢复。
逻辑备份则不同,它是把数据内容通过SQL语句表达出来。金仓的sys_dump就是PostgreSQLpg_dump的适配版本,导出的本质是CREATE TABLE加INSERT等SQL语句。因为不涉及物理文件路径、OID、WAL位置这些物理细节,逻辑备份可以跨小版本恢复,甚至可以导入到其他兼容PostgreSQL协议的数据库中。
但从存储结构的角度看,逻辑备份有个巨大的坑:如果表已经膨胀到80GB,实际有效数据只有12GB,逻辑备份虽然能导出较干净的数据,但备份过程的IO开销仍然和物理文件大小强相关——pg_dump需要全表扫描,读取所有页面(包括那些满是死元组的页面),速度会非常慢。
7.2 存储结构决定恢复策略的几个细节
我在实际做恢复演练时,总结过几个和存储结构强关联的注意点:
恢复目标机的路径要和原机一致或做好映射。物理备份恢复时,如果数据目录路径不一致,必须修改配置文件或启动参数。KingbaseES的
sys_restore脚本一般会读取备份时的路径信息,跨机器恢复时容易踩路径不一致的坑。表空间目录必须提前准备。如果原实例使用了自定义表空间,备份中会记录表空间对应的文件系统路径。恢复前要确认目标机上这些路径存在且有权限,否则恢复过程会报错"tablespace directory does not exist"。
WAL归档的连续性不能断。物理备份恢复的粒度取决于归档的完整性。如果归档目录里WAL文件有缺失,时间点恢复就无法推进到最新状态。建议给归档目录加上对象存储或独立的定时同步机制,别让归档文件和主库放在同一块磁盘上。
磁盘大小规划要预留膨胀余量。恢复完成后,数据库不会立即执行VACUUM,所以那些在源库中已经膨胀的表,恢复到目标库后依旧膨胀。如果你规划的目标库磁盘空间刚好等于源库的"有效数据量",那恢复过程中很容易磁盘写满。
高频更新表的备份顺序。对于每日逻辑备份的数据库,建议在备份前先做一次
VACUUM ANALYZE,能显著减小导出文件体积,同时让优化器拿到更准的统计信息。
8. 几个容易踩的存储陷阱与最后的实操建议
写到这里,存储结构的主干已经讲完了。最后想集中讲几个我和团队在实际维护中反复踩过的坑,每一个都和存储结构相关,每一个都能单独写成一篇文章。
第一个坑:误以为删数据=释放空间。这在前面已经反复强调过,但还是要再提醒一次。生产环境里,执行DELETE之后,如果发现磁盘空间没有变化,先不要慌张。正常现象。你要做的是结合n_dead_tup判断是否需要VACUUM,以及检查是否存在长事务阻塞。
第二个坑:pg_wal目录的无限增长。WAL目录大小取决于max_wal_size和检查点频率,如果你的业务写入量很大,但max_wal_size设得偏小,检查点就会频繁发生,反而导致WAL目录中积累更多文件。同时,如果有备库或复制槽存在且长期断连,WAL会一直保留到备库追上,这种情况下磁盘暴涨是必然的。
复制槽这块尤其隐蔽。很多人建了流复制,备库下线后忘了删除复制槽,主库的WAL就无限保留。我见过一个案例:备库停机维护了两周,主库pg_wal目录直接涨了1TB。排查时用一条SQL就能发现:
SELECT slot_name, active, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) FROM pg_replication_slots;如果某个槽active为 false,且WAL堆积量持续增长,那就基本可以确认是这个槽导致的。
第三个坑:TOAST表被忽略。有些大字段(比如文本、JSON、二进制)会被自动迁移到TOAST表,它们的物理文件不在主表的relfilenode下,而是一个独立的pg_toast_xxx文件。你查主表大小可能只有几百MB,但加上TOAST表后总量可能翻几倍。用pg_total_relation_size()才能看到完整体积。
第四个坑:修改ALTER TABLE导致的文件重建。某些DDL操作(如修改列类型、添加带有默认值的非空约束)会重写整张表,生成新的relfilenode,过程中需要临时的额外磁盘空间。在空间本就不多的实例上,执行这种DDL前一定要预留足够的余量,否则表会重建到一半因磁盘写满而失败。
最后给一些可执行的建议:
- 建表时就不建议把表放在默认表空间之外不做规划。给大表和索引指定独立的表空间,后续维护会轻松很多。
- 定期巡检
pg_stat_user_tables里的n_dead_tup和last_autovacuum,不要等告警再处理。 - 对核心大表,制定固定的维护窗口,低峰期执行
VACUUM FULL或REINDEX。 - 每次做备份恢复演练时,把表空间路径、数据目录路径、WAL归档源都记录成文档,别只依赖"上次就是这么弄的"。
我在实际项目里最深的一点体会是:KingbaseES虽然兼容性做得不错,但它的内核思维还是PostgreSQL那一套,谁把它当Oracle或者MySQL来用,谁就会在存储问题上栽跟头。反过来,一旦理解了它的存储结构,很多问题根本不需要查资料,根据原理就能推断出解决方案。这也是我愿意花这么大力气把文件层级、页面布局、MVCC、WAL这些底层的细节串成一条线来写的原因——这些东西,官方文档里都有,但散落在各处,真到用的时候,很少有人能把它完整串起来。