Redis容量評估模型

https://gameinstitute.qq.com/community/detail/114987

業務側申請redis服務器資源時,需要事先對redis容量做一個大致評估,之前的容量評估公式基本只是簡單的(key長度 value長度)* key個數,誤差較大,後期經常需要進行縮擴容調整,因此提出一個較精確的redis容量評估模型就顯得很有必要。 Ps:本文所用的redis版本爲2.4.8,不同版本的redis所用底層數據結構可能會有所不同,請知悉。

 

一、redis常用數據結構

做容量評估之前,有必要對redis常用數據結構有大概瞭解。

1、SDS

redis沒有直接使用c語言傳統的字符串(以空字符爲結尾的字符數組),而是自己創建了一種名爲SDS(簡單動態字符串)的抽象類型,用作redis默認的字符串。

SDS的定義如下(sds.h/sdshdr):

<span style="color:#333333">struct sdshdr {
    int len;         // 記錄buf數組中已使用字節的數量
    int free;        // 記錄buf數組中未使用字節的數量
    char buf[];      // 字節數組,用於保存實際字符串
}</span>

 下圖1展示了一個SDS實例:

圖1. SDS示例圖

 圖1的SDS實例中存儲了字符串“Redis”, sdshdr中對應的free長度爲5,len長度爲5, SDS佔用的總字節數爲sizeof(int) * 2 5 5 1 = 19。

2、鏈表

鏈表在redis中的應用非常廣泛,列表鍵的底層實現之一就是鏈表。每個鏈表節點使用一個listNode結構來表示,具體定義如下(adlist.h/listNode):

<span style="color:#333333">typedef struct listNode {
    struct listNode *prev;              // 前置節點
    struct listNode *next;              // 後置節點
    void *value;                        // 節點的值
} listNode;</span>

 redis另外還使用了list結構來管理鏈表,以方便操作,具體定義如下(adlist.h/list):

<span style="color:#333333">typedef struct list {
    listNode *head;                             // 表頭節點
    listNode *tail;                             // 表尾結點
    void *(*dup)(void *ptr);                    // 節點值複製函數
    void (*free)(void *ptr);                    // 節點值釋放函數
    int (*match)(void *ptr, void *key);         // 節點值對比函數
    unsigned int len;                           // 鏈表所包含的節點數量
} list;</span>

 listNode結構佔用的總字節數爲24,list結構佔用的總字節數爲48。

3、跳躍表

redis採用跳躍表(skiplist)作爲有序集合鍵的底層實現之一,跳躍表是一種有序數據結構,它通過在每個節點中維持多個指向其他節點的指針,從而達到快速訪問節點的目的。

跳躍表由redis.h/zskiplistNode和redis.h/zskiplist兩個結構定義,zskiplistNode結構具體定義如下:

<span style="color:#333333">typedef struct zskiplistNode {
    robj *obj;                                 // 成員對象
    double score;                              // 成員對象分值
    struct zskiplistNode *backward;            // 後退指針
    struct zskiplistLevel                      // 節點層
    {
        struct zskiplistNode *forward;         // 前進指針
        unsigned int span;                     // 跨度
    } level[];
} zskiplistNode;</span>

跳躍表可以理解爲多層的有序雙向鏈表,zskiplistNode結構用於表示跳躍表節點,obj屬性和score屬性分別表示具體的值對象和對應的排序分值,backward屬性和forward屬性分別表示後退和前進指針,和普通鏈表不同,前進指針可以直接指向後續第n個節點,兩個節點之間的距離用span屬性表示。每個跳躍表節點的level數組大小不定,當節點新生成時,程序都會根據冪次定律(power low,越大的數出現的概率越小)隨機生成一個介於1和32之間的值作爲level數組的大小。zskiplistNode結構佔用的總字節數爲(24 16*n),n爲level數組的大小。

zskiplist結構具體定義如下:

 

<span style="color:#333333">typedef struct zskiplist {
    struct zskiplistNode *header, *tail;      // 表頭節點和表尾結點
    unsigned long length;                     // 表中節點的數量
    int level;                                // 表中層數最大的節點的層數
} zskiplist;</span>

 zskiplist結構則用於保存跳躍表節點的相關信息,header和tail分別指向跳躍表的表頭和表尾節點,length記錄節點總數量,level記錄跳躍表中層高最大的那個節點的層數量。zskiplist結構佔用的總字節數爲32。

