Golang 實現 Redis(11): RDB 檔案解析

發表於2022-05-09

RDB 檔案使用二進位制方式儲存 Redis 記憶體中的資料,具有體積小、載入快的優點。本文主要介紹 RDB 檔案的結構和編碼方式,並藉此探討二進位制編解碼和檔案處理方式,希望對您有所幫助。

本文基於 RDB version9 編寫, 完整解析器原始碼在 github.com/HDT3213/rdb

RDB 檔案的整體結構

如下圖所示,我們可以將 RDB 檔案劃分為檔案頭、元屬性區、資料區、結尾四個部分:

RDB概覽

  • 檔案頭包含 Magic Number 和版本號兩部分
    • RDB檔案以 ASCII 編碼的 'REDIS' 開頭作為魔數(File Magic Number)表示自身的檔案型別
    • 接下來的 4 個位元組表示 RDB 檔案的版本號,RDB 檔案的版本歷史可以參考:RDB_Version_History
  • 元屬性區儲存諸如檔案建立時間、建立它的 Redis 例項的版本號、檔案中鍵的個數等資訊
  • 資料區按照資料庫來組織,開頭為當前資料庫的編號和資料庫中的鍵個數,隨後是資料庫中各鍵值對。

Redis 定義了一系列 RDB_OPCODE 來儲存一些特殊資訊,在下文中遇到各種 OPCODE 時再進行說明。

元屬性區

元屬性區資料格式為:RDB_OPCODE_AUX(0xFA) + key + value, 如下面的示例:

5245 4449 5330 3030 39fa 0972 6564 6973  REDIS0009..redis 
2d76 6572 0536 2e30 2e36 fa0a 7265 6469  -ver.6.0.6..redi

您可以使用 xxd 命令來檢視 rdb 檔案的內容,或者使用 vim 開啟然後在命令模式中輸入::%!xxd 開啟二進位制編輯
xxd 使用十六進位制展示,兩個十六進位制數為一個位元組,兩個位元組顯示為一列

上圖中第 10 個位元組 0xFA 為 RDB_OPCODE_AUX,它表示接下來有一個元屬性鍵值對。接下來為兩個字串 0972 6564 6973 2d76 65720536 2e30 2e36,它們分別表示 "redis-ver", "6.0.6",這三部分組成了一個完整的元屬性描述。

在 xxd 中可以看出字串編碼 0972 6564 6973 2d76 6572 由開頭的長度編碼 0x09 和後面 "redis-ver" 的 ascii 編碼組成,我們將在下文字串編碼部分詳細介紹它的編碼規則。

資料區

資料區開頭為資料庫編號、資料庫中鍵個數、有 TTL 的鍵個數,接下來為若干鍵值對:

65c0 00fe 00fb 0101 fcd3 569a a380 0100  e.........V.....
0000 0568 656c 6c6f 0577 6f72 6c64 ff10  ...hello.world..
d4ea 6453 5f49 3d0a                      ..dS_I=.

注意示例中的 fe 00fb 0701,0xFE 為 RDB_OPCODE_SELECTDB 表示接下來一個位元組 0x00 是資料庫編號。

0xFB 為 RDB_OPCODE_RESIZEDB 表示接下來兩個長度編碼(Length Encode): 0x01、0x01 分別為雜湊表中鍵的數量和有 TTL 的鍵的數量。

在資料庫開頭部分就給出鍵的數量可以在載入 RDB 時提前準備好合適大小的雜湊表,避免耗時費力的 ReHash 操作。

具體的鍵值對編碼格式為: [RDB_OPCODE_EXPIRETIME expire_timestamp] type_code key object, 舉例來說:

65c0 00fe 00fb 0101 fcd3 569a a380 0100  e.........V.....
0000 0568 656c 6c6f 0577 6f72 6c64 ff10  ...hello.world..
d4ea 6453 5f49 3d0a                      ..dS_I=.

0xFC 為 RDB_OPCODE_EXPIRETIME_MS 隨後為一個小端序的 uint64 表示 key 的過期時間(毫秒為單位的 unix 時間戳),這裡過期時間的二進位制串 d3569aa380010000 轉換為整型是 1652012242643 即 2022-05-08 20:17:22。

