透过存储看PostgreSQL

前言

PostgreSQL数据库目前不支持使用裸设备和块设备,所以在PostgreSQL中,表里的数据总是存放在一个或多个物理的数据文件中。而相应的数据文件又分为多个固定大小的数据块,PG数据块默认是8k,大多数的块设备扇区大小是512字节的,数据就放在这些数据块中。一旦出现块写坏的情况,便很难恢复至一致性的状态,会提示:ERROR: invalid page header in block xxx of relation base/xxx/xxx,数据块的损坏一般只会影响到一个表,使得该表的数据不能查询或者是备份。数据块的损坏的情形比较复杂,所以如何恢复,或者是能恢复到什么情形不能一概而论。最好的情况是丢失一个数据块里面的所有记录(也有可能只丢失某些记录,但是方法比较复杂),最坏也有可能整个表丢失。
今天分享一下常规的处理方法,以及一些Dirty hack的处理方式。

故障模拟

建一个测试表,为了演示,存储上需要超过1个G,这样就分为了多个segment,同时做个checkpoint,确保数据落盘
postgres=# create table t1(id int,info char(12));
CREATE TABLE
postgres=# insert into t1 select n,left(md5(random()::text),11) from generate_series(1,30000000) as n;
INSERT 0 30000000
postgres=# checkpoint;
CHECKPOINT
postgres=# analyze t1;
ANALYZE

查询一下表的oid和数据块数量,可以看到,t1这个表占了191083个block,3千万数据,每个数据块大概157行

postgres=# select oid,relfilenode,relpages,reltuples,reltuples/relpages as tup_avg from pg_class where relname = 't1';
oid | relfilenode | relpages |   reltuples   |     tup_avg      
-------+-------------+----------+---------------+--------------------
83189 |       83189 |   191083 | 3.0000032e+07 | 157.00000523332793
(1 row)

在存储上分为了两个segment,注意有时还会看到xxx.init,这个是unlogged table

[postgres@xiongcc 13578]$ ll | grep 83189
-rw------- 1 postgres postgres 1073741824 Apr 21 19:24 83189
-rw------- 1 postgres postgres 491610112 Apr 21 19:26 83189.1
-rw------- 1 postgres postgres     401408 Apr 21 19:22 83189_fsm
-rw------- 1 postgres postgres     40960 Apr 21 19:25 83189_vm

这里假设第133333个块坏掉,进行查询的时候就会报:

postgres=# select * from t1 where ctid = '(133333,1)';
ERROR: invalid page in block 133333 of relation base/13578/83189

这个时候该如何处理呢?


Dirty hack

因为PostgreSQL物理上默认是满1GB切换成一个新的文件,这么做的目的是为了防止文件系统对磁盘文件大小的限制而导致的写入失败,PostgreSQL为此做了分段的机制,如此例,t1表随着不断写入,会超过1GB,此时就会fork一个新的segment,叫83189.1。这个在源码的注释里写的很清楚

