基於redis構建數據服務

今天的話,我們的主題就如之前預告所說,來聊聊如何擴展數據服務,如何實現分片(sharding)以及高可用(high availability)。


分佈式系統不存在完美的設計,處處都體現了trade off。

因此我們在開始正文前,需要確定後續的討論原則,仍然以分佈式系統設計中的CAP原則爲例。由於主角是redis,那性能表現肯定是最高設計目標,之後討論過程中的所有抉擇,都會優先考慮CAP中的AP性質。




兩個點按順序來,先看分片。


何謂分片?簡單來說,就是對單機redis做水平擴展。

當然,做遊戲的同學可能要問了,一服一個redis,爲什麼需要水平擴展?這個話題我們在之前幾篇文章中都有討論,可以看這裏,或這裏,小說君不再贅述。

如果要實現服務級別的複用,那麼數據服務的定位往往是全局服務。如此僅用單實例的redis就難以應對多變的負載情況——畢竟redis是單線程的。


從mysql一路用過來的同學這時都會習慣性地水平拆分,redis中也是類似的原理,將整體的數據進行切分,每一部分是一個分片(shard),不同的分片維護不同的key集合。


那麼,分片問題的實質就是如何基於多個redis實例設計全局統一的數據服務。同時,有一個約束條件,那就是我們無法保證強一致性。

也就是說,數據服務進行分片擴展的前提是,不提供跨分片事務的保障。redis cluster也沒有提供類似支持,因爲分佈式事務本來就跟redis的定位是有衝突的。


因此,我們的分片方案有兩個限制:

  • 不同分片中的數據一定是嚴格隔離的,比如是不同組服的數據,或者是完全不相干的數據。要想實現跨分片的數據交互,必須依賴更上層的協調機制保證,數據服務層面不做任何承諾。而且這樣一來,如果想給應用層提供協調機制,只要在每個分片上部署上篇文章介紹的單實例簡易鎖機制即可,簡單明瞭。

  • 我們的分片方案無法在分片間做類似分佈式存儲系統的數據冗餘機制,換言之,一份數據交叉存在多個分片中。




如何實現分片?

首先,我們要確定分片方案需要解決什麼問題。


分片的redis集羣,實際上共同組成了一個有狀態服務(stateful service)。設計有狀態服務,我們通常會從兩點考慮:

cluster membership,系統間各個節點,或者說各個分片的關係是怎樣的。

work distribution,外部請求應該如何、交由哪個節點處理,或者說用戶(以下都簡稱dbClient)的一次讀或寫應該去找哪個分片。


針對第一個問題,解決方案通常有三:

  • presharding,也就是sharding靜態配置。

  • gossip protocol,其實就是redis cluster採用的方案。簡單地說就是集羣中每個節點會由於網絡分化、節點抖動等原因而具有不同的集羣全局視圖。節點之間通過gossip protocol進行節點信息共享。這是業界比較流行的去中心化的方案。

  • consensus system,這種方案跟上一種正相反,是依賴外部分佈式一致性設施,由其仲裁來決定集羣中各節點的身份。


需求決定解決方案,小說君認爲,對於遊戲服務端以及大多數應用型後端情景,後兩者的成本太高,會增加很多不確定的複雜性,因此兩種方案都不是合適的選擇。而且,大部分服務通常是可以在設計階段確定每個分片的容量上限的,也不需要太複雜的機制支持。


但是presharding的缺點也很明顯,做不到動態增容減容,而且無法高可用。不過其實只要稍加改造,就足以滿足需求了。


不過,在談具體的改造措施之前,我們先看之前提出的分片方案要解決的第二個問題——work distribution


這個問題實際上是從另一種維度看分片,解決方案很多,但是如果從對架構的影響上來看,大概分爲兩種:

  • 一種是proxy-based,基於額外的轉發代理。例子有twemproxy/Codis。

  • 一種是client sharding,也就是dbClient(每個對數據服務有需求的服務)維護sharding規則,自助式選擇要去哪個redis實例。redis cluster本質上就屬於這種,dblient側緩存了部分sharding信息。


第一種方案的缺點顯而易見——在整個架構中增加了額外的間接層,流程中增加了一趟round-trip。如果是像twemproxy或者Codis這種支持高可用的還好,但是github上隨便一翻還能找到特別多的沒法做到高可用的proxy-based方案,無緣無故多個單點,這樣就完全搞不明白sharding的意義何在了。

第二種方案的缺點,小說君能想到的就是集羣狀態發生變化的時候沒法即時通知到dbClient。


第一種方案,我們其實可以直接pass掉了。因爲這種方案更適合私有云的情景,開發數據服務的部門有可能和業務部門相去甚遠,因此需要統一的轉發代理服務。但是對於一些簡單的應用開發情景,數據服務邏輯服務都是一幫人寫的,沒什麼增加額外中間層的必要。


那麼,看起來只能選擇第二種方案了。


將presharding與client sharding結合起來後,現在我們的成果是:數據服務是全局的,redis可以開多個實例,不相干的數據需要到不同的分片上存取,dbClient掌握這個映射關係。