小端序二進位制轉整型程式碼:binary.LittleEndian.Uint64([]byte{0xd3, 0x56, 0x9a, 0xa3, 0x80, 0x01, 0x00, 0x00})

後面的 0x00 是 RDB_TYPE_STRING, 一種 redis 資料型別可能有多個 type_code ,比如 list 資料結構可以使用的編碼型別有:RDB_TYPE_LIST、RDB_TYPE_LIST_ZIPLIST、RDB_TYPE_LIST_QUICKLIST 等。

接下來的 0568 656c 6c6f 是字串 "hello" 的編碼,0577 6f72 6c64 是字串 "world" 的編碼。

後面的 0xff 是 RDB_OPCODE_EOF 表示 RDB 檔案結尾,剩下的部分是 RDB 的 CRC64 校驗碼。

RDB 中的各種編碼

在上文中我們已經提到了長度編碼、字串編碼等概念,接下來我們可以具體看一下 RDB 中怎麼編碼不同型別的物件的。

LengthEncoding

Length Encoding 是一種可變長度的無符號整型編碼,因為通常被用來儲存字串長度、列表長度等長度資料所以被稱為 Length Encoding.

  • 如果前兩位是 00 那麼下面剩下的 6 位就表示具體長度
  • 如果前兩位是 01 那麼會再讀取一個位元組的資料,加上前面剩下的6位,共14位用於表示具體長度
  • 如果前兩位是 10 如果剩下的 6 位都是 0 那麼後面 32 個位元組表示具體長度。如果剩下的 6 位為 000001, 那麼後面的 64 個位元組表示具體長度。(注意有些較老的文章沒有提及 64 位的 Length Encoding)
  • 如果前兩位是 11 表示為使用字串儲存整數的特殊編碼,我們在接下來的 String Encoding 部分來介紹。為了方便,下文中我們將 11 開頭的Length Encoding 稱為「特殊長度編碼」,其它 3 種稱為 「普通長度編碼」。

採用變長編碼可以顯著的節約空間,0~63 只需要一個位元組,64 ~ 16383 只需要兩個位元組。考慮到 Redis 中大多數資料結構的長度並不長,Length Ecnoding 的節約效果更加顯著。

貼一下解析 Length Encoding 的原始碼readLength

func (dec *Decoder) readLength() (uint64, bool, error) {
	firstByte, err := dec.readByte() // 先讀一個位元組
	if err != nil {
		return 0, false, fmt.Errorf("read length failed: %v", err)
	}
	lenType := (firstByte & 0xc0) >> 6 // 取前兩位
	var length uint64
	special := false
	switch lenType {
	case len6Bit: /
		length = uint64(firstByte) & 0x3f // 前兩位是 00,讀剩餘 6 位
	case len14Bit:
		nextByte, err := dec.readByte()
		if err != nil {
			return 0, false, fmt.Errorf("read len14Bit failed: %v", err)
		}
		// 前兩位是01,讀第一個位元組剩餘 6 位作為整數高位,讀第二個位元組做整數低位
		length = (uint64(firstByte)&0x3f)<<8 | uint64(nextByte) 
	case len32or64Bit: // 前兩位是 10
		if firstByte == len32Bit { // len32Bit = 0x80 = 0b10000000, 即前兩位是 10後面 6 位全是 0
			err = dec.readFull(dec.buffer[0:4]) // 接下來的 4 個位元組 32 位表示具體長度
			if err != nil {
				return 0, false, fmt.Errorf("read len32Bit failed: %v", err)
			}
			length = uint64(binary.BigEndian.Uint32(dec.buffer))
		} else if firstByte == len64Bit { // len32Bit = 0x81 = 0b10000001
			err = dec.readFull(dec.buffer) // 接下來的 8 個位元組 64 位表示具體長度, dec.buffer 是長度為 8 的 byte 切片, 它是為了減少記憶體分配而設計的可複用緩衝區
			if err != nil {
				return 0, false, fmt.Errorf("read len64Bit failed: %v", err)
			}
			length = binary.BigEndian.Uint64(dec.buffer)
		} else {
			return 0, false, fmt.Errorf("illegal length encoding: %x", firstByte)
		}
	case lenSpecial: // 前兩位為 11, 我們留給接下來的 readString 去處理。
		special = true
		length = uint64(firstByte) & 0x3f
	}
	return length, special, nil
}

