請問你知道分散式系統的預寫日誌設計模式麼?

乾貨滿滿張雜湊發表於2021-02-09

原文地址:https://martinfowler.com/articles/patterns-of-distributed-systems/wal.html

Write-Ahead log 預寫日誌

預寫日誌(WAL,Write-Ahead Log)將每次狀態更新抽象為一個命令追加寫入一個日誌中,這個日誌只追加寫入,也就是順序寫入,所以 IO 會很快。相比於更新儲存的資料結構並且更新落盤這個隨機 IO 操作,寫入速度更快了,並且也提供了一定的永續性,也就是資料不會丟失,可以根據這個日誌恢復資料。

背景介紹

如果遇到了伺服器儲存資料失敗,例如已經確認客戶端的請求,但是儲存過程中,重啟程式導致真正儲存的資料沒有落盤,在重啟後,也需要保證已經答應客戶端的請求資料更新真正落盤成功。

解決方案

image

將每一個更新,抽象為一個指令,並將這些指令儲存在一個檔案中。每個程式順序追加寫各自獨立的一個檔案,簡化了重啟後日志的處理,以及後續的線上更新操作。每個日誌記錄有一個獨立 id,這個 id 可以用來實現分段日誌(Segmented Log)或者最低水位線(Low-Water Mark)清理老的日誌。日誌更新可以使用單一更新佇列(Singular Update Queue)這種設計模式。

日誌記錄的結構類似於:

class WALEntry {
  //日誌id
  private final Long entryId;
  //日誌內容
  private final byte[] data;
  //型別
  private final EntryType entryType;
  //時間
  private long timeStamp;
}

在每次重新啟動時讀取日誌檔案,回放所有日誌條目來恢復當前資料狀態。

假設有一記憶體鍵值對資料庫:

class KVStore {
  private Map<String, String> kv = new HashMap<>();

  public String get(String key) {
      return kv.get(key);
  }

  public void put(String key, String value) {
      appendLog(key, value);
      kv.put(key, value);
  }

  private Long appendLog(String key, String value) {
      return wal.writeEntry(new SetValueCommand(key, value).serialize());
  }
}

put 操作被抽象為 SetValueCommand,在更新記憶體 hashmap 之前將其序列化並儲存在日誌中。SetValueCommand 可以序列化和反序列化。

class SetValueCommand {
  final String key;
  final String value;

  public SetValueCommand(String key, String value) {
      this.key = key;
      this.value = value;
  }

  @Override
  public byte[] serialize() {
      try {
          //序列化
          var baos = new ByteArrayOutputStream();
          var dataInputStream = new DataOutputStream(baos);
          dataInputStream.writeInt(Command.SetValueType);
          dataInputStream.writeUTF(key);
          dataInputStream.writeUTF(value);
          return baos.toByteArray();

      } catch (IOException e) {
          throw new RuntimeException(e);
      }
  }

  public static SetValueCommand deserialize(InputStream is) {
      try {
          //反序列化
          DataInputStream dataInputStream = new DataInputStream(is);
          return new SetValueCommand(dataInputStream.readUTF(), dataInputStream.readUTF());
      } catch (IOException e) {
          throw new RuntimeException(e);
      }
  }
}

這可以確保即使程式重啟,這個 hashmap 也可以通過在啟動時讀取日誌檔案來恢復。

class KVStore {
  public KVStore(Config config) {
      this.config = config;
      this.wal = WriteAheadLog.openWAL(config);
      this.applyLog();
  }

  public void applyLog() {
      List<WALEntry> walEntries = wal.readAll();
      applyEntries(walEntries);
  }

  private void applyEntries(List<WALEntry> walEntries) {
      for (WALEntry walEntry : walEntries) {
          Command command = deserialize(walEntry);
          if (command instanceof SetValueCommand) {
              SetValueCommand setValueCommand = (SetValueCommand)command;
              kv.put(setValueCommand.key, setValueCommand.value);
          }
      }
  }

