【51CTO.com原創稿件】對于開發或設計分布式系統的架構師工程師來說,CAP 是必須要掌握的理論。

圖片來自 Pexels
But:這個文章的重點并不是討論 CAP 理論和細節,重點是說說 CAP 在微服務中的開發怎么起到一個指引作用,會通過幾個微服務開發的例子說明,盡量的去貼近開發。
CAP 定理又被稱為布魯爾定理,是加州大學計算機科學家埃里克·布魯爾提出來的猜想,后來被證明成為分布式計算領域公認的定理。
不過布魯爾在出來 CAP 的時候并沒有對 CAP 三者(Consistency,Availability,Partition tolerance)進行詳細的定義,所以在網上也出現了不少對 CAP 不同解讀的聲音。
CAP 定理
CAP 定理在發展中存在過兩個版本,我們以第二個版本為準:
在一個分布式系統中(指互相連接并共享數據的節點集合)中,當涉及到讀寫操作時,只能保證一致性(Consistence)、可用性(Availability)、分區容錯性(Partition Tolerance)三者中的兩個,另外一個必須被犧牲。

這個版本的 CAP 理論在探討分布式系統,更加強調兩點是互聯和共享數據,其實也是理清楚了第一個版本中三選二的一些缺陷。
分布式系統不一定都存在互聯和共享數據,例如 Memcached 集群相互間就沒有存在連接和共享數據。
所以 Memcached 集群這類的分布式系統并不在 CAP 理論討論的范圍,而像 MySQL 集群就是互聯和數據共享復制,因此 MySQL 集群是屬于 CAP 理論討論的對象。
一致性(Consistency)
一致性意思就是寫操作之后進行讀操作無論在哪個節點都需要返回寫操作的值。
可用性(Availability)
非故障的節點在合理的時間內返回合理的響應。
分區容錯性(Partition Tolerance)
當網絡出現分區后,系統依然能夠繼續旅行社職責。
在分布式的環境下,網絡無法做到 100% 可靠,有可能出現故障,因此分區是一個必須的選項。
如果選擇了 CA 而放棄了 P,若發生分區現象,為了保證 C,系統需要禁止寫入,此時就與 A 發生沖突;如果是為了保證 A,則會出現正常的分區可以寫入數據,有故障的分區不能寫入數據,則與 C 就沖突了。
因此分布式系統理論上不可能選擇 CA 架構,而必須選擇 CP 或 AP 架構。
分布式事務 BASE 理論
BASE 理論是對 CAP 的延伸和補充,是對 CAP 中的 AP 方案的一個補充,即使在選擇 AP 方案的情況下,如何更好的最終達到 C。
BASE 是基本可用,柔性狀態,最終一致性三個短語的縮寫,核心的思想是即使無法做到強一致性,但應用可以采用適合的方式達到最終一致性。
CAP 在服務中實際的應用例子
理解貌似講多了,項目的 CAP 可以參考下李運華的《從零開始學架構》的書里面的 21,22 章,比較詳細的描繪了 CAP 的理論細節和 CAP 的版本演化過程。
這里著重講解的是神一樣的 CAP 在我們的微服務中怎么去指導和應用起來,大概會舉幾個平時常見的例子。

服務注冊中心,是選擇 CA 還是選擇 CP?
服務注冊中心解決的問題
在討論 CAP 之前先明確下服務注冊中心主要是解決什么問題:
- 服務注冊:實例將自身服務信息注冊到注冊中心,這部分信息包括服務的主機 IP 和服務的 Port,以及暴露服務自身狀態和訪問協議信息等。
- 服務發現:實例請求注冊中心所依賴的服務信息,服務實例通過注冊中心,獲取到注冊到其中的服務實例的信息,通過這些信息去請求它們提供的服務。

