純技術(shù)干貨分享:分布式事務(wù)處理方式總結(jié)
在項(xiàng)目開發(fā)中,經(jīng)常會(huì)需要處理分布式事務(wù)。例如數(shù)據(jù)庫(kù)分庫(kù)分表之后,原來在一個(gè)單庫(kù)上的操作可能會(huì)跨越多個(gè)數(shù)據(jù)庫(kù)。系統(tǒng)服務(wù)化拆分之后,原來的在一個(gè)系統(tǒng)上的操作可能會(huì)跨越多個(gè)系統(tǒng)。就連我們平時(shí)經(jīng)常使用到的緩存(如redis、memcache等)也可能涉及分布式事務(wù),因?yàn)榫彺婧蛿?shù)據(jù)庫(kù)是兩個(gè)不同的實(shí)體,如何保證數(shù)據(jù)在緩存和數(shù)據(jù)庫(kù)間的一致性也是要重點(diǎn)考慮的。分布式事務(wù)就是指事務(wù)要處理的資源分別位于分布式系統(tǒng)中的不同節(jié)點(diǎn)之上的事務(wù)。
對(duì)于單機(jī)系統(tǒng),通常我們借助數(shù)據(jù)庫(kù)實(shí)現(xiàn)本地事務(wù),例如下面JDBC代碼實(shí)現(xiàn)了一個(gè)事務(wù):
- Connection con = datasource.getConnection();
- con.setAutoCommit(false);
- ...
- 執(zhí)行CRUD操作,可能會(huì)涉及到多個(gè)表
- ...
- con.commit()/con.rollback()
由于在分布式系統(tǒng)中,多個(gè)系統(tǒng)無法共用同一個(gè)數(shù)據(jù)庫(kù)鏈接,所以無法簡(jiǎn)單借用上面的處理方式實(shí)現(xiàn)分布式事務(wù)。
下面將介紹幾種本人在實(shí)際開發(fā)中使用過的處理分布式事務(wù)的方式,最后再引出分布式事務(wù)的相關(guān)理論并進(jìn)行總結(jié)。
避免出現(xiàn)分布式事務(wù)
由于分布式事務(wù)比較難于處理,所以應(yīng)該盡量避免分布式事務(wù)的發(fā)生。例如對(duì)于一個(gè)客戶信息系統(tǒng),由于注冊(cè)用戶數(shù)太多導(dǎo)致存儲(chǔ)的數(shù)據(jù)量過大,所以對(duì)其進(jìn)行分庫(kù)分表存儲(chǔ)。而客戶信息模型又分為多個(gè)子模型,對(duì)應(yīng)數(shù)據(jù)庫(kù)中的多個(gè)表,例如客戶基本信息表、客戶登錄賬號(hào)表、客戶登錄密碼表、客戶聯(lián)系方式表等等。假設(shè)登錄賬號(hào)表和客戶基本信息表的關(guān)聯(lián)關(guān)系如下所示:

user_id和login_id分別是兩個(gè)表的主鍵,user_id還作為login_info表的外鍵使兩個(gè)表關(guān)聯(lián)。在用戶注冊(cè)時(shí)會(huì)自動(dòng)生成user_id和login_id的值。 user_info和login_info兩個(gè)表分別采用user_id和login_id計(jì)算分庫(kù)分表規(guī)則 。假設(shè)我們對(duì)每個(gè)模型分十庫(kù)一百表存儲(chǔ),即存在user_info_00 ~ user_info_99一百個(gè)表,其中user_info_00 ~ user_info_09屬于第一個(gè)庫(kù),user_info_10 ~ user_info_19屬于第二個(gè)庫(kù),依次類推。
在分庫(kù)分表之后,如果我們不仔細(xì)的考慮user_id和login_id的生成規(guī)則(例如隨意生成一個(gè)數(shù)字字符串或簡(jiǎn)單使用遞增sequence),就可能導(dǎo)致同一個(gè)用戶的user_info信息和login_info信息被存儲(chǔ)到兩個(gè)不同的庫(kù),這就會(huì)導(dǎo)致分布式事務(wù)發(fā)生。
面對(duì)這種問題,最好的解決思路就是考慮如何避免分布式事務(wù)的發(fā)生。只要想辦法讓跟一個(gè)用戶相關(guān)的所有模型數(shù)據(jù)全部存入到一個(gè)庫(kù)中,就可以避免分布式事務(wù)了。由于每個(gè)模型數(shù)據(jù)的分庫(kù)分表路由規(guī)則又是由各個(gè)表的主鍵id決定的(例如user_id、login_id),所以只要對(duì)各個(gè)表的主鍵生成規(guī)則進(jìn)行定制,就可以保證一個(gè)用戶的所有模型數(shù)據(jù)全部存到同一個(gè)庫(kù)。假設(shè)有下面的id生成規(guī)則:

