postgreSQL的闪回

postgreSQL的闪回

PostgreSQL基于MVCC机制的闪回实战

前言

闪回查询(Flashback Query)是一种在数据库中执行时间点查询的技术。

1.闪回查询

1.1 概述

闪回查询(Flashback Query)是一种在数据库中执行时间点查询的技术。它允许查询数据库中过去某个时间点的数据状态,并返回相应的查询结果。通常闪回查询分为表级以及行级的闪回查询。

PostgreSQL数据库由于MVCC的机制,对于DML的操作,更改或者删除的元祖暂时标记为死元祖并未真正的在物理上清理,直到vacuum运行时才清理这些死元祖,这为行级的闪回查询提供了可能。

1.2 flashback前提

1.延迟VACUUM,确保误操作的数据还没有被垃圾回收。
vacuum_defer_cleanup_age = 5000000  
# 延迟500万个事务再回收垃圾,
误操作后在500万个事务内,
如果发现了误操作,才有可能使用本文提到的方法闪回。
2.记录未被freeze,确保无操作的数据,
以及后面提交的事务号没有被freeze(抹去)。
vacuum_freeze_min_age = 50000000   
# 事务年龄大于5000万时,才可能被抹去事务号。
3、开启事务提交时间跟踪,确保可以从xid得到事务结束的时间
track_commit_timestamp = on     
# 开启事务结束时间跟踪,开启事务结束时间跟踪后,
会开辟一块共享内存区存储这个信息。

2.pg_dirtyread插件

pg_dirtyread是PostgreSQL数据库的一个扩展插件。当在PG执行了误操作SQL(如UPDATE或DELETE) 后,它可以从表中读取未被vacuum的死元祖,可用于查看意外删除或更改的受损数据,达到类似“闪回查询”的功能。pg_dirtyread基于MVCC多版本机制,通过检索查询旧版本,获取指定老版本数据,实现行级的数据还原。

2.1安装插件pg_dirtyread

pg_dirtyread 不存在于 contrib 目录下,
因此需要单独编译
GitHub地址:https://github.com/df7cb/pg_dirtyread

1.下载安装包:

安装包:pg_dirtyread-2.6.tar.gz
https://github.com/df7cb/pg_dirtyread/archive/refs/tags/2.6.tar.gz

2.授权解压

cp /opt/pg_dirtyread-2.6.tar.gz /home/postgres/
chown postgres:postgres /home/postgres/pg_dirtyread-2.6.tar.gz
su - postgres
tar -xzvf pg_dirtyread-2.6.tar.gz
cd pg_dirtyread-2.6

3.编译和安装

make
make install

4.安装插件

(注意:需要在哪个数据库下使用这个插件,就需要在哪个数据库下面安装这个插件)

psql
postgres=# CREATE EXTENSION pg_dirtyread;
postgres=# select * from pg_available_extensions;

3.安装插件pageinspect

pageinspect模块提供函数让你从低层次观察数据库页面的内容,这对于调试目的很有用。所有这些函数只能被超级用户使用。
pageinspect的源码在postgres源码包的contrib目录下,解压postgre源码包后进入对应的目录。(pg16默认已经安装)

[root@centos79 ~]# find / -name contrib
/pgccc/soft/postgresql-15.6/contrib
/usr/share/git-core/contrib
/usr/share/doc/git-1.8.3.1/contrib
/home/postgres/pg_dirtyread-2.6/contrib

cd  /pgccc/soft/postgresql-15.6/contrib/pageinspect/
make && make install

4.闪回案例

4.1删除找回

官方案例

-创建测试表
CREATE TABLE foo (bar bigint, baz text);  

-- 测试方便,先把自动vacuum关闭掉。
ALTER TABLE foo SET (
  autovacuum_enabled = false, toast.autovacuum_enabled = false
);

--插入数据
INSERT INTO foo VALUES (1, 'Test'), (2, 'New Test');  
--删除所有数据
DELETE FROM foo;  

postgres=# select * from foo;
postgres=# SELECT * FROM pg_dirtyread('foo') as t(bar bigint, baz text);

