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