天天看點

分布式事務、分布式鎖、分布式session

分布式事務、分布式鎖、分布式session

session 是啥?浏覽器有個 cookie,在一段時間内這個 cookie 都存在,然後每次發請求過來都帶上一個特殊的 <code>jsessionid cookie</code>,就根據這個東西,在服務端可以維護一個對應的 session 域,裡面可以放點資料。

一般的話隻要你沒關掉浏覽器,cookie 還在,那麼對應的那個 session 就在,但是如果 cookie 沒了,session 也就沒了。常見于什麼購物車之類的東西,還有登入狀态儲存之類的。

這個不多說了,懂 Java 的都該知道這個。

單塊系統的時候這麼玩兒 session 沒問題,但是你要是分布式系統呢,那麼多的服務,session 狀态在哪兒維護啊?

(1)完全不用 session

使用 JWT Token 儲存使用者身份,然後再從資料庫或者 cache 中擷取其他的資訊。這樣無論請求配置設定到哪個伺服器都無所謂

(2)tomcat + redis

這個其實還挺友善的,就是使用 session 的代碼,跟以前一樣,還是基于 tomcat 原生的 session 支援即可,然後就是用一個叫做 <code>Tomcat RedisSessionManager</code> 的東西,讓所有我們部署的 tomcat 都将 session 資料存儲到 redis 即可。

在 tomcat 的配置檔案中配置:

然後指定 redis 的 host 和 port 就 ok 了。

還可以用上面這種方式基于 redis 哨兵支援的 redis 高可用叢集來儲存 session 資料,都是 ok 的。

(3)spring session + redis

上面所說的第二種方式會與 tomcat 容器重耦合,如果我要将 web 容器遷移成 jetty,難道還要重新把 jetty 都配置一遍?

因為上面那種 tomcat + redis 的方式好用,但是會嚴重依賴于web容器,不好将代碼移植到其他 web 容器上去,尤其是你要是換了技術棧咋整?比如換成了 spring cloud 或者是 spring boot 之類的呢?

是以現在比較好的還是基于 Java 一站式解決方案,也就是 spring。人家 spring 基本上承包了大部分我們需要使用的架構,spirng cloud 做微服務,spring boot 做腳手架,是以用 sping session 是一個很好的選擇。

在 pom.xml 中配置:

  在 spring 配置檔案中配置:

在 web.xml 中配置:

示例代碼:

上面的代碼就是 ok 的,給 sping session 配置基于 redis 來存儲 session 資料,然後配置了一個 spring session 的過濾器,這樣的話,session 相關操作都會交給 spring session 來管了。接着在代碼中,就用原生的 session 操作,就是直接基于 spring sesion 從 redis 中擷取資料了。

實作分布式的會話有很多種方式,我說的隻不過是比較常見的幾種方式,tomcat + redis 早期比較常用,但是會重耦合到 tomcat 中;近些年,通過 spring session 來實作。

當我們的單個資料庫的性能産生瓶頸的時候,我們可能會對資料庫進行分區,這裡所說的分區指的是實體分區,分區之後可能不同的庫就處于不同的伺服器上了,這個時候單個資料庫的ACID已經不能适應這種情況了,而在這種ACID的叢集環境下,再想保證叢集的ACID幾乎是很難達到,或者即使能達到那麼效率和性能會大幅下降,最為關鍵的是再很難擴充新的分區了,這個時候如果再追求叢集的ACID會導緻我們的系統變得很差,這時我們就需要引入一個新的理論原則來适應這種叢集的情況,就是 CAP 原則或者叫CAP定理,那麼CAP定理指的是什麼呢?  

CAP定理是由加州大學伯克利分校Eric Brewer教授提出來的,他指出WEB服務無法同時滿足一下3個屬性:

一緻性(Consistency) :用戶端知道一系列的操作都會同時發生(生效)

可用性(Availability) :每個操作都必須以可預期的響應結束

分區容錯性(Partition tolerance) :即使出現單個元件無法可用,操作依然可以完成

  具體地講在分布式系統中,在任何資料庫設計中,一個Web應用至多隻能同時支援上面的兩個屬性。顯然,任何橫向擴充政策都要依賴于資料分區。是以,設計人員必須在一緻性與可用性之間做出選擇。