/*
*The magnetic disk storage manager keeps track of open file
*descriptors in its own descriptor pool. This is done to make it
*easier to support relations that are larger than the operating
*system's file size limit (often 2GBytes). In order to do that,
*we break relations up into "segment" files that are each shorter than
*the OS file size limit. The segment size is set by the RELSEG_SIZE
*configuration constant in pg_config.h.
...

此例是第133333个block坏了,那么需要定位该block位于哪里。

计算一下,133333* 8 * 1024 = 1,092,263,936,单位是字节,那么按照上面分segment的逻辑,1,092,263,936 / 1024 / 1024 / 1024  > 1,说明再第二个segment,也就是83189.1,偏移量取余即可,为1,092,263,936% ( 1024 ^ 3) = 18522112,单位是字节,转换为16进制,也就是0x011aa000。

[postgres@xiongcc ~]$ echo $((0x11aa000))
18521572

那么具体故障的block位置定位到了,133333个block位于第1个segment的0x011aa000偏移处。

用hexdump看一下,常用的方式是-C,以16进制和相应的ASCII字符显示文件里的字符

[postgres@xiongcc 13578]$ hexdump -C 83189.1 | grep -w 011aa000 -C 100
011a99c0 62 31 30 36 65 36 31 64 20 00 00 00 00 00 00 00 |b106e61d .......|
011a99d0 4e 88 bb 00 00 00 00 00 00 00 00 00 02 00 d4 08 |N...............|
011a99e0 21 00 02 00 02 09 18 00 25 6a 3f 01 1b 66 66 66 |!.......%j?..fff|
011a99f0 32 63 35 65 66 34 66 62 20 00 00 00 00 00 00 00 |2c5ef4fb .......|
011a9a00 4e 88 bb 00 00 00 00 00 00 00 00 00 02 00 d4 08 |N...............|
011a9a10 20 00 02 00 02 09 18 00 24 6a 3f 01 1b 30 37 39 | .......$j?..079|
011a9a20 30 35 33 34 34 30 64 36 20 00 00 00 00 00 00 00 |053440d6 .......|
...中间省略,直接到0x011aa000
011a9f80 03 00 02 00 02 09 18 00 07 6a 3f 01 1b 65 36 34 |.........j?..e64|
011a9f90 34 63 35 30 64 30 65 30 20 00 00 00 00 00 00 00 |4c50d0e0 .......|
011a9fa0 4e 88 bb 00 00 00 00 00 00 00 00 00 02 00 d4 08 |N...............|
011a9fb0 02 00 02 00 02 09 18 00 06 6a 3f 01 1b 61 31 65 |.........j?..a1e|
011a9fc0 30 39 39 31 33 38 34 62 20 00 00 00 00 00 00 00 |0991384b .......|
011a9fd0 4e 88 bb 00 00 00 00 00 00 00 00 00 02 00 d4 08 |N...............|
011a9fe0 01 00 02 00 02 09 18 00 05 6a 3f 01 1b 61 37 37 |.........j?..a77|
011a9ff0 63 35 36 39 31 38 61 32 20 00 00 00 00 00 00 00 |c56918a2 .......|
011aa000 13 00 00 00 f8 15 16 3c 00 00 04 00 8c 02 90 02 |.......<........|
011aa010 00 20 04 20 00 00 00 00 d0 9f 52 00 a0 9f 52 00 |. . ......R...R.|
011aa020 70 9f 52 00 40 9f 52 00 10 9f 52 00 e0 9e 52 00 |p.R.@.R...R...R.|
011aa030 b0 9e 52 00 80 9e 52 00 50 9e 52 00 20 9e 52 00 |..R...R.P.R. .R.|
011aa040 f0 9d 52 00 c0 9d 52 00 90 9d 52 00 60 9d 52 00 |..R...R...R.`.R.|
011aa050 30 9d 52 00 00 9d 52 00 d0 9c 52 00 a0 9c 52 00 |0.R...R...R...R.|
011aa060 70 9c 52 00 40 9c 52 00 10 9c 52 00 e0 9b 52 00 |p.R.@.R...R...R.|
011aa070 b0 9b 52 00 80 9b 52 00 50 9b 52 00 20 9b 52 00 |..R...R.P.R. .R.|

hexdump已经获取到了具体数据,其中每一行为16个字节,最左侧以16进制显示偏移量

011a9fe0  01 00 02 00 02 09 18 00  05 6a 3f 01 1b 61 37 37  |.........j?..a77|
011a9ff0 63 35 36 39 31 38 61 32 20 00 00 00 00 00 00 00 |c56918a2 .......|
011aa000 13 00 00 00 f8 15 16 3c 00 00 04 00 8c 02 90 02 |.......<........|

因为PostgreSQL的块结构,是倒着放的,0x011aa000是第133333个block的“末尾”,hexdump最下面看到的数据,对应表里面最前面的数据

根据ctid查一下表里的数据,注意ctid是(0,1),(0,2)这样开始的,所以要减一 ?,这里成功把我带到了坑里。info是a77c56918a2,跟hexdump看到的数据一致,再找一下id的位置。id是4个字节,info是12个字节,前面挨着的4个字节就是id,也就是05 6a 3f 01。

postgres=# select * from t1 where ctid='(133333,1)';
id | info
----------+--------------
20933282 | 8ddf4e18bba
(1 row)

postgres=# select * from t1 where ctid='(133332,1)';
id | info
----------+--------------
20933125 | a77c56918a2
(1 row)

这里还涉及到大小端:大端(Big-endian)是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端,即正序排列,高尾端;小端(Little-endian)是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端,即逆序排列,低尾端;

16bit宽的数0x1234在两种模式CPU内存中的存放方式(假设从地址0x4000开始存放)为:

32bit宽的数0x12345678在两种模式CPU内存中的存放方式(假设从地址0x4000开始存放)为:

那么05 6a 3f 01转换一下,就是013f6a05,转化成10进制,就是20933125

[postgres@xiongcc ~]$ echo $((0x013f6a05))
20933125

OK,至此,一条数据,通过hexdump就可以完整的解密出来了,id = 20933125,info = a77c56918a2

同理,类似的,通过了解PG的页结构,结合hexdump可以进一步分析每一个字节的内容

源码注释上的这个图画的倒是很好看:

/*
* A postgres disk page is an abstraction layered on top of a postgres
* disk block (which is simply a unit of i/o, see block.h).
*
* specifically, while a disk block can be unformatted, a postgres
* disk page is always a slotted page of the form:
*
* +----------------+---------------------------------+
* | PageHeaderData | linp1 linp2 linp3 ... |
* +-----------+----+---------------------------------+
* | ... linpN | |
* +-----------+--------------------------------------+
* | ^ pd_lower |
* | |
* | v pd_upper |
* +-------------+------------------------------------+
* | | tupleN ... |
* +-------------+------------------+-----------------+
* | ... tuple3 tuple2 tuple1 | "special space" |
* +--------------------------------+-----------------+
* ^ pd_special
*
* a page is full when nothing can be added between pd_lower and
* pd_upper.
*
* all blocks written out by an access method must be disk pages.
*

typedef struct PageHeaderData
{
/* XXX LSN is member of *any* block, not only page-organized ones */
PageXLogRecPtr pd_lsn; /* LSN: next byte after last byte of xlog
* record for last change to this page */
uint16 pd_checksum; /* checksum */
uint16 pd_flags; /* flag bits, see below */
LocationIndex pd_lower; /* offset to start of free space */
LocationIndex pd_upper; /* offset to end of free space */
LocationIndex pd_special; /* offset to start of special space */
uint16 pd_pagesize_version;
TransactionId pd_prune_xid; /* oldest prunable XID, or zero if none */
ItemIdData pd_linp[FLEXIBLE_ARRAY_MEMBER]; /* line pointer array */
} PageHeaderData;
/*
* For historical reasons, the 64-bit LSN value is stored as two 32-bit
* values.
*/
typedef struct
{
uint32 xlogid; /* high bits */
uint32 xrecoff; /* low bits */
} PageXLogRecPtr;

