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,說明模型預估的十分精確。