這個定理在迄今為止的分布式系統中都是适用的! 為什麼這麼說呢?

這個時候有同學可能會把資料庫的2PC(兩階段送出)搬出來說話了。OK,我們就來看一下資料庫的兩階段送出。

對資料庫分布式事務有了解的同學一定知道資料庫支援的2PC,又叫做 XA Transactions。

其中,XA 是一個兩階段送出協定,該協定分為以下兩個階段:

第一階段:事務協調器要求每個涉及到事務的資料庫預送出(precommit)此操作,并反映是否可以送出.

第二階段:事務協調器要求每個資料庫送出資料。

其中,如果有任何一個資料庫否決此次送出,那麼所有資料庫都會被要求復原它們在此事務中的那部分資訊。這樣做的缺陷是什麼呢? 咋看之下我們可以在資料庫分區之間獲得一緻性。

如果CAP 定理是對的,那麼它一定會影響到可用性。

如果說系統的可用性代表的是執行某項操作相關所有元件的可用性的和。那麼在兩階段送出的過程中,可用性就代表了涉及到的每一個資料庫中可用性的和。我們假設兩階段送出的過程中每一個資料庫都具有99.9%的可用性,那麼如果兩階段送出涉及到兩個資料庫,這個結果就是99.8%。根據系統可用性計算公式,假設每個月43200分鐘,99.9%的可用性就是43157分鐘, 99.8%的可用性就是43114分鐘,相當于每個月的當機時間增加了43分鐘。

以上,可以驗證出來,CAP定理從理論上來講是正确的,CAP我們先看到這裡,等會再接着說。

在分布式系統中,要實作分布式事務,無外乎那幾種解決方案。

分布式事務的實作主要有以下 5 種方案:

XA 方案

TCC 方案

本地消息表

可靠消息最終一緻性方案

最大努力通知方案

所謂的 XA 方案,即:兩階段送出,有一個事務管理器的概念,負責協調多個資料庫(資料總管)的事務,事務管理器先問問各個資料庫你準備好了嗎?如果每個資料庫都回複 ok,那麼就正式送出事務,在各個資料庫上執行操作;如果任何其中一個資料庫回答不 ok,那麼就復原事務。

這種分布式事務方案,比較适合單塊應用裡,跨多個庫的分布式事務,而且因為嚴重依賴于資料庫層面來搞定複雜的事務,效率很低,絕對不适合高并發的場景。如果要玩兒,那麼基于 <code>Spring + JTA</code> 就可以搞定,自己随便搜個 demo 看看就知道了。

這個方案,我們很少用,一般來說某個系統内部如果出現跨多個庫的這麼一個操作,是不合規的。我可以給大家介紹一下, 現在微服務,一個大的系統分成幾十個甚至幾百個服務。一般來說,我們的規定和規範,是要求每個服務隻能操作自己對應的一個資料庫。

如果你要操作别的服務對應的庫,不允許直連别的服務的庫,違反微服務架構的規範,你随便交叉胡亂通路,幾百個服務的話,全體亂套,這樣的一套服務是沒法管理的,沒法治理的,可能會出現資料被别人改錯,自己的庫被别人寫挂等情況。

如果你要操作别人的服務的庫,你必須是通過調用别的服務的接口來實作,絕對不允許交叉通路别人的資料庫。

TCC 的全稱是:<code>Try</code>、<code>Confirm</code>、<code>Cancel</code>。

Try 階段:這個階段說的是對各個服務的資源做檢測以及對資源進行鎖定或者預留。

Confirm 階段:這個階段說的是在各個服務中執行實際的操作。

Cancel 階段:如果任何一個服務的業務方法執行出錯,那麼這裡就需要進行補償,就是執行已經執行成功的業務邏輯的復原操作。(把那些執行成功的復原)

這種方案說實話幾乎很少人使用,我們用的也比較少,但是也有使用的場景。因為這個事務復原實際上是嚴重依賴于你自己寫代碼來復原和補償了,會造成補償代碼巨大,非常之惡心。

比如說我們,一般來說跟錢相關的,跟錢打交道的,支付、交易相關的場景,我們會用 TCC,嚴格保證分布式事務要麼全部成功,要麼全部自動復原,嚴格保證資金的正确性,保證在資金上不會出現問題。