4.2drop列恢复

这里使用dropped_N来访问第N列,从1开始计数。
局限:由于PG删除了原始列的元数据信息,因此需要在表列名中指定正确的类型,这样才能进行少量的完整性检查。包括类型长度、类型对齐、类型修饰符,并且采取的是按值传递。

CREATE TABLE ab(a text, b text);  
INSERT INTO ab VALUES ('Hello', 'World');  

ALTER TABLE ab DROP COLUMN b;  
DELETE FROM ab; 

postgres=# select * from ab;


postgres=# SELECT * FROM pg_dirtyread('ab') ab(a text, dropped_2 text);
   a   | dropped_2
-------+-----------
 Hello | World
(1 row)

可以看到,虽然b列被drop掉了,但是仍然可以读取到数据。
如何指定列:这里使用dropped_N来访问第N列,从1开始计数。

4.3基于时间点闪回

pg_xact_commit_timestamp函数:查询事务提交时间
如果只想恢复到其中的某一个时间点的数据:

  • 首先需要通过系统函数 pg_xact_commit_timestamp,得到每个元祖写入事务的提交时间(xmin)以及删除/更新事务提交时间(xmax)。
  • 加以处理后,进而实现基于时间点的闪回查询。
--设置参数
track_commit_timestamp = on   

--模拟数据
create table bak (id int,info text);
insert into bak values(1,'aaa'),(2,'bbb'),(3,'ccc');
delete from bak;

--通过事务提交时间,查询数据历史版本
SELECT
	pg_xact_commit_timestamp ( xmin ) AS xmin_time,
	pg_xact_commit_timestamp ( CASE xmax WHEN 0 THEN NULL ELSE xmax END ) AS xmax_time,* 
FROM
	pg_dirtyread ( 'bak' ) AS T ( tableoid OID, ctid TID, xmin XID, xmax XID, cmin CID, cmax CID, ID INT, info TEXT );

根据xmin_time,xmax_time,我们可以查看每个元祖的历史版本操作,何时插入以及何时进行更新/删除的

-- 加上时间条件
SELECT
	pg_xact_commit_timestamp ( xmin ) AS xmin_time,
	pg_xact_commit_timestamp ( CASE xmax WHEN 0 THEN NULL ELSE xmax END ) AS xmax_time,* 
FROM
	pg_dirtyread ( 'bak' ) AS T ( tableoid OID, ctid TID, xmin XID, xmax XID, cmin CID, cmax CID, ID INT, info TEXT )
WHERE pg_xact_commit_timestamp ( xmin )>='2025-09-23 16:34:45'

闪回查询某个时间点的数据
根据事务提交顺序,逆序,逐个事务排除,逐个事务回退,其语法为:

postgres=# INSERT INTO foo SELECT * FROM pg_dirtyread('foo') as t(bar bigint, baz text) where bar = 1;
INSERT 0 1
postgres=# SELECT * FROM foo;
 bar |   baz    
-----+----------
   2 | New Test
   1 | Test
(2 rows)

字段说明:

1.pg_dirtyread 只是把页面里还没被 vacuum 掉的 行版本(tuple) 捞出来。
它给的信息只有:
- xmin:插入该行的事务号
- xmax:把该行标记为“不可见”的事务号(0 表示仍然可见)
- ctid:物理位置
没有任何字段记录“操作类型”。
2.pg_xact_commit_timestamp() 只能告诉你 该事务的提交时间,同样不会记录操作类型。
3.在 PostgreSQL 内部,UPDATE 会被拆成“插入新元组 + 把旧元组标记为无效”,所以
- 你看到一条 xmin=123 的行,可能是 123 做的 INSERT,也可能是 123 做的 UPDATE 插入的新版本;
- 你看到一条 xmax=456 的行,可能是 456 做的 UPDATE 把旧版本标脏,也可能是 456 做的 DELETE;
从 pg_dirtyread 本身无法区分。

5.总结

PostgreSQL数据库由于MVCC的机制,对于DML的操作,更改或者删除的元祖暂时标记为死元祖并未真正的在物理上清理,直到vacuum运行时才清理这些死元祖,这为行级的闪回查询提供了可能。