下圖2展示了一個跳躍表示例:

圖2. 跳躍表示例圖

位於圖片最左邊的是zskiplist結構,位於zskiplist結構右邊的是四個zskiplistNode結構,header指向跳躍表的表頭節點,表頭節點和其他節點的構造是一樣的,但後退指針、分值、成員對象這些屬性都不會被用到,所以被省略,只顯示其各個層。

4、字典

字典在redis中的應用很廣泛,redis的數據庫就是使用字典作爲底層實現的,具體數據結構定義如下(dict.h/dict):

 

<span style="color:#333333">typedef struct dict {
    dictType *type;      // 字典類型
    void *privdata;      // 私有數據
    dictht ht[2];        // 哈希表數組
    int rehashidx;       // rehash索引,當不進行rehash時,值爲-1
    int iterators;       // 當前該字典迭代器個數
} dict;</span>

 type屬性和privdata屬性是爲了針對不同類型的鍵值對而設置的,此處瞭解即可。dict中還保存了一個長度爲2的dictht哈希表數組,哈希表負責保存具體的鍵值對,一般情況下字典只使用ht[0]哈希表,只有在rehash時才使用ht[1]。dict結構佔用的總節數爲88。

字典所使用的哈希表dictht結構定義如下(dict.h/dictht):

 

<span style="color:#333333">typedef struct dictht {
    dictEntry **table;        // 哈希表節點數組
    unsigned long size;       // 哈希表大小
    unsigned long sizemask;   // 哈希表大小掩碼,用於計算索引值,等於size-1
    unsigned long used;       // 該哈希表已有節點的數量
} dictht;</span>

 table屬性是一個數組,數組中每個元素都是一個指向dictEntry結構的指針,每個dictEntry結構就是一個哈希表節點,保存一個具體的鍵值對。size記錄了哈希表總大小,used記錄了哈希表已有節點的數量,sizemark值總是等於size -1,它和哈希值一起決定每個鍵的索引。dictht結構佔用的總節數爲32。

哈希節點使用dictEntry結構表示,具體定義如下(dict.h/dictEntry):

<span style="color:#333333">typedef struct dictEntry {
    void *key;
    void *val;
    struct dictEntry *next;
} dictEntry;</span>

redis的哈希表採用鏈地址法來解決哈希衝突問題,多個哈希值相同的鍵值對通過鏈表連接在一起。dictEntry結構佔用的總字節數爲24。

字典的整體結構關係如下圖3所示:

圖3. 字典整體結構關係圖

隨着哈希表保存的鍵值對逐漸增多,哈希表中每個桶的衝突鏈會越來越長,爲了讓哈希表的負載因子維持在一個合理範圍,redis會自動通過rehash的方式擴展哈希表。rehash的過程大概就是先爲ht[1]分配對應的空間,然後將ht[0]中的所有節點轉移到ht[1]中,最後再釋放ht[0]所佔用的空間。rehash後新生成的dictEntry節點數組大小等於超過當前key個數向上求整的2的n次方,比如當前key個數爲100,則新生成的節點數組大小就是128。

5、對象

前面介紹了redis的常用數據結構,但redis大多數情況下並沒有直接使用這些數據結構來實現鍵值對數據庫,而是基於這些數據結構創建了一個對象系統,每個對象都包含了一種具體數據結構。比如,當redis數據庫新創建一個鍵值對時,就需要創建一個值對象,值對象的*ptr屬性指向具體的SDS字符串。

每個對象都由一個redisObject結構表示,具體定義如下(redis.h/redisObject):

<span style="color:#333333"><span style="color:#444444">typedef struct redisObject {
    unsigned type: 4;        // 對象類型
    unsigned storage: 2;     // REDIS_VM_MEMORY or REDIS_VM_SWAPPING
    unsigned encoding: 4;    // 對象所使用的編碼
    unsigned lru: 22;        // lru time (relative to server.lruclock)
    int refcount;            // 對象的引用計數
    void *ptr;               // 指向對象的底層實現數據結構
} robj;</span></span>

 具體屬性此處不再詳細描述,只需知道redisObject結構佔用的總字節數爲16。

 

二、jemalloc內存分配規則