不過目前的方案只能算是滿足了應用對數據服務的基本需求。


遊戲行業中,大部分採用redis的團隊,一般最終會選定這個方案作爲自己的數據服務。後續的擴展其實對他們來說不是不可以做,但是可能有維護上的複雜性與不確定性。


但是作爲一名有操守的程序員,小說君選擇繼續擴展。


現在的這個方案存在兩個問題:


  • 首先,雖然我們沒有支持在線數據遷移的必要,但是離線數據遷移是必須得有的,畢竟presharding做不到萬無一失。而在這個方案中,如果用單純的哈希算法,增加一個shard會導致原先的key到shard的對應關係變得非常亂,擡高數據遷移成本。

  • 其次,分片方案固然可以將整個數據服務的崩潰風險分散在不同shard中,比如相比於不分片的數據服務,一臺機器掛掉了,隻影響到一部分client。但是,我們理應可以對數據服務做更深入的擴展,讓其可用程度更強。

  

針對第一個問題,處理方式跟proxy-based採用的處理方式沒太大區別,由於目前的數據服務方案比較簡單,採用一致性哈希即可。或者採用一種比較簡單的兩段映射,第一段是靜態的固定哈希,第二段是動態的可配置map。前者通過算法,後者通過map配置維護的方式,都能最小化影響到的key集合。

而對於第二個問題,解決方案就是實現高可用。




如何讓數據服務高可用?在討論這個問題之前,我們首先看redis如何實現「可用性」。


對於redis來說,可用性的本質是什麼?其實就是redis實例掛掉之後可以有後備節點頂上。 


redis通過兩種機制支持這一點。


第一種機制是replication。通常的replication方案主要分爲兩種。

  • 一種是active-passive,也就是active節點先修改自身狀態,然後寫統一持久化log,然後passive節點讀log跟進狀態。

  • 另一種是active-active,寫請求統一寫到持久化log,然後每個active節點自動同步log進度。

redis的replication方案採用的是一種一致性較弱的active-passive方案。也就是master自身維護log,將log向其他slave同步,master掛掉有可能導致部分log丟失,client寫完master即可收到成功返回,是一種異步replication。

這個機制只能解決節點數據冗餘的問題,redis要具有可用性就還得解決redis實例掛掉讓備胎自動頂上的問題,畢竟由人肉去監控master狀態再人肉切換是不現實的。 因此還需要第二種機制。


第二種機制是redis自帶的能夠自動化fail-over的redis sentinel。reds sentinel實際上是一種特殊的redis實例,其本身就是一種高可用服務——可以多開,可以自動服務發現(基於redis內置的pub-sub支持,sentinel並沒有禁用掉pub-sub的command map),可以自主leader election(基於raft算法實現,作爲sentinel的一個模塊),然後在發現master掛掉時由leader發起fail-over,並將掉線後再上線的master降爲新master的slave。


redis基於這兩種機制,已經能夠實現一定程度的可用性。




接下來,我們來看數據服務如何高可用。


數據服務具有可用性的本質是什麼?除了能實現redis可用性的需求——redis實例數據冗餘、故障自動切換之外,還需要將切換的消息通知到每個dbClient。

也就是說把最開始的圖,改成下面這個樣子:


每個分片都要改成主從模式。


如果redis sentinel負責主從切換,拿最自然的想法就是讓dbClient向sentinel請求當前節點主從連接信息。但是redis sentinel本身也是redis實例,數量也是動態的,redis sentinel的連接信息不僅在配置上成了一個難題,動態更新時也會有各種問題。

而且,redis sentinel本質上是整個服務端的static parts(要向dbClient提供服務),但是卻依賴於redis的啓動,並不是特別優雅。另一方面,dbClient要想問redis sentinel要到當前連接信息,只能依賴其內置的pub-sub機制。redis的pub-sub只是一個簡單的消息分發,沒有消息持久化,因此需要輪詢式的請求連接信息模型。


那麼,我們是否可以以較低的成本定製一種服務,既能取代redis sentinel,又能解決上述問題?


回憶下前文我們解決resharding問題的思路:

  1. 一致性哈希。

  2. 採用一種比較簡單的兩段映射,第一段是靜態的固定哈希,第二段是動態的可配置map。前者通過算法,後者通過map配置維護的方式,都能最小化影響到的key集合。


兩種方案都可以實現動態resharding,dbClient可以動態更新:

  • 如果採用兩段映射,那麼我們可以動態下發第二段的配置數據。

  • 如果採用一致性哈希,那麼我們可以動態下發分片的連接信息。


再梳理一下,我們要實現的服務(下文簡稱爲watcher),至少要實現這些需求

  • 要能夠監控redis的生存狀態。這一點實現起來很簡單,定期的PING redis實例即可。需要的信息以及做出客觀下線和主觀下線的判斷依據都可以直接照搬sentinel實現。

  • 要做到自主服務發現,包括其他watcher的發現與所監控的master-slave組中的新節點的發現。在實現上,前者可以基於消息隊列的pub-sub功能,後者只要向redis實例定期INFO獲取信息即可。

  • 要在發現master客觀下線的時候選出leader進行後續的故障轉移流程。這部分實現起來算是最複雜的部分,接下來會集中討論。

  • 選出leader之後將一個最合適的slave提升爲master,然後等老的master再上線了就把它降級爲新master的slave。