6.实现原理分析

来简单的进行分析一下,pg_dirtyread是如何实现的。这个插件很简单就一个函数,我们再将这个函数的实现再简化一下,保留一些主干,就得到了如下的伪代码。

Datum
pg_dirtyread(PG_FUNCTION_ARGS)
{
    if (SRF_IS_FIRSTCALL())
    {
        // 初次调用函数 做一些初始化的动作
        // 获取一些信息 比如获取输入的表的OID 打开表
        relid = PG_GETARG_OID(0);
        heap_open(relid, AccessShareLock);
        
        // 根据输入的字段信息 生成相应的映射关系
        usr_ctx->map = dirtyread_convert_tuples_by_name(usr_ctx->reltupdesc, ...
        // 设定扫描表的策略 
        usr_ctx->scan = heap_beginscan(usr_ctx->rel, SnapshotAny, 0, NULL ...
    }

    // 每次需要执行的动作 尝试扫描表,获取一个表中的元组 
    if ((tuplein = heap_getnext(usr_ctx->scan, ForwardScanDirection)) != NULL)
    {
        // 将获取的到元组数据 根据map的映射关系 填充生成待返回的元组 然后return 
        tuplein = dirtyread_do_convert_tuple(tuplein, usr_ctx->map, usr_ctx->oldest_xmin);
        ...
    }
    else
    {
        // 扫描到表的最后了 没有下一个元组数据量 做一些资源释放和清理工作
        heap_endscan(usr_ctx->scan);
        heap_close ...
    }
}

关于字段名匹配的函数dirtyread_convert_tuples_by_name实际匹配调用的是dirtyread_convert_tuples_by_name_map,简化一下就是

AttrNumber *
dirtyread_convert_tuples_by_name_map(TupleDesc indesc, TupleDesc outdesc, const char *msg)
{
  for (i = 0; i < n; i++)
  {
    // ...
    for (j = 0; j < indesc->natts; j++)
    {
      Form_pg_attribute inatt = TupleDescAttr(indesc, j);
      // ...
      if (strcmp(attname, NameStr(inatt->attname)) == 0)
      {
        /* Found it, check type */
        // ...
      }
    }

    /* Check dropped columns */
    if (attrMap[i] == 0)
      if (strncmp(attname, "dropped_", sizeof("dropped_") - 1) == 0)
      {
        // ...
      }

    /* Check system columns */
    if (attrMap[i] == 0)
      for (j = 0; system_columns[j].attname; j++)
        if (strcmp(attname, system_columns[j].attname) == 0)
        {
         // ...
        }
  }
  // ...
}

system_columns的声明则是如下

static const struct system_columns_t {
	char	   *attname;
	Oid			atttypid;
	int32		atttypmod;
	int			attnum;
} system_columns[] = {
	{ "ctid",     TIDOID,  -1, SelfItemPointerAttributeNumber },
#if PG_VERSION_NUM < 120000
	{ "oid",      OIDOID,  -1, ObjectIdAttributeNumber },
#endif
	{ "xmin",     XIDOID,  -1, MinTransactionIdAttributeNumber },
	{ "cmin",     CIDOID,  -1, MinCommandIdAttributeNumber },
	{ "xmax",     XIDOID,  -1, MaxTransactionIdAttributeNumber },
	{ "cmax",     CIDOID,  -1, MaxCommandIdAttributeNumber },
	{ "tableoid", OIDOID,  -1, TableOidAttributeNumber },
	{ "dead",     BOOLOID, -1, DeadFakeAttributeNumber }, /* fake column to return HeapTupleIsSurelyDead */
	{ 0 },
};

7.pg_dirtyread使用注意事项和优缺点

pg_dirtyread 使用注意事项(生产环境可落地版)

前置条件

  • 1.数据被 DELETE/UPDATE 后尚未发生 VACUUM(含 autovacuum)。
    – 第一时间执行以下sql,关闭自动VACUUM,防止VACUUM
ALTER TABLE <tbl>
SET (autovacuum_enabled = off, toast_autovacuum_enabled = off);
  • 2.参数 track_commit_timestamp = on(否则拿不到事务提交时间)。
  • 3.已安装扩展
CREATE EXTENSION pg_dirtyread;

权限与隔离

  • 只对普通堆表生效,不支持 TRUNCATE、DROP、VACUUM FULL、unlogged 表、临时表。
  • 需要超级用户或具有表 SELECT + 扩展 EXECUTE 权限。
  • 建议把受损库克隆到测试机再插件扫描,避免脏读造成 IO 抖动。

列定义必须 100 % 匹配

  • 原表加减列、TYPE 修改后,插件仍按旧元组物理布局读取;少一列都会 core 宕。
  • 若记不清旧结构,先用以下sql把系统列扫出来,再拼完整字段列表。
SELECT tableoid, ctid, xmin, xmax, cmin, cmax, dead FROM pg_dirtyread('tbl') AS t(tableoid oid, ctid tid, xmin xid, xmax xid, cmin cid, cmax cid, dead boolean);

时间窗口

  • 高并发更新场景下,dead 元组可能 5 ~ 15 min 就被 autovacuum 回收;
  • 关闭 autovacuum 后也要盯紧表膨胀,必要时手动 VACUUM FREEZE 只针对其他表。

恢复流程(实战顺序)

  • a) 禁用 autovacuum →
-- 确认提交时间可查
SHOW track_commit_timestamp;          -- 需要 on
-- 确认扩展已装
CREATE EXTENSION IF NOT EXISTS pg_dirtyread;
--立即关掉 autovacuum(仅对该表)
ALTER TABLE cities
SET (autovacuum_enabled = off, toast_autovacuum_enabled = off);
  • b) 用 pg_dirtyread 把数据抽到中间表 tmp_rescue →
