自省 自行 自醒

这种数据恢复方式,我敢打赌,99%的人没有见过!

Word count: 1.2kReading time: 5 min
2025/01/05
loading

前言

关于数据恢复,笔者也写过很多期了,大致思路便是

  1. 如果数据还没有被清理,可以用 pg_dirtyread/pg_recovery/pg_resetwal/pageinspect 这类工具进行抢救
  2. 如果数据已经被清理,可以用 walminer/PITR/延迟备库(删除的数据还在延迟窗口内) 等方式进行抢救
  3. 某些国产库,实现了类似 Oracle 的闪回查询,基于闪回也可以进行抢救
  4. 审计日志类

今天和各位再分享一个 16 版本中一个鲜为人知的技巧:pg_waldump: Add –save-fullpage=PATH to save full page images from WAL records,即使你的数据被 VACUUM 清理了,依然有可能抢救回来!

This is a tool aimed mostly for very experienced users, useful for fixing page-level corruption or just analyzing the past state of a page, and there were no easy way to do that with the in-core tools up to now when looking at WAL.

没错,基于全页写。

小试牛刀

由于是基于全页写的原理来实现的,因此需要打开 full_page_writes 参数。让我们构建一个测试用例,同时利用 fillfactor,使得一个数据块 (默认 8KB) 内只可以容纳 2 条数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
postgres=# create table test_fpi(id int,info char(2000)) with (fillfactor = 50);
CREATE TABLE
postgres=# insert into test_fpi values(1,'A'),(2,'B'),(3,'C');
INSERT 0 3
postgres=# analyze test_fpi;
ANALYZE
postgres=# select relpages,reltuples,relname from pg_class where relname = 'test_fpi';
relpages | reltuples | relname
----------+-----------+----------
2 | 3 | test_fpi
(1 row)

postgres=# select pg_relation_filepath('test_fpi');
pg_relation_filepath
----------------------
base/5/16521
(1 row)

可以看到,现在 3 条数据占了 2 个数据块,为了测试目的,手动执行一下检查点。

1
2
3
4
5
6
postgres=# checkpoint ;
CHECKPOINT
postgres=# delete from test_fpi where id = 2;
DELETE 1
postgres=# vacuum ;
VACUUM

这样的话,更新语句变为记录 FPW 到 WAL 日志中。现在,让我们提取一下 FPI

1
2
3
4
5
6
7
8
9
10
11
[postgres@mypg pg_wal]$ pg_waldump --save-fullpage=FPI ./00000001000000000000000A
...

[postgres@mypg pg_wal]$ ls -lrth FPI/ | grep 16521
-rw-rw-r-- 1 postgres postgres 8.0K Jan 5 17:58 00000001-00000000-0A00A3F0.1663.5.16521.0_main
-rw-rw-r-- 1 postgres postgres 8.0K Jan 5 17:58 00000001-00000000-0A003AE0.1663.5.16521.2_fsm
-rw-rw-r-- 1 postgres postgres 8.0K Jan 5 17:58 00000001-00000000-0A012760.1663.5.16521.0_fsm
-rw-rw-r-- 1 postgres postgres 8.0K Jan 5 17:58 00000001-00000000-0A010710.1663.5.16521.1_fsm
-rw-rw-r-- 1 postgres postgres 8.0K Jan 5 17:58 00000001-00000000-0A00FEA8.1663.5.16521.1_main
-rw-rw-r-- 1 postgres postgres 8.0K Jan 5 17:58 00000001-00000000-0A00DE58.1663.5.16521.2_fsm
-rw-rw-r-- 1 postgres postgres 8.0K Jan 5 17:58 00000001-00000000-0A00BE00.1663.5.16521.0_vm

这些文件的命名方式是:TIMELINE-LSN + RELTABLESPACE + DATOID + RELNODE + BLKNO_FORK,因此我们只需要根据表的 OID 来过滤即可。

现在,我们有了该表的 FPI 记录,接着创建一个镜像表,用于恢复

1
2
3
4
5
6
7
postgres=# create table test_restore(like test_fpi including all);
CREATE TABLE
postgres=# select pg_relation_filepath('test_restore');
pg_relation_filepath
----------------------
base/5/16526
(1 row)

恢复方式很简单,直接 cat 即可,此处只恢复第一个数据块

1
2
3
4
5
6
7
8
9
10
11
12
13
[postgres@mypg pg_wal]$ cat FPI/00000001-00000000-0A00A3F0.1663.5.16521.0_main > ../base/5/16526

postgres=# select count(*) from test_restore ;
count
-------
1
(1 row)

postgres=# select id,left(info,10) from test_restore ;
id | left
----+------
1 | A
(1 row)

由于 FPI 是记录的全页数据,即使被 vacuum 了,WAL 中也包含着数据

1
2
3
4
5
6
postgres=# SELECT lp,t_xmin,t_ctid,infomask(t_infomask, 1) as infomask,left(t_data::text,32) FROM heap_page_items(get_raw_page('test_restore', 0));
lp | t_xmin | t_ctid | infomask | left
----+--------+--------+-------------------------------------------+----------------------------------
1 | 836 | (0,1) | XMAX_INVALID|XMIN_COMMITTED|HASVARWIDTH | \x01000000501f000041202020202020
2 | 836 | (0,2) | XMAX_COMMITTED|XMIN_COMMITTED|HASVARWIDTH | \x02000000501f000042202020202020
(2 rows)

只不过其 xmax 是 committed 的状态,正常查询是不可见的,这个时候,我们就可以利用 pg_dirtyread 等工具将其读取出来即可 👇🏻

1
2
3
4
5
6
7
8
9
10
11
12
13
postgres=# select id,left(info,10) from pg_dirtyread('test_restore') as ab(id int,info char(2000));
id | left
----+------
1 | A
2 | B
(2 rows)

postgres=# select id,left(info,10) from pg_dirtyread('test_fpi') as ab(id int,info char(2000));
id | left
----+------
1 | A
3 | C
(2 rows)

如果后续还有其他需要,可以使用 cat >> 追加的方式继续恢复其他数据块,如果不小心覆盖了,也没关系,毕竟是幂等的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
postgres=# select id,left(info,10) from pg_dirtyread('test_restore') as ab(id int,info char(2000));
id | left
----+------
1 | A
2 | B
3 | C
(3 rows)

postgres=# select id,left(info,10) from pg_dirtyread('test_fpi') as ab(id int,info char(2000));
id | left
----+------
1 | A
3 | C
(2 rows)

小结

这种恢复方式需要对 PostgreSQL 有足够的了解,并且恢复方式也比较 tricky,为了演示目的,我也使用了一个比较 toy 的方式,实际情况中,可能还有 FPI FOR Hint (wal_log_hints 或者 checksum),页剪枝等等的影响,再加上 TOAST,因此恢复并不是一件简单的事情,做好备份是重中之重

但是这种方式,可以给我们一个新的选择,当数据被清理了,后知后觉想要抢救的时候,或许也能救你一命,毕竟能抢救一些是一些。

参考

https://git.postgresql.org/gitweb/?p=postgresql.git;a=commit;h=d497093cbecccf6df26365e06a5f8f8614b591c8

CATALOG
  1. 1. 前言
  2. 2. 小试牛刀
  3. 3. 小结
  4. 4. 参考