京東 Redis Rehash機制的探索和實踐
背景
Squirrel(松鼠)是京東技術團隊基於Redis Cluster打造的快取系統。經過不斷的迭代研發,目前已形成一整套自動化運維體系:涵蓋一鍵運維叢集、細粒度的監控、支援自動擴縮容以及熱點Key監控等完整的解決方案。同時服務端通過Docker進行部署,最大程度的提高運維的靈活性。分散式快取Squirrel產品自2014年上線至今,已在京東內部廣泛使用,儲存容量超過80T,日均呼叫量也超過萬億次,逐步成為京東目前最主要的快取系統之一。
隨著使用的量和場景不斷深入,Squirrel團隊也不斷髮現Redis的若干"坑"和不足,因此也在持續的改進Redis以支撐內部快速發展的業務需求。本文嘗試分享在運維過程中踩過的Redis Rehash機制的一些坑以及我們的解決方案,其中在高負載情況下物理機發生丟包的現象和解決方案已經寫成部落格。感興趣的同學可以參考:
案例
Redis 滿容狀態下由於Rehash導致大量Key驅逐
我們先來看一張監控圖(上圖,我們線上真實案例),Redis在滿容有驅逐策略的情況下,Master/Slave 均有大量的Key驅逐淘汰,導致Master/Slave 主從不一致。
Root Cause 定位
由於Slave記憶體區域比Master少一個repl-backlog buffer(線上一般配置為128M),正常情況下Master到達滿容後根據驅逐策略淘汰Key並同步給Slave。所以Slave這種情況下不會因滿容觸發驅逐。
按照以往經驗,排查思路主要聚焦在造成Slave記憶體陡增的問題上,包括客戶端連線、輸入/輸出緩衝區、業務資料存取訪問、網路抖動等導致Redis記憶體陡增的所有外部因素,通過Redis監控和業務鏈路監控均沒有定位成功。
於是,通過梳理Redis原始碼,我們嘗試將目光投向了Redis會佔用記憶體開銷的一個重要機制——Redis Rehash。
Redis Rehash 內部實現
在Redis中,鍵值對(Key-Value Pair)儲存方式是由字典(Dict)儲存的,而字典底層是通過雜湊表來實現的。通過雜湊表中的節點儲存字典中的鍵值對。類似Java中的HashMap,將Key通過雜湊函式對映到雜湊表節點位置。
接下來我們一步步來分析Redis Dict Reash的機制和過程。
(1) Redis 雜湊表結構體:
/* hash表結構定義 */
typedef struct dictht {
dictEntry **table; //
unsigned long size; // 雜湊表的大小
unsigned long sizemask; // 雜湊表大小掩碼
unsigned long used; // 雜湊表現有節點的數量
} dictht;
實體化一下,如下圖所指一個大小為4的空雜湊表(Redis預設初始化值為4):
(2) Redis 雜湊桶 Redis 雜湊表中的table陣列存放著雜湊桶結構(dictEntry),裡面就是Redis的鍵值對;類似Java實現的HashMap,Redis的dictEntry也是通過連結串列(next指標)方式來解決hash衝突:
/* 雜湊桶 */
typedef struct dictEntry {
void *key; // 鍵定義
// 值定義
union {
void *val; // 自定義型別
uint64_t u64; // 無符號整形
int64_t s64; // 有符號整形
double d; // 浮點型
} v;
struct dictEntry *next; //指向下一個雜湊表節點
} dictEntry;
(3) 字典 Redis Dict 中定義了兩張雜湊表,是為了後續字典的擴充套件作Rehash之用:
/* 字典結構定義 */
typedef struct dict {
dictType *type; // 字典型別
void *privdata; // 私有資料
dictht ht[2]; // 雜湊表[兩個]
long rehashidx; // 記錄rehash 進度的標誌,值為-1表示rehash未進行
int iterators; // 當前正在迭代的迭代器數
} dict;
總結一下:
- 在Cluster模式下,一個Redis例項對應一個RedisDB(db0);
- 一個RedisDB對應一個Dict;
- 一個Dict對應2個Dictht,正常情況只用到ht[0];ht[1] 在Rehash時使用。
如上,我們回顧了一下Redis KV儲存的實現。(Redis內部還有其他結構體,由於跟Rehash不涉及,這裡不再贅述)
我們知道當HashMap中由於Hash衝突(負載因子)超過某個閾值時,出於連結串列效能的考慮,會進行Resize的操作。Redis也一樣【Redis中通過dictExpand()實現】,很多好的設計都相互借鑑和參考。我們看一下Redis中的實現方式:
/* 根據相關觸發條件擴充套件字典 */
static int _dictExpandIfNeeded(dict *d)
{
if (dictIsRehashing(d)) return DICT_OK; // 如果正在進行Rehash,則直接返回
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE); // 如果ht[0]字典為空,則建立並初始化ht[0]
/* (ht[0].used/ht[0].size)>=1前提下,
當滿足dict_can_resize=1或ht[0].used/t[0].size>5時,便對字典進行擴充套件 */
if (d->ht[0].used >= d->ht[0].size &&
(dict_can_resize ||
d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
{
return dictExpand(d, d->ht[0].used*2); // 擴充套件字典為原來的2倍
}
return DICT_OK;
}
...
/* 計算儲存Key的bucket的位置 */
static int _dictKeyIndex(dict *d, const void *key)
{
unsigned int h, idx, table;
dictEntry *he;
/* 檢查是否需要擴充套件雜湊表,不足則擴充套件 */
if (_dictExpandIfNeeded(d) == DICT_ERR)
return -1;
/* 計算Key的雜湊值 */
h = dictHashKey(d, key);
for (table = 0; table <= 1; table++) {
idx = h & d->ht[table].sizemask; //計算Key的bucket位置
/* 檢查節點上是否存在新增的Key */
he = d->ht[table].table[idx];
/* 在節點連結串列檢查 */
while(he) {
if (key==he->key || dictCompareKeys(d, key, he->key))
return -1;
he = he->next;
}
if (!dictIsRehashing(d)) break; // 掃完ht[0]後,如果雜湊表不在rehashing,則無需再掃ht[1]
}
return idx;
}
...
/* 將Key插入雜湊表 */
dictEntry *dictAddRaw(dict *d, void *key)
{
int index;
dictEntry *entry;
dictht *ht;
if (dictIsRehashing(d)) _dictRehashStep(d); // 如果雜湊表在rehashing,則執行單步rehash
/* 呼叫_dictKeyIndex() 檢查鍵是否存在,如果存在則返回NULL */
if ((index = _dictKeyIndex(d, key)) == -1)
return NULL;
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
entry = zmalloc(sizeof(*entry)); // 為新增的節點分配記憶體
entry->next = ht->table[index]; // 將節點插入連結串列表頭
ht->table[index] = entry; // 更新節點和桶資訊
ht->used++; // 更新ht
/* 設定新節點的鍵 */
dictSetKey(d, entry, key);
return entry;
}
...
/* 新增新鍵值對 */
int dictAdd(dict *d, void *key, void *val)
{
dictEntry *entry = dictAddRaw(d,key); // 新增新鍵
if (!entry) return DICT_ERR; // 如果鍵存在,則返回失敗
dictSetVal(d, entry, val); // 鍵不存在,則設定節點值
return DICT_OK;
}
繼續dictExpand的原始碼實現:
int dictExpand(dict *d, unsigned long size)
{
dictht n; // 新雜湊表
unsigned long realsize = _dictNextPower(size); // 計算擴充套件或縮放新雜湊表的大小(呼叫下面函式_dictNextPower())
/* 如果正在rehash或者新雜湊表的大小小於現已使用,則返回error */
if (dictIsRehashing(d) || d->ht[0].used > size)
return DICT_ERR;
/* 如果計算出雜湊表size與現雜湊表大小一樣,也返回error */
if (realsize == d->ht[0].size) return DICT_ERR;
/* 初始化新雜湊表 */
n.size = realsize;
n.sizemask = realsize-1;
n.table = zcalloc(realsize*sizeof(dictEntry*)); // 為table指向dictEntry 分配記憶體
n.used = 0;
/* 如果ht[0] 為空,則初始化ht[0]為當前鍵值對的雜湊表 */
if (d->ht[0].table == NULL) {
d->ht[0] = n;
return DICT_OK;
}
/* 如果ht[0]不為空,則初始化ht[1]為當前鍵值對的雜湊表,並開啟漸進式rehash模式 */
d->ht[1] = n;
d->rehashidx = 0;
return DICT_OK;
}
...
static unsigned long _dictNextPower(unsigned long size) {
unsigned long i = DICT_HT_INITIAL_SIZE; // 雜湊表的初始值:4
if (size >= LONG_MAX) return LONG_MAX;
/* 計算新雜湊表的大小:第一個大於等於size的2的N 次方的數值 */
while(1) {
if (i >= size)
return i;
i *= 2;
}
}
總結一下具體邏輯實現:
v |= ~m1;
v = rev(v);
v++;
v = rev(v);
可以確認當Redis Hash衝突到達某個條件時就會觸發dictExpand()函式來擴充套件HashTable。
DICT_HT_INITIAL_SIZE初始化值為4,通過上述表示式,取當4*2^n >= ht[0].used*2的值作為字典擴充套件的size大小。即為:ht[1].size 的值等於第一個大於等於ht[0].used*2的2^n的數值。
Redis通過dictCreate()建立詞典,在初始化中,table指標為Null,所以兩個雜湊表ht[0].table和ht[1].table都未真正分配記憶體空間。只有在dictExpand()字典擴充套件時才給table分配指向dictEntry的記憶體。
由上可知,當Redis觸發Resize後,就會動態分配一塊記憶體,最終由ht[1].table指向,動態分配的記憶體大小為:realsize*sizeof(dictEntry*),table指向dictEntry*的一個指標,大小為8bytes(64位OS),即ht[1].table需分配的記憶體大小為:8*2*2^n (n大於等於2)。
梳理一下雜湊表大小和記憶體申請大小的對應關係:
ht[0].size |
觸發Resize時,ht[1]需分配的記憶體 |
4 |
64bytes |
8 |
128bytes |
16 |
256bytes |
... |
... |
65536 |
1024K |
... |
... |
8388608 |
128M |
16777216 |
256M |
33554432 |
512M |
67108864 |
1024M |
... |
... |
復現驗證
我們通過測試環境資料來驗證一下,當Redis Rehash過程中,記憶體真正的佔用情況。
上述兩幅圖中,Redis Key個數突破Redis Resize的臨界點,當Key總數穩定且Rehash完成後,Redis記憶體(Slave)從3586M降至為3522M:3586-3522=64M。即驗證上述Redis在Resize至完成的中間狀態,會維持一段時間記憶體消耗,且佔用記憶體的值為上文列表相應的記憶體空間。
進一步觀察一下Redis內部統計資訊:
/* Redis節點800萬左右Key時候的Dict狀態資訊:只有ht[0]資訊。*/
"[Dictionary HT]
Hash table 0 stats (main hash table):
table size: 8388608
number of elements: 8003582
different slots: 5156314
max chain length: 9
avg chain length (counted): 1.55
avg chain length (computed): 1.55
Chain length distribution:
0: 3232294 (38.53%)
1: 3080243 (36.72%)
2: 1471920 (17.55%)
3: 466676 (5.56%)
4: 112320 (1.34%)
5: 21301 (0.25%)
6: 3361 (0.04%)
7: 427 (0.01%)
8: 63 (0.00%)
9: 3 (0.00%)
"
/* Redis節點840萬左右Key時候的Dict狀態資訊正在Rehasing中,包含了ht[0]和ht[1]資訊。*/
"[Dictionary HT]
[Dictionary HT]
Hash table 0 stats (main hash table):
table size: 8388608
number of elements: 8019739
different slots: 5067892
max chain length: 9
avg chain length (counted): 1.58
avg chain length (computed): 1.58
Chain length distribution:
0: 3320716 (39.59%)
1: 2948053 (35.14%)
2: 1475756 (17.59%)
3: 491069 (5.85%)
4: 123594 (1.47%)
5: 24650 (0.29%)
6: 4135 (0.05%)
7: 553 (0.01%)
8: 78 (0.00%)
9: 4 (0.00%)
Hash table 1 stats (rehashing target):
table size: 16777216
number of elements: 384321
different slots: 305472
max chain length: 6
avg chain length (counted): 1.26
avg chain length (computed): 1.26
Chain length distribution:
0: 16471744 (98.18%)
1: 238752 (1.42%)
2: 56041 (0.33%)
3: 9378 (0.06%)
4: 1167 (0.01%)
5: 119 (0.00%)
6: 15 (0.00%)
"
/* Redis節點840萬左右Key時候的Dict狀態資訊(Rehash完成後);ht[0].size從8388608擴充套件到了16777216。*/
"[Dictionary HT]
Hash table 0 stats (main hash table):
table size: 16777216
number of elements: 8404060
different slots: 6609691
max chain length: 7
avg chain length (counted): 1.27
avg chain length (computed): 1.27
Chain length distribution:
0: 10167525 (60.60%)
1: 5091002 (30.34%)
2: 1275938 (7.61%)
3: 213024 (1.27%)
4: 26812 (0.16%)
5: 2653 (0.02%)
6: 237 (0.00%)
7: 25 (0.00%)
"
經過Redis Rehash內部機制的深入、Redis狀態監控和Redis內部統計資訊,我們可以得出結論:當Redis 節點中的Key總量到達臨界點後,Redis就會觸發Dict的擴充套件,進行Rehash。申請擴充套件後相應的記憶體空間大小。
如上,Redis在滿容驅逐狀態下,Redis Rehash是導致Redis Master和Slave大量觸發驅逐淘汰的根本原因。
除了導致滿容驅逐淘汰,Redis Rehash還會引起其他一些問題:
- 在tablesize級別與現有Keys數量不在同一個區間內,主從切換後,由於Redis全量同步,從庫tablesize降為與現有Key匹配值,導致記憶體傾斜;
- Redis Cluster下的某個分片由於Key數量相對較多提前Resize,導致叢集分片記憶體不均。 等等...
Redis Rehash機制優化
那麼針對在Redis滿容驅逐狀態下,如何避免因Rehash而導致Redis抖動的這種問題。
- 我們在Redis Rehash原始碼實現的邏輯上,加上了一個判斷條件,如果現有的剩餘記憶體不夠觸發Rehash操作所需申請的記憶體大小,即不進行Resize操作;
- 通過提前運營進行規避,比如容量預估時將Rehash佔用的記憶體考慮在內,或者通過監控定時擴容。
Redis Rehash機制除了會影響上述記憶體管理和使用外,也會影響Redis其他內部與之相關聯的功能模組。下面我們分享一下由於Rehash機制而踩到的第二個坑。
Redis使用Scan清理Key由於Rehash導致清理資料不徹底
Squirrel平臺提供給業務清理Key的API後臺邏輯,是通過Scan來實現的。實際線上執行效果並不是每次都能完全清理乾淨。即通過Scan掃描清理相匹配的Key,較低頻率會有遺漏、Key未被全部清理掉的現象。有了前幾次的相關經驗後,我們直接從原理入手。
Scan原理
為了高效地匹配出資料庫中所有符合給定模式的Key,Redis提供了Scan命令。該命令會在每次呼叫的時候返回符合規則的部分Key以及一個遊標值Cursor(初始值使用0),使用每次返回Cursor不斷迭代,直到Cursor的返回值為0代表遍歷結束。
Redis官方定義Scan特點如下:
- 整個遍歷從開始到結束期間, 一直存在於Redis資料集內的且符合匹配模式的所有Key都會被返回;
- 如果發生了rehash,同一個元素可能會被返回多次,遍歷過程中新增或者刪除的Key可能會被返回,也可能不會。
具體實現
上述提及Redis的Keys是以Dict方式來儲存的,正常只要一次遍歷Dict中所有Hash桶就可以完整掃描出所有Key。但是在實際使用中,Redis Dict是有狀態的,會隨著Key的增刪不斷變化。
接下來根據Dict四種狀態來分析一下Scan的不同實現。
Dict的四種狀態場景:
- 字典tablesize保持不變,沒有擴縮容;
- 字典Resize,Dict擴大了(完成狀態);
- 字典Resize,Dict縮小了(完成狀態);
- 字典正在Rehashing(擴充套件或收縮)。
(1) 字典tablesize保持不變,在Redis Dict穩定的狀態下,直接順序遍歷即可; (2) 字典Resize,Dict擴大了,如果還是按照順序遍歷,就會導致掃描大量重複Key。比如字典tablesize從8變成了16,假設之前訪問的是3號桶,那麼表擴充套件後則是繼續訪問4~15號桶;但是,原先的0~3號桶中的資料在Dict長度變大後被遷移到8~11號桶中,因此,遍歷8~11號桶的時候會有大量的重複Key被返回; (3) 字典Resize,Dict縮小了,如果還是按照順序遍歷,就會導致大量的Key被遺漏。比如字典tablesize從8變成了4,假設當前訪問的是3號桶,那麼下一次則會直接返回遍歷結束了;但是之前4~7號桶中的資料在縮容後遷移帶可0~3號桶中,因此這部分Key就無法掃描到; (4) 字典正在Rehashing,這種情況如(2)和(3)情況一下,要麼大量重複掃描、要麼遺漏很多Key。
那麼在Dict非穩定狀態,即發生Rehash的情況下,Scan要如何保證原有的Key都能遍歷出來,又盡少可能重複掃描呢?Redis Scan通過Hash桶掩碼的高位順序訪問來解決。
高位順序訪問即按照Dict sizemask(掩碼),在有效位(上圖中Dict sizemask為3)上從高位開始加一列舉;低位則按照有效位的低位逐步加一訪問。 低位序:0→1→2→3→4→5→6→7 高位序:0→4→2→6→1→5→3→7
Scan採用高位序訪問的原因,就是為了實現Redis Dict在Rehash時儘可能少重複掃描返回Key。
舉個例子,如果Dict的tablesize從8擴充套件到了16,梳理一下Scan掃描方式:
- Dict(8) 從Cursor 0開始掃描;
- 準備掃描Cursor 6時發生Resize,擴充套件為之前的2倍,並完成Rehash;
- 客戶端這時開始從Dict(16)的Cursor 6繼續迭代;
- 這時按照 6→14→1→9→5→13→3→11→7→15 Scan完成。
可以看出,高位序Scan在Dict Rehash時即可以避免重複遍歷,又能完整返回原始的所有Key。同理,字典縮容時也一樣,字典縮容可以看出是反向擴容。
上述是Scan的理論基礎,我們看一下Redis原始碼如何實現。
(1) 非Rehashing 狀態下的實現:
if (!dictIsRehashing(d)) { // 判斷是否正在rehashing,如果不在則只有ht[0]
t0 = &(d->ht[0]); // ht[0]
m0 = t0->sizemask; // 掩碼
/* Emit entries at cursor */
de = t0->table[v & m0]; // 目標桶
while (de) {
fn(privdata, de);
de = de->next; // 遍歷桶中所有節點,並通過回撥函式fn()返回
}
...
/* 反向二進位制迭代演算法具體實現邏輯——遊標實現的精髓 */
/* Set unmasked bits so incrementing the reversed cursor
* operates on the masked bits of the smaller table */
v |= ~m0;
/* Increment the reverse cursor */
v = rev(v);
v++;
v = rev(v);
return v;
}
原始碼中Redis將Cursor的計算通過Reverse Binary Iteration(反向二進位制迭代演算法)來實現上述的高位序掃描方式。
(2) Rehashing 狀態下的實現:
...
else { // 否則說明正在rehashing,就存在兩個雜湊表ht[0]、ht[1]
t0 = &d->ht[0];
t1 = &d->ht[1]; // 指向兩個雜湊表
/* Make sure t0 is the smaller and t1 is the bigger table */
if (t0->size > t1->size) { 確保t0小於t1
t0 = &d->ht[1];
t1 = &d->ht[0];
}
m0 = t0->sizemask;
m1 = t1->sizemask; // 相對應的掩碼
/* Emit entries at cursor */
/* 迭代(小表)t0桶中的所有節點 */
de = t0->table[v & m0];
while (de) {
fn(privdata, de);
de = de->next;
}
/* Iterate over indices in larger table that are the expansion
* of the index pointed to by the cursor in the smaller table */
/* */
do {
/* Emit entries at cursor */
/* 迭代(大表)t1 中所有節點,迴圈迭代,會把小表沒有覆蓋的slot全部掃描一遍 */
de = t1->table[v & m1];
while (de) {
fn(privdata, de);
de = de->next;
}
/* Increment bits not covered by the smaller mask */
v = (((v | m0) + 1) & ~m0) | (v & m0);
/* Continue while bits covered by mask difference is non-zero */
} while (v & (m0 ^ m1));
}
/* Set unmasked bits so incrementing the reversed cursor
* operates on the masked bits of the smaller table */
v |= ~m0;
/* Increment the reverse cursor */
v = rev(v);
v++;
v = rev(v);
return v;
如上Rehashing時,Redis 通過else分支實現該過程中對兩張Hash表進行掃描訪問。
梳理一下邏輯流程:
Redis在處理dictScan()時,上面細分的四個場景的實現分成了兩個邏輯:
- 此時不在Rehashing的狀態: 這種狀態,即Dict是靜止的。針對這種狀態下的上述三種場景,Redis採用上述的Reverse Binary Iteration(反向二進位制迭代演算法): Ⅰ. 首先對遊標(Cursor)二進位制位翻轉; Ⅱ. 再對翻轉後的值加1; Ⅲ. 最後再次對Ⅱ的結果進行翻轉。
通過窮舉高位,依次向低位推進的方式(即高位序訪問的實現)來確保所有元素都會被遍歷到。
這種演算法已經儘可能減少重複元素的返回,但是實際實現和邏輯中還是會有可能存在重複返回,比如在Dict縮容時,高位合併到低位桶中,低位桶中的元素就會被重複取出。
- 正在Rehashing的狀態: Redis在Rehashing狀態的時候,dictScan()實現通過一次性掃描現有的兩種字典表,避免中間狀態無法維護。 具體實現就是在遍歷完小表Cursor位置後,將小表Cursor位置可能Rehash到的大表所有位置全部遍歷一遍,然後再返回遍歷元素和下一個小表遍歷位置。
Root Cause 定位
Rehashing狀態時,遊標迭代主要邏輯程式碼實現:
/* Increment bits not covered by the smaller mask */
v = (((v | m0) + 1) & ~m0) | (v & m0); //BUG
Ⅰ. v低位加1向高位進位; Ⅱ. 去掉v最前面和最後面的部分,只保留v相較於m0的高位部分; Ⅲ. 保留v的低位,高位不斷加1。即低位不變,高位不斷加1,實現了小表到大表桶的關聯。
舉個例子,如果Dict的tablesize從8擴充套件到了32,梳理一下Scan掃描方式:
- Dict(8) 從Cursor 0開始掃描;
- 準備掃描Cursor 4時發生Resize,擴充套件為之前的4倍,Rehashing;
- 客戶端先訪問Dict(8)中的4號桶;
- 然後再到Dict(32)上訪問:4→12→20→28。
這裡可以看到大表的相關桶的順序並非是按照之前所述的二進位制高位序,實際上是按照低位序來遍歷大表中高出小表的有效位。
大表t1高位都是向低位加1計算得出的,掃描的順序卻是從低位加1,向高位進位。Redis針對Rehashing時這種邏輯實現在擴容時是可以執行正常的,但是在縮容時高位序和低位序的遍歷在大小表上的混用在一定條件下會出現問題。
再次示例,Dict的tablesize從32縮容到8:
- Dict(32) 從Cursor 0開始掃描;
- 準備掃描Cursor 20時發生Resize,縮容至原來的四分之一即tablesize為8,Rehashing;
- 客戶端發起Cursor 20,首先訪問Dict(8)中的4號桶;
- 再到Dict(32)上訪問:20→28;
- 最後返回Cursor = 2。
可以看出大表中的12號桶沒有被訪問到,即遍歷大表時,按照低位序訪問會遺漏對某些桶的訪問。
上述這種情況發生需要具備一定的條件:
- 在Dict縮容Rehash時Scan;
- Dict縮容至至少原Dict tablesize的四分之一,只有在這種情況下,大表相對小