而且最好是你的各個業務執行的時間都比較短。

但是說實話,一般盡量别這麼搞,自己手寫復原邏輯,或者是補償邏輯,實在太惡心了,那個業務代碼是很難維護的。

分布式事務、分布式鎖、分布式session

本地消息表其實是國外的 ebay 搞出來的這麼一套思想。

這個大概意思是這樣的:

A 系統在自己本地一個事務裡操作同時,插入一條資料到消息表;

接着 A 系統将這個消息發送到 MQ 中去;

B 系統接收到消息之後,在一個事務裡,往自己本地消息表裡插入一條資料,同時執行其他的業務操作,如果這個消息已經被處理過了,那麼此時這個事務會復原,這樣保證不會重複處理消息;

B 系統執行成功之後,就會更新自己本地消息表的狀态以及 A 系統消息表的狀态;

如果 B 系統處理失敗了,那麼就不會更新消息表狀态,那麼此時 A 系統會定時掃描自己的消息表,如果有未處理的消息,會再次發送到 MQ 中去,讓 B 再次處理;

這個方案保證了最終一緻性,哪怕 B 事務失敗了,但是 A 會不斷重發消息,直到 B 那邊成功為止。

這個方案說實話最大的問題就在于嚴重依賴于資料庫的消息表來管理事務啥的,如果是高并發場景咋辦呢?咋擴充呢?是以一般确實很少用。

這個的意思,就是幹脆不要用本地的消息表了,直接基于 MQ 來實作事務。比如阿裡的 RocketMQ 就支援消息事務。

大概的意思就是:

A 系統先發送一個 prepared 消息到 mq,如果這個 prepared 消息發送失敗那麼就直接取消操作别執行了;

如果這個消息發送成功過了,那麼接着執行本地事務,如果成功就告訴 mq 發送确認消息,如果失敗就告訴 mq 復原消息;

如果發送了确認消息,那麼此時 B 系統會接收到确認消息,然後執行本地的事務;

mq 會自動定時輪詢所有 prepared 消息回調你的接口,問你,這個消息是不是本地事務處理失敗了,所有沒發送确認的消息,是繼續重試還是復原?一般來說這裡你就可以查下資料庫看之前本地事務是否執行,如果復原了,那麼這裡也復原吧。這個就是避免可能本地事務執行成功了,而确認消息卻發送失敗了。

這個方案裡,要是系統 B 的事務失敗了咋辦?重試咯,自動不斷重試直到成功,如果實在是不行,要麼就是針對重要的資金類業務進行復原,比如 B 系統本地復原後,想辦法通知系統 A 也復原;或者是發送報警由人工來手工復原和補償。

這個還是比較合适的,目前國内網際網路公司大都是這麼玩兒的,要不你舉用 RocketMQ 支援的,要不你就自己基于類似 ActiveMQ?RabbitMQ?自己封裝一套類似的邏輯出來,總之思路就是這樣子的。

分布式事務、分布式鎖、分布式session

這個方案的大緻意思就是:

系統 A 本地事務執行完之後,發送個消息到 MQ;

這裡會有個專門消費 MQ 的最大努力通知服務,這個服務會消費 MQ 然後寫入資料庫中記錄下來,或者是放入個記憶體隊列也可以,接着調用系統 B 的接口;

要是系統 B 執行成功就 ok 了;要是系統 B 執行失敗了,那麼最大努力通知服務就定時嘗試重新調用系統 B,反複 N 次,最後還是不行就放棄。

如果你真的被問到,可以這麼說,我們某某特别嚴格的場景,用的是 TCC 來保證強一緻性;然後其他的一些場景基于阿裡的 RocketMQ 來實作分布式事務。

你找一個嚴格資金要求絕對不能錯的場景,你可以說你是用的 TCC 方案;如果是一般的分布式事務場景,訂單插入之後要調用庫存服務更新庫存,庫存資料沒有資金那麼的敏感,可以用可靠消息最終一緻性方案。

友情提示一下,RocketMQ 3.2.6 之前的版本,是可以按照上面的思路來的,但是之後接口做了一些改變,我這裡不再贅述了。

當然如果你願意,你可以參考可靠消息最終一緻性方案來自己實作一套分布式事務,比如基于 RocketMQ 來玩兒。