--用 pg_dirtyread 把“死”行抽出来(假设原表结构:id int, name text, population float4, elevation int4)
-- 2-1 建中间救援表,与源表结构相同
CREATE TABLE tmp_rescue (LIKE cities INCLUDING ALL);

-- 2-2 把被 DELETE/UPDATE 标脏但尚未被回收的行捞进来
INSERT INTO tmp_rescue
SELECT t.*
FROM pg_dirtyread('cities') AS t(tableoid oid, ctid tid,
                                xmin xid, xmax xid,
                                cmin cid, cmax cid,
                                id int, name text,
                                population float4, elevation int4)
WHERE xmax <> 0                          -- 已被删或旧版本
  AND pg_xact_commit_timestamp(xmin) >= '2025-10-29 16:34:45';
  • c) 与当前生产数据做 LEFT JOIN 找缺失行 →
与当前生产数据对比,找出真正丢失的行
(用主键 id 做 LEFT JOIN,NULL 一侧即为需要补回的行)
-- 3-1 仅保留生产库已不存在的主键
DELETE FROM tmp_rescue r
USING cities c
WHERE c.id = r.id;

-- 3-2 验证一下数量
SELECT count(*) AS missing_rows FROM tmp_rescue;
  • d) 用 INSERT … SELECT 或 COPY 补回;
把缺失数据写回生产表
-- 4-1 最简单的 INSERT
INSERT INTO cities
SELECT * FROM tmp_rescue;

-- 如果表很大,可改用 COPY 提速:
-- COPY (SELECT * FROM tmp_rescue) TO '/tmp/rescue.csv' CSV;
-- COPY cities FROM '/tmp/rescue.csv' CSV;
  • e) 确认无误后重新开启 autovacuum。
ALTER TABLE cities RESET (autovacuum_enabled, toast_autovacuum_enabled);
  • f清理中间表
DROP TABLE tmp_rescue;

局限性

  • 只能看到“行版本”,无法区分 INSERT/UPDATE/DELETE;
  • 无法恢复被 TRUNCATE、DROP、重写式 DDL 清掉的行;
  • 更新链太长时,旧版本可能跨页,插件只能扫到尚未被 reuse 的 ctid。
posted @ 2026-05-19 11:05  数据库小白(专注)  阅读(56)  评论(0)    收藏  举报