  public void initialiseFromSnapshot(SnapShot snapShot) {
      kv.putAll(snapShot.deserializeState());
  }
}

實現考慮

首先是保證 WAL 日誌真的寫入了磁碟。所有程式語言提供的檔案處理庫提供了一種機制,強制作業系統將檔案更改flush落盤。在flush時,需要考慮的是一種權衡。對於日誌的每一條記錄都flush一次,保證了強永續性,但是嚴重影響了效能並且很快會成為效能瓶頸。如果是非同步flush,效能會提高,但是如果在flush前程式崩潰,則有可能造成日誌丟失。大部分的實現都採用批處理,減少flush帶來的效能影響,同時也儘量少丟資料。

另外,我們還需要保證日誌檔案沒有損壞。為了處理這個問題,日誌條目通常伴隨 CRC 記錄寫入,然後在讀取檔案時進行驗證。

同時,採用單個日誌檔案可能變得很難管理(很難清理老日誌,重啟時讀取檔案過大)。為了解決這個問題,通常採用之前提到的分段日誌(Segmented Log)或者最低水位線(Low-Water Mark)來減少程式啟動時讀取的檔案大小以及清理老的日誌。

最後,要考慮重試帶來的重複問題,也就是冪等性。由於 WAL 日誌僅附加,在發生客戶端通訊失敗和重試時,日誌可能包含重複的條目。當讀取日誌條目時,可能會需要確保重複項被忽略。但是如果儲存類似於 HashMap,其中對同一鍵的更新是冪等的,則不需要排重,但是可能會存在 ABA 更新問題。一般都需要實現某種機制來標記每個請求的唯一識別符號並檢測重複請求。

舉例

各種 MQ 中的類似於 CommitLog 的日誌

MQ 中的訊息儲存,由於訊息佇列的特性導致訊息儲存和日誌類似,所以一般用日誌直接作為儲存。這個訊息儲存一般就是 WAL 這種設計模式,以 RocketMQ 為例子:

RocketMQ:
image

RocketMQ 儲存首先將訊息儲存在 Commitlog 檔案之中,這個檔案採用的是 mmap (檔案對映記憶體)技術寫入與儲存。關於這個技術,請參考另一篇文章JDK核心JAVA原始碼解析(5) - JAVA File MMAP原理解析

當訊息來時,寫入檔案的核心方法是MappedFileappendMessagesInner方法:

public AppendMessageResult appendMessagesInner(final MessageExt messageExt, final AppendMessageCallback cb) {
    assert messageExt != null;
    assert cb != null;
    //獲取當前寫入位置
    int currentPos = this.wrotePosition.get();
    //如果當前寫入位置小於檔案大小則嘗試寫入
    if (currentPos < this.fileSize) {
        //mappedByteBuffer是公用的,在這裡不能修改其position影響讀取
        //mappedByteBuffer是檔案對映記憶體抽象出來的檔案的記憶體ByteBuffer
        //對這個buffer的寫入,就相當於對檔案的寫入
        //所以通過slice方法生成一個共享原有相同記憶體的新byteBuffer,設定position
        //如果writeBuffer不為空,則證明啟用了TransientStorePool,使用其中快取的記憶體寫入
        ByteBuffer byteBuffer = writeBuffer != null ? writeBuffer.slice() : this.mappedByteBuffer.slice();
        byteBuffer.position(currentPos);
        AppendMessageResult result;
        //分單條訊息還有批量訊息的情況
        if (messageExt instanceof MessageExtBrokerInner) {
            result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos, (MessageExtBrokerInner) messageExt);
        } else if (messageExt instanceof MessageExtBatch) {
            result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos, (MessageExtBatch) messageExt);
        } else {
            return new AppendMessageResult(AppendMessageStatus.UNKNOWN_ERROR);
        }
        //增加寫入大小
        this.wrotePosition.addAndGet(result.getWroteBytes());
        //更新最新訊息儲存時間
        this.storeTimestamp = result.getStoreTimestamp();
        return result;
    }
    log.error("MappedFile.appendMessage return null, wrotePosition: {} fileSize: {}", currentPos, this.fileSize);
    return new AppendMessageResult(AppendMessageStatus.UNKNOWN_ERROR);
}