目前作為注冊中心的一些組件大致有:
- Dubbo 的 Zookeeper
- Spring Cloud 的 Eureka,Consul
- RocketMQ 的 nameServer
- HDFS 的 nameNode
目前微服務主流是 Dubbo 和 Spring Cloud,使用最多是 Zookeeper 和 Eureka,我們就來看看應該根據 CAP 理論怎么去選擇注冊中心。(Spring Cloud 也可以用 ZK,不過不是主流不討論)
Zookeeper 選擇 CP
Zookeeper 保證 CP,即任何時刻對 Zookeeper 的訪問請求能得到一致性的數據結果,同時系統對網絡分割具備容錯性,但是它不能保證每次服務的可用性。
從實際情況來分析,在使用 Zookeeper 獲取服務列表時,如果 ZK 正在選舉或者 ZK 集群中半數以上的機器不可用,那么將無法獲取數據。所以說,ZK 不能保證服務可用性。
Eureka 選擇 AP
Eureka 保證 AP,Eureka 在設計時優先保證可用性,每一個節點都是平等的。
一部分節點掛掉不會影響到正常節點的工作,不會出現類似 ZK 的選舉 Leader 的過程,客戶端發現向某個節點注冊或連接失敗,會自動切換到其他的節點。
只要有一臺 Eureka 存在,就可以保證整個服務處在可用狀態,只不過有可能這個服務上的信息并不是最新的信息。
ZK 和 Eureka 的數據一致性問題
先要明確一點,Eureka 的創建初心就是為一個注冊中心,但是 ZK 更多是作為分布式協調服務的存在。
只不過因為它的特性被 Dubbo 賦予了注冊中心,它的職責更多是保證數據(配置數據,狀態數據)在管轄下的所有服務之間保持一致。
所以這個就不難理解為何 ZK 被設計成 CP 而不是 AP,ZK 最核心的算法 ZAB,就是為了解決分布式系統下數據在多個服務之間一致同步的問題。
更深層的原因,ZK 是按照 CP 原則構建,也就是說它必須保持每一個節點的數據都保持一致。
如果 ZK 下節點斷開或者集群中出現網絡分割(例如交換機的子網間不能互訪),那么 ZK 會將它們從自己的管理范圍中剔除,外界不能訪問這些節點,即使這些節點是健康的可以提供正常的服務,所以導致這些節點請求都會丟失。
而 Eureka 則完全沒有這方面的顧慮,它的節點都是相對獨立,不需要考慮數據一致性的問題,這個應該是 Eureka 的誕生就是為了注冊中心而設計。
相對 ZK 來說剔除了 Leader 節點選取和事務日志機制,這樣更有利于維護和保證 Eureka 在運行的健壯性。

再來看看,數據不一致性在注冊服務中會給 Eureka 帶來什么問題,無非就是某一個節點被注冊的服務多,某個節點注冊的服務少,在某一個瞬間可能導致某些 IP 節點被調用數多,某些 IP 節點調用數少的問題。
也有可能存在一些本應該被刪除而沒被刪除的臟數據。

服務注冊應該選擇 AP 還是 CP
對于服務注冊來說,針對同一個服務,即使注冊中心的不同節點保存的服務注冊信息不相同,也并不會造成災難性的后果。
對于服務消費者來說,能消費才是最重要的,就算拿到的數據不是最新的數據,消費者本身也可以進行嘗試失敗重試。總比為了追求數據的一致性而獲取不到實例信息整個服務不可用要好。
所以,對于服務注冊來說,可用性比數據一致性更加的重要,選擇 AP。
分布式鎖,是選擇 CA 還是選擇 CP?
這里實現分布式鎖的方式選取了三種:
- 基于數據庫實現分布式鎖
- 基于 redis 實現分布式鎖
- 基于 Zookeeper 實現分布式鎖
基于數據庫實現分布式鎖
構建表結構:

利用表的 UNIQUE KEY idx_lock(method_lock)作為唯一主鍵,當進行上鎖時進行 Insert 動作,數據庫成功錄入則以為上鎖成功,當數據庫報出 Duplicate entry 則表示無法獲取該鎖。