jemalloc是一種通用的內存管理方法,着重於減少內存碎片和支持可伸縮的併發性,我們部門的redis版本中就引入了jemalloc,做redis容量評估前必須對jemalloc的內存分配規則有一定了解。

jemalloc基於申請內存的大小把內存分配分爲三個等級:small,large,huge:

  • Small Object的size以8字節,16字節,32字節等分隔開,小於頁大小;
  • Large Object的size以分頁爲單位,等差間隔排列,小於chunk的大小;
  • Huge Object的大小是chunk大小的整數倍。

對於64位系統,一般chunk大小爲4M,頁大小爲4K,內存分配的具體規則如下:

 

三、redis容量評估

redis容量評估模型根據key類型而有所不同。

1、string

一個簡單的set命令最終會產生4個消耗內存的結構,中間free掉的不考慮:

  • 1個dictEntry結構,24字節,負責保存具體的鍵值對;
  • 1個redisObject結構,16字節,用作val對象;
  • 1個SDS結構,(key長度 9)字節,用作key字符串;
  • 1個SDS結構,(val長度 9)字節,用作val字符串;

當key個數逐漸增多,redis還會以rehash的方式擴展哈希表節點數組,即增大哈希表的bucket個數,每個bucket元素都是個指針(dictEntry*),佔8字節,bucket個數是超過key個數向上求整的2的n次方。

真實情況下,每個結構最終真正佔用的內存還要考慮jemalloc的內存分配規則,綜上所述,string類型的容量評估模型爲:

總內存消耗 = (dictEntry大小 redisObject大小 key_SDS大小 val_SDS大小)* key個數 bucket個數 * 指針大小

測試驗證

string類型容量評估測試腳本如下:

<span style="color:#333333"><span style="color:#444444">#!/bin/sh
old_memory=`./redis-cli -h 0 -p 10009 info|grep used_memory:|awk -F: '{printf "%d", $2}'`
echo "before test, memory used: $old_memory"
for((i=1000; i<3000; i  ))
do
    ./redis-cli -h 0 -p 10009 set test_key_$i test_value_$i > /dev/null
    sleep 0.2
done
new_memory=`./redis-cli -h 0 -p 10009 info|grep used_memory:|awk -F: '{printf "%d", $2}'`
echo "after test, memory used: $new_memory"
let difference=new_memory-old_memory
echo "difference is: $difference"</span></span>

測試用例中,key長度爲 13,value長度爲15,key個數爲2000,根據上面總結的容量評估模型,容量預估值爲2000 * (32 16 32 32) 2048 * 8 = 240384

運行測試腳本,得到結果如下:

結果都是240384,說明模型預估的十分精確。

 

2、hash

哈希對象的底層實現數據結構可能是zipmap或者hashtable,當同時滿足下面這兩個條件時,哈希對象使用zipmap這種結構(此處列出的條件都是redis默認配置,可以更改):

  • 哈希對象保存的所有鍵值對的鍵和值的字符串長度都小於64字節;
  • 哈希對象保存的鍵值對的數量都小於512個;

可以看出,業務側真實使用場景基本都不能滿足這兩個條件,所以哈希類型大部分都是hashtable結構,因此本篇文章只講hashtable,對zipmap結構感興趣的同學可以私下諮詢我。

與string類型不同的是,hash類型的值對象並不是指向一個SDS結構,而是指向又一個dict結構,dict結構保存了哈希對象具體的鍵值對,hash類型結構關係如圖4所示:

 

 圖4. hash類型結構關係圖

一個hmset命令最終會產生以下幾個消耗內存的結構:

  • 1個dictEntry結構,24字節,負責保存當前的哈希對象;
  • 1個SDS結構,(key長度 9)字節,用作key字符串;
  • 1個redisObject結構,16字節,指向當前key下屬的dict結構;
  • 1個dict結構,88字節,負責保存哈希對象的鍵值對;
  • n個dictEntry結構,24*n字節,負責保存具體的field和value,n等於field個數;
  • n個redisObject結構,16*n字節,用作field對象;
  • n個redisObject結構,16*n字節,用作value對象;
  • n個SDS結構,(field長度 9)*n字節,用作field字符串;
  • n個SDS結構,(value長度 9)*n字節,用作value字符串;

