RedisSyncer同步引擎的設計與實現

京東雲發表於2022-08-23

RedisSyncer一款透過replication協議模擬slave來獲取源Redis節點資料並寫入目標Redis從而實現資料同步的Redis同步中介軟體。 該專案主要包括以下子專案:

  • redis 同步服務引擎 redissyncer-server

  • redissycner 客戶端 redissyncer-cli

  • redis 資料校驗工具 redissycner-compare

  • 基於docker-compse的一體化部署方案 redissyncer

本文主要介紹reidssyncer引擎(既redissyncer-server)的設計與實現,以及引擎執行的機制。

同步流程

原生redis master slave 模式主要分為兩個階段,第一個階段同步rdb映象,也就是全量同步部分;全量同步完成後進入命令傳播模式,每個執行成功的資料變更操作會同步給slave節點。redissyncer 的模擬了這一機制並將兩部分拆解,既可以執行完整同步任務也可以單獨執行全量或增量同步。

圖片

  
  • 建立socket

  • 傳送auth user password (6.0新增user)

          OK 成功
         其他 error
  • send->ping

           返回:
            ERR invalid password    密碼錯誤
            NOAUTH Authentication required.沒有傳送密碼
            operation not permitted 操作沒許可權
            PONG  密碼成功
     
          作用:
             檢測主從節點之間的網路是否可用。
             檢查主從節點當前是否接受處理命令。
  • 傳送從節點埠資訊

          REPLCONF listening-port <port>
     
             -->OK 成功
             -->其他  失敗
  • 傳送從節點IP

          REPLCONF ip-address <IP>
     
              --> OK 成功
              --> 其他  失敗
  • 傳送EOF能力(capability)

          REPLCONF capa eof

            --> OK 成功
            --> 失敗
         作用:
            是否支援EOF風格的RDB傳輸,用於無盤複製,就是能夠解析出RDB檔案的EOF流格式。用於無盤複製的方式中。
            redis4.0支援兩種能力 EOF 和 PSYNC2
            redis4.0之前版本僅支援EOF能力
  • 傳送PSYNC2能力

           REPLCONF capa  PSYNC2

              --> OK 成功
              --> 失敗
          作用:
             告訴master支援PSYNC2命令 ,  master 會忽略它不支援的能力.  PSYNC2則表示支援Redis4.0最新的PSYN複製操作。
  • 傳送PSYNC

          PSYNC {replid} {offset}

           -->  FULLRESYNC  {replid}  {offset}   完整同步
           -->  CONTINUE 部分同步
           -->  -ERR 主伺服器低於2.8,不支援psync,從伺服器需要傳送sync
           -->  NOMASTERLINK  重試
           -->  LOADING       重試
           -->  超過重試機制閾值宕掉任務

         讀取PSYNC命令狀態,判斷是部分同步還是完整同步
  • PSYNC ---> 啟動heartbeat

          REPLCONF ACK <replication_offset>
         心跳檢測
           在命令傳播階段,從伺服器預設會以每秒一次的頻率
           傳送REPLCONF ACK命令對於主從伺服器有三個作用:
         作用:
           檢測主從伺服器的網路連線狀態;
           輔助實現min-slaves選項;
           檢測命令丟失。
     
         REPLCONF  GETACK
           ->REPLCONF ACK <replication_offset>

rdb 映象同步完成後進入命令傳播,master 會不斷將變化資料推送給slave。
為了保證
RedisSyncer內部有斷點續傳、資料補償、斷線重連等機制來保證資料同步過程中穩定性和可用性,具體的機制如下。

斷點續傳機制

RedisSyncer的斷點續傳機制是基於Redis的replid和offset來實現的,RedisSyncer有兩個版本的斷點續傳機制v1和v2。

  • v1版本:

v1版本資料寫入到目的端redis後,將offset持久化到本地,這樣下次重啟就從上次的offset拉取。但是由於該方案寫目的端的操作和offset持久化不是一個原子的操作。如果中間發生中斷會導致資料的不一致。 例如,先寫入資料到目的端成功,後持久化offset還沒成功就發生了當機、重啟等情況,那麼再次斷點續傳拉取上一次的offset資料最後就不一致了。  


  • v2版本:

在v2版本策略中RedisSyncer會將每一個pipeline批次中不存在事務的的命令透過multi和exec進行包裝,並在事務尾部插入offset檢查點。 當斷點續傳時需要從目標Redis的所以db庫中查詢checkpoint並找到所對應源節點當最大offset,再根據該offset進行斷點續傳。目前v2版本只支援目標為單機Redis的情況。 在v2版本中


  • v2命令事務封裝結構

    [object Object]
  • v2 checkpoint檢查點結構:

    HASH  hset redis-syncer-checkpoint {value}
    {value}:
       * {ip}:{port}-runid     {replid}
       * {ip}:{port}-offset    {offset}
       * pointcheckVersion     {version}

在Redis的事務機制中雖然不支援回滾,並且如果事務中間命令執行出錯後但是事務還是被執行完成,但是除特殊情況外能夠保證一致性。 在v2的機制中,為了防止'寫放大'會在目標redis的每一個邏輯庫中寫入一個checkpoint,因此在執行斷點續傳操作的時候,同步工具會先掃描目標各個邏輯庫中的checkpoint並選出裡面最大offset的checkpoint作為斷點續傳的引數。  

資料補償機制

在資料同步過程中,存在由於網路穩定性或其他因素導致key寫入失敗的情況,為此redissyncer實現了一套補償機制來保證源端與目的端資料的一致性。 資料補償的前提是命令寫入的冪等性,因此在RedisSyncer中會先將INCR、INCRBY、INCRBYFLOAT、APPEND、DECR、DECRBY等部分非冪等命令轉換成冪等命令後再寫入目標端Redis。 RedisSyncer在目標為單機Redis或者Proxy的時候是透過pipeline機制將資料寫入到目標Redis中的,每一個批次的pipeline的提交會返回一個結果列表, 同步工具會驗證pipeline中結果的正確性,如果部分命令寫入失敗,同步工具對該批次與該key相關的命令進行重試。 如果重試超過指定的閥值,將會宕掉任務。對於存在大key的list等非冪等結構,將不會進行資料補償,強制結束任務待人工處理。

斷線重連機制

 由於網路抖動等原因可能會導致同步工具源端與目標端連線在同步過程中斷開,因此需要斷線重試機制來保證在任務同步的過程中如果出現異常斷開的問題。斷線重連機制存在於與源Redis節點和RedisSyncer、RedisSyncer與目標Redis節點的連線之間,兩者分別有各自的處理機制。  

圖片

  • 源端重連機制

    源Redis與RedisSyncer的斷線重連機制是透過記錄的offset來實現的,當因網路異常等原因斷開了連線時,RedisSyncer會重新嘗試與源Redis節點建立連線,並透過當前任務記錄的runid、offset等資訊去拉取斷開之前的增量資料,連線重新建立成功後RedisSyncer的同步任務將會無感知繼續同步。當斷線重連超過指定重試閥值或者因為offset刷過導致沒有辦法續傳資料時,RedisSyncer會宕掉當前當同步任務,等待人工干預。

圖片

  • 目標端重連機制

    RedisSyncer與目標Redis之間的斷線重連機制是透過快取上一批次的pipeline的命令來實現的,當連線斷開異常時RedisSyncer進行重重連回放上一批次寫入失敗的命令。當回放失敗或者超過連續重試次數RedisSyncer會宕掉當前當同步任務,等待人工干預。

圖片

命令的鏈式處理

RedisSyncer中採用鏈式策略處理同步資料,任何一個策略返回失敗,該key都將不會被同步。鏈式策略流程如圖所示

圖片

 每一個key在RedisSyncer都會經過一個策略鏈進行處理,只要有一個策略未透過則這個key將不會同步到目標Redis,比如key過期時間的計算策略如果計算出全量階段key已過期,則將會自動拋棄該key。

策略鏈中的策略包括

型別策略描述
DataAnalysisStrategy命令統計分析
KeyFilterStrategy命令過濾
DbMappingStrategyDb對映
TimeCalculationStrategy過期時間計算
RdbCommandSendStrategy全量資料寫入
AofCommandSendStrategy增量資料寫入
..........


任務管理

  • 任務啟動流程

    圖片

  • 任務停止及清理流程

    任務主動停止時,RedisSyncer會先停止源Redis端的資料寫入然後進入資料保護狀態,確保可能還處在RedisSyncer中未寫入目標的少部分資料能夠完整的寫入目標端,並且正確的記錄寫入的最後一條資料的offset並持久化,保證斷點續傳時RedisSyncer能夠提供正確的offset。

  • 任務狀態

    TYPEcodedescriptionstatus
    STOP0任務停止已使用
    CREATING1建立中已使用
    CREATED2建立完成已使用
    RUN3執行狀態已使用
    BROKEN5任務異常已使用
    RDBRUNING6全量RDB同步過程中已使用
    COMMANDRUNING7增量同步中已使用
    FINISH8完成狀態已使用(用於檔案匯入)
  • 任務異常處理原則

    在RedisSycner任務中如果遇到可能會導致資料不一致的錯誤,RedisSyncer都會宕掉任務,等待人工干預。