不過這種方式對于單主卻無法自動切換主從的 MySQL 來說,基本就無法實現 P 分區容錯性(MySQL 自動主從切換在目前并沒有十分完美的解決方案)。
可以說這種方式強依賴于數據庫的可用性,數據庫寫操作是一個單點,一旦數據庫掛掉,就導致鎖的不可用。這種方式基本不在 CAP 的一個討論范圍。
基于 Redis 實現分布式鎖
Redis 單線程串行處理天然就是解決串行化問題,用來解決分布式鎖是再適合不過。
實現方式:
setnx key value Expire_time 獲取到鎖 返回 1 , 獲取失敗 返回 0
為了解決數據庫鎖的無主從切換的問題,可以選擇 Redis 集群,或者是 Sentinel 哨兵模式,實現主從故障轉移,當 Master 節點出現故障,哨兵會從 Slave 中選取節點,重新變成新的 Master 節點。

哨兵模式故障轉移是由 Sentinel 集群進行監控判斷,當 Maser 出現異常即復制中止,重新推選新 Slave 成為 Master,Sentinel 在重新進行選舉并不在意主從數據是否復制完畢具備一致性。
所以 Redis 的復制模式是屬于 AP 的模式。保證可用性,在主從復制中“主”有數據,但是可能“從”還沒有數據。
這個時候,一旦主掛掉或者網絡抖動等各種原因,可能會切換到“從”節點,這個時候可能會導致兩個業務線程同時獲取得兩把鎖。

這個過程如下:
- 業務線程 -1 向主節點請求鎖
- 業務線程 -1 獲取鎖
- 業務線程 -1 獲取到鎖并開始執行業務
- 這個時候 Redis 剛生成的鎖在主從之間還未進行同步
- Redis 這時候主節點掛掉了
- Redis 的從節點升級為主節點
- 業務線程 -2 想新的主節點請求鎖
- 業務線程 -2 獲取到新的主節點返回的鎖
- 業務線程 -2 獲取到鎖開始執行業務
- 這個時候業務線程 -1 和業務線程 -2 同時在執行任務
上述的問題其實并不是 Redis 的缺陷,只是 Redis 采用了 AP 模型,它本身無法確保我們對一致性的要求。
Redis 官方推薦 Redlock 算法來保證,問題是 Redlock 至少需要三個 Redis 主從實例來實現,維護成本比較高。
相當于 Redlock 使用三個 Redis 集群實現了自己的另一套一致性算法,比較繁瑣,在業界也使用得比較少。
能不能使用 Redis 作為分布式鎖?這個本身就不是 Redis 的問題,還是取決于業務場景。
我們先要自己確認我們的場景是適合 AP 還是 CP , 如果在社交發帖等場景下,我們并沒有非常強的事務一致性問題,Redis 提供給我們高性能的 AP 模型是非常適合的。
但如果是交易類型,對數據一致性非常敏感的場景,我們可能要尋找一種更加適合的 CP 模型。
基于 Zookeeper 實現分布式鎖
剛剛也分析過,Redis 其實無法確保數據的一致性,先來看 Zookeeper 是否適合作為我們需要的分布式鎖。
首先 ZK 的模式是 CP 模型,也就是說,當 ZK 鎖提供給我們進行訪問的時候,在 ZK 集群中能確保這把鎖在 ZK 的每一個節點都存在。

這個實際上是 ZK 的 Leader 通過二階段提交寫請求來保證的,這個也是 ZK 的集群規模大了的一個瓶頸點。
①ZK 鎖實現的原理
說 ZK 的鎖問題之前先看看 Zookeeper 中幾個特性,這幾個特性構建了 ZK 的一把分布式鎖。
ZK 的特性如下:
- 有序節點:當在一個父目錄下如 /lock 下創建 有序節點,節點會按照嚴格的先后順序創建出自節點 lock000001,lock000002,lock0000003,以此類推,有序節點能嚴格保證各個自節點按照排序命名生成。
- 臨時節點:客戶端建立了一個臨時節點,在客戶端的會話結束或會話超時,Zookepper 會自動刪除該節點 ID。
- 事件監聽:在讀取數據時,我們可以對節點設置監聽,當節點的數據發生變化(1 節點創建 2 節點刪除 3 節點數據變成 4 自節點變成)時,Zookeeper 會通知客戶端。