第一個最普通的實作方式,就是在 redis 裡建立一個 key,這樣就算加鎖。

執行這個指令就 ok。

<code>NX</code>:表示隻有 <code>key</code> 不存在的時候才會設定成功。(如果此時 redis 中存在這個 key,那麼設定失敗,傳回 <code>nil</code>)

<code>PX 30000</code>:意思是 30s 後鎖自動釋放。别人建立的時候如果發現已經有了就不能加鎖了。

釋放鎖就是删除 key ,但是一般可以用 <code>lua</code> 腳本删除,判斷 value 一樣才删除:

為啥要用随機值呢?因為如果某個用戶端擷取到了鎖,但是阻塞了很長時間才執行完,比如說超過了 30s,此時可能已經自動釋放鎖了,此時可能别的用戶端已經擷取到了這個鎖,要是你這個時候直接删除 key 的話會有問題,是以得用随機值加上面的 <code>lua</code> 腳本來釋放鎖。

但是這樣是肯定不行的。因為如果是普通的 redis 單執行個體,那就是單點故障。或者是 redis 普通主從,那 redis 主從異步複制,如果主節點挂了(key 就沒有了),key 還沒同步到從節點,此時從節點切換為主節點,别人就可以 set key,進而拿到鎖。

這個場景是假設有一個 redis cluster,有 5 個 redis master 執行個體。然後執行如下步驟擷取一把鎖:

擷取目前時間戳,機關是毫秒;

跟上面類似,輪流嘗試在每個 master 節點上建立鎖,過期時間較短,一般就幾十毫秒;

嘗試在大多數節點上建立一個鎖,比如 5 個節點就要求是 3 個節點 <code>n / 2 + 1</code>;

用戶端計算建立好鎖的時間,如果建立鎖的時間小于逾時時間,就算建立成功了;

要是鎖建立失敗了,那麼就依次之前建立過的鎖删除;

隻要别人建立了一把分布式鎖,你就得不斷輪詢去嘗試擷取鎖。

zk 分布式鎖,其實可以做的比較簡單,就是某個節點嘗試建立臨時 znode,此時建立成功了就擷取了這個鎖;這個時候别的用戶端來建立鎖會失敗,隻能注冊個監聽器監聽這個鎖。釋放鎖就是删除這個 znode,一旦釋放掉就會通知用戶端,然後有一個等待着的用戶端就可以再次重新加鎖。

  

也可以采用另一種方式,建立臨時順序節點:

如果有一把鎖,被多個人給競争,此時多個人會排隊,第一個拿到鎖的人會執行,然後釋放鎖;後面的每個人都會去監聽排在自己前面的那個人建立的 node 上,一旦某個人釋放了鎖,排在自己後面的人就會被 zookeeper 給通知,一旦被通知了之後,就 ok 了,自己就擷取到了鎖,就可以執行代碼了。

redis 分布式鎖,其實需要自己不斷去嘗試擷取鎖,比較消耗性能。

zk 分布式鎖,擷取不到鎖,注冊個監聽器即可,不需要不斷主動嘗試擷取鎖,性能開銷較小。

另外一點就是,如果是 redis 擷取鎖的那個用戶端 出現 bug 挂了,那麼隻能等待逾時時間之後才能釋放鎖;而 zk 的話,因為建立的是臨時 znode,隻要用戶端挂了,znode 就沒了,此時就自動釋放鎖。

redis 分布式鎖大家沒發現好麻煩嗎?周遊上鎖,計算時間等等......zk 的分布式鎖語義清晰實作簡單。

是以先不分析太多的東西,就說這兩點,我個人實踐認為 zk 的分布式鎖比 redis 的分布式鎖牢靠、而且模型簡單易用。 

程式員的眼裡,不止有代碼和bug,還有詩與遠方和妹子!!!

往期推薦

卧槽!竟然可以直接白嫖 Github Action 的 2C7G 伺服器!

阿裡取消“P”序列職級顯示引熱議,網友:P3、P4流下了感動的淚水

RocketMQ 消息丢失場景及解決辦法

IntelliJ IDEA 2020.2.1 釋出,Lombok插件可能被官方支援

思科前員工離職後删庫,直接損失達 240 萬美元