今天,我們來聊聊如何擴充資料服務,如何實作分片(sharding)以及高可用(high availability)。
分布式系統不存在完美的設計,處處都展現了trade off。
是以我們在開始正文前,需要确定後續的讨論原則,仍然以分布式系統設計中的cap原則為例。由于主角是redis,那性能表現肯定是最高設計目标,之後讨論過程中的所有抉擇,都會優先考慮cap中的ap性質。
◆ ◆ ◆ ◆ ◆
兩個點按順序來,先看分片。
何謂分片?簡單來說,就是對單機redis做水準擴充。
當然,做遊戲的同學可能要問了,一服一個redis,為什麼需要水準擴充?這個話題我們在之前幾篇文章中都有讨論,可以看下面兩篇文章,這裡就不再贅述。
https://mp.weixin.qq.com/s/ime_gydkajmtird1nwrnua
http://mp.weixin.qq.com/s__biz=mziwndu2mti4nq==&mid=2247483728&idx=1&sn=c2076dbc98de6fbd
40b87236f2033925&chksm=973f0fbaa04886ac83c975b7046885f7171be8d26695d23fcab974124ce054a65d
10caea3db5&scene=21#wechat_redirect
如果要實作服務級别的複用,那麼資料服務的定位往往是全局服務。如此僅用單執行個體的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的團隊,一般最終會標明這個方案作為自己的資料服務。後續的擴充其實對他們來說不是不可以做,但是可能有維護上的複雜性與不确定性。
但是作為一名有操守的程式員,我選擇繼續擴充。
現在的這個方案存在兩個問題:
針對第一個問題,處理方式跟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,又能解決上述問題?
回憶下前文《如何快速搭建資料服務》(https://mp.weixin.qq.com/s/ime_gydkajmtird1nwrnua)中我們解決resharding問題的思路:
一緻性哈希。
采用一種比較簡單的兩段映射,第一段是靜态的固定哈希,第二段是動态的可配置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要配置資料的機制更加優雅。
看下最後的架構圖:
原文釋出時間為:2016-12-21
本文來自雲栖社群合作夥伴dbaplus