結合這幾個特點,來看下 ZK 是怎么組合分布式鎖:
- 業務線程 -1,業務線程 -2 分別向 ZK 的 /lock 目錄下,申請創建有序的臨時節點。
- 業務線程 -1 搶到 /lock0001 的文件,也就是在整個目錄下最小序的節點,也就是線程 -1 獲取到了鎖。
- 業務線程 -2 只能搶到 /lock0002 的文件,并不是最小序的節點,線程 2 未能獲取鎖。
- 業務線程 -1 與 lock0001 建立了連接,并維持了心跳,維持的心跳也就是這把鎖的租期。
- 當業務線程 -1 完成了業務,將釋放掉與 ZK 的連接,也就是釋放了這把鎖。
②ZK 分布式鎖的代碼實現
ZK 官方提供的客戶端并不支持分布式鎖的直接實現,我們需要自己寫代碼去利用 ZK 的這幾個特性去進行實現。

究竟該用 CP 還是 AP 的分布式鎖
首先得了解清楚我們使用分布式鎖的場景,為何使用分布式鎖,用它來幫我們解決什么問題,先聊場景后聊分布式鎖的技術選型。
無論是 Redis,ZK,例如 Redis 的 AP 模型會限制很多使用場景,但它卻擁有了幾者中最高的性能。
Zookeeper 的分布式鎖要比 Redis 可靠很多,但他繁瑣的實現機制導致了它的性能不如 Redis,而且 ZK 會隨著集群的擴大而性能更加下降。
簡單來說,先了解業務場景,后進行技術選型。
分布式事務,是怎么從 ACID 解脫,投身 CAP/BASE
如果說到事務,ACID 是傳統數據庫常用的設計理念,追求強一致性模型,關系數據庫的 ACID 模型擁有高一致性+可用性,所以很難進行分區。
在微服務中 ACID 已經是無法支持,我們還是回到 CAP 去尋求解決方案,不過根據上面的討論,CAP 定理中,要么只能 CP,要么只能 AP。
如果我們追求數據的一致性而忽略可用性這個在微服務中肯定是行不通的,如果我們追求可用性而忽略一致性,那么在一些重要的數據(例如支付,金額)肯定出現漏洞百出,這個也是無法接受。所以我們既要一致性,也要可用性。

都要是無法實現的,但我們能不能在一致性上作出一些妥協,不追求強一致性,轉而追求最終一致性,所以引入 BASE 理論。
在分布式事務中,BASE 最重要是為 CAP 提出了最終一致性的解決方案,BASE 強調犧牲高一致性,從而獲取可用性,數據允許在一段時間內不一致,只要保證最終一致性就可以了。
實現最終一致性
弱一致性:系統不能保證后續訪問返回更新的值。需要在一些條件滿足之后,更新的值才能返回。
從更新操作開始,到系統保證任何觀察者總是看到更新的值的這期間被稱為不一致窗口。
最終一致性:這是弱一致性的特殊形式;存儲系統保證如果沒有對某個對象的新更新操作,最終所有的訪問將返回這個對象的最后更新的值。
BASE 模型
BASE 模型是傳統 ACID 模型的反面,不同于 ACID,BASE 強調犧牲高一致性,從而獲得可用性,數據允許在一段時間內的不一致,只要保證最終一致就可以了。
BASE 模型反 ACID 模型,完全不同 ACID 模型,犧牲高一致性,獲得可用性或可靠性:Basically Available 基本可用。
支持分區失敗(e.g. sharding碎片劃分數據庫)Soft state 軟狀態,狀態可以有一段時間不同步,異步。
Eventually consistent 最終一致,最終數據是一致的就可以了,而不是時時一致。
分布式事務
在分布式系統中,要實現分布式事務,無外乎幾種解決方案。方案各有不同,不過其實都是遵循 BASE 理論,是最終一致性模型:
- 兩階段提交(2PC)
- 補償事務(TCC)
- 本地消息表
- MQ 事務消息
①兩階段提交(2PC)
還有一個數據庫的 XA 事務,不過目前在真正的互聯網中實際的應用基本很少,兩階段提交就是使用 XA 原理。