rdb跨版本同步實現

rdb檔案存在向前相容問題,即高版本的rdb檔案無法匯入低rdb版本的Redis

  • 跨版本遷移實現機制

  1. 對於可能存在大key的結構比如:SET,ZSET,LIST,HASH等結構:

  2. 對於其他命令如:String等結構: 為保證其命令冪等性,命令解析器會根據目標REDIS節點的RDB版本進行序列化(實現DUMP),傳輸模組會使用REPLACE反序列化到目標節點。(其中在redis3.0以下版本REPLACE命令不支援[REPLACE])

  1. 對於對資料成員沒有順序性要求的命令如:SET,ZSET,HASH命令解析器將其解析成一個或多個sadd,zadd,hmset等命令進行處理

  2. 對於對資料成員有順序性要求的命令如:List等命令,若被命令解析器判斷為大key並將其拆分為多個子命令,此時必須保證按順序傳送至目標REDIS節點

  1. REDIS跨版本間存在的問題: 由於REDIS是向下相容(低版本無法相容高版本RDB),在其RDB檔案協議中存在一個vesion版本號標識,REDIS在RDB匯入或者全量同步執行rdbLoad時會先檢測RDB VERSION是否符合向下相容,如果不符合則會丟擲 Can’t handle RDB format version   錯誤。

  2. syncer跨版本實現機制 對於全量同步RDB資料部分syncer將其分命令為兩類進行處理

RDB檔案協議中關於 RDB VERSION部分

REDIS RDB檔案結構開頭部分示例
----------------------------# RDB is a binary format. There are no new lines or spaces in the file.
52 45 44 49 53              # Magic String "REDIS"
30 30 30 37                 # 4 digit ASCCII RDB Version Number. In this case, version = "0007" = 7   RDB VERSION欄位
----------------------------
FE 00                       # FE = code that indicates database selector. db number = 00

關於 RDB VERSION檢查部分虛擬碼

def rdbLoad(filename):
   rio =  rioInitWithFile(filename);
   # 設定標記:
   # a. 伺服器狀態:rdb_loading = 1
   # b. 載入時間:loading_start_time = now_time
   # c. 載入大小:loading_total_bytes = filename.size
   startLoading(rio)
   # 1.檢查該檔案是否為RDB檔案(即檔案開頭前5個字元是否為"REDIS")
   if !checkRDBHeader(rio):
       redislog("error, Wrong signature trying to load DB from file")
       return
   # 2.檢查當前RDB檔案版本是否相容(向下相容)
   if !checkRDBVersion(rio):
       redislog("error, Can't handle RDB format version")
       return
.........
   //Redis中關於RDB_VERSION檢查的程式碼
   rdbver = atoi(buf+5);
   if (rdbver < 1 || rdbver > RDB_VERSION) {
       rdbCheckError("Can't handle RDB format version %d",rdbver);
       goto err;
   }


RDB 同步過程中的大 Key 拆分

RedisSyncer在全量同步階段在遇到LIST、SET、ZSET、HASH等結構等時候,當資料大小超過閥值後RedisSyncer會透過迭代器的形式將key拆分成多個子命令寫入目標庫。防止部分超大key一次性讀入記憶體導致程式產生oom並提高同步的速度。而對於不存在大key的命令同步工具會透過序列化逆序列化的形式寫入目標。

附錄一  Redis RDB協議

redis RDB Dump 檔案格式

----------------------------# RDB is a binary format. There are no new lines or spaces in the file.
52 45 44 49 53              # Magic String "REDIS"
30 30 30 37                 # 4 digit ASCCII RDB Version Number. In this case, version = "0007" = 7
----------------------------
FE 00                       # FE = code that indicates database selector. db number = 00
----------------------------# Key-Value pair starts
FD $unsigned int            # FD indicates "expiry time in seconds". After that, expiry time is read as a 4 byte unsigned int
$value-type                 # 1 byte flag indicating the type of value - set, map, sorted set etc.
$string-encoded-key         # The key, encoded as a redis string
$encoded-value              # The value. Encoding depends on $value-type
----------------------------
FC $unsigned long           # FC indicates "expiry time in ms". After that, expiry time is read as a 8 byte unsigned long
$value-type                 # 1 byte flag indicating the type of value - set, map, sorted set etc.
$string-encoded-key         # The key, encoded as a redis string
$encoded-value              # The value. Encoding depends on $value-type
----------------------------
$value-type                 # This key value pair doesn't have an expiry. $value_type guaranteed != to FD, FC, FE and FF
$string-encoded-key
$encoded-value
----------------------------
FE $length-encoding         # Previous db ends, next db starts. Database number read using length encoding.
----------------------------
...                         # Key value pairs for this database, additonal database

