天天看點

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
  1. 讀取舊主本地WAL,擷取并記錄影響的資料塊到filemap中對應的

    file_entry

    中的pagemap

pagemap屬于塊級别的拷貝。為了避免檔案級别的拷貝做重複的事情,提取影響的塊号是做了一些過濾,具體如下:

  • FILE_ACTION_NONE:隻記錄小于等于新主size的塊
  • FILE_ACTION_TRUNCATE:隻記錄小于等于新主size的塊
  • FILE_ACTION_COPY_TAIL:隻記錄小于等于舊主size的塊
  • 其他:不記錄
  1. 周遊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)

針對上面的流程,現在回答幾個關鍵的問題

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);