在 XA 協議中分為兩階段:
- 事務管理器要求每個涉及到事務的數據庫預提交(Precommit)此操作,并反映是否可以提交。
- 事務協調器要求每個數據庫提交數據,或者回滾數據。
說一下,為何在互聯網的系統中沒被改造過的兩階段提交基本很少被業界應用,最大的缺點就是同步阻塞問題。
在資源準備就緒之后,資源管理器中的資源就一直處于阻塞,直到提交完成之后,才進行資源釋放。
這個在互聯網高并發大數據的今天,兩階段的提交是不能滿足現在互聯網的發展。
還有就是兩階段提交協議雖然為分布式數據強一致性所設計,但仍然存在數據不一致性的可能。
例如:在第二階段中,假設協調者發出了事務 Commit 的通知,但是因為網絡問題該通知僅被一部分參與者所收到并執行了 Commit 操作,其余的參與者則因為沒有收到通知一直處于阻塞狀態,這時候就產生了數據的不一致性。
②補償事務(TCC)
TCC 是服務化的兩階段編程模型,每個業務服務都必須實現 Try,Confirm,Cancel 三個方法,這三個方式可以對應到 SQL 事務中 Lock,Commit,Rollback。

相比兩階段提交,TCC 解決了幾個問題:同步阻塞,引入了超時機制,超時后進行補償,并不會像兩階段提交鎖定了整個資源,將資源轉換為業務邏輯形式,粒度變小。
因為有了補償機制,可以由業務活動管理器進行控制,保證數據一致性。
Try 階段:Try 只是一個初步的操作,進行初步的確認,它的主要職責是完成所有業務的檢查,預留業務資源。
Confirm 階段:Confirm 是在 Try 階段檢查執行完畢后,繼續執行的確認操作,必須滿足冪等性操作,如果 Confirm 中執行失敗,會有事務協調器觸發不斷的執行,直到滿足為止。
Cancel 是取消執行:在 Try 沒通過并釋放掉 Try 階段預留的資源,也必須滿足冪等性,跟 Confirm 一樣有可能被不斷執行。
一個下訂單,生成訂單扣庫存的例子:

接下來看看,我們的下單扣減庫存的流程怎么加入 TCC:

在 Try 的時候,會讓庫存服務預留 N 個庫存給這個訂單使用,讓訂單服務產生一個“未確認”訂單,同時產生這兩個預留的資源。
在 Confirm 的時候,會使用在 Try 預留的資源,在 TCC 事務機制中認為,如果在 Try 階段能正常預留的資源,那么在 Confirm 一定能完整的提交。

在 Try 的時候,有任務一方為執行失敗,則會執行 Cancel 的接口操作,將在 Try 階段預留的資源進行釋放。
這個并不是重點要論 TCC 事務是怎么實現,重點還是討論分布式事務在 CAP+BASE 理論的應用。
實現可以參考:
https://github.com/changmingxie/tcc-transaction
③本地消息表
本地消息表這個方案最初是 eBay 提出的,eBay 的完整方案:
https://queue.acm.org/detail.cfm?id=1394128
本地消息表這種實現方式應該是業界使用最多的,其核心思想是將分布式事務拆分成本地事務進行處理。

對于本地消息隊列來說,核心就是將大事務轉變為小事務,還是用上面下訂單扣庫存的例子說明:
- 當我們去創建訂單的時候,我們新增一個本地消息表,把創建訂單和扣減庫存寫入到本地消息表,放在同一個事務(依靠數據庫本地事務保證一致性)。
- 配置一個定時任務去輪詢這個本地事務表,掃描這個本地事務表,把沒有發送出去的消息,發送給庫存服務,當庫存服務收到消息后,會進行減庫存,并寫入服務器的事務表,更新事務表的狀態。
- 庫存服務器通過定時任務或直接通知訂單服務,訂單服務在本地消息表更新狀態。
這里須注意的是,對于一些掃描發送未成功的任務,會進行重新發送,所以必須保證接口的冪等性。
本地消息隊列是 BASE 理論,是最終一致性模型,適用對一致性要求不高的情況。
④MQ 事務
RocketMQ 在 4.3 版本已經正式宣布支持分布式事務,在選擇 RokcetMQ 做分布式事務請務必選擇 4.3 以上的版本。
RocketMQ 中實現了分布式事務,實際上是對本地消息表的一個封裝,將本地消息表移動到了 MQ 內部。