StringEncoding

RDB 的 StringEncoding 可以分為三種型別:

  • 簡單字串編碼
  • 整數字符串
  • LZF 壓縮字串

StringEncode 總是以 LengthEncoding 開頭, 普通字串編碼由普通長度編碼 + 字串的 ASCII 序列組成, 整數字符串和 LZF 壓縮字串則以特殊長度編碼開頭。

上文中提到的 0568 656c 6c6f 就是簡單字串編碼,它的第一個位元組 0x05 是前兩位為 00 的長度編碼,表示字串長度為 5 個位元組,接下來的 5 個位元組0x68656c6c6f則是 "hello" 對應的 ASCII 序列。

若字串開頭為特殊長度編碼(即第一個位元組前兩位為 11),則第一個位元組剩下的 6 位會表示具體編碼方式。我們直接貼程式碼: readString

func (dec *Decoder) readString() ([]byte, error) {
	length, special, err := dec.readLength()
	if err != nil {
		return nil, err
	}

	if special { // 前兩位為 11 時 special = true
		switch length { // 此時的 length 為第一個位元組的後 6 位
		case encodeInt8: // 第一個位元組為 0xc0
			// 第一個位元組後 6 位為 000000,表示下一個位元組為補碼錶示的整數
			// 讀取下一個位元組並使用 Itoa 轉換為字串
			b, err := dec.readByte() // readByte 其實就是 readInt8
			return []byte(strconv.Itoa(int(b))), err
		case encodeInt16:// 第一個位元組為 0xc1
			// 與 encodeInt8 類似,區別在於長度為接下來的兩位
			b, err := dec.readUint16() // 將 uint 轉換為 int 過程實際上是把同一個二進位制序列改為用補碼來解釋
			return []byte(strconv.Itoa(int(b))), err
		case encodeInt32: // // 第一個位元組為 0xc2
			b, err := dec.readUint32()
			return []byte(strconv.Itoa(int(b))), err
		case encodeLZF: // 第一個位元組為 0xc3
			// 讀取 LZF 壓縮字串
			return dec.readLZF()
		default:
			return []byte{}, errors.New("Unknown string encode type ")
		}
	}

	res := make([]byte, length)
	err = dec.readFull(res)
	return res, err
}

這裡舉一個整數字符串的例子:c0fe, 第一個位元組 0xc0 表示 encodeInt8 特殊長度編碼, 接下來的 8 位0xfe視作補碼處理,0xfe 轉換為整數為 254, 通過 Itoa 輸出最終結果:"254"。 使用簡單字串編碼表示 "254" 為 03323534 佔用 4 個位元組比整數字符串多了一倍。

object encoding 命令顯示編碼型別為 int 的物件的實際儲存方式就是整型字串:

127.0.0.1:6379> set a -1
OK
127.0.0.1:6379> object encoding a
"int"

LZF 字串由:表示壓縮後長度的 Length Encoding + 表示壓縮前長度的 Length Encoding + 壓縮後的二進位制資料 三部分組成,有興趣的朋友可以閱讀readLZF這裡不再詳細描述。

ListEncoding & SetEncoding & HashEncoding

ListEncoding 開頭為一個普通長度編碼塊表示 List 的長度,隨後是對應個數的 StringEncoding 塊。具體可以看 readList

SetEncoding 與 ListEncoding 完全相同。具體可以看 readSet

HashEncoding 開頭為一個普通長度編碼塊表示雜湊表中的鍵值對個數,隨後為對應個數的:Key StringEncoding + Value StringEncoding 組成的鍵值對。具體可以看 readHashMap.

ZSetEncoding & ZSet2Encoding

這兩種表示有序集合方式非常類似,開頭是一個普通長度編碼塊表示元素數,隨後是對應個數的表示score的float值 + 表示 member 的 StringEncode。唯一的區別是,ZSet 的 score 採用字串來儲存浮點數,ZSet2 使用 IEEE 754 規定的二進位制格式儲存 float.

