餓了麼 Redis 平臺建設
Redis Cluster
Redis作為一個支援多種資料結構的鍵值對記憶體資料庫, 被廣泛用於餓了麼的各類業務場景中。其官方叢集方案Redis Cluster因其支援分片,高可用和平滑擴縮容,被我們選為最主要的分散式Redis方案。
我們的主要工作是給 Redis Cluster 做自動化運維和管理工具。
Redis Cluster 簡介
Redis Cluster由多個分片組成, 每個分片包含一個master和多個slave來實現高可用, 每個master和slave都是單執行緒。Redis每個請求和資料都對應一個key, Redis Cluster將請求的所有key通過crc16雜湊到16384(2的14次方)個slot中, 並將這16384個slot分配到叢集中的所有分片上. 這樣每個分片就只負責一部分請求和資料。Redis Cluster的擴縮容就是在分片間遷移slot和增加剔除分片節點.
自動化運維平臺
Redis Cluster功能完善, 但建立和變更叢集都要按一套複雜流程調叢集介面來實現.
例如擴容叢集要走以下流程:
- 傳送 CLUSTER MEET 命令新增新節點
- 對遷移slot的兩個節點使用 CLUSTER SETSLOT 標記遷移狀態
- 迴圈傳送 MIGRATE 命令遷移資料
- 再次使用 CLUSTER SETSLOT 命令變更slot所有權
而官方對此提供了一個Ruby指令碼來簡化運維工作。使用這個指令碼意味著必須登上機器操作, 這對於需要運維成百上千個叢集的使用者來說是完全不夠的。
因此早期大規模使用Redis Cluster的使用者實現自動化運維的方案一般就是重新實現這個指令碼並在此基礎上實現一個運維平臺, 也就是我們的初期方案。
這樣對應到平臺上面的功能就是:
- 挑選機器部署redis節點
- 指定redis節點新增到叢集
- 遷移slot到新節點
叢集結構規範化
為什麼以上三個步驟不合併為一個擴容操作呢? 因為當時並沒有想明白每個叢集真正需要做的變更操作, 拆分出來的小功能點會用於多處. 例如部署新節點可能用於擴容, 也可能用於補充因宕機缺失的從節點. 而各個叢集的結構千差萬別, 有每個分片一個slave, 有每個分片多個slave, 也有因人為疏忽缺失slave節點的叢集, 針對不同結構的叢集做變更和修復需要結合多個小功能點.
而無法確定需要哪些操作的原因是叢集結構不一. 叢集結構也就是哪些機器上部署節點, master-slave怎麼分佈.

然而我們一開始人肉管理叢集結構導致線上叢集的結構越來越不同, 問題越來越多, 需要運維工具提供的操作越來越複雜.
問題有:
- 缺少slave節點
- master和slave節點分佈在同一臺機器上, 機器宕機後該分片沒有其它slave節點就會不可用
- 過半數master節點都在某一臺機器上, 該機器宕機導致整個叢集不可用
- 宕機後master節點切換導致少數機器網絡卡被打爆
- slot不平均
- 每臺機器壓力不平均
這時我們發現無論實現運維工具還是做運維操作都非常困難, 運維工具開始需要使用複雜的圖演算法, 運維人員在面板上需要做大量的小操作才能完成任務, 而且線上事故頻發.
Redis Cluster自身不是多執行緒, 無法在一個叢集上面提供租戶功能, 我們不得不將各個業務方對應的叢集混合部署在多臺機器上. 對於這種單臺機器有多個master slave節點的結構, 叢集結構決定了穩定性. 只有讓程式管理叢集才能保證良好的叢集結構, 才能減少運維人員操作的複雜度. 而讓程式管理叢集結構必須建立一套叢集結構的規則.
運維叢集和實現運維工具的核心是規範化叢集.
規範與Chunk演算法
叢集結構規範需要滿足以下規則:
- master節點和slave節點不能在一臺機器上
- 不能有過半數(包括半數)master節點在同一臺機器上(否則宕機後集群無法選舉)
- 叢集在掛一臺機器的情況下, 壓力應儘可能平均分流到其它機器
- master節點和slave節點在各臺機器上的分佈應當平均
有一些公司採取的是根據機器各項指標(CPU, 記憶體, 網絡卡使用率)分配單個節點的方案, 並且像遷移容器一樣動態遷移單個redis節點來實現機器的壓力平衡, 配以以上規則來約束節點分配和遷移.
而我們希望用更簡單的分配方式和避免動態遷移節點來減少複雜度.
我們的方案是首先將每4個redis節點分為一組, 並且滿足以下結構, 平均分到兩臺機器上, 兩兩互為master-slave.

