本文大部分知識整理自網上,在正文結束后都會附上參考地址。如果想要深入或者詳細學習可以通過文末鏈接跳轉學習。
前言
本文主要介紹關於Redis的五種基本數據結構的底層實現原理,然后來分析我們常用的使用場景。先簡單回顧一下知識點。
Redis 是一個開源(BSD許可)的,內存中的數據結構存儲系統,它可以用作數據庫、緩存和消息中間件. 它支持多種類型的數據結構,如 字符串(strings), 散列(hashes), 列表(lists), 集合(sets), 有序集合(sorted sets) 與范圍查詢, bitmaps, hyperloglogs和 地理空間(geospatial) 索引半徑查詢。
簡單來說,Redis的數據結構主要分為五種基本的數據結構+三種高級的數據結構。我們下面所要介紹的就是這五種基本的數據結構的相關知識。
結構模型介紹
在我們基本的數據結構中,它又有自己的內部編碼。
總結一下
(1)每種數據結構都有自己底層的內部編碼實現,而且是多種實現,這樣Redis會在合適的場景選擇合適的內部編碼。
(2)可以看到每種數據結構都有兩種以上的內部編碼實現,例如string數據結構就包含了raw、int和embstr三種內部編碼。
(3)同時,有些內部編碼可以作為多種外部數據結構的內部實現,例如ziplist就是hash、list和zset共有的內部編碼。
結構模型基礎
我們在上面了解到了關於鍵的基本數據結構。而我們Redis中的所有value都是以object的形式存在的。其通用結構結構源碼如下:
typedef struct redisObject {
unsigned [type] 4;
unsigned [encoding] 4;
unsigned [lru] REDIS_LRU_BITS;
int refcount;
void *ptr;
} robj;
(1)type指的就是我們的基本數據結構,如string,list等其他類型;
(2)encoding指的是這些結構內部類型的具體實現方式,如string可以用int來實現也可以用char[]來實現;list可以用ziplist或者鏈表來實現;
(3)lru表示本對象的空轉時長,用於有限內存下長時間不訪問的對象清理;
(4)refcount對象引用計數,用於GC;
(5)ptr指向以encoding方式實現這個對象實際實現者的地址,如String對象對應的SDS(Simple Dynamic String 結構)地址。
示意圖如下:
String類型
關於string內部結構,上面也介紹了主要是以三種編碼形式來組成的,分別是int,raw,embstr。這里int主要是用來存放整形值的字符串,embstr用來存放字符串的短字符串(大小不超過44個字節),raw存放字符的長字符串(大小不超過44個字節)。
SDS結構
我們在上面介紹了關於鍵的基本結構redisObject,但其實在我們的String中還用着另外一種結構,也就是我們的SDS結構(Simple Dynamic String 結構)。
從源碼的文件里面可以看見,同樣一組結構Redis使用泛型定義了好多次。那么為什么不直接用int類型呢?這里呢主要是因為當字符串比較短的時候,len和alloc可以使用byte和short來表示,Redis為了對內存做極致的優化,不同長度的字符串使用不同的結構體來表示。
為什么不直接使用C字符串呢?
我們為什么要重新在定義一個SDS的動態字符串的結構?其實呢這主要是為了從Redis對字符串安全性和效率以及功能方面的要求。C 語言使用了一個長度為 N+1 的字符數組來表示長度為 N 的字符串,並且字符數組最后一個元素總是 '\0'
。而在Redis中\0
可能會被判定為提前結束而識別不了字符串。通過分析可以發現,總共有以下一些缺點:
(1)獲取字符串長度為O(n),因為C字符串需要去遍歷。
(2)不能很好的杜絕緩沖區溢出/內存泄漏的問題,因為原因同上,進行字符串拼接等其他操作獲取長度的時候易出現問題。
(3)C字符串只能保存文本數據,因為必須符合某種編碼(如ASCLL)。像一些\0
就不易處理。
表格區別匯總如下。
C字符串 | SDS |
---|---|
獲取字符串長度的復雜度為O(N) | 獲取字符串長度的復雜度為O(1) |
API是不安全的,可能會造成緩沖區溢出 | API是安全的,不會造成緩沖區溢出 |
修改字符串長度N次必然需要執行N次內存重分配 | 修改字符串長度N次最多需要執行N次內存重分配 |
只能保存文本數據 | 可以保存文本或者二進制數據 |
可以使用所有<string.h>庫中的函數 | 可以使用一部分<string.h>庫中的函數 |
raw與embstr的區別
(1)redis並未提供任何修改embstr的方式,即embstr是只讀的形式。對embstr的修改實際上是先轉換為raw再進行修改。
(2)采用內存分配方式不同,雖然raw和embstr編碼方式都是使用redisObject結構和sdshdr結構。但是raw編碼方式采用兩次分配內存的方式,分別創建redisObject和sdshdr,而embstr編碼方式則是采用一次分配,分配一個連續的空間給redisObject和sdshdr。(embstr一次性分配內存的方式:1,使得分配空間的次數減少。2、釋放內存也只需要一次。3、在連續的內存塊中,利用了緩存的優點。)
String的應用場景
(1)緩存功能
字符串最經典的使用場景,redis最為緩存層,Mysql作為儲存層,絕大部分請求數據都是 redis中獲取,由於redis具有支撐高並發特性,所以緩存通常能起到加速讀寫和降低后端壓力的作用。
(2)計數器
許多應用都會使用redis作為計數的基礎工具,因為redis的INCR
命令具有原子性的自增操作,在並發下也可以保證一個線程安全的問題。如果我們常見的論壇,網站的點贊數或者視頻播放數就是使用redis作為計數的基礎組件。
(3)共享session
出於負載均衡的考慮,分布式服務會將用戶信息的訪問均衡到不同服務器上,這樣可能我們用戶在第一次訪問和第二次訪問的時候不是同一台服務器的話,session不同步,就會導致重新登錄。為避免這個問題可以使用redis將用戶session集中管理。(示意圖如下)
當客戶端第一次發送請求后,nginx將這個請求分發給服務器實例M ,然后將服務器實例M 產生的Session 放入Redis中,此時客戶端、服務器實例M 和Redis中都會有一個相同的Session,當客戶端發送第二次請求的時候,nginx將請求分發給服務器實例N (已知服務器實例N 中無Session),因為客戶端自己攜帶了一個Session,那么服務器實例N就可以拿着客戶端帶來的Session中的ID去Redis中找到Session,找到這個Session后,就能正常執行之后的操作。
(5)限流
我們的redis處於安全考慮或者在高並發訪問,都會進行一個限流或者限速。比如防止某個接口被頻繁調用而崩潰或者像手機驗證碼驗證,防止短信接口不被頻繁訪問。
我們常見的限流算法有很多,如令牌桶,漏桶,計數器,滑動窗口等。而用String的話,就可以使用計數器,我們如果要設置一個一分鍾最多只能訪問100次的限流接口,只要設置鍵的一分鍾過期時間就行,然后在一分鍾之內通過計數器來進行計數。關於具體的限流算法,我后面會繼續更新補充一下這一塊的知識點。(點擊跳轉,待補充)
List類型
我們在最開始的圖上面也介紹了,list列表的數據結構使用的是壓縮列表ziplist和普通的雙向鏈表linkedlist組成。元素少的時候會用ziplist,元素多的時候會用linkedlist。然后針對這兩種的弊端又設計出了一個快速列表。關於雙向鏈表,老數據結構不介紹了,這里重點介紹一下壓縮列表和快速列表。
壓縮列表
ziplist是一種壓縮鏈表,它的好處是更能節省內存空間,因為它所存儲的內容都是在連續的內存區域當中的。當列表對象元素不大,每個元素也不大的時候,就采用ziplist存儲。但當數據量過大時就ziplist就不是那么好用了。因為為了保證他存儲內容在內存中的連續性,插入的復雜度是O(N),即每次插入都會重新進行realloc。如下圖所示,對象結構中ptr所指向的就是一個ziplist。整個ziplist只需要malloc一次,它們在內存中是一塊連續的區域。
ziplist的結構表如下:
1、zlbytes:用於記錄整個壓縮列表占用的內存字節數
2、zltail:記錄要列表尾節點距離壓縮列表的起始地址有多少字節
3、zllen:記錄了壓縮列表包含的節點數量。
4、entryX:要說列表包含的各個節點
5、zlend:用於標記壓縮列表的末端
為什么數據量大不使用ziplist?
我們在上面也說到了,因為它的插入的時間復雜度是O(n),而且插入一個新的元素就要調用realloc進行擴展內存。取決於內存分配器算法和當前的ziplist內存大小,realloc可能會重新分配新的內存空間,並將之前的內容一次性拷貝到新的地址,也可能直接原地擴展。而如果我們的數據量大的話,那么重新分配內存和拷貝內存就會有很大的消耗。所以ziplist不適合大型字符串,存儲的元素也不宜過多。
快速列表
其實這里如果看網上早期的博客很容易漏掉一個數據結構。我們的Redis早期版本list內部編碼是ziplist或者linkedlist,但是這兩者都有着自己的缺點。ziplist的數據量大不適合用,在上面也重點介紹了,而linkedlist的附加空間相對太高,prev和next指針就要占去16個字節,而且每一個結點都是單獨分配,會加劇內存的碎片化,影響內存管理效率。
所以針對這兩種編碼數據結構,后續版本進行了改造了,誕生了quicklist。
quicklist是ziplist和linkedlist的混合體,它將linkedlist按段切分,每一段使用ziplist來緊湊存儲,多個ziplist之間使用雙指針串接起來。
ziplist的長度
quicklist內部默認單個ziplist長度為8k字節,超出了這個字節數,就會新起一個ziplist。關於長度可以使用list-max-ziplist-size
來決定。
壓縮深度
我們上面說到了quicklist下是用多個ziplist組成的,同時為了進一步節約空間,Redis還會對ziplist進行壓縮存儲,使用LZF算法壓縮,可以選擇壓縮深度。
quicklist默認的壓縮深度是0,也就是不壓縮。壓縮的實際深度由配置參數list-compress-depth
決定。為了支持快速的 push/pop
操作,quicklist 的首尾兩個 ziplist 不壓縮,此時深度就是 1。如果深度為 2,就表示 quicklist 的首尾第一個 ziplist 以及首尾第二個 ziplist 都不壓縮。
關於壓縮具體介紹可以參考這里👉點擊跳轉
List的應用場景
(1)消息隊列
redis的lpush+brpop
命令組合即可實現阻塞隊列,生產者客戶端是用lupsh
從列表左側插入元素,多個消費者客戶端使用brpop
命令阻塞時的“搶”列表尾部的元素,多個客戶端保證了消費的負載均衡和高可用性。
(2)最新列表
list類型的lpush
命令和lrange
命令能實現最新列表的功能,每次通過lpush
命令往列表里插入新的元素,然后通過lrange
命令讀取最新的元素列表,如朋友圈的點贊列表、評論列表。
(3)排行榜
適用於定時計算的排行榜。 list類型的lrange
命令可以分頁查看隊列中的數據。可將每隔一段時間計算一次的排行榜存儲在list類型中,如京東每日的手機銷量排行、學校每次月考學生的成績排名、斗魚年終盛典主播排名等排行榜。
Hash類型
哈希類型的底層編碼可以是ziplist也可以是我們的hashtable。ziplist在上面我們已經介紹了,這里我們着重介紹一下hashtable。
HashTable結構
我們的hashtable主要是通過dict來實現的。
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
int iterators; /* number of iterators currently running */
} dict;
可以看到我們每個dict結構里面都有兩個hashtable。(ht[0]和ht[1])
雖然dict結構有兩個hashtable,但是通常情況下只有一個hashtable是有值的。但是在dict擴容縮容的時候,需要分配新的hashtable,然后進行漸近式搬遷,這時候兩個hashtable存儲的舊的hashtable和新的hashtable。搬遷結束后,舊hashtable刪除,新的取而代之。
hashtable的結構和Java的HashMap幾乎是一樣的,都是通過分桶的方式來解決hash沖突的。第一維是數組,第二維是鏈表。而數組中存儲的是第二維鏈表的第一個元素的指針。
漸進式rehash
所謂漸進式rehash是指我們的大字典的擴容是比較消耗時間的,需要重新申請新的數組,然后將舊字典所有鏈表的元素重新掛接到新的數組下面,是一個O(n)的操作。但是因為我們的redis是單線程的,無法承受這樣的耗時過程,所以采用了漸進式rehash小步搬遷,雖然慢一點,但是可以搬遷完畢。
這里我們將說說擴容條件和縮容條件,然后再介紹一下rehash的過程。
擴容條件
我們的擴容一般會在Hash表中的元素個數等於第一維數組的長度的時候,就會開始擴容。擴容的大小是原數組的兩倍。不過在redis在做bgsave(RDB持久化操作的過程),為了減少內存頁的過多分離(Copy On Write),redis不會去擴容。但是如果hash表的元素個數已經到達了第一維數組長度的5倍的時候,就會強制擴容,不管你是否在持久化。
這里不擴容主要是為了盡可能減少內存頁過多分離,系統后需要更多的開銷去回收內存。
縮容條件
當我們的hash表元素逐漸刪除的越來越少的時候,第一維數組長度太長也不是太好。redis於是就會對hash表進行縮容來減少第一維數組長度的空間占用。縮容的條件是元素個數低於數組長度的10%,並且縮容不考慮是否在做redis持久化。
這里不用考慮bgsave主要是因為我們的縮容的內存都是已經使用過的,縮容的時候可以直接置空,而且由於申請的內存比較小,同時會釋放掉一些已經使用的內存,不會增大系統的壓力。
rehash步驟
1、為ht[1] 分配空間,讓字典同時持有ht[0]和ht[1]兩個哈希表;
2、在幾點鍾(定時)維持一個索引計數器變量rehashidx,並將它的值設置為0,表示rehash 開始;
3、在rehash 進行期間,每次對字典執行CRUD操作時,程序除了執行指定的操作以外,還會將ht[0]中的數據rehash 到ht[1]表中,並且將rehashidx加一;
4、當ht[0]中所有數據轉移到ht[1]中時,將rehashidx 設置成-1,表示rehash 結束;
(采用漸進式rehash 的好處在於它采取分而治之的方式,避免了集中式rehash 帶來的龐大計算量。特別的在進行rehash是只能對ht[0]進行使得h[0]元素減少的操作,如查詢和刪除;而查詢是在兩個哈希表中查找的,而插入只能在ht[1]中進行,ht[1]也可以查詢和刪除。)
5、將ht[0]釋放,然后將ht[1]設置成ht[0],最后為ht[1]分配一個空白哈希表。有安全迭代器可用, 安全迭代器保證, 在迭代起始時, 字典中的所有結點, 都會被迭代到, 即使在迭代過程中對字典有插入操作。
相關知識補充
hash函數
hashtable的性能取決於hash函數的質量,如果hash把key打散的比較均勻,就是一個好函數。redis默認的函數是siphash,不僅打散均勻而且性能還特別快。
hash攻擊
hash攻擊指的是如果我們的hash函數的打散不均勻的話,存在偏向性。那么黑客就有可能利用這種偏向性對服務器進行攻擊,存在偏向性的hash函數在特定模式下的輸入會導致hash第二維鏈表長度即為不均勻,導致查找速率急劇下降,從O(1)到O(n)。有限的服務器計算能力就會被hashtable的查找效率徹底拖垮。
Hash的應用場景
哈希結構相對於字符串序列化緩存信息更加直觀,並且在更新操作上更加便捷。所以常常用於用戶信息,購物車等管理,但是哈希類型和關系型數據庫有所不同,哈希類型是稀疏的,而關系型數據庫是完全結構化的,關系型數據庫可以做復雜的關系查詢,而redis去模擬關系型復雜查詢開發困難,維護成本高。
這里舉一個實例,以用戶id為key,商品id為field,商品數量為value,恰好構成了購物車的3個要素,如下圖所示。
Set類型
Redis 的集合相當於 Java 語言中的 HashSet,它內部的鍵值對是無序、唯一的。它的內部實現相當於一個特殊的字典,字典中所有的 value 都是一個值 NULL。集合Set類型底層編碼包括hashtable和inset。hashtable在上面介紹過了,我們就只介紹inset。
inset的結構
intset底層本質是一個有序的、不重復的、整型的數組、支持不同類型整數。
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
(1)encoding 的值可以是以下三個常量的其中一個 :
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))
(2)length就是數組的實際長度。
(3)contents 數組是實際保存元素的地方,數組中的元素有以下兩個特性:
- 沒有重復元素;
- 元素在數組中從小到大排列;
inset的查詢
intset是一個有序集合,查找元素的復雜度為O(logN)(采用二分法),但插入時不一定為O(logN),因為有可能涉及到升級操作。比如當集合里全是int16_t型的整數,這時要插入一個int32_t,那么為了維持集合中數據類型的一致,那么所有的數據都會被轉換成int32_t類型,涉及到內存的重新分配,這時插入的復雜度就為O(N)了。是intset不支持降級操作。
補充
這里需要注意的是,這里的說inset是有序不要和我們zset搞混,zset是設置一個score來進行排序,而inset這里只是單純的對整數進行升序而已。
Set的應用場景
(1)交集,並集,差集。這里如交集可以用來如一個用戶對娛樂 、體育比較感興趣,另一個可能對新聞比較感興趣,他們就有共同的標簽,可以做互相推薦的功能,喜歡體育的人還喜歡娛樂。類似其他的功能都可以抽象一點去想象用法。
(2)隨機數。這里可以使用spop/srandmember
命令來獲取隨機數,可以做一個抽獎功能等。
(3)社交需求。類似sadd/sinter
命令可以添加你有多少個朋友的共同好友等操作,類似可能認識的人。
(4)大膽發揮你的想象。
Zset類型
Zset有序集合和set集合有着必然的聯系,他保留了集合不能有重復成員的特性,但不同的是,有序集合中的元素是可以排序的,但是它和列表的使用索引下標作為排序依據不同的是,它給每個元素設置一個分數,作為排序的依據。 (有序集合中的元素不可以重復,但是csore可以重復,就和一個班里的同學學號不能重復,但考試成績可以相同)。
簡單來說,它類似於 Java 中 SortedSet 和 HashMap 的結合體,一方面它是一個 set,保證了內部 value 的唯一性,另一方面它可以為每個 value 賦予一個 score 值,用來代表排序的權重。
zet的底層編碼有兩種數據結構,一個ziplist,一個是skiplist。這里因為ziplis也做了排序,所以也要簡單再介紹一下。
ziplist排序
我們之前也介紹過了ziplist,底層就是壓縮列表。這里我們為了實現排序,每個集合元素使用兩個緊挨在一起的壓縮列表節點來保存,第一個節點保存元素的成員(member),而第二個元素則保存元素的分值(score)。
壓縮列表內的集合元素按分值從小到大進行排序,分值較小的元素被放置在靠近表頭的方向,而分值較大的元素則被放在靠近表尾的方向。可以參考示意圖如下。
skiplist跳表
關於skiplist比較復雜,這里我只簡單介紹一下,具體可以參考這篇文章,可以全面了解這個數據結構。點擊跳轉
skiplist是與dict結合使用的,結構如下:
/*
* 跳躍表
*/
typedef struct zskiplist {
// 頭節點,尾節點
struct zskiplistNode *header, *tail;
// 節點數量
unsigned long length;
// 目前表內節點的最大層數
int level;
} zskiplist;
/* ZSETs use a specialized version of Skiplists */
/*
* 跳躍表節點
*/
typedef struct zskiplistNode {
// member 對象
robj *obj;
// 分值
double score;
// 后退指針
struct zskiplistNode *backward;
// 層
struct zskiplistLevel {
// 前進指針
struct zskiplistNode *forward;
// 這個層跨越的節點數量
unsigned int span;
} level[];
} zskiplistNode;
head和tail分別指向頭節點和尾節點,然后每個skiplistNode里面的結構又是分層的(即level數組)。每一列都代表一個節點,保存了member和score,按score從小到大排序。每個節點有不同的層數,這個層數是在生成節點的時候隨機生成的數值。每一層都是一個指向后面某個節點的指針。這種結構使得跳躍表可以跨越很多節點來快速訪問。(前進可以跳躍式的跳過幾個節點,而后退只能后退一個節點,可以看看下面示意圖可比較容易理解)。
為什么不使用平衡樹,而使用跳躍表?
這里redis的設計者antirez也給出了原因,主要是從內存占用、對范圍查找、實現難易程度來考慮的。
(1)在做范圍查找的時候,平衡樹比skiplist操作要復雜。在平衡樹上,我們找到指定范圍的小值之后,還需要以中序遍歷的順序繼續尋找其它不超過大值的節點。如果不對平衡樹進行一定的改造,這里的中序遍歷並不容易實現。而在skiplist上進行范圍查找就非常簡單,只需要在找到小值之后,對第1層鏈表進行若干步的遍歷就可以實現。
(2)平衡樹的插入和刪除操作可能引發子樹的調整,邏輯復雜,而skiplist的插入和刪除只需要修改相鄰節點的指針,操作簡單又快速。
(3)從內存占用上來說,skiplist比平衡樹更靈活一些。一般來說,平衡樹每個節點包含2個指針(分別指向左右子樹),而skiplist每個節點包含的指針數目平均為1/(1-p),具體取決於參數p的大小。如果像Redis里的實現一樣,取p=1/4,那么平均每個節點包含1.33個指針,比平衡樹更有優勢。
為什么要使用skiplist與dict結合使用呢?
這里需要我們去思考一下,為什么我們要兩個混合着使用呢?
其實呢,我們可以分析一下就知道了。首先是我們的跳躍表,可以跨過多個節點進行查詢,時間復雜度是O(lgn)左右,但是可以保持有序性。我們的dict是用hashtable實現,因為是哈希,所以查詢是O(1)的大小,但是是無序性。
而我們使用兩者混合,在進行分數索引的時候查詢使用跳躍表,進行數據索引查找的時候,可以使用哈希的O(1)查找。設想如果沒有字典, 如果想按數據查分數, 就必須進行遍歷O(logn)。兩套底層數據結構均只作為索引使用, 即不直接持有數據本身.。數據被封裝在SDS中, 由跳躍表與字典共同持有,而數據的分數則由跳躍表結點直接持有(double類型數據), 由字典間接持有。
Zset的應用場景
Zset的使用場景和set很是類似,而且還可以我們上面String做不了的實時排行榜。
(1)實時排行榜
比如我們要做一個一小時熱搜,我們可以把當前的時間戳作為zset的key,把帖子ID作為member,點擊數評論數作為score,當score發生變化時更新score。然后可以利用zrevrange
或zrange
來來查到對應數量的在時間內的記錄。
(2)延時隊列
zset會按照score進行排序,如果score代表想要執行時間的時間戳。在某個時間將它插入zset集合中,它便會按照時間戳大小進行排序,也就是對執行時間前后進行排序。
(3)限流
滑動窗口是限流常見的一種策略。如果我們把一個用戶的 ID 作為 key 來定義一個 zset ,member 或者 score 都為訪問時的時間戳。我們只需統計某個 key 下在指定時間戳區間內的個數,就能得到這個用戶滑動窗口內訪問頻次,與最大通過次數比較,來決定是否允許通過。
(4)發揮你的想象
總結
關於Redis的數據結構,這里介紹的其實也不是特別全。因為redis的一直在更新,而且很多知識點也不是一篇博客可以講完,比如在redis5.0之后更新了緊湊列表listpack來替代了ziplist,但是因為ziplist應用在數據結構里面范圍太大了,不太好更新,所以現在還沒有取代,但是它卻是比ziplist要好的存在,解決了ziplist存在問題。
所以,關於redis的知識點還是要繼續學習,強烈推薦閱讀《redis設計與實現》!!!
參考資料
《Redis設計與實現》
《Redis深度歷險:核心原理與應用實踐》