01 KV資料庫結構
可以存哪些資料
對於鍵值資料庫來說,基本的資料模型是key-value模型。
我們對於KV資料庫選項時,一個重要的考慮因素是它支援的value型別:
- Memcached僅支援string
- Redis支援String、雜湊表、列表、集合等
資料操作
- PUT/SET:新寫入或更新一個kv對
- GET:根據key獲取對應value
- DELETE:根據key刪除整個kv對
- SACN:根據一段key範圍返回相應value
資料庫內部結構
- 訪問框架
- 索引模組:定位鍵值對位置
- 操作模組
- 儲存模組
訪問框架
- 函式庫呼叫
- 網路框架:socket通訊+請求解析(Memcached、Redis)
索引模組
Memcached 和 Redis 採用雜湊表作為 key-value 索引
原因:鍵值資料儲存在記憶體中,記憶體的高效能隨機訪問特性可以很好地與雜湊表 O(1) 的操作複雜度相匹配。
操作模組
不同操作的具體邏輯:
- GET/SCAN:根據 value 的儲存位置返回 value
- PUT:為鍵值對分配記憶體空間
- DELETE:刪除鍵值對,並釋放相應的記憶體空間,這個過程由分配器完成。
儲存模組
重啟後快速提供服務:
- 記憶體分配器
- 持久化(AOF/RDB)
Redis其他特性
- 高可用叢集:主從、哨兵
- 高可擴充套件叢集:資料分片
02 底層資料結構
底層資料結構一共有 6 種,分別是簡單動態字串、雙向連結串列、壓縮列表、雜湊表、跳錶和整數陣列。
- 鍵和值結構組織:雜湊表
- String:簡單動態字串(sds)
- List:雙向連結串列(quicklist),壓縮列表(ziplist)
- hash:壓縮列表,hash表
- zset(sortset):hash表,跳錶(skiplist)
- set:hash表,整數陣列
雜湊表
雜湊表就是陣列,陣列的每個元素稱為一個雜湊桶,儲存指向具體值的指標。
- 優點:可以用 O(1) 的時間複雜度來快速查詢到鍵值對。
- 風險:雜湊衝突和rehash可能帶來的操作阻塞。
雜湊衝突
兩個 key 的雜湊值和雜湊桶計算對應關係時,正好落在了同一個雜湊桶中。
解決方法:鏈式雜湊,即同一個雜湊桶中的多個元素用一個連結串列來儲存,它們之間依次用指標連線。
rehash
增加現有的雜湊桶數量,讓逐漸增多的 entry 元素能在更多的桶之間分散儲存,減少單個桶中的元素數量,從而減少單個桶中的衝突。
原因:雜湊衝突鏈上的元素只能透過指標逐一查詢再操作,會導致某些雜湊衝突鏈過長,進而導致這個鏈上的元素查詢耗時長,效率降低。
備註:Redis 預設使用了兩個全域性雜湊表。
執行過程:
- 給雜湊表 2 分配更大的空間,例如是當前雜湊表 1 大小的兩倍;
- 把雜湊表 1 中的資料重新對映並複製到雜湊表 2 中(漸進式rehash);
- 釋放雜湊表 1 的空間,雜湊表 1留作下一次 rehash 擴容備用。
漸進式rehash
Redis 仍然正常處理客戶端請求,每處理一個請求時,從雜湊表 1 中的第一個索引位置開始,順帶著將這個索引位置上的所有 entries 複製到雜湊表 2 中;等處理下一個請求時,再順帶複製雜湊表 1 中的下一個索引位置的 entries。
問題1:漸進式 rehash在處理複製的時候,還在處理客戶端請求,這個時候怎麼保證客戶端請求的資料不會落在之前已經複製過了的索引上?或者說如果落在之前的索引上了怎麼再回去複製到表2中?
答:因為在進行漸進式rehash的過程中,字典會同時使用ht[0]和ht[1]兩個雜湊表,所以在漸進式rehash進行期間,字典的刪除(delete)、查詢(find)、更新(update)等操作會在兩個雜湊表上進行。例如,要在字典裡面查詢一個鍵的話,程式會先在ht[0]裡面進行查詢,如果沒找到的話,就會繼續到ht[1]裡面進行查詢,諸如此類。
另外,在漸進式rehash執行期間,新新增到字典的鍵值對一律會被儲存到ht[1]裡面,而ht[0]則不再進行任何新增操作,這一措施保證了ht[0]包含的鍵值對數量會只減不增,並隨著rehash操作的執行而最終變成空表。
問題2:一次請求一個entrys,那後續如果再也沒有請求來的時候,餘下的entrys是怎麼處理的呢?是就留在hash1中了還是有定時任務後臺更新過去呢
答案: 漸進式rehash執行時,除了根據鍵值對的操作來進行資料遷移,Redis本身還會有一個定時任務在執行rehash,如果沒有鍵值對操作時,這個定時任務會週期性地(例如每100ms一次)搬移一些資料到新的雜湊表中,這樣可以縮短整個rehash的過程。
集合資料操作效率
一個集合型別的值,第一步是透過全域性雜湊表找到對應的雜湊桶位置,第二步是在集合中再增刪改查。
- 與集合的底層資料結構有關: 使用雜湊表實現的集合,比使用連結串列實現的集合訪問效率更高。
- 操作本身的執行特點有關:讀寫一個元素的操作要比讀寫所有元素的效率高
底層資料結構
集合型別的底層資料結構主要有 5 種:整數陣列、雙向連結串列、雜湊表、壓縮列表和跳錶。
整數陣列和雙向連結串列
順序讀寫,透過陣列下標/連結串列指標逐個元素訪問,操作複雜度基本是 O(N),操作效率比較低;
壓縮列表
壓縮列表結構:
- 類似於一個陣列,陣列中的每一個元素都對應儲存一個資料。
- 表頭有三個欄位 zlbytes、zltail 和 zllen,分別表示列表長度、列表尾的偏移量和列表中的 entry 個數;
- 表尾還有一個 zlend,表示列表結束。
查詢效率: - 第一個元素和最後一個元素,可以透過表頭三個欄位的長度直接定位,複雜度是 O(1)
- 其他元素只能逐個查詢,此時的複雜度就是 O(N)
跳錶
跳錶在連結串列的基礎上,增加了多級索引,透過索引位置的幾個跳轉,實現資料的快速定位
查詢效率:當資料量很大時,跳錶的查詢複雜度就是 O(logN)。
不同操作的複雜度
口訣:
- 單元素操作是基礎;
- 範圍操作非常耗時;
- 統計操作通常高效;
- 例外情況只有幾個。
單元素操作
單元素操作,是指每一種集合型別對單個資料實現的增刪改查操作。
例如,Hash 型別的 HGET、HSET 和 HDEL,Set 型別的 SADD、SREM、SRANDMEMBER 等。
這些操作的複雜度由集合採用的資料結構決定:
- HGET、HSET 和 HDEL 是對雜湊表做操作,所以它們的複雜度都是 O(1);
- Set 型別用雜湊表作為底層資料結構時,它的 SADD、SREM、SRANDMEMBER 複雜度也是 O(1)
集合型別支援同時對多個元素進行增刪改查,這些操作的複雜度,就是由單個元素操作複雜度和元素個數決定的。
例如,HMSET 增加 M 個元素時,複雜度就從 O(1) 變成 O(M) 了。
範圍操作
範圍操作,是指集合型別中的遍歷操作,可以返回集合中的所有資料
比如 Hash 型別的 HGETALL 和 Set 型別的 SMEMBERS,或者返回一個範圍內的部分資料,比如 List 型別的 LRANGE 和 ZSet 型別的 ZRANGE。
這類操作的複雜度一般是 O(N),比較耗時,我們應該儘量避免。
SCAN 系列操作(包括 HSCAN,SSCAN 和 ZSCAN),這類操作實現了漸進式遍歷,每次只返回有限數量的資料。
統計操作
集合型別對集合中所有元素個數的記錄,例如 LLEN 和 SCARD。
這類操作複雜度只有 O(1),這是因為當集合型別採用壓縮列表、雙向連結串列、整數陣列這些資料結構時,這些結構中專門記錄了元素的個數統計,因此可以高效地完成相關操作。
例外情況
是指某些資料結構的特殊記錄,例如壓縮列表和雙向連結串列都會記錄表頭和表尾的偏移量。
對於 List 型別的 LPOP、RPOP、LPUSH、RPUSH 這四個操作來說,它們是在列表的頭尾增刪元素,這就可以透過偏移量直接定位,所以它們的複雜度也只有 O(1),可以實現快速操作。
小結
- 集合型別的範圍操作,因為要遍歷底層資料結構,複雜度通常是 O(N)。這裡,我的建議是:用其他命令來替代,例如可以用 SCAN 來代替,避免在 Redis 內部產生費時的全集合遍歷操作。
- List 型別的兩種底層實現結構:雙向連結串列和壓縮列表的操作複雜度都是 O(N)。因此,我的建議是:因地制宜地使用 List 型別。例如,既然它的 POP/PUSH 效率很高,那麼就將它主要用於 FIFO 佇列場景,而不是作為一個可以隨機讀寫的集合。
問題:整數陣列和壓縮列表在查詢時間複雜度方面並沒有很大的優勢,那為什麼 Redis 還會把它們作為底層資料結構呢?
答案:
1、記憶體利用率,陣列和壓縮列表都是非常緊湊的資料結構,它比連結串列佔用的記憶體要更少。Redis是記憶體資料庫,大量資料存到記憶體中,此時需要做盡可能的最佳化,提高記憶體的利用率。
2、陣列對CPU快取記憶體支援更友好,所以Redis在設計時,集合資料元素較少情況下,預設採用記憶體緊湊排列的方式儲存,同時利用CPU快取記憶體不會降低訪問速度。當資料元素超過設定閾值後,避免查詢時間複雜度太高,轉為雜湊和跳錶資料結構儲存,保證查詢效率。