所有叢集都由一個個chunk組合而成, 這樣master-slave必不在同一臺機器上, 且每臺機器上的master-slave比例永遠是1:1, 只要每臺機器分配的節點數是平均的, master slave節點在每臺機器的數量也是平均的, 規則(1)(4)就滿足了.
而我們設計了一個分配演算法讓分配出來的叢集可以滿足(2)(3)。詳細說明和證明有興趣可以看文末附件.
這種結構還能讓增刪節點(增刪chunk)後集群還能滿足上述的規則.
規範了叢集的結構後, 只要叢集不滿足初始的結構(例如宕機或master-slave切換), 就把叢集修復到原來的結構.
這樣叢集一直處於健康的結構, 並且由於結構是固定並且有規律的, 無需提供紛繁的小操作, 運維人員常規做的只有:
- 建立叢集
- 替換機器(也可以用於處理宕機)
- 遷移叢集(用於擴縮容)
Redis Cluster的坑
- 運維Redis Cluster兩年期間我們發現了不少叢集相關的bug, 如
- epoch解決衝突機制失效導致同一分片同時存在兩個master節點
- 選舉有時候需要花幾分鐘而不是常規的30秒內
- 節點間握手可能會一直不成功
- 叢集元資訊不一致但是不能自動修復 (這個問題官方最近開始考慮修復了)
- 遷移slot失敗(例如遷移中遷出節點宕機)後部分遷移中的slot會無人管.
遇上這種問題往往很難定位叢集中哪些狀態是不正確的, 而操作不健康的叢集又容易踩進其它坑裡面, 通過當場修復不正確的狀態往往需要耗費大量. 早年多起相關問題的事故都教訓了我們不要浪費時間去修復叢集. 對於這類叢集損壞的問題我們目前全部都是通過切換到一個新叢集來解決.
而正常的變更也不容易做好. 從很多工具(包括官方指令碼)都沒有處理好叢集中各種隱含的狀態和邊界條件. 比如遷移slot最後的 CLUSTER SETSLOT 如果操作順序不對會引發大量請求重定向. Redis github issue上也可以看到不少用官方指令碼操作後集群完全損壞的問題.
而擴容中, 我們發現有時 MIGRATE 命令會返回 busy loading 的錯誤, 這是由於節點會有一個很短暫的初始化階段, 這個階段的節點會拒絕服務幾乎所有型別的請求. 此外, 還需要考慮實現遷移slot的並行化來加速叢集的擴縮容. 我們在官方的slot遷移上做了很多功夫, 最後發現可靠的方案邏輯複雜, 速度又慢. 最後還是不得不拋棄原生的擴縮容, 使用一致性差但是穩定性更好的建新叢集加導資料的方案.
後續的工作
目前我們正在做叢集的快速擴縮和新的Redis Cluster Proxy, 歡迎有興趣的同學加入我們, 發簡歷到 [email protected]
chunk分配演算法及證明
by 楊瑒
The node allocation algorithm aims to distribute chunks of nodes to achieve the maximum of balance, aka. trying best to spread the failover of slaves on the lost host most widely across the whole cluster. Note: 1. In this proof, we'll use "=" as assignment, and "==" as mathematical equal to conform to python's notations. 2. It is hard to write mathematical symbols and notations in plain text, and therefore we adopt some of python's functions to help explanation. Glossary: 1. chunk: A group of nodes in 4 across 2 hosts where 1 master node and 1 slave node each. Either HOST 1 or HOST 2 fails, redis node A and B should work fine after failing-over to the other host. ------------------------------- one chunk | HOST1 || HOST2 | | master A | <--> | slaveA | | slaveB | <--> | master B | ------------------------------- 2. link(ed) host: One chunk consists of 2 hosts, and we call them linked since the slave of the first host's master is on the second host, and vice versa. Algorithm Description: Assume there're n hosts, whose corresponding available node number is host[i], where i in range(0, n) (aka. len(host) == n) The available node number on each host should meet the premise of: 1. sum(host) % 4 == 0 2. host[i] is integer for i in range(0, n) 3. host[i] % 2 == 0 for i in range(0, n) 4. host[m] <= sum(<host[i]>, i in range(0, n) && i != m), where m = max_index(host) (the index of the max value in host) Build up a link table (lt) which tracks the number of links from host[i] to host[j], where i, j in range(0, n): || HOST 0| HOST 1| HOST 2| HOST 3| HOST 4| |--------|----------|--------- |----------|--------- |----------| | HOST 0 | 0| lt[0, 1] | lt[0, 2] | lt[0, 3] | lt[0, 4] | | HOST 1 | lt[0, 1] | 0| lt[1, 2] | lt[1, 3] | lt[1, 4] | | HOST 2 | lt[0, 2] | lt[1, 2] | 0| lt[2, 3] | lt[2, 4] | | HOST 3 | lt[0, 3] | lt[1, 3] | lt[2, 3] | 0| lt[3, 4] | | HOST 4 | lt[0, 4] | lt[1, 4] | lt[2, 4] | lt[3, 4] | 0| (A link table of hosts of 5) Steps: 1. Check the input, if host does not meet up with the premise, reject. 2. Initialize the lt (link table) to zeros. 3. while any(host > 0) do following: 3.1. m = max_index(host) (the index of the max value in host) 3.2. llh = find_least_linked_host_index(m) (search the link table at row m, and find the minimum excluding self) i.e. || HOST 0| HOST 1| HOST 2| HOST 3| HOST 4| |--------|----------|--------- |----------|--------- |----------| | HOST 0 | 0| 2| 4| 4| 4| In such case, llh is 1 (HOST 1) (remember to exclude self, which is 0) 3.3. establish a link between m and llh and create a chunk 3.4lt[m, llh] = lt[m, llh] + 1 3.5. host[m]= host[m]- 2 3.6. host[llh]= host[llh]- 2 Proof of convergence: (Prove that sum(host) == 0 finally) Lemma 1. For each iteration, host[m] <= sum(host[i], i in range(0, n) && i != m), where m = max_index(host) is always true. Proof of Lemma 1: According to premise 4, the initial state is true. Let host_next list be the next state of host after one iteration, p be the index of host linked with m in the new chunk. Therefore, we have: 1. host_next[m] = host[m] - 2 2. host_next[p] = host[p] - 2 The next iteration state diverges into 2 following conditions: 1) Assume m is still the max_index of host_next, which is: m == max_index(host_next) Left of inequation: host_next[m] == host[m] - 2 Right of inequation: sum(host_next[i], i in range(0, n) && i != m) == sum(<host[i]>, i in range(0, n) && i != m) - 2 Premise 4: host[m] <= sum(<host[i]>, i in range(0, n) && i != m) Therefore: host[m] - 2 <= sum(<host[i]>, i in range(0, n) && i != m) - 2 host_next[m] <= sum(<host_next[i]>, i in range(0, n) && i != m) 2) Assume q = max_index(host_next), and q != m Suppose q == p, then, host_next[q] == host_next[p] == host[p] - 2 Since, 1. host_next[m] == host[m] - 2 2. host[m] > host[p] (Use condition 1 for host[m] == host[p]) Then host_next[m] > host_next[p] == host_next[q] which is in contradiction to q being the max_index of host_next, and therefore q != p With q != m and q != p, we have 1. host_next[q] == host[q] > host_next[m] 2. host_next[m] == host[m] - 2 ==> host[q] > host[m] - 2 We also know that host[i] % 2 == 0 for i in range(0, n) ... Premise 3 host[m] >= host[q]... Premise 4 We apply 3 conditions mentioned above together: 1. host[i] % 2 == 0 for i in range(0, n) 2. host[q] > host[m] - 2 3. host[q] <= host[m] Then, 1. host[m] - 2 < host[q] <= host[m] 2. host[q] % 2 == 0 Therefore, host[q] == host[m] Left of inequation: host_next[q] == host[q] Right of inequation: sum(<host_next[i]>, i in range(0, n) && i != q) == sum(<host[j]>) + host[m] - 2 - 2 == sum(<host[j]>) + host[q] - 4 (since host[q] == host[m]) , where j in range(0, n) && j != q && j != m Therefore, we need to prove that sum(<host[j]>) - 4 >= 0 , where j in range(0, n) && j != q && j != m According to premise 2, 3, sum of host[j] results in 3 following conditions: 2.1) sum(<host[j]>) == 0 Since 1. q != m && q != p 2. j != q && j != m 3. host[p] >= 2 (host_next[p] = host[p] - 2 >= 0) Then, sum(<host[j]>) >= host[p] >= 2 which is in contradiction to sum(<host[j]>) == 0, and therefore condition 2.1 is logically impossible. 2.2) sum(<host[j]>) == 2 According to premise 3, non-zero values in host are {host[m], host[q], 2} Apply premise 1 sum(host) % 4 ==0 ==> host[m] + host[q] + 2 == 4*x ==> host[m] + host[q] == 4*x - 2 ==> 2 * host[q] == 4*x - 2 (since host[q] == host[m]) ==> host[q] == 2*x - 1 host[q] == 2*x - 1 is in contradiction to premise 3, and therefore condition 2.2 is logically impossible. 2.3) sum(<host[j]>) >= 4, conforms. Therefore, sum(<host[j]>) - 4 >= 0 ==> host[q] <= host[q] + sum(<host[j]>) - 4 ==> host[q] <= host[m] + sum(<host[j]>) - 4 ==> host_next[q] <= host_next[m] + sum(<host_next[j]>) ==> host_next[q] <= sum(<host_next[i]>) , where i in range(0, n) && i != q, j in range(0, n) && j != q && j != m Since condition 1) and 2) are respectively proved, lemma 2 is proved. For each iteration, sum(host_next) == sum(host) - 2 - 2 Apply Lemma 1. host[m] <= sum(<host[i]>, i in range(0, n) && i != m), aka. 2*host[m] <= sum(host) is always true. Since sum(host) is monotonically decreasing, and 2*host[m] <= sum(host), host[m], aka. the maximum value of host inclines to zero. The algorithm converges. Q.E.D.
作者簡介
黃光星,16年加入餓了麼, 在餓了麼主要負責Redis Cluster.
另外感謝2018開源資料庫論壇(ODF)中國Redis使用者組(CRUG)給我們頒獎! 希望各位Redis使用者能加入CRUG貢獻自己的經驗!
