RDB 檔案使用二進位制方式儲存 Redis 記憶體中的資料,具有體積小、載入快的優點。本文主要介紹 RDB 檔案的結構和編碼方式,並藉此探討二進位制編解碼和檔案處理方式,希望對您有所幫助。
本文基於 RDB version9 編寫, 完整解析器原始碼在 github.com/HDT3213/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 6572
、0536 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。
釋義:
- 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 記憶體壓縮原理