圖解 Redis String 底層數據結構 SDS 與計數器實戰
我是 Redis,給開發者提供了 String(字符串)、Hashes(散列表)、Lists(列表)、Sets(無序集合)、Sorted Sets(可根據范圍查詢的排序集合)、Bitmap(位圖)、HyperLogLog、Geospatial (地理空間)和 Stream(流)等數據類型。
接下來我要重點介紹的是,String 數據類型的使用技巧和使用場景,以及String 數據類型底層數據結構原理。
數據類型的使用技法和以及每種數據類型底層實現原理是你核心筑基必經之路,好好修煉。
筑基穩固,修煉心法,讓你的程序更快還能做到極致節省內存。
2.1.1 String(字符串)
1、是什么
字符串類型的使用最為廣泛,比如計數器、緩存、分布式鎖、用于存儲登錄后的用戶信息,key = token,value = Java 對象序列化成 JSON 后的字符串。
如下指令。
接下來,我先帶你深入了解 String 類型,底層數據結構和使用場景。
?
MySQL:“你都是用 C 語言開發出來的,C 語言本就有字符串,嚇唬誰呢。”
格局能不能打開一點,我并沒有直接使用 C 語言的字符串,而是自己搞了一個 SDS 結構體來表示字符串。SDS 的全稱是 Simple Dynamic String,中文叫做“簡單動態字符串”。
?
MySQL:“搞 SDS 的目的是啥?”
字符串使用最為廣泛,我要保證能支持豐富和高性能的字符串操作函數,能保存二進制數據,同時還能節省內存占用。
實現了你們領導平時經常對你們提出的既要又要還要的目標。
先看 C 語言字符串數組的結構。比如通過 char *s = "MageByte"定義字符串變量。
注意,數組的最后一個字符串是 "\0",它表示字符串的結束。
因為 C 語言標準庫 string.h中的字符串有以下幾點不足,所以我才設計了 SDS。
- C 語言使用char* 字符串數組來實現字符串,在創建字符串的時候就要需要手動檢查和分配字符串空間。由于沒有 length屬性記錄字符串長度,想要獲取一個字符串長度就要從頭開始遍歷,直到 \0為止,作為唯快不破的我來說是不能容忍的。
- 無法做到“安全的二進制存儲”:比如圖片等二進制數據無法保存。無法存儲
\0
這種特殊字符是因為\0
在 C 語言字符串中表示結尾。 - 字符串的擴容和縮容:char 數組的長度在創建字符串的時候就確定下來,如果想要追加數據,要重新申請一塊空間,把追加后的字符串內容拷貝進去,再釋放舊的空間,十分消耗資源。
2、修煉心法
?
MySQL:“說說 SDS 結構體吧,你是如何解決這些問題的。”
為了存儲字符串實際內容,我需要有一個 char 類型數組來存儲,使用一個 int 類型的 len 字段用于記錄 char 數組使用了多少字節。
除此之外,還要有一個 int 類型 的 alloc 字段記錄分配的 char 數組總長度,alloc - len 就等于 char 類型的 buf 數組未使用的字節數(Redis 7.0 已經去掉了表示未使用字節數 free 字段)。
?SDS 也遵循 C 字符串以空字符“\0”結尾的慣例,保存空字符的大小不計算在 SDS 的 len 屬性中。
此外,添加空字符串“\0” 到字符串末尾等操作,都是由 SDS 函數自動完成的。
O(1) 時間復雜度獲取字符串長度
SDS 中 len 保存了字符串的長度,實現了O(1) 時間復雜度獲取字符串長度。
你注意到了沒,SDS 結構有一個 flags 字段,表示的是 SDS 類型。實際上 SDS 一共設計了 5 種類型,分別是sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,區別在于數組的 len 長度和分配空間長度 alloc。
比如 sdshdr8。
len、alloc 字段都是 uint8_t 這個類型,在 Java 中 int 就是 32 位,而 C 語言里面有不同長度的 int 值,uint8_t 就是占 8 位的無符號 int 值,能表示的最大值就是 2^8-1,那它的 buf 數組,最大長度就是 2^8 -1。
節省內存
之所以這么設計,就是為了針對不同大小的字符串,使用不同的 SDS 類型保存,從而節省內存占用。?
?
MySQL:“SDS 能存儲多大的字符串?”
alloc 表示當前 sds 結構允許容納的最大字符長度, 比如 uint32_t alloc? 的取值范圍是 0~2^32 = 4294967296。理論上 char 數組最大長度為 4294967296,一個 char 字符占用一個字節,可以存儲 4 G,更不用說 sdshdr64 了。
這些都是理論值,實際上 Redis 內部會限制最大的字符串長度是 512M。?
編碼格式
我還對 String 類型的數據采用了三種編碼格式來存儲,分別是 int、embstr、raw,你可使用 OBJECT encoding key 來查值對象所使用的編碼類型。
編碼選擇流程如圖 2-3 所示。
- int 編碼,8 個字節的長整型,值是數字類型且數字的長度小于 20
- embstr,小于等于 44 字節的字符串。
- 大于 44 字節的字符串。
?
MySQL:“__attribute__ ((__packed__))是什么玩意?”
這是我使用了專門的編譯優化手段來節省內存空間。作用就是告訴編譯器,不要使用字節對齊的方式,而是采用緊湊的方式分配內存。
默認情況下,編譯器會按照 8 字節對齊的方式分配內存,即使這個變量的大小不到 8 字節。
使用了 __attribute__ ((__packed__)) 定義結構體,編譯器會按照實際占用來分配內存空間。?
二進制安全
SDS 不僅可以存儲 String 類型數據,還能存儲二進制數據。SDS 并不是通過“\0” 來判斷字符串結束,用的是 len 標志結束,所以可以直接將二進制數據存儲。
空間預分配
在需要對 SDS 的空間進行擴容時,不僅僅分配所需的空間,還會分配額外的未使用空間。
通過預分配策略,減少了執行字符串增長所需的內存重新分配次數,降低由于字符串增加操作的性能損耗。
惰性空間釋放
當對 SDS 進行縮短操作時,程序并不會回收多余的內存空間,如果后面需要 append 追加操作,則直接使用 buf 數組 alloc - len中未使用的空間。
通過惰性空間釋放策略,避免了減小字符串所需的內存重新分配操作,為未來增長操作提供了優化。?
3、出招實戰:分布式 ID 生成器
我相信你會經常遇到要生成唯一 ID 的場景,比如標識每次請求、生成一個訂單編號、創建用戶需要創建一個用戶 ID。
分布式 ID 生成器需要滿足以下特性。
- 有序性之單調遞增,想要分而治之、二分法查找就必須實現。另外,MySQL 是你們用的最多的數據庫,B+ 樹為了維護 ID 的有序性,就會頻繁的在索引的中間位置插入而挪動后面節點的位置,甚至導致頻繁的頁分裂,這對于性能的影響是極大的。
- 全局唯一性,ID 不唯一就會出現主鍵沖突。
- 高性能,生成 ID 是高頻操作,如果性能緩慢,系統的整體性能都會受到限制。
- 高可用,也就是在給定的時間間隔內,一個系統總的可用時間占的比例。
- 存儲空間小,用 MySQL 的 InnoDB B+樹來說,普通索引(非聚集索引)會存儲主鍵值,主鍵越大,每個 Page 頁可以存儲的數據就越少,訪問磁盤 I/O 的次數就會增加。
Redis 集群能保證高可用和高性能,為了節省內存,ID 可以使用數字的形式,并且通過遞增的方式來創建新的 ID。
防止重啟數據丟失,你還需要把 Redis AOF 持久化開啟。
?
MySQL:“開啟 AOF 持久,為了性能設置成 everysec 策略還是有可能丟失一秒的數據,所以你還可以使用一個異步機制將生成的最大 ID 持久化到一個 MySQL。”
好主意,在生成 ID 之后發送一條消息到 MQ 消息隊列中,把值持久化到 MySQL 中。
我提供了 INCR 指令,它能把 key 中存儲的數字加 1 并返回客戶端。如果 key 不存在,那么 key 的 value 先被初始化成 0,再執行加 1 操作并返回給客戶端。
該指令的值限制在 64 位有符號數字之內。
設計思路
- 假設訂單 ID 生成器的 key 是“counter:order”,當應用服務啟動的時候先從數據庫中查詢出最大值 M。執行EXISTS counter:order 判斷是否存在 key。
- Redis 中不存在 key “counter:order”,執行SET counter:order M 將 M 值作寫入 Redis。
- Redis 中存在 key “counter:order”,值為 K,那么就比較 M 和 K 的值,執行SET counter:order max(M, N)將最大值寫入 Redis,相等的話就不操作。
- 應用服務啟動完成后,每次需要生成 ID 的時候,應用程序就向 Redis 服務器發送INCR counter:order指令。
- 應用程序將獲取到的 ID 值發送到 MQ 消息隊列,消費者監聽隊列把值更新到 MySQL。
String 類型的實戰以及底層存儲原理就到這里了,接下來我會繼續介紹其他數據類型的底層存儲原理和實戰。