所以pd_lsn是8个字节,高8个字节,就是0x00000013,低8个字节,就是0x3af6c138,那么对应到数据库就是13/3af6c138该LSN,计算一下,可以看到,位于00000005000000130000003A这个WAL日志中,至此这个pg_lsn就成功解析出来了。WAL日志中显示表为rel 1663/13578/83189,也确实如此。

[postgres@xiongcc 13578]$ hexdump -C 83189.1 -n 8
00000000 13 00 00 00 38 c1 f6 3a |....8..:|
00000008
[postgres@xiongcc 13578]$ psql
psql (13.2)
Type "help" for help.

postgres=# select pg_walfile_name('13/3af6c138');
pg_walfile_name
--------------------------
00000005000000130000003A
(1 row)
[postgres@xiongcc pg_wal]$ pg_waldump 00000005000000130000003A | grep -i '13/3af6c138'
rmgr: XLOG len (rec/tot): 49/ 8237, tx: 0, lsn: 13/3AF6C138, prev 13/3AF6C0F8, desc: FPI_FOR_HINT , blkref #0: rel 1663/13578/83189 blk 131073 FPW
rmgr: Heap2 len (rec/tot): 59/ 59, tx: 0, lsn: 13/3AF6E180, prev 13/3AF6C138, desc: VISIBLE cutoff xid 12290126 flags 0x01, blkref #0: rel 1663/13578/83189 fork vm blk 4, blkref #1: rel 1663/13578/83189 blk 131073

