Golang 實現 Redis(11): RDB 文件格式

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 內存壓縮原理

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章