因爲hash類型內部有兩個dict結構,所以最終會有產生兩種rehash,一種rehash基準是field個數,另一種rehash基準是key個數,結合jemalloc內存分配規則,hash類型的容量評估模型爲:

總內存消耗 = [(redisObject大小 * 2 field_SDS大小  val_SDS大小  dictEntry大小)* field個數 field_bucket個數 * 指針大小 dict大小 redisObject大小 key_SDS大小 dictEntry大小 ] * key個數 key_bucket個數 * 指針大小

測試驗證

hash類型容量評估測試腳本如下:

<span style="color:#333333"><span style="color:#444444">#!/bin/sh
value_prefix="test_value_123456789012345678901234567890123456789012345678901234567890_"
old_memory=`./redis-cli -h 0 -p 10009 info|grep used_memory:|awk -F: '{printf "%d", $2}'`
echo "before test, memory used: $old_memory"
for((i=100; i<300; i  ))
do
    for((j=100; j<300; j  ))
    do
        ./redis-cli -h 0 -p 10009 hset test_key_$i test_field_$j $value_prefix$j > /dev/null
    done
    sleep 0.5
done
new_memory=`./redis-cli -h 0 -p 10009 info|grep used_memory:|awk -F: '{printf "%d", $2}'`
echo "after test, memory used: $new_memory"
let difference=new_memory-old_memory
echo "difference is: $difference"</span></span>

測試用例中,key長度爲 12,field長度爲14,value長度爲75,key個數爲200,field個數爲200,根據上面總結的容量評估模型,容量預估值爲[(16 16 32 96 32)* 200 256 * 8 96 16 32 32 ] * 200 256 * 8 = 8126848

運行測試腳本,得到結果如下:

結果相差40,說明模型預測比較準確。

 

3、zset

同哈希對象類似,有序集合對象的底層實現數據結構也分兩種:ziplist或者skiplist,當同時滿足下面這兩個條件時,有序集合對象使用ziplist這種結構(此處列出的條件都是redis默認配置,可以更改):

  • 有序集合對象保存的元素數量小於128個;
  • 有序集合保存的所有元素成員的長度都小於64字節;

業務側真實使用時基本都不能同時滿足這兩個條件,因此這裏只講skiplist結構的情況。skiplist類型的值對象指向一個zset結構,zset結構同時包含一個字典和一個跳躍表,佔用的總字節數爲16,具體定義如下(redis.h/zset):

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

跳躍表按分值從小到大保存了所有集合元素,每個跳躍表節點都保存了一個集合元素,dict字典爲有序集合創建了一個從成員到分值的映射,字典中的每個鍵值對都保存了一個集合元素,這兩種數據結構會通過指針來共享相同元素的成員和分值,沒有浪費額外的內存。zset類型的結構關係如圖5所示:

圖5. zset類型結構關係圖

一個zadd命令最終會產生以下幾個消耗內存的結構:

  • 1個dictEntry結構,24字節,負責保存當前的有序集合對象;
  • 1個SDS結構,(key長度 9)字節,用作key字符串;
  • 1個redisObject結構,16字節,指向當前key下屬的zset結構;
  • 1個zset結構,16字節,負責保存下屬的dict和zskiplist結構;
  • 1個dict結構,88字節,負責保存集合元素中成員到分值的映射;
  • n個dictEntry結構,24*n字節,負責保存具體的成員和分值,n等於集合成員個數;
  • 1個zskiplist結構,32字節,負責保存跳躍表的相關信息;
  • 1個32層的zskiplistNode結構,24 16*32=536字節,用作跳躍表頭結點;
  • n個zskiplistNode結構,(24 16*m)*n字節,用作跳躍表節點,m等於節點層數;
  • n個redisObject結構,16*n字節,用作集合中的成員對象;
  • n個SDS結構,(value長度 9)*n字節,用作成員字符串;

因爲每個zskiplistNode節點的層數都是根據冪次定律隨機生成的,而容量評估需要確切值,因此這裏採用概率中的期望值來代替單個節點的大小,結合jemalloc內存分配規則,經計算,單個zskiplistNode節點大小的期望值爲53.336。

zset類型內部同樣包含兩個dict結構,所以最終會有產生兩種rehash,一種rehash基準是成員個數,另一種rehash基準是key個數,zset類型的容量評估模型爲:

總內存消耗 = [(val_SDS大小  redisObject大小  zskiplistNode大小  dictEntry大小)* value個數 value_bucket個數 * 指針大小 32層zskiplistNode大小  zskiplist大小  dict大小 zset大小 redisObject大小 key_SDS大小 dictEntry大小 ] * key個數 key_bucket個數 * 指針大小

測試驗證

zset類型容量評估測試腳本如下:

<span style="color:#333333"><span style="color:#444444">#!/bin/sh
value_prefix="test_value_123456789012345678901234567890123456789012345678901234567890_"
old_memory=`./redis-cli -h 0 -p 10009 info|grep used_memory:|awk -F: '{printf "%d", $2}'`
echo "before test, memory used: $old_memory"
for((i=100; i<300; i  ))
do
    for((j=100; j<300; j  ))
    do
        ./redis-cli -h 0 -p 10009 zadd test_key_$i $j $value_prefix$j > /dev/null
    done
    sleep 0.5
done
new_memory=`./redis-cli -h 0 -p 10009 info|grep used_memory:|awk -F: '{printf "%d", $2}'`
echo "after test, memory used: $new_memory"
let difference=new_memory-old_memory
echo "difference is: $difference"</span></span>

測試用例中,key長度爲 12,value長度爲75,key個數爲200,value個數爲200,根據上面總結的容量評估模型,容量預估值爲[(96 16 53.336 32)* 200 256 * 8 640 32 96 16 16 32 32 ] * 200 256 * 8 = 8477888

運行測試腳本,得到結果如下:

結果相差672,說明模型預測比較準確。

 

4、list

列表對象的底層實現數據結構同樣分兩種:ziplist或者linkedlist,當同時滿足下面這兩個條件時,列表對象使用ziplist這種結構(此處列出的條件都是redis默認配置,可以更改):

  • 列表對象保存的所有字符串元素的長度都小於64字節;
  • 列表對象保存的元素數量小於512個;

因爲實際使用情況,這裏同樣只講linkedlist結構。linkedlist類型的值對象指向一個list結構,具體結構關係如圖6所示:

 

圖6. linkedlist類型結構關係圖

一個rpush或者lpush命令最終會產生以下幾個消耗內存的結構:

  • 1個dictEntry結構,24字節,負責保存當前的列表對象;
  • 1個SDS結構,(key長度 9)字節,用作key字符串;
  • 1個redisObject結構,16字節,指向當前key下屬的list結構;
  • 1個list結構,48字節,負責管理鏈表節點;
  • n個listNode結構,24*n字節,n等於value個數;
  • n個redisObject結構,16*n字節,用作鏈表中的值對象;
  • n個SDS結構,(value長度 9)*n字節,用作值對象指向的字符串;

list類型內部只有一個dict結構,rehash基準爲key個數,綜上,list類型的容量評估模型爲:

總內存消耗 = [(val_SDS大小  redisObject大小  listNode大小)* value個數   list大小    redisObject大小 key_SDS大小 dictEntry大小 ] * key個數 key_bucket個數 * 指針大小

測試驗證

list類型容量評估測試腳本如下:

<span style="color:#333333"><span style="color:#444444">#!/bin/sh
value_prefix="test_value_123456789012345678901234567890123456789012345678901234567890_"
old_memory=`./redis-cli -h 0 -p 10009 info|grep used_memory:|awk -F: '{printf "%d", $2}'`
echo "before test, memory used: $old_memory"
for((i=100; i<300; i  ))
do
    for((j=100; j<300; j  ))
    do
        ./redis-cli -h 0 -p 10009 rpush test_key_$i $value_prefix$j > /dev/null
    done
    sleep 0.5
done
new_memory=`./redis-cli -h 0 -p 10009 info|grep used_memory:|awk -F: '{printf "%d", $2}'`
echo "after test, memory used: $new_memory"
let difference=new_memory-old_memory
echo "difference is: $difference"</span></span>

測試用例中,key長度爲 12,value長度爲75,key個數爲200,value個數爲200,根據上面總結的容量評估模型,容量預估值爲[(96 16 32)* 200 48 16 32 32 ] * 200 256 * 8 = 5787648

運行測試腳本,得到結果如下:

結果都是5787648,說明模型預估的十分精確。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章