- 開始的兩位是標(biāo)識(shí)模型位,例如user_id以01開頭,login_id以02開頭。
- 接下來的11位是sequence遞增序列號(hào),如果想要更多的ID可以擴(kuò)大這部分的位數(shù),但對(duì)于存儲(chǔ)用戶信息而言,11位的長(zhǎng)度足夠。
- 接下來是分庫(kù)分表位,如果每個(gè)模型的分庫(kù)分表算法都相同,那么只要保證每個(gè)模型的主鍵ID的分庫(kù)分表位都相同,就能保證一個(gè)用戶的所有模型數(shù)據(jù)都會(huì)存到同一個(gè)庫(kù)中。
- 最后一位是id校驗(yàn)位,這一位根據(jù)前面15位的內(nèi)容生成,方便對(duì)一個(gè)id進(jìn)行校驗(yàn)。
根據(jù)這個(gè)思想,我們可以在用戶注冊(cè)的時(shí)候先生成user_id,user_id的分庫(kù)分表位可以隨機(jī)生成。然后在為其它模型生成主鍵id時(shí)(例如login_id),必須讓這個(gè)模型的主鍵id的分庫(kù)分表位與user_id的分庫(kù)分表位相同。另外一點(diǎn)也要注意,一個(gè)表的查詢條件不一定只有主鍵id一個(gè),如果有其它查詢條件列,那就要保證那一列的生成規(guī)則也要包含相同的分庫(kù)分表位,否則就不能使用該列進(jìn)行查詢。
通過這種方式,就可以保證一個(gè)用戶的所有模型數(shù)據(jù)全部存儲(chǔ)到同一個(gè)庫(kù)中,有效的避免分布式事務(wù)的發(fā)生。
事務(wù)補(bǔ)償
通常情況下,應(yīng)對(duì)高并發(fā)的一個(gè)主要手段就是增加分布式緩存(如redis)以提高查詢性能。增加分布式緩存后系統(tǒng)查詢數(shù)據(jù)的流程如下圖:

即先嘗試從緩存中查詢數(shù)據(jù),如果緩存命中就直接返回結(jié)果,否則嘗試從DB中查詢數(shù)據(jù)。如果查詢DB命中則將數(shù)據(jù)補(bǔ)充到緩存,以備下次查詢時(shí)可以命中緩存。
而在更新數(shù)據(jù)時(shí),通常是先更新DB中的數(shù)據(jù),DB寫入成功后再更新緩存中的數(shù)據(jù)。那么就有一個(gè)問題, 如何保證緩存和DB間數(shù)據(jù)的一致性? 由于緩存和DB是兩個(gè)不同的實(shí)體,寫入DB成功后再去更新緩存,如果緩存更新失敗(例如網(wǎng)絡(luò)抖動(dòng)造成短暫的緩存不可用)就會(huì)造成緩存和DB的不一致。此時(shí)按照上圖的查詢邏輯,先查緩存就會(huì)查詢到“臟”的數(shù)據(jù),就會(huì)嚴(yán)重影響業(yè)務(wù)。這也是一個(gè)典型的分布式事務(wù)問題——緩存和DB要嘛同時(shí)更新成功,要嘛同時(shí)更新失敗。解決這個(gè)問題的一個(gè)較好方式就是事務(wù)補(bǔ)償。
我們可以在DB中創(chuàng)建一張事務(wù)補(bǔ)償表transaction_log,transaction_log表可以和業(yè)務(wù)數(shù)據(jù)在一個(gè)庫(kù)中,也可以在不同的庫(kù)。在更新數(shù)據(jù)前,先將要更新的模型數(shù)據(jù)記錄到transaction_log中。例如我們更新user_info表中的數(shù)據(jù),就將userId記錄到transaction_log中。
transaction_log記錄成功后,再去更新業(yè)務(wù)數(shù)據(jù)表user_info中的內(nèi)容,最后更新緩存中的userInfo數(shù)據(jù)。緩存更新成功后,就可以刪除transaction_log表中對(duì)應(yīng)的記錄。
假設(shè)在更新完user_info表之后,由于網(wǎng)絡(luò)抖動(dòng)等原因?qū)е戮彺娓率。瑒ttransaction_log表中對(duì)應(yīng)的記錄就會(huì)一直存在,表示這個(gè)事務(wù)沒有完成的一種記錄。
應(yīng)用會(huì)創(chuàng)建一個(gè)定時(shí)任務(wù),周期性的掃描transaction_log表中的記錄(例如每隔2S掃描一次)。發(fā)現(xiàn)有符合條件的記錄,就嘗試執(zhí)行補(bǔ)償邏輯。例如更新用戶信息時(shí),DB中的user_info表更新成功,但緩存更新失敗,定時(shí)任務(wù)發(fā)現(xiàn)transaction_log表中對(duì)應(yīng)的記錄沒有刪除且已經(jīng)超過正常等待時(shí)間,就嘗試使緩存和DB一致(可以刪除緩存中對(duì)應(yīng)的數(shù)據(jù),也可以根據(jù)userId重新查詢DB再補(bǔ)充的緩存)。補(bǔ)償任務(wù)執(zhí)行完成后,就可以刪除transaction_log表中對(duì)應(yīng)的記錄。如果補(bǔ)償任務(wù)執(zhí)行再次失敗,就保留transaction_log表中的記錄,等待下個(gè)周期再次執(zhí)行。
事務(wù)補(bǔ)償這種方式保證的是事務(wù)的最終一致性,即如果發(fā)生意外,會(huì)存在一個(gè)時(shí)間窗口(例如2S),在這個(gè)窗口內(nèi)DB和緩存間是不一致的,但能保證最終兩者的數(shù)據(jù)是一致的。至于定時(shí)任務(wù)周期的設(shè)定,要結(jié)合業(yè)務(wù)對(duì)“臟”數(shù)據(jù)的敏感程度以及系統(tǒng)的負(fù)載。
事務(wù)型消息
對(duì)于一個(gè)金融系統(tǒng),假設(shè)有一個(gè)需求是用戶注冊(cè)成功后自動(dòng)為用戶創(chuàng)建一個(gè)賬戶。客戶的信息維護(hù)在客戶中心系統(tǒng),客戶的賬戶信息維護(hù)的賬務(wù)中心系統(tǒng),如果用戶注冊(cè)成功,必須保證客戶的賬戶在賬務(wù)系統(tǒng)創(chuàng)建成功。這顯然也是一個(gè)分布式事務(wù)問題。
處理這個(gè)問題,顯然也可以采用上一小節(jié)介紹的事務(wù)補(bǔ)償機(jī)制來處理。但注冊(cè)和開戶并不要求一定是同步完成,且需要感知用戶注冊(cè)成功事件的系統(tǒng)并不只有賬務(wù)系統(tǒng)一個(gè)(例如營(yíng)銷系統(tǒng)可能也需要感知用戶注冊(cè)成功的事件,給用戶發(fā)優(yōu)惠券),所以使用消息機(jī)制異步通知更加合適。那么問題就變成了“如果用戶注冊(cè)成功,一定要保證消息發(fā)送成功”。
應(yīng)對(duì)這種場(chǎng)景,可以使用事務(wù)型消息。但前提條件是使用的MQ中間件必須支持事務(wù)型消息,比如阿里的RocketMQ。目前市面上其它一些主流的MQ中間件都不支持事務(wù)型消息,比如Kafka和RabbitMQ都不支持。
下面的序列圖是事務(wù)型消息的執(zhí)行流程:

- 相比于普通消息,發(fā)布者發(fā)送消息后,MQ并不是馬上將消息發(fā)送給訂閱者,而僅僅是將消息持久化存儲(chǔ)下來。
- 發(fā)送消息成功之后,發(fā)布者執(zhí)行本地事務(wù)。例如我們例子中提到的用戶注冊(cè)。
- 根據(jù)本地事務(wù)執(zhí)行是否成功,發(fā)布者決定對(duì)之前已經(jīng)發(fā)送的消息是commit還是rollback。如果是rollback,MQ會(huì)刪除之前存儲(chǔ)的消息。假設(shè)我們這里發(fā)送commit。
- MQ接收到發(fā)布者發(fā)送的commit后,才會(huì)將消息發(fā)送給訂閱者。之后,就可以利用MQ的消息可靠傳輸特性促使訂閱者完成剩余事務(wù)操作,例如上面例子中提到的開戶操作。
細(xì)心的小伙伴會(huì)發(fā)現(xiàn),如果在上圖中的第5步發(fā)生問題導(dǎo)致發(fā)送commit失敗,不還是會(huì)導(dǎo)致消息發(fā)布者和消息訂閱者間事務(wù)的不一致嗎?為了防止這種情況的發(fā)生,增加MQ超時(shí)回調(diào)機(jī)制。
下面的序列圖是事務(wù)型消息commit失敗時(shí)的執(zhí)行流程:

當(dāng)MQ長(zhǎng)時(shí)間收不到發(fā)布者的commit/rollback通知時(shí),MQ會(huì)回調(diào)發(fā)布者應(yīng)用詢問本地事務(wù)是否執(zhí)行成功,是commit還是rollback之前的消息。發(fā)布者需要提供對(duì)應(yīng)的callback,在callback中判斷本地事務(wù)是否執(zhí)行成功。
TCC兩階段提交
在某些場(chǎng)景下,一個(gè)分布式事務(wù)可能會(huì)涉及到多個(gè)參與者,且每個(gè)參與者需要根據(jù)自己當(dāng)時(shí)的狀態(tài)對(duì)事務(wù)進(jìn)行響應(yīng)。
假設(shè)這樣一個(gè)場(chǎng)景,一個(gè)電商網(wǎng)站可以允許用戶在支付時(shí)選擇多種支付方式。例如總共需要支付100元錢,用戶可以選擇積分支付10元,賬戶余額支付90元。用戶的積分由營(yíng)銷系統(tǒng)負(fù)責(zé),賬戶余額由賬務(wù)系統(tǒng)負(fù)責(zé),訂單的狀態(tài)管理由訂單系統(tǒng)負(fù)責(zé)。
- 首先,要先確保事務(wù)的各個(gè)參與者滿足條件才能執(zhí)行事務(wù)。例如積分系統(tǒng)要確保用戶的積分超過10元錢,賬務(wù)系統(tǒng)要確保用戶的賬戶余額大于90元錢才能發(fā)起這次交易。
- 其次,就是要滿足事務(wù)的原子性。這里的用戶積分、用戶余額、訂單狀態(tài),要嘛全部處理成功,要嘛全部保持不變。
應(yīng)對(duì)這種分布式事務(wù)場(chǎng)景,可以采用TCC兩階段提交的方式進(jìn)行處理。
TCC將整個(gè)事務(wù)分成兩個(gè)階段——try和commit/cancel。TCC整個(gè)流程具有三種角色——事務(wù)發(fā)起者、事務(wù)參與者、事務(wù)協(xié)調(diào)者。以上面的訂單支付為例,采用TCC實(shí)現(xiàn)處理事務(wù)的流程如下:

- 第一階段try,訂單系統(tǒng)分別調(diào)用promotion和account兩個(gè)系統(tǒng),詢問該用戶是否有足夠的積分和賬戶余額。為了防止資源爭(zhēng)搶,在這個(gè)階段會(huì)對(duì)資源進(jìn)行鎖定,即營(yíng)銷系統(tǒng)會(huì)鎖住用戶的10元積分,賬務(wù)系統(tǒng)會(huì)鎖住用戶的90元賬戶余額。
- 如果在try階段有任何一個(gè)參與者處理失敗(例如用戶積分不夠10元或者用戶的余額不夠90元),則事務(wù)發(fā)起方(訂單系統(tǒng))會(huì)通知事務(wù)協(xié)調(diào)組件,后者會(huì)通知所有的事務(wù)參與者cancel在try階段鎖定的資源。
- 如果在try階段所有的參與者都處理成功,則事務(wù)發(fā)起方通知協(xié)調(diào)者commit這個(gè)事務(wù),協(xié)調(diào)者會(huì)通知所有的參與者完成事務(wù)的commit。這時(shí)系統(tǒng)會(huì)完成真正的余額和積分扣減。2.2步是假設(shè)訂單系統(tǒng)也要更新訂單的狀態(tài)。
但僅是這樣處理還是有一致性問題,例如在第二階段commit時(shí)如果發(fā)生宕機(jī)、網(wǎng)絡(luò)抖動(dòng)等異常情況,就可能導(dǎo)致事務(wù)處于“非最終一致”狀態(tài)(參與者只執(zhí)行了try階段,沒有執(zhí)行第二階段。或部分參與者第二階段commit成功,部分參與者commit失敗)。為了應(yīng)對(duì)這種情況,需要增加事務(wù)日志,以便發(fā)生異常時(shí)回復(fù)事務(wù)。
可以利用DB這種可靠存儲(chǔ)來記錄事務(wù)日志。日志中應(yīng)包含事務(wù)執(zhí)行過程中的上下文、事務(wù)執(zhí)行狀態(tài)、事務(wù)的參與者等信息。事務(wù)日志可以由事務(wù)發(fā)起發(fā)負(fù)責(zé)記錄,也可以交由事務(wù)協(xié)調(diào)方進(jìn)行記錄。
事務(wù)日志可以由主事務(wù)記錄日志和從事務(wù)記錄日志組成:
- 主事務(wù)記錄日志 用于記錄事務(wù)發(fā)起方信息以及事務(wù)執(zhí)行的整體狀態(tài)。
- 從事務(wù)記錄日志 用于記錄所有的事務(wù)參與者信息,以及每個(gè)參與者所屬的從事務(wù)的執(zhí)行狀態(tài)。與主事務(wù)記錄日志是一對(duì)多的關(guān)系。
有了事務(wù)日志后,就可以周期性的不斷掃描事務(wù)日志,找到異常中斷的事務(wù)。根據(jù)事務(wù)日志中記錄的信息,推動(dòng)剩余的參與者commit或者cancel,以便使整個(gè)分布式事務(wù)達(dá)到“最終一致性”。
下面是commit階段發(fā)生異常時(shí)的事務(wù)補(bǔ)償邏輯:

TCC兩階段提交的實(shí)現(xiàn)需要注意如下事項(xiàng):
- 事務(wù)中的任何一個(gè)參與者都要確保在try階段操作成功,在第二階段就一定能commit成功。
- 參與者在實(shí)現(xiàn)commit和cancel接口時(shí)要考慮冪等,對(duì)重復(fù)的commit/cancel請(qǐng)求要能夠正確處理。
- 業(yè)務(wù)上要考慮對(duì)兩階段中間狀態(tài)(一階段已完成,二階段未開始)的處理。一般可以通過一些特殊文案,比如顯示當(dāng)前被凍結(jié)的賬戶余額。
- 對(duì)于狀態(tài)型數(shù)據(jù),當(dāng)多個(gè)事務(wù)共同操作同一個(gè)資源時(shí),要確保資源隔離。例如賬戶余額,確保不同的事務(wù)操作的金額是隔離的,彼此互不影響。
- 由于網(wǎng)絡(luò)丟包、亂序等因素的影響,可能會(huì)導(dǎo)致參與者接收到一階段try請(qǐng)求后,永遠(yuǎn)收不到commit/cancel請(qǐng)求,導(dǎo)致參與者的資源一直被鎖定,永遠(yuǎn)不會(huì)被釋放,這種情況叫做事務(wù)懸掛。為了防止事務(wù)懸掛的發(fā)生,可以在第一階段try成功后,指定一個(gè)最大等待時(shí)間。超過這個(gè)最大等待時(shí)間就自動(dòng)釋放被鎖定的資源。
總結(jié)
傳統(tǒng)的單機(jī)事務(wù)應(yīng)滿足A(原子性)、C(一致性)、I(隔離型)、D(持久性)四個(gè)特性,屬于剛性事務(wù)。由于分布式系統(tǒng)具有多個(gè)節(jié)點(diǎn)的特點(diǎn),要求完全滿足ACID這四個(gè)規(guī)范會(huì)非常的困難。所以就誕生了柔性事務(wù)BASE理論(Basic availability、Soft state、Eventual consistency)。
相比于單機(jī)事務(wù),分布式事務(wù)在A和D上仍能夠嚴(yán)格保證,但在C和I上就要有一定程度的限制放寬(允許看到中間狀態(tài)數(shù)據(jù)、最終一致性)。