pg_rewind
的功能是在主备切换后回退旧主库上多余的事务变更,以便可以作为新主的备机和新主建立复制关系。通过pg_rewind
可以在故障切换后快速恢复旧主,避免整库重建。对于大库,整库重建会很耗时间。
如何识别旧主上多余的变更?
这就用到了PostgreSQL独有的时间线技术,数据库实例的初始时间线是1。以后每次主备切换时,需要提升备库为新主。提升操作会将新主的时间线加1,并且会记录提升时间线的WAL位置(LSN)。这个LSN位点我们称其为新主旧主在时间线上的分叉点。
我们只要扫描旧主上在分叉点之后的WAL记录,就能找到旧主上所有多余的变更。
如何回退旧主上多余的变更?
可能有人会想到可以通过解析分叉点以后的WAL,生成undo SQL,再逆序执行undo SQL实现事务回退。这也是mysql上常用的实现方式。 PG同样也有walminer插件可以支持WAL到undo SQL的解析。但是,这种方式存在很多限制,数据一致性也难以保证。
pg_rewind
使用的是不同的方式,过程概述如下
- 解析旧主上分叉点后的WAL,记录这些事务修改了哪些数据块
- 对数据文件以外的文件,直接从新主上拉取后覆盖旧主上的文件
- 对于数据文件,只从新主拉取被旧主修改了的数据块,并覆盖旧主数据文件中对应的数据块
- 从新主上拉取最新的WAL,覆盖旧主的WAL
- 把旧主改成恢复模式,恢复的起点则设置为分叉点前的最近一次checkpoint
- 启动旧主,旧主进入宕机恢复过程,旧主应用完从新主拷贝来的所有WAL后,数据就和新主一致了。
如何保证主备一致?
分叉点之后,新主和旧主上可能有各种各样的变更。除了常规的数据的增删改,还有truncate,表结构变更,表的DROP和CREATE,数据库参数配置变更等等。如何保证pg_rewind
之后这些都能一致呢?
下面我们重点看一下pg_rewind
对数据文件的拷贝处理。
- 通过比较target和source节点的数据目录,构建filemap
filemap中对每个文件记录了不同的处理方式(即action),对于数据文件,如下
- 仅存在于新主:FILE_ACTION_COPY
- 仅存在于旧主:FILE_ACTION_REMOVE
- 新主文件size>旧主:FILE_ACTION_COPY_TAIL
- 新主文件size<旧主:FILE_ACTION_TRUNCATE
- 新主文件size=旧主:FILE_ACTION_NONE
- 读取旧主本地WAL,获取并记录影响的数据块到filemap中对应的
file_entry
中的pagemap
pagemap属于块级别的拷贝。为了避免文件级别的拷贝做重复的事情,提取影响的块号是做了一些过滤,具体如下:
- FILE_ACTION_NONE:只记录小于等于新主size的块
- FILE_ACTION_TRUNCATE:只记录小于等于新主size的块
- FILE_ACTION_COPY_TAIL:只记录小于等于旧主size的块
- 其他:不记录
-
遍历filemap,对其中每个
file_entry
,从新主拷贝必要的数据- 从新主拷贝pagemap中记录的块覆盖旧主
-
根据action,执行不同的文件拷贝操作
- FILE_ACTION_NONE:无需处理
- FILE_ACTION_COPY:从新主拷贝数据,只拷贝到生成action时看到的新主上的size
- FILE_ACTION_TRUNCATE:旧主truncate到新主的size
- FILE_ACTION_COPY_TAIL:从新主拷贝尾部数据,即新主size超出旧主的部分
- FILE_ACTION_CREATE:创建目录
- FILE_ACTION_REMOVE:删除文件(或目录)
上面的过程汇总后如下:
-
仅存在于新主(FILE_ACTION_COPY)
- 从新主拷贝数据,只拷贝到生成action时看到的新主上的size
-
仅存在于旧主(FILE_ACTION_REMOVE)
- 删除文件
-
新主文件 size > 旧主(FILE_ACTION_COPY_TAIL)
- 对偏移位置小于等于旧主文件 size 的块,从新主拷贝受旧主分叉后更新影响的块
- 偏移位置为旧主文件 size ~ 新主文件 size 之间的块,从新主拷贝
-
新主文件 size < 旧主(FILE_ACTION_TRUNCATE)
- 对偏移位置小于等于新主文件 size 的块,从新主拷贝受旧主分叉后更新影响的块
- 对偏移位置大于新主文件 size 的块,truncate 掉
-
新主文件 size = 旧主(FILE_ACTION_NONE)
- 对偏移位置小于等于新主文件 size 的块,从新主拷贝受旧主分叉后更新影响的块
针对上面的流程,现在回答几个关键的问题
pg_rewind
拷贝数据时,新主还处于活动中,拷贝的这些数据块不在同一个事务一致点上,如何将不一致的数据状态变成一致的?
这里用到的技术,就是数据块最擅长的宕机恢复的技术。通过启动旧主后,回放WAL使数据库达到一致的状态。
我们以一个微观的数据块为例进行说明。
具体到某一个数据块,只有三种情况,我们分别讨论
-
需要从新主拷贝
- 如果拷贝时发现新主上这个块所在的文件被删掉了,那么也会删掉旧主上的文件。
- 如果拷贝时新主上这个块被 truncate 掉了,会忽略这个块的拷贝。
- 如果拷贝时这个块正在被修改,可能导致
pg_rewind
读到了一个不一致的块。一半是修改前的,另一半是修改后的。
这并没有关系,因为如果这个块被变更了,变更这个块的事务已经记录到WAL了,回放这个WAL时可以修复数据到一致状态。
pg_rewind
运行的前提条件时数据库必须开启full_page_write
,开启full_page_write
后WAL中会记录每个checkpoint后第一次修改的page的完整镜像,宕机恢复时,使用这个镜像,就可以修复数据文件中损坏的数据块。 -
保留旧主
- 如果后来新主上这个块变更了,回放WAL时自然可以追到一致的状态
-
需要从旧主删除
- 如果后来新主上有新增了这个数据块,同样,回放WAL时自然可以追到一致的状态
如果一个表先被删了,之后又创建一个表结构不一样的同名的表,pg_rewind
处理这个表时会不会有问题?
PostgreSQL中数据文件名是filenode,初始时它等于表的oid,当表被重写(比如执行truncate或vacuum full)后,会赋值为下一个oid。因此先后创建的同名表,它们对应的文件名是不一样的(MySQL采用表名作为数据文件名。虽然比较直观,但会有很多潜在的问题,比如特殊字符,PostgreSQL的filenode的方式要严谨得多)。
PostgreSQL回放WAL时如何保证可正常执行?
具体要回答几个问题
- 宕机恢复阶段,回放建表的WAL时,如果对应的文件已存在,结果如何?
- 宕机恢复阶段,回放删表的WAL时,如果对应的文件不存在,结果如何?
- 宕机恢复阶段,回放extend数据文件的WAL时,如果对应的块已存在,结果如何?
- 宕机恢复阶段,回放write数据文件的WAL时,如果对应的块或者文件不存在,结果如何?
- 宕机恢复阶段,回放truncate数据文件的WAL时,如果对应的块或者文件不存在,结果如何?
存储层的一些接口,已经考虑到REDO的使用场景,做了一些容错,支持幂等性。
-
REDO中创建文件时,容忍文件已存在
void mdcreate(SMgrRelation reln, ForkNumber forkNum, bool isRedo) { ... fd = PathNameOpenFile(path, O_RDWR | O_CREAT | O_EXCL | PG_BINARY); if (fd < 0) { int save_errno = errno; if (isRedo) fd = PathNameOpenFile(path, O_RDWR | PG_BINARY); ...
-
删除文件时,容忍文件不存在
static void mdunlinkfork(RelFileNodeBackend rnode, ForkNumber forkNum, bool isRedo) { ... ret = unlink(pcfile_path); if (ret < 0 && errno != ENOENT) ereport(WARNING, (errcode_for_file_access(), errmsg("could not remove file \"%s\": %m", pcfile_path)));
-
REDO中打开文件时,都会带上
O_CREAT
flag,文件不存在时会创建一个空的static MdfdVec * _mdfd_getseg(SMgrRelation reln, ForkNumber forknum, BlockNumber blkno, bool skipFsync, int behavior) { if ((behavior & EXTENSION_CREATE) || (InRecovery && (behavior & EXTENSION_CREATE_RECOVERY))) { ... flags = O_CREAT;
-
读或写数据块时,可以 seek 到超出文件大小的位置
void mdwrite(SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, char *buffer, bool skipFsync) { ... v = _mdfd_getseg(reln, forknum, blocknum, skipFsync, EXTENSION_FAIL | EXTENSION_CREATE_RECOVERY); ... nbytes = FileWrite(v->mdfd_vfd, buffer, BLCKSZ, seekpos, WAIT_EVENT_DATA_FILE_WRITE);
-
REDO中 truncate 时,如果文件大小已小于 truncate 目标,无视
void mdtruncate(SMgrRelation reln, ForkNumber forknum, BlockNumber nblocks) { ... curnblk = mdnblocks(reln, forknum); if (nblocks > curnblk) { /* Bogus request ... but no complaint if InRecovery */ if (InRecovery) return;
-
回放truncate记录时,先强制执行一次创建关系的操作
void smgr_redo(XLogReaderState *record) { ... else if (info == XLOG_SMGR_TRUNCATE) { ... /* * Forcibly create relation if it doesn't exist (which suggests that * it was dropped somewhere later in the WAL sequence). As in * XLogReadBufferForRedo, we prefer to recreate the rel and replay the * log as best we can until the drop is seen. */ smgrcreate(reln, MAIN_FORKNUM, true); ... smgrtruncate(reln, forks, nforks, blocks);