兩種編碼格式的處理函式都是 readZSet 通過 zset2 標誌來區分。

ZSet2 的 float 值可以直接使用 math.Float64frombits 來讀取,ZSet 的 float 字串是第一個位元組表示長度+ ASCII 序列組成,具體實現在readLiteralFloat, 這裡不再詳細介紹。

zipList

ziplist 是一種非常緊湊的順序結構,它將資料和編碼資訊儲存在一段連續空間中。在 RDB 檔案中除了 list 結構外,hash、sorted set 結構也會使用 ziplist 編碼。由於 ziplist 存在寫放大的問題,Redis 通常在資料量較小的時候使用 ziplist。

ziplist結構

釋義:

  • zlbytes 是整個 ziplist 所佔的位元組數,包括自己所佔的 4 個位元組。
  • zltail 表示從 ziplist 開頭到最後一個 entry 開頭的偏移量,從而可以在 O(1) 時間內訪問尾節點
  • zllen 表示 ziplist 中 entry 的個數
  • entry 是 ziplist 中元素,在下文詳細介紹
  • zlend 表示 ziplist 的結束,固定為 255(0xff)

接下來我們來研究一下 entry 的編碼:

<prevlen><encoding><entry-data>

prevlen 表示前一個 entry 的長度,用於從尾節點開始向前遍歷.前節點長度小於254時,佔用1位元組用來表示前節點長度, 前節點長度大於等於254時,佔用5位元組。其中第1個位元組為特殊值0xFE(254),後面4位元組用來表示實際長度。

encoding 表示 entry-data 的型別,encoding 的第一個位元組的前兩位為 11 時表示 entry-data 為整數,其它情況表示 entry-data 為字串。具體如下表:

encoding encoding位元組數 說明
11000000 1 int16
11010000 1 int32
11100000 1 int64
11110000 1 24位有符號整數
11111110 1 int8
1111xxxx 1 xxxx 取值範圍 [0001,1101],用 encoding 剩餘的 4 位表示整數
00xxxxxx 1 長度不超過 63 的字串,encoding 剩下的 6 位儲存字串長度
01xxxxxx 2 長度不超過 16383 (2^14-1) 的字串,用 encoding 第一個字元剩下的 6 位和第二個字元表示字串長度(採用大端序)
10000000 5 長度不超過 2^32-1 的字串,用接下來的 4 個位元組表示字串長度(大端序)

那麼 redis 會在何時使用 ziplist 呢?

  • list: 位元組數 <= list-max-ziplist-value 且 元素數 <= list-max-ziplist-entries,type_code 為 RDB_TYPE_LIST_ZIPLIST
  • hash: 位元組數 <= hash-max-ziplist-value 且 元素數 <= hash-max-ziplist-entries,type_code 為 RDB_TYPE_HASH_ZIPLIST
  • zset: 位元組數 <= zset-max-ziplist-value 且 元素數 <= zset-max-ziplist-entries,type_code 為 RDB_TYPE_ZSET_ZIPLIST

list 還有還有一種編碼方式 RDB_TYPE_LIST_QUICKLIST, 它的開頭是一個 LengthEncoding 隨後是對應數量的 ziplist, 它的詳細實現在readQuickList:

func (dec *Decoder) readQuickList() ([][]byte, error) {
	size, _, err := dec.readLength()
	if err != nil {
		return nil, err
	}
	entries := make([][]byte, 0)
	for i := 0; i < int(size); i++ {
		page, err := dec.readZipList()
		if err != nil {
			return nil, err
		}
		entries = append(entries, page...)
	}
	return entries, nil
}

hash 還有一種 RDB_TYPE_HASH_ZIPMAP 編碼方式,它與 ziplist 類似,同樣用於編碼較小的結構。zipmap 在 Redis 2.6 之後就已被棄用,這裡我們就不詳細講解了,可以參考readZipMapHash

更多關於 Redis 編碼的內容可以閱讀 Redis 記憶體壓縮原理

相關文章