事務消息作為一種異步確保型事務, 將兩個事務分支通過 MQ 進行異步解耦,RocketMQ 事務消息的設計流程同樣借鑒了兩階段提交理論。
整體交互流程如下圖所示:

MQ 事務是對本地消息表的一層封裝,將本地消息表移動到了 MQ 內部,所以也是基于 BASE 理論,是最終一致性模式,對強一致性要求不那么高的事務適用,同時 MQ 事務將整個流程異步化了,也非常適合在高并發情況下使用。
RocketMQ 選擇同步/異步刷盤,同步/異步復制,背后的 CP 和 AP 思考
雖然同步刷盤/異步刷盤,同步/異步復制,并沒有對 CAP 直接的應用,但在配置的過程中也一樣涉及到可用性和一致性的考慮。
同步刷盤/異步刷盤

RocketMQ 的消息是可以做到持久化的,數據會持久化到磁盤,RocketMQ 為了提高性能,盡可能保證磁盤的順序寫入。
消息在 Producer 寫入 RocketMQ 的時候,有兩種寫入磁盤方式:
- 異步刷盤:消息快速寫入到內存的 Pagecache,就立馬返回寫成功狀態,當內存的消息累計到一定程度的時候,會觸發統一的寫磁盤操作。這種方式可以保證大吞吐量,但也存在著消息可能未存入磁盤丟失的風險。
- 同步刷盤:消息快速寫入內存的 Pagecahe,立刻通知刷盤線程進行刷盤,等待刷盤完成之后,喚醒等待的線程,返回消息寫成功的狀態。
同步復制/異步復制

一個 Broker 組有 Master 和 Slave,消息需要從 Master 復制到 Slave 上,所以有同步和異步兩種復制方式:
- 同步復制:是等 Master 和 Slave 均寫成功后才反饋給客戶端寫成功狀態。
- 異步復制:是只要 Master 寫成功即可反饋給客戶端寫成功狀態。
異步復制的優點是可以提高響應速度,但犧牲了一致性 ,一般實現該類協議的算法需要增加額外的補償機制。
同步復制的優點是可以保證一致性(一般通過兩階段提交協議),但是開銷較大,可用性不好(參見 CAP 定理),帶來了更多的沖突和死鎖等問題。
值得一提的是 Lazy+Primary/Copy 的復制協議在實際生產環境中是非常實用的。

RocketMQ 的設置要結合業務場景,合理設置刷盤方式和主從復制方式,尤其是 SYNC_FLUSH 方式,由于頻繁的觸發寫磁盤動作,會明顯降低性能。
通常情況下,應該把 Master 和 Slave 設置成 ASYNC_FLUSH 的刷盤方式,主從之間配置成 SYNC_MASTER 的復制方式,這樣即使有一臺機器出故障,仍然可以保證數據不丟。
總結
在微服務的構建中,永遠都逃離不了 CAP 理論,因為網絡永遠不穩定,硬件總會老化,軟件可能出現 Bug,所以分區容錯性在微服務中是躲不過的命題。
可以這么說,只要是分布式,只要是集群都面臨著 AP 或者 CP 的選擇,但你很貪心的時候,既要一致性又要可用性,那只能對一致性作出一點妥協,也就是引入了 BASE 理論,在業務允許的情況下實現最終一致性。
究竟是選 CA 還是選 CP,真的在于對業務的了解,例如金錢,庫存相關會優先考慮 CP 模型,例如社區發帖相關可以優先選擇 AP 模型,這個說白了基于對業務的了解是一個選擇和妥協的過程。
作者:陳于喆
簡介:十余年的開發和架構經驗,國內較早一批微服務開發實施者。曾任職國內互聯網公司網易和唯品會高級研發工程師,后在創業公司擔任技術總監/架構師,目前在洋蔥集團任職技術研發副總監。
【51CTO原創稿件,合作站點轉載請注明原文作者和出處為51CTO.com】