解決這些問題,watcher就兼具了擴展性、定製性,同時還提供分片數據服務的部分在線遷移機制。這樣,我們的數據服務也就更加健壯,可用程度更高。




這樣一來,雖然保證了redis每個分片的master-slave組具有可用性,但是因爲我們引入了新的服務,那就引入了新的不確定性——如果引入這個服務的同時還要保證數據服務具有可用性,那我們就還得保證這個服務本身是可用的。

說起來可能有點繞,換個說法,也就是服務A藉助服務B實現了高可用,那麼服務B本身也需要高可用。


先簡單介紹一下redis sentinel是如何做到高可用的。同時監控同一組主從的sentinel可以有多個,master掛掉的時候,這些sentinel會根據redis自己實現的一種raft算法選舉出leader,算法流程也不是特別複雜,至少比paxos簡單多了。所有sentinel都是follower,判斷出master客觀下線的sentinel會升級成candidate同時向其他follower拉票,所有follower同一epoch內只能投給第一個向自己拉票的candidate。在具體表現中,通常一兩個epoch就能保證形成多數派,選出leader。有了leader,後面再對redis做SLAVEOF的時候就容易多了。


如果想用watcher取代sentinel,最複雜的實現細節可能就是這部分邏輯了。

這部分邏輯說白了就是要在分佈式系統中維護一個一致狀態,舉個例子,可以將「誰是leader」這個概念當作一個狀態量,由分佈式系統中的身份相等的幾個節點共同維護,既然誰都有可能修改這個變量,那究竟誰的修改才奏效呢?


幸好,針對這種常見的問題情景,我們有現成的基礎設施抽象可以解決。


這種基礎設施就是分佈式系統的協調器組件(coordinator),老牌的有zookeeper(基於對paxos改進過的zab協議,下面都簡稱zk了),新一點的有etcd(這個大家都清楚,基於raft協議)。這種組件通常沒有重複開發的必要,像paxos這種算法理解起來都得老半天,實現起來的細節數量級更是難以想象。因此很多開源項目都是依賴這兩者實現高可用的,比如codis一開始就是用的zk。




zk解決了什麼問題?


以通用的應用服務需求來說,zk可以用來選leader,還可以用來維護dbClient的配置數據——dbClient直接去找zk要數據就行了。


zk的具體原理小說君就不再介紹了,有時間有精力可以研究下paxos,看看lamport的paper,沒時間沒精力的話搜一下看看zk實現原理的博客就行了。


簡單介紹下如何基於zk實現leader election。zk提供了一個類似於os文件系統的目錄結構,目錄結構上的每個節點都有類型的概念同時可以存儲一些數據。zk還提供了一次性觸發的watch機制。


應用層要做leader election就可以基於這幾點概念實現。

假設有某個目錄節點「/election」,watcher1啓動的時候在這個節點下面創建一個子節點,節點類型是臨時順序節點,也就是說這個節點會隨創建者掛掉而掛掉,順序的意思就是會在節點的名字後面加個數字後綴,唯一標識這個節點在/election的子節點中的id。


  • 一個簡單的方案是讓每個watcher都watch/election的所有子節點,然後看自己的id是否是最小的,如果是就說明自己是leader,然後告訴應用層自己是leader,讓應用層進行後續操作就行了。但是這樣會產生驚羣效應,因爲一個子節點刪除,每個watcher都會收到通知,但是至多一個watcher會從follower變爲leader。

  • 優化一些的方案是每個節點都關注比自己小一個排位的節點。這樣如果id最小的節點掛掉之後,id次小的節點會收到通知然後瞭解到自己成爲了leader,避免了驚羣效應。


小說君在實踐中發現,還有一點需要注意,臨時順序節點的臨時性體現在一次session而不是一次連接的終止。

例如watcher1每次申請節點都叫watcher1,第一次它申請成功的節點全名假設是watcher10002(後面的是zk自動加的序列號),然後下線,watcher10002節點還會存在一段時間,如果這段時間內watcher1再上線,再嘗試創建watcher1就會失敗,然後之前的節點過一會兒就因爲session超時而銷燬,這樣就相當於這個watcher1消失了。

解決方案有兩個,可以創建節點前先顯式delete一次,也可以通過其他機制保證每次創建節點的名字不同,比如guid。


至於配置下發,就更簡單了。配置變更時直接更新節點數據,就能借助zk通知到關注的dbClient,這種事件通知機制相比於輪詢請求sentinel要配置數據的機制更加優雅。


看下最後的架構圖:




這篇文章是服務端系列的倒數第二篇,篇幅稍長,內容也大部分都整理自小說君年初寫的某篇關於遊戲服務端的博客,改動比較少。

下一篇作爲結篇,主要聊聊RPC相關的話題。


小說君暫時還沒想好後續的系列主題,如果有同學有想了解或想討論的也可以直接後臺留言發發消息什麼的。

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