Squirrel(松鼠)是美團技術團隊基於Redis Cluster打造的緩存系統。通過不斷的迭代研發,目前已造成一整套自動化運維體系:涵蓋一鍵運維集羣、細粒度的監控、支持自動擴縮容以及熱點Key監控等完整的解決方案。同時服務端經過Docker進行部署,最大程度的提升運維的靈活性。分佈式緩存Squirrel產品自2015年上線至今,已在美團內部普遍使用,存儲容量超過60T,日均調用量也超過萬億次,逐步成爲美團目前最主要的緩存系統之一。html
隨着使用的量和場景不斷深刻,Squirrel團隊也不斷髮現Redis的若干"坑"和不足,所以也在持續的改進Redis以支撐美團內部快速發展的業務需求。本文嘗試分享在運維過程當中踩過的Redis Rehash機制的一些坑以及咱們的解決方案,其中在高負載狀況下物理機發生丟包的現象和解決方案已經寫成博客。感興趣的同窗能夠參考:Redis 高負載下的中斷優化。git
咱們先來看一張監控圖(上圖,咱們線上真實案例),Redis在滿容有驅逐策略的狀況下,Master/Slave 均有大量的Key驅逐淘汰,致使Master/Slave 主從不一致。github
因爲Slave內存區域比Master少一個repl-backlog buffer(線上通常配置爲128M),正常狀況下Master到達滿容後根據驅逐策略淘汰Key並同步給Slave。因此Slave這種狀況下不會因滿容觸發驅逐。redis
按照以往經驗,排查思路主要聚焦在形成Slave內存陡增的問題上,包括客戶端鏈接、輸入/輸出緩衝區、業務數據存取訪問、網路抖動等致使Redis內存陡增的全部外部因素,經過Redis監控和業務鏈路監控均沒有定位成功。算法
因而,經過梳理Redis源碼,咱們嘗試將目光投向了Redis會佔用內存開銷的一個重要機制——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;
總結一下:
如上,咱們回顧了一下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; } }
總結一下具體邏輯實現:
能夠確認當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還會引發其餘一些問題:
那麼針對在Redis滿容驅逐狀態下,如何避免因Rehash而致使Redis抖動的這種問題。
Redis Rehash機制除了會影響上述內存管理和使用外,也會影響Redis其餘內部與之相關聯的功能模塊。下面咱們分享一下因爲Rehash機制而踩到的第二個坑。
Squirrel平臺提供給業務清理Key的API後臺邏輯,是經過Scan來實現的。實際線上運行效果並非每次都能徹底清理乾淨。即經過Scan掃描清理相匹配的Key,較低頻率會有遺漏、Key未被所有清理掉的現象。有了前幾回的相關經驗後,咱們直接從原理入手。
爲了高效地匹配出數據庫中全部符合給定模式的Key,Redis提供了Scan命令。該命令會在每次調用的時候返回符合規則的部分Key以及一個遊標值Cursor(初始值使用0),使用每次返回Cursor不斷迭代,直到Cursor的返回值爲0表明遍歷結束。
Redis官方定義Scan特色以下:
具體實現
上述說起Redis的Keys是以Dict方式來存儲的,正常只要一次遍歷Dict中全部Hash桶就能夠完整掃描出全部Key。可是在實際使用中,Redis Dict是有狀態的,會隨着Key的增刪不斷變化。
接下來根據Dict四種狀態來分析一下Scan的不一樣實現。
Dict的四種狀態場景:
(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掃描方式:
能夠看出,高位序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()時,上面細分的四個場景的實現分紅了兩個邏輯:
經過窮舉高位,依次向低位推動的方式(即高位序訪問的實現)來確保全部元素都會被遍歷到。
這種算法已經儘量減小重複元素的返回,可是實際實現和邏輯中仍是會有可能存在重複返回,好比在Dict縮容時,高位合併到低位桶中,低位桶中的元素就會被重複取出。
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掃描方式:
這裏能夠看到大表的相關桶的順序並不是是按照以前所述的二進制高位序,其實是按照低位序來遍歷大表中高出小表的有效位。
大表t1高位都是向低位加1計算得出的,掃描的順序倒是從低位加1,向高位進位。Redis針對Rehashing時這種邏輯實如今擴容時是能夠運行正常的,可是在縮容時高位序和低位序的遍歷在大小表上的混用在必定條件下會出現問題。
再次示例,Dict的tablesize從32縮容到8:
能夠看出大表中的12號桶沒有被訪問到,即遍歷大表時,按照低位序訪問會遺漏對某些桶的訪問。
上述這種狀況發生須要具有必定的條件:
可見,只有知足上述三種狀況纔會發生Scan遍歷過程當中漏掉了一些Key的狀況。在執行清理Key的時候,若是清理的Key數量很大,致使了Redis內部的Hash表縮容至少原Dict tablesize的四分之一,就可能存在一些Key被漏掉的風險。
修復邏輯就是所有都從高位開始增長進行遍歷,即大小表都使用高位序訪問,修復源碼以下:
unsigned long dictScan(dict *d, unsigned long v, dictScanFunction *fn, dictScanBucketFunction* bucketfn, void *privdata) { dictht *t0, *t1; const dictEntry *de, *next; unsigned long m0, m1; if (dictSize(d) == 0) return 0; if (!dictIsRehashing(d)) { t0 = &(d->ht[0]); m0 = t0->sizemask; /* Emit entries at cursor */ if (bucketfn) bucketfn(privdata, &t0->table[v & m0]); de = t0->table[v & m0]; while (de) { next = de->next; fn(privdata, de); de = next; } /* Set unmasked bits so incrementing the reversed cursor * operates on the masked bits */ v |= ~m0; /* Increment the reverse cursor */ v = rev(v); v++; v = rev(v); } else { 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 = &d->ht[1]; t1 = &d->ht[0]; } m0 = t0->sizemask; m1 = t1->sizemask; /* Emit entries at cursor */ if (bucketfn) bucketfn(privdata, &t0->table[v & m0]); de = t0->table[v & m0]; while (de) { next = de->next; fn(privdata, 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 */ if (bucketfn) bucketfn(privdata, &t1->table[v & m1]); de = t1->table[v & m1]; while (de) { next = de->next; fn(privdata, de); de = next; } /* Increment the reverse cursor not covered by the smaller mask.*/ v |= ~m1; v = rev(v); v++; v = rev(v); /* Continue while bits covered by mask difference is non-zero */ } while (v & (m0 ^ m1)); } return v; }
咱們團隊已經將此PR Push到Redis官方:Fix dictScan(): It can't scan all buckets when dict is shrinking,並已經被官方Merge。
至此,基於Redis Rehash以及Scan實現中涉及Rehash的兩個機制已經基本瞭解和優化完成。
本文主要闡述了因Redis的Rehash機制踩到的兩個坑,從現象到原理進行了詳細的介紹。這裏簡單總結一下,第一個案例會形成線上集羣進行大量淘汰,並且產生主從不一致的狀況,在業務層面也會發生大量超時,影響業務可用性,問題嚴重,很是值得你們關注;第二個案例會形成數據清理沒法徹底清理,可是能夠再利用Scan清理一遍也可以清理完畢。
注:本文中源碼基於Redis 3.2.8。
春林,2017年加入美團,畢業後一直深耕在運維線,從網絡工程師到Oracle DBA再到MySQL DBA多種崗位轉變,如今美團主要負責Redis運維開發和優化工做。
趙磊,2017年加入美團,畢業後一直從事Redis內核方面的研究和改進,已提交若干優化到社區並被社區採納。
美團Squirrel技術團隊,負責整個美團大規模分佈式緩存Squirrel的研發和運維工做,支撐了美團業務快速穩定的發展。同時,Squirrel團隊也將持續不斷的將內部優化和發現的問題提交到開源社區,回饋社區,但願跟業界一塊兒推進Redis健碩與繁榮。若是有對Redis感興趣的同窗,歡迎參與進來:hao.zhu#dianping.com。