对于LSN,还有一个快速计算所在WAL日志的方法,LSN -1 除以16M除以256,16M等于2的24次方,除下来就是LSN的高32位对应于logid,也就是斜杠左边的,logseg是LSN -1 除以16M除以256的余数,对应于logseg,即FF,剩下的后6位,也就是偏移量。也就是说商就是logseg,余数就是WAL的偏移量,直接将xlog当成了哈希表使用,骚操作+1

其他也是类似,通过hexdump一步步可以计算出每一个值。

所以回到前面的话题,假如一个block坏了,查询报错,那么我们可以通过前面介绍的方式,去存储上深度挖掘该数据文件是否损坏,看看是否还有抢救的价值。


zero_damaged_pages

zero_damaged_pages (boolean)是PostgreSQL提供给开发者的一个调试参数,当PostgreSQL检测到损坏的页面时通常会报错,从而中止当前事务。将zero_damaged_pages设置为on会让系统变为告警,将内存中损坏的页面清零,然后继续处理。此行为将破坏数据,即损坏页面上的所有行。不过,这样做确实能允许你略过错误并从未损坏的页面中获取表中未受损的行。如果由于硬件或软件错误而发生损坏,它对于恢复数据很有用。通常,除非您已放弃从表的损坏页面中恢复数据的希望,否则不应该将其设置为on。抹零的页面并不会强制刷回磁盘,因此建议在重新关闭该选项之前重建受损的表或索引。默认设置为关闭,并且只能由超级用户更改。

意思也就是,会变成如下:

postgres=# select count(*) from t1 ;
WARNING: invalid page header in block 133333 of relation base/13578/83189; zeroing out page
count
-------
xxx
(1 row)

你可以继续操作而不报错,毕竟假如对于一个上TB的数据表,万一查询到99%的时候来一个坏块以至于报错,事务回滚,简直不能让人更抓狂啊!

另外需要注意的是,vacuum对于这一类的抹零页面,会擅作主张,替你修复好这个抹零页面,修复好之后,会随着下次checkpoint的触发,覆盖存储上的页面,这可能不是你想要的,毕竟坏块了脏数据,也是数据。


dd

类似的,你还可以使用dd来强制抹掉这个坏块,抹掉之后记得清楚一下page cache,使用pg_dropbuffers或者操作系统层面echo 3 >/proc/sys/vm/drop_caches,这样你就能看到效果了。

postgres@xiongcc pg_wal]$ dd if=/dev/zero of=/home/xiongcancan/pgdata/base/13578/83189 seek=133332 bs=8192 count=1 conv=notrunc
postgres@xiongcc pg_wal]$ echo 3 >/proc/sys/vm/drop_caches


小结

前面一阵操作之后,突然理解到,难怪为什么XC或者AK都要求数据加密这一项呢,没有加密,我拿到数据文件,可以全部给你解析出来。PostgreSQL的痛点啊,目前还没有成熟的TDE解决方案。cybertec倒是有一个:https://www.cybertec-postgresql.com/en/products/postgresql-transparent-data-encryption/

最后,备份重中之重,简单粗暴,记得长备份。


请使用浏览器的分享功能分享到微信等