RocketMQ 將訊息儲存在 Commitlog 檔案後,非同步更新 ConsumeQueue 還有 Index 檔案。這個 ConsumeQueue 還有 Index 檔案可以理解為儲存狀態,CommitLog 在這裡扮演的就是 WAL 日誌的角色:只有寫入到 ConsumeQueue 的訊息才會被消費者消費,只有 Index 檔案中存在的記錄才能被讀取定位到。如果訊息成功寫入 CommitLog 但是非同步更新還沒執行,RocketMQ 程式掛掉了,這樣就存在了不一致。所以在 RocketMQ 啟動的時候,會通過如下機制保證 Commitlog 與 ConsumeQueue 還有 Index 的最終一致性.

入口是DefaultMessageStoreload方法:

public boolean load() {
    boolean result = true;
    try {
        //RocketMQ Broker啟動時會建立${ROCKET_HOME}/store/abort檔案,並新增JVM shutdownhook刪除這個檔案
        //通過這個檔案是否存判斷是否為正常退出
        boolean lastExitOK = !this.isTempFileExist();
        log.info("last shutdown {}", lastExitOK ? "normally" : "abnormally");

        //載入延遲佇列訊息,這裡先忽略
        if (null != scheduleMessageService) {
            result = result && this.scheduleMessageService.load();
        }

        //載入 Commit Log 檔案
        result = result && this.commitLog.load();

        //載入 Consume Queue 檔案
        result = result && this.loadConsumeQueue();

        if (result) {
            //載入儲存檢查點
            this.storeCheckpoint =
                new StoreCheckpoint(StorePathConfigHelper.getStoreCheckpoint(this.messageStoreConfig.getStorePathRootDir()));
            //載入 index,如果不是正常退出,銷燬所有索引上次刷盤時間小於索引檔案最大訊息時間戳的檔案
            this.indexService.load(lastExitOK);
            //進行 recover 恢復之前狀態
            this.recover(lastExitOK);
            log.info("load over, and the max phy offset = {}", this.getMaxPhyOffset());
        }
    } catch (Exception e) {
        log.error("load exception", e);
        result = false;
    }
    if (!result) {
        this.allocateMappedFileService.shutdown();
    }
    return result;
}

進行恢復是DefaultMessageStorerecover方法:

private void recover(final boolean lastExitOK) {
    long maxPhyOffsetOfConsumeQueue = this.recoverConsumeQueue();
    //根據上次是否正常退出,採用不同的恢復方式
    if (lastExitOK) {
        this.commitLog.recoverNormally(maxPhyOffsetOfConsumeQueue);
    } else {
        this.commitLog.recoverAbnormally(maxPhyOffsetOfConsumeQueue);
    }

    this.recoverTopicQueueTable();
}

當上次正常退出時:

public void recoverNormally(long maxPhyOffsetOfConsumeQueue) {
    boolean checkCRCOnRecover = this.defaultMessageStore.getMessageStoreConfig().isCheckCRCOnRecover();
    final List<MappedFile> mappedFiles = this.mappedFileQueue.getMappedFiles();
    if (!mappedFiles.isEmpty()) {
        //只掃描最後三個檔案
        int index = mappedFiles.size() - 3;
        if (index < 0)
            index = 0;
        MappedFile mappedFile = mappedFiles.get(index);
        ByteBuffer byteBuffer = mappedFile.sliceByteBuffer();
        long processOffset = mappedFile.getFileFromOffset();
        long mappedFileOffset = 0;
        while (true) {
            //檢驗儲存訊息是否有效
            DispatchRequest dispatchRequest = this.checkMessageAndReturnSize(byteBuffer, checkCRCOnRecover);
            int size = dispatchRequest.getMsgSize();
            //如果有效,新增這個偏移
            if (dispatchRequest.isSuccess() && size > 0) {
                mappedFileOffset += size;
            }
            //如果有效,但是大小是0,代表到了檔案末尾,切換檔案
            else if (dispatchRequest.isSuccess() && size == 0) {
                index++;
                if (index >= mappedFiles.size()) {
                    // Current branch can not happen
                    log.info("recover last 3 physics file over, last mapped file " + mappedFile.getFileName());
                    break;
                } else {
                    mappedFile = mappedFiles.get(index);
                    byteBuffer = mappedFile.sliceByteBuffer();
                    processOffset = mappedFile.getFileFromOffset();
                    mappedFileOffset = 0;
                    log.info("recover next physics file, " + mappedFile.getFileName());
                }
            }
            //只有有無效的訊息,就在這裡停止,之後會丟棄掉這個訊息之後的所有內容
            else if (!dispatchRequest.isSuccess()) {
                log.info("recover physics file end, " + mappedFile.getFileName());
                break;
            }
        }
        processOffset += mappedFileOffset;
        this.mappedFileQueue.setFlushedWhere(processOffset);
        this.mappedFileQueue.setCommittedWhere(processOffset);
        //根據有效偏移量,刪除這個偏移量以後的所有檔案,以及所有檔案(正常是隻有最後一個有效檔案,而不是所有檔案)中大於這個偏移量的部分
        this.mappedFileQueue.truncateDirtyFiles(processOffset);
        //根據 commit log 中的有效偏移量,清理 consume queue
        if (maxPhyOffsetOfConsumeQueue >= processOffset) {
            log.warn("maxPhyOffsetOfConsumeQueue({}) >= processOffset({}), truncate dirty logic files", maxPhyOffsetOfConsumeQueue, processOffset);
            this.defaultMessageStore.truncateDirtyLogicFiles(processOffset);
        }
    } else {
        //所有commit log都刪除了,那麼偏移量就從0開始
        log.warn("The commitlog files are deleted, and delete the consume queue files");
        this.mappedFileQueue.setFlushedWhere(0);
        this.mappedFileQueue.setCommittedWhere(0);
        this.defaultMessageStore.destroyLogics();
    }
}

當上次沒有正常退出時:

public void recoverAbnormally(long maxPhyOffsetOfConsumeQueue) {
    boolean checkCRCOnRecover = this.defaultMessageStore.getMessageStoreConfig().isCheckCRCOnRecover();
    final List<MappedFile> mappedFiles = this.mappedFileQueue.getMappedFiles();
    if (!mappedFiles.isEmpty()) {
        // 從最後一個檔案開始,向前尋找第一個正常的可以恢復訊息的檔案
        // 從這個檔案開始恢復訊息,因為裡面的訊息有成功寫入過 consumer queue 以及 index 的,所以從這裡恢復一定能保證最終一致性
        // 但是會造成某些已經寫入過 consumer queue 的訊息再次寫入,也就是重複消費。
        int index = mappedFiles.size() - 1;
        MappedFile mappedFile = null;
        for (; index >= 0; index--) {
            mappedFile = mappedFiles.get(index);
            //尋找第一個有正常訊息的檔案
            if (this.isMappedFileMatchedRecover(mappedFile)) {
                log.info("recover from this mapped file " + mappedFile.getFileName());
                break;
            }
        }
        //如果小於0,就恢復所有 commit log,或者代表沒有 commit log
        if (index < 0) {
            index = 0;
            mappedFile = mappedFiles.get(index);
        }
        ByteBuffer byteBuffer = mappedFile.sliceByteBuffer();
        long processOffset = mappedFile.getFileFromOffset();
        long mappedFileOffset = 0;
        while (true) {
            //驗證訊息有效性
            DispatchRequest dispatchRequest = this.checkMessageAndReturnSize(byteBuffer, checkCRCOnRecover);
            int size = dispatchRequest.getMsgSize();
            //如果訊息有效
            if (dispatchRequest.isSuccess()) {
                if (size > 0) {
                    mappedFileOffset += size;

                    if (this.defaultMessageStore.getMessageStoreConfig().isDuplicationEnable()) {
                        //如果允許訊息重複轉發,則需要判斷當前訊息是否訊息偏移小於已確認的偏移,只有小於的進行重新分發
                        if (dispatchRequest.getCommitLogOffset() < this.defaultMessageStore.getConfirmOffset()) {
                            //重新分發訊息,也就是更新 consume queue 和 index
                            this.defaultMessageStore.doDispatch(dispatchRequest);
                        }
                    } else {
                        //重新分發訊息,也就是更新 consume queue 和 index
                        this.defaultMessageStore.doDispatch(dispatchRequest);
                    }
                }
                //大小為0代表已經讀完,切換下一個檔案
                else if (size == 0) {
                    index++;
                    if (index >= mappedFiles.size()) {
                        // The current branch under normal circumstances should
                        // not happen
                        log.info("recover physics file over, last mapped file " + mappedFile.getFileName());
                        break;
                    } else {
                        mappedFile = mappedFiles.get(index);
                        byteBuffer = mappedFile.sliceByteBuffer();
                        processOffset = mappedFile.getFileFromOffset();
                        mappedFileOffset = 0;
                        log.info("recover next physics file, " + mappedFile.getFileName());
                    }
                }
            } else {
                log.info("recover physics file end, " + mappedFile.getFileName() + " pos=" + byteBuffer.position());
                break;
            }
        }

        //更新偏移
        processOffset += mappedFileOffset;
        this.mappedFileQueue.setFlushedWhere(processOffset);
        this.mappedFileQueue.setCommittedWhere(processOffset);
        this.mappedFileQueue.truncateDirtyFiles(processOffset);

        //清理
        if (maxPhyOffsetOfConsumeQueue >= processOffset) {
            log.warn("maxPhyOffsetOfConsumeQueue({}) >= processOffset({}), truncate dirty logic files", maxPhyOffsetOfConsumeQueue, processOffset);
            this.defaultMessageStore.truncateDirtyLogicFiles(processOffset);
        }
    }
    // Commitlog case files are deleted
    else {
        log.warn("The commitlog files are deleted, and delete the consume queue files");
        this.mappedFileQueue.setFlushedWhere(0);
        this.mappedFileQueue.setCommittedWhere(0);
        this.defaultMessageStore.destroyLogics();
    }
}

總結起來就是:

  • 首先,根據 abort 檔案是否存在判斷上次是否正常退出。
  • 對於正常退出的:
    • 掃描倒數三個檔案,記錄有效訊息的偏移
    • 掃描到某個無效訊息結束,或者掃描完整個檔案
    • 設定最新偏移,同時根據這個偏移量清理 commit log 和 consume queue
  • 對於沒有正常退出的:
    • 從最後一個檔案開始,向前尋找第一個正常的可以恢復訊息的檔案
    • 從這個檔案開始恢復並重發訊息,因為裡面的訊息有成功寫入過 consumer queue 以及 index 的,所以從這裡恢復一定能保證最終一致性。但是會造成某些已經寫入過 consumer queue 的訊息再次寫入,也就是重複消費。
    • 更新偏移,清理

資料庫

基本上所有的資料庫都會有 WAL 類似的設計,例如 MySQL 的 Innodb redo log 等等。
image

image

一致性儲存

例如 ZK 還有 ETCD 這樣的一致性中介軟體。

相關文章