FF                          ## End of RDB file indicator
8 byte checksum             ## CRC 64 checksum of the entire file.

RDB檔案以魔術字串“REDIS”開頭。
52 45 44 49 53 # "REDIS"

RDB 版本號
接下來的 4 個位元組儲存 rdb 格式的版本號。這 4 個位元組被解釋為 ascii 字元,然後使用字串到整數轉換轉換為整數。
00 00 00 03 # Version = 3

Database Selector
一個Redis例項可以有多個資料庫。
單個位元組0xFE標記資料庫選擇器的開始。在該位元組之後,一個可變長度欄位指示資料庫編號。請參閱“長度編碼”部分以瞭解如何讀取此資料庫編號。

鍵值對
在資料庫選擇器之後,該檔案包含一系列鍵值對。
za
每個鍵值對有 4 個部分 -

1.金鑰到期時間戳。
2.指示值型別的一位元組標誌
3.金鑰,編碼為 Redis 字串。請參閱“Redis 字串編碼”
4.根據值型別編碼的值。參見“Redis 值編碼”

附錄二 Redis RESP協議

Redis RESP協議

RESP 協議是在 Redis 1.2 中引入的,但它成為了 Redis 2.0 中與 Redis 伺服器通訊的標準方式。是在Redis 客戶端中實現的協議。 RESP 實際上是一種序列化協議,它支援以下資料型別:簡單字串、錯誤、整數、批次字串和陣列。

RESP 在 Redis 中用作請求-響應協議的方式如下:

  • 客戶端將命令作為批次字串的 RESP 陣列傳送到 Redis 伺服器。

  • 伺服器根據命令實現以其中一種 RESP 型別進行回覆。

在 RESP 中,某些資料的型別取決於第一個位元組:

  • 對於簡單字串,回覆的第一個位元組是“+”

  • 對於錯誤,回覆的第一個位元組是“-”

  • 對於整數,回覆的第一個位元組是“:”

  • 對於批次字串,回覆的第一個位元組是“$”

  • 對於陣列,回覆的第一個位元組是“ *”

RESP 能夠使用稍後指定的批次字串或陣列的特殊變體來表示 Null 值。在 RESP 中,協議的不同部分總是以“\r\n”(CRLF)終止。

RESP Simple Strings

'+' 字元開頭,後跟不能包含 CR 或 LF 字元(不允許換行)的字串,以 CRLF 結尾(即“\r\n”)。如:

"+OK\r\n"

RESP Errors

 "-Error message\r\n"

如:

-ERR unknown command 'foobar'
-WRONGTYPE Operation against a key holding the wrong kind of value

RESP Integers

Integers只是一個 CRLF 終止的字串,代表一個整數,以“:”位元組為字首。 例如

":0\r\n" 
":1000\r\n"

Bulk Strings

用於表示長度最大為 512 MB 的單個二進位制安全字串。批次字串按以下方式編碼:

  • “$”位元組後跟組成字串的位元組數(字首長度),以 CRLF 結尾。

  • 實際的字串資料。

  • 最後的 CRLF。

“foobar”的編碼如下:

"$6\r\nfoobar\r\n"

當字串為空

"$0\r\n\r\n"

Bulk Strings還可以用於表示 Null 值的特殊格式來表示值不存在。在這種特殊格式中,長度為 -1,並且沒有資料,因此 Null 表示為:

"$-1\r\n"

RESP Arrays

格式:

  • 一個'*'字元作為第一個位元組,然後是陣列中元素的數量作為十進位制數,然後是 CRLF。

  • Array 的每個元素的附加 RESP 型別。 空陣列表示為:

"*0\r\n"

“foo”和“bar”的陣列表示為

"*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n"

["foo",nil,"bar"] (Null elements in Arrays)

*3\r\n$3\r\nfoo\r\n$-1\r\n$3\r\nbar\r\n


相關文章