所有文章是按github的markdown格式书写的,如此页面显示不正常,请跳转到github版

pg_rewind实现原理简单分析

pg_rewind的功能是在主备切换后回退旧主库上多余的事务变更,以便可以作为新主的备机和新主建立复制关系。通过pg_rewind可以在故障切换后快速恢复旧主,避免整库重建。对于大库,整库重建会很耗时间。

如何识别旧主上多余的变更?

这就用到了PostgreSQL独有的时间线技术,数据库实例的初始时间线是1。以后每次主备切换时,需要提升备库为新主。提升操作会将新主的时间线加1,并且会记录提升时间线的WAL位置(LSN)。这个LSN位点我们称其为新主旧主在时间线上的分叉点。

我们只要扫描旧主上在分叉点之后的WAL记录,就能找到旧主上所有多余的变更。

如何回退旧主上多余的变更?

可能有人会想到可以通过解析分叉点以后的WAL,生成undo SQL,再逆序执行undo SQL实现事务回退。这也是mysql上常用的实现方式。 PG同样也有walminer插件可以支持WAL到undo SQL的解析。但是,这种方式存在很多限制,数据一致性也难以保证。

pg_rewind使用的是不同的方式,过程概述如下

  1. 解析旧主上分叉点后的WAL,记录这些事务修改了哪些数据块
  2. 对数据文件以外的文件,直接从新主上拉取后覆盖旧主上的文件
  3. 对于数据文件,只从新主拉取被旧主修改了的数据块,并覆盖旧主数据文件中对应的数据块
  4. 从新主上拉取最新的WAL,覆盖旧主的WAL
  5. 把旧主改成恢复模式,恢复的起点则设置为分叉点前的最近一次checkpoint
  6. 启动旧主,旧主进入宕机恢复过程,旧主应用完从新主拷贝来的所有WAL后,数据就和新主一致了。

如何保证主备一致?

分叉点之后,新主和旧主上可能有各种各样的变更。除了常规的数据的增删改,还有truncate,表结构变更,表的DROP和CREATE,数据库参数配置变更等等。如何保证pg_rewind之后这些都能一致呢?

下面我们重点看一下pg_rewind对数据文件的拷贝处理。

  1. 通过比较target和source节点的数据目录,构建filemap

    filemap中对每个文件记录了不同的处理方式(即action),对于数据文件,如下

    • 仅存在于新主:FILE_ACTION_COPY
    • 仅存在于旧主:FILE_ACTION_REMOVE
    • 新主文件size>旧主:FILE_ACTION_COPY_TAIL
    • 新主文件size<旧主:FILE_ACTION_TRUNCATE
    • 新主文件size=旧主:FILE_ACTION_NONE
  2. 读取旧主本地WAL,获取并记录影响的数据块到filemap中对应的file_entry中的pagemap

    pagemap属于块级别的拷贝。为了避免文件级别的拷贝做重复的事情,提取影响的块号是做了一些过滤,具体如下:

    • FILE_ACTION_NONE:只记录小于等于新主size的块
    • FILE_ACTION_TRUNCATE:只记录小于等于新主size的块
    • FILE_ACTION_COPY_TAIL:只记录小于等于旧主size的块
    • 其他:不记录
  3. 遍历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使数据库达到一致的状态。

我们以一个微观的数据块为例进行说明。

具体到某一个数据块,只有三种情况,我们分别讨论

  1. 需要从新主拷贝

    • 如果拷贝时发现新主上这个块所在的文件被删掉了,那么也会删掉旧主上的文件。

    • 如果拷贝时新主上这个块被 truncate 掉了,会忽略这个块的拷贝。

    • 如果拷贝时这个块正在被修改,可能导致pg_rewind读到了一个不一致的块。一半是修改前的,另一半是修改后的。

      这并没有关系,因为如果这个块被变更了,变更这个块的事务已经记录到WAL了,回放这个WAL时可以修复数据到一致状态。pg_rewind运行的前提条件时数据库必须开启 full_page_write,开启full_page_write后WAL中会记录每个checkpoint后第一次修改的page的完整镜像,宕机恢复时,使用这个镜像,就可以修复数据文件中损坏的数据块。

  2. 保留旧主

    • 如果后来新主上这个块变更了,回放WAL时自然可以追到一致的状态
  3. 需要从旧主删除

    • 如果后来新主上有新增了这个数据块,同样,回放WAL时自然可以追到一致的状态

如果一个表先被删了,之后又创建一个表结构不一样的同名的表,pg_rewind处理这个表时会不会有问题?

PostgreSQL中数据文件名是filenode,初始时它等于表的oid,当表被重写(比如执行truncate或vacuum full)后,会赋值为下一个oid。因此先后创建的同名表,它们对应的文件名是不一样的(MySQL采用表名作为数据文件名。虽然比较直观,但会有很多潜在的问题,比如特殊字符,PostgreSQL的filenode的方式要严谨得多)。

PostgreSQL回放WAL时如何保证可正常执行?

具体要回答几个问题

  1. 宕机恢复阶段,回放建表的WAL时,如果对应的文件已存在,结果如何?
  2. 宕机恢复阶段,回放删表的WAL时,如果对应的文件不存在,结果如何?
  3. 宕机恢复阶段,回放extend数据文件的WAL时,如果对应的块已存在,结果如何?
  4. 宕机恢复阶段,回放write数据文件的WAL时,如果对应的块或者文件不存在,结果如何?
  5. 宕机恢复阶段,回放truncate数据文件的WAL时,如果对应的块或者文件不存在,结果如何?

存储层的一些接口,已经考虑到REDO的使用场景,做了一些容错,支持幂等性。

  1. 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);
    ...
    
  2. 删除文件时,容忍文件不存在

    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)));
    
  3. 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;
    
  4. 读或写数据块时,可以 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);
    
  5. 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;
    
  6. 回放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);
    
August 4, 2020