文接上篇累提。 在上篇里我們介紹了皱碘, 消息時(shí)序的一致性 和 冗余表的一致性财剖。 消息時(shí)序我們是利用LAMPORT時(shí)間戳來解決,允許大方向是消息有序小范圍允許亂序的思想寸谜。而且主要還是以服務(wù)器端的時(shí)間為準(zhǔn)竟稳。 在冗余表的一致性里,有2個(gè)重要的思想熊痴,一個(gè)是看先寫哪個(gè)失敗之后對(duì)業(yè)務(wù)影響更加小他爸,另外一個(gè)是看對(duì)服務(wù)的性能要求有多高,而決定是同步還是異步果善。最后我們看如何對(duì)增量數(shù)據(jù)維護(hù)一個(gè)額外的修復(fù)機(jī)制诊笤,是拉還是推。
這篇文章是最后2個(gè)問題巾陕,分布式事務(wù)的一致性和庫(kù)存扣減的一致性讨跟。
7. 分布式事務(wù)的一致性
一、案例緣起
我們經(jīng)常使用事務(wù)來保證數(shù)據(jù)庫(kù)層面數(shù)據(jù)的ACID特性鄙煤。
舉個(gè)栗子晾匠,用戶下了一個(gè)訂單,需要修改余額表馆类,訂單表混聊,流水表弹谁,于是會(huì)有類似的偽代碼:
start transaction;
CURDtable t_account; any Exception rollback;
CURDtable t_order; any Exceptionrollback;
CURDtable t_flow; any Exceptionrollback;
commit;
如果對(duì)余額表乾巧,訂單表,流水表的SQL操作全部成功预愤,則全部提交沟于,如果任何一個(gè)出現(xiàn)問題,則全部回滾植康,以保證數(shù)據(jù)的一致性旷太。
互聯(lián)網(wǎng)的業(yè)務(wù)特點(diǎn),數(shù)據(jù)量較大,并發(fā)量較大供璧,經(jīng)常使用拆庫(kù)的方式提升系統(tǒng)的性能存崖。如果進(jìn)行了拆庫(kù),余額睡毒、訂單来惧、流水可能分布在不同的數(shù)據(jù)庫(kù)上,甚至不同的數(shù)據(jù)庫(kù)實(shí)例上演顾,此時(shí)就不能用事務(wù)來保證數(shù)據(jù)的一致性了供搀。這種情況下如何保證數(shù)據(jù)的一致性,是今天要討論的話題钠至。
二葛虐、補(bǔ)償事務(wù)
補(bǔ)償事務(wù)是一種在業(yè)務(wù)端實(shí)施業(yè)務(wù)逆向操作事務(wù),來保證業(yè)務(wù)數(shù)據(jù)一致性的方式棉钧。
舉個(gè)栗子屿脐,修改余額表事務(wù)為
int Do_AccountT(uid, money){
start transaction;
//余額改變money這么多
CURDtable t_account with money; anyException rollback return NO;
commit;
return YES;
}
那么補(bǔ)償事務(wù)可以是:
int Compensate_AccountT(uid, money){
//做一個(gè)money的反向操作
returnDo_AccountT(uid, -1*money){
}
同理,訂單表操作為
Do_OrderT宪卿,新增一個(gè)訂單
Compensate_OrderT摄悯,刪除一個(gè)訂單
要保重余額與訂單的一致性,可能要寫這樣的代碼:
// 執(zhí)行第一個(gè)事務(wù)
int flag = Do_AccountT();
if(flag=YES){
//第一個(gè)事務(wù)成功愧捕,則執(zhí)行第二個(gè)事務(wù)
flag= Do_OrderT();
if(flag=YES){
// 第二個(gè)事務(wù)成功奢驯,則成功
returnYES;
}
else{
// 第二個(gè)事務(wù)失敗,執(zhí)行第一個(gè)事務(wù)的補(bǔ)償事務(wù)
Compensate_AccountT();
}
}
該方案的不足是:
(1)不同的業(yè)務(wù)要寫不同的補(bǔ)償事務(wù)次绘,不具備通用性
(2)沒有考慮補(bǔ)償事務(wù)的失敗
(3)如果業(yè)務(wù)流程很復(fù)雜瘪阁,if/else會(huì)嵌套非常多層
例如,如果上面的例子加上流水表的修改邮偎,加上Do_FlowT和Compensate_FlowT管跺,可能會(huì)變成一個(gè)這樣的if/else:
// 執(zhí)行第一個(gè)事務(wù)
int flag = Do_AccountT();
if(flag=YES){
//第一個(gè)事務(wù)成功,則執(zhí)行第二個(gè)事務(wù)
flag= Do_OrderT();
if(flag=YES){
// 第二個(gè)事務(wù)成功禾进,則執(zhí)行第三個(gè)事務(wù)
flag= Do_FlowT();
if(flag=YES){
//第三個(gè)事務(wù)成功豁跑,則成功
returnYES;
}
else{
// 第三個(gè)事務(wù)失敗,則執(zhí)行第二泻云、第一個(gè)事務(wù)的補(bǔ)償事務(wù)
flag =Compensate_OrderT();
if … else … // 補(bǔ)償事務(wù)執(zhí)行失斖摹?
flag= Compensate_AccountT();
if … else … // 補(bǔ)償事務(wù)執(zhí)行失敵璐俊卸夕?
}
}
else{
// 第二個(gè)事務(wù)失敗,執(zhí)行第一個(gè)事務(wù)的補(bǔ)償事務(wù)
Compensate_AccountT();
if … else … // 補(bǔ)償事務(wù)執(zhí)行失斊殴稀快集?
}
}
三贡羔、事務(wù)拆分分析與后置提交優(yōu)化
單庫(kù)是用這樣一個(gè)大事務(wù)保證一致性:
start transaction;
CURDtable t_account; any Exception rollback;
CURDtable t_order; any Exceptionrollback;
CURDtable t_flow; any Exceptionrollback;
commit;
拆分成了多個(gè)庫(kù),大事務(wù)會(huì)變成三個(gè)小事務(wù):
start transaction1;
//第一個(gè)庫(kù)事務(wù)執(zhí)行
CURDtable t_account; any Exception rollback;
…
// 第一個(gè)庫(kù)事務(wù)提交
commit1;
start transaction2;
//第二個(gè)庫(kù)事務(wù)執(zhí)行
CURDtable t_order; any Exceptionrollback;
…
// 第二個(gè)庫(kù)事務(wù)提交
commit2;
start transaction3;
//第三個(gè)庫(kù)事務(wù)執(zhí)行
CURDtable t_flow; any Exceptionrollback;
…
// 第三個(gè)庫(kù)事務(wù)提交
commit3;
一個(gè)事務(wù)个初,分成執(zhí)行與提交兩個(gè)階段乖寒,執(zhí)行的時(shí)間其實(shí)是很長(zhǎng)的,而commit的執(zhí)行其實(shí)是很快的院溺,于是整個(gè)執(zhí)行過程的時(shí)間軸如下:
第一個(gè)事務(wù)執(zhí)行200ms宵统,提交1ms;
第二個(gè)事務(wù)執(zhí)行120ms覆获,提交1ms马澈;
第三個(gè)事務(wù)執(zhí)行80ms,提交1ms弄息;
那在什么時(shí)候系統(tǒng)出現(xiàn)問題痊班,會(huì)出現(xiàn)不一致呢?
回答:第一個(gè)事務(wù)成功提交之后摹量,最后一個(gè)事務(wù)成功提交之前涤伐,如果出現(xiàn)問題(例如服務(wù)器重啟,數(shù)據(jù)庫(kù)異常等)缨称,都可能導(dǎo)致數(shù)據(jù)不一致凝果。
如果改變事務(wù)執(zhí)行與提交的時(shí)序,變成事務(wù)先執(zhí)行睦尽,最后一起提交器净,情況會(huì)變成什么樣呢:
第一個(gè)事務(wù)執(zhí)行200ms;
第二個(gè)事務(wù)執(zhí)行120ms当凡;
第三個(gè)事務(wù)執(zhí)行80ms山害;
第一個(gè)事務(wù)執(zhí)行1ms;
第二個(gè)事務(wù)執(zhí)行1ms沿量;
第三個(gè)事務(wù)執(zhí)行1ms浪慌;
那在什么時(shí)候系統(tǒng)出現(xiàn)問題,會(huì)出現(xiàn)不一致呢朴则?
問題的答案與之前相同:第一個(gè)事務(wù)成功提交之后权纤,最后一個(gè)事務(wù)成功提交之前,如果出現(xiàn)問題(例如服務(wù)器重啟乌妒,數(shù)據(jù)庫(kù)異常等)汹想,都可能導(dǎo)致數(shù)據(jù)不一致。
這個(gè)變化的意義是什么呢芥被?
方案一總執(zhí)行時(shí)間是303ms欧宜,最后202ms內(nèi)出現(xiàn)異常都可能導(dǎo)致不一致;
方案二總執(zhí)行時(shí)間也是303ms拴魄,但最后2ms內(nèi)出現(xiàn)異常才會(huì)導(dǎo)致不一致;
雖然沒有徹底解決數(shù)據(jù)的一致性問題,但不一致出現(xiàn)的概率大大降低了匹中!
事務(wù)提交后置降低了數(shù)據(jù)不一致的出現(xiàn)概率夏漱,會(huì)帶來什么副作用呢?
回答:事務(wù)提交時(shí)會(huì)釋放數(shù)據(jù)庫(kù)的連接顶捷,第一種方案挂绰,第一個(gè)庫(kù)事務(wù)提交,數(shù)據(jù)庫(kù)連接就釋放了服赎,后置事務(wù)提交的方案葵蒂,所有庫(kù)的連接,要等到所有事務(wù)執(zhí)行完才釋放重虑。這就意味著践付,數(shù)據(jù)庫(kù)連接占用的時(shí)間增長(zhǎng)了,系統(tǒng)整體的吞吐量降低了缺厉。
四永高、總結(jié)
trx1.exec();
trx1.commit();
trx2.exec();
trx2.commit();
trx3.exec();
trx3.commit();
優(yōu)化為:
trx1.exec();
trx2.exec();
trx3.exec();
trx1.commit();
trx2.commit();
trx3.commit();
這個(gè)小小的改動(dòng)(改動(dòng)成本極低),不能徹底解決多庫(kù)分布式事務(wù)數(shù)據(jù)一致性問題提针,但能大大降低數(shù)據(jù)不一致的概率命爬,帶來的副作用是數(shù)據(jù)庫(kù)連接占用時(shí)間會(huì)增長(zhǎng),吞吐量會(huì)降低辐脖。對(duì)于一致性與吞吐量的折衷饲宛,還需要業(yè)務(wù)架構(gòu)師謹(jǐn)慎權(quán)衡折衷。
我的思考和啟示
- 上述方法對(duì)現(xiàn)有事務(wù)的機(jī)制沒有改動(dòng)把失敗概率盡可能降低嗜价。
- 如果真的發(fā)生失敗落萎,需要補(bǔ)救機(jī)制。這時(shí)就會(huì)比較麻煩炭剪。
- 我想到的做法是练链,針對(duì)這3個(gè)改動(dòng)的事務(wù)每次COMMIT成功后打一條LOG。 如果有中間失敗奴拦,需要人去做手動(dòng)回滾了媒鼓。
- 如果可以的話,是否能引入二階段提交呢错妖?
8. 庫(kù)存一致性
業(yè)務(wù)復(fù)雜绿鸣、數(shù)據(jù)量大、并發(fā)量大的業(yè)務(wù)場(chǎng)景下暂氯,典型的互聯(lián)網(wǎng)架構(gòu)潮模,一般會(huì)分為這么幾層:
調(diào)用層,一般是處于端上的browser或者APP
站點(diǎn)層痴施,一般是拼裝html或者json返回的web-server層
服務(wù)層擎厢,一般是提供RPC調(diào)用接口的service層
數(shù)據(jù)層究流,提供固化數(shù)據(jù)存儲(chǔ)的db
對(duì)于庫(kù)存業(yè)務(wù),一般有個(gè)庫(kù)存服務(wù)动遭,提供庫(kù)存的查詢芬探、扣減、設(shè)置等RPC接口:
-
庫(kù)存查詢厘惦,stock-service本質(zhì)上執(zhí)行的是
select num from stock where sid=$sid
-
庫(kù)存扣減偷仿,stock-service本質(zhì)上執(zhí)行的是
update stock set num=num-sid
-
庫(kù)存設(shè)置,stock-service本質(zhì)上執(zhí)行的是
update stock set num=sid
用戶下單前宵蕉,一般會(huì)對(duì)庫(kù)存進(jìn)行查詢酝静,有足夠的存量才允許扣減:
如上圖所示,通過查詢接口羡玛,得到庫(kù)存是5别智。
用戶下單時(shí),接著會(huì)對(duì)庫(kù)存進(jìn)行扣減:
如上圖所示缝左,購(gòu)買3單位的商品亿遂,通過扣減接口,最終得到庫(kù)存是2渺杉。
希望設(shè)計(jì)往往有容錯(cuò)機(jī)制蛇数,例如“重試”,如果通過扣減接口來修改庫(kù)存是越,在重試時(shí)耳舅,可能會(huì)得到錯(cuò)誤的數(shù)據(jù),導(dǎo)致重復(fù)扣減:
如上圖所示倚评,如果數(shù)據(jù)庫(kù)層面有重試容錯(cuò)機(jī)制浦徊,可能導(dǎo)致一次扣減執(zhí)行兩次,最終得到一個(gè)負(fù)數(shù)的錯(cuò)誤庫(kù)存天梧。
重試導(dǎo)致錯(cuò)誤的根本原因盔性,是因?yàn)椤翱蹨p”操作是一個(gè)非冪等的操作,不能夠重復(fù)執(zhí)行呢岗,改成設(shè)置操作則不會(huì)有這個(gè)問題:
如上圖所示冕香,同樣是購(gòu)買3單位的商品,通過設(shè)置庫(kù)存操作后豫,即使有重試容錯(cuò)機(jī)制悉尾,也不會(huì)得到錯(cuò)誤的庫(kù)存,設(shè)置庫(kù)存是一個(gè)冪等操作挫酿。
在并發(fā)量很大的情況下构眯,還會(huì)有其他的問題:
如上圖所示,兩個(gè)并發(fā)的操作早龟,查詢庫(kù)存惫霸,都得到了庫(kù)存是5猫缭。
接下來用戶發(fā)生了并發(fā)的購(gòu)買動(dòng)作(秒殺類業(yè)務(wù)特別容易出現(xiàn)):
如上圖所示:
用戶1購(gòu)買了3個(gè)庫(kù)存,于是庫(kù)存要設(shè)置為2
用戶2購(gòu)買了2個(gè)庫(kù)存它褪,于是庫(kù)存要設(shè)置為3
這兩個(gè)設(shè)置庫(kù)存的接口并發(fā)執(zhí)行饵骨,庫(kù)存會(huì)先變成2翘悉,再變成3茫打,導(dǎo)致數(shù)據(jù)不一致(實(shí)際賣出了5件商品,但庫(kù)存只扣減了2妖混,最后一次設(shè)置庫(kù)存會(huì)覆蓋和掩蓋前一次并發(fā)操作)
其根本原因是老赤,設(shè)置操作發(fā)生的時(shí)候,沒有檢查庫(kù)存與查詢出來的庫(kù)存有沒有變化制市,理論上:
庫(kù)存為5時(shí)抬旺,用戶1的庫(kù)存設(shè)置才能成功
庫(kù)存為5時(shí),用戶2的庫(kù)存設(shè)置才能成功
實(shí)際執(zhí)行的時(shí)候:
庫(kù)存為5祥楣,用戶1的set stock 2確實(shí)應(yīng)該成功
庫(kù)存變?yōu)?了开财,用戶2的set stock 3應(yīng)該失敗掉
升級(jí)修改很容易,將庫(kù)存設(shè)置接口误褪,stock-service上執(zhí)行的:
update stock set num=sid
升級(jí)為:
update stock set num=sid and num=$num_old
這正是大家常說的“Compare And Set”(CAS)责鳍,是一種常見的降低讀寫鎖沖突,保證數(shù)據(jù)一致性的方法兽间。
總結(jié)
在業(yè)務(wù)復(fù)雜历葛,數(shù)據(jù)量大,并發(fā)量大的情況下嘀略,庫(kù)存扣減容易引發(fā)數(shù)據(jù)的不一致恤溶,常見的優(yōu)化方案有兩個(gè):
調(diào)用“設(shè)置庫(kù)存”接口,能夠保證數(shù)據(jù)的冪等性
在實(shí)現(xiàn)“設(shè)置庫(kù)存”接口時(shí)帜羊,需要加上原有庫(kù)存的比較咒程,才允許設(shè)置成功,能解決高并發(fā)下庫(kù)存扣減的一致性問題
我的思考和啟示
- 設(shè)置可以保證冪等性讼育,COMPARE AND SET 解決并發(fā)下的問題帐姻。