分布式場景下如何進行快照讀是一個很常見的問題,因為在這種場景下極易讀取到分布式事務(wù)的“中間狀態(tài)”。針對這一點,騰訊云數(shù)據(jù)庫TDSQL設(shè)計了全局一致性讀方案,解決了分布式節(jié)點間數(shù)據(jù)的讀一致性問題。
近日騰訊云數(shù)據(jù)庫專家工程師張文在第十二屆中國數(shù)據(jù)庫技術(shù)大會上為大家分享了“TDSQL全局一致性讀技術(shù)”。以下是分享實錄:
分布式下一致性讀問題
近年來很多企業(yè)都會發(fā)展自己的分布式數(shù)據(jù)庫應(yīng)用,一種常見的發(fā)展路線是基于開源MySQL,典型方案有共享存儲方案、分表方案,TDSQL架構(gòu)是一種典型的分區(qū)表方案。
以圖例的銀行場景為例,是一種典型的基于MySQL分布式架構(gòu),前端為SQL引擎,后端以MySQL作為存儲引擎,整體上計算與存儲相分離,各自實現(xiàn)橫向擴展。
銀行的轉(zhuǎn)賬業(yè)務(wù)一般是先扣款再加余額,整個交易為一個分布式事務(wù)。分布式事務(wù)基于兩階段提交,保證了交易的最終一致性,但無法保證讀一致性。
轉(zhuǎn)賬操作先給A賬戶扣款再給B賬戶增加余額,這兩個操作要么都成功,要么都不成功,不會出現(xiàn)一個成功一個不成功,這就是分布式事務(wù)。在分布式數(shù)據(jù)庫下,各節(jié)點相對獨立,一邊做扣款的同時另一邊可能已經(jīng)增加余額成功。在某個節(jié)點的存儲引擎內(nèi)部,如果事務(wù)沒有完成提交,那么SQL引擎對于前端仍是阻塞狀態(tài),只有所有子事務(wù)全部完成之后才會返回客戶端成功,這是分布式事務(wù)的最終一致性原理。但是,如果該分布式事務(wù)在返回給前端成功之前,即子事務(wù)還在執(zhí)行過程中,此時,剛好有查詢操作,正好查到這樣的狀態(tài),即A賬戶扣款還沒有成功,但B賬戶余額已經(jīng)增加成功,這便出現(xiàn)了分布式場景下的讀一致性的問題。
部分銀行對這種場景沒有苛刻的要求,出報表的時候如果有數(shù)據(jù)處于這種“中間”狀態(tài),一般通過業(yè)務(wù)流水或其他方式補償,使數(shù)據(jù)達到平衡狀態(tài)。但部分敏感型業(yè)務(wù)對這種讀一致性有強依賴,認為補償操作的代價太高,同時對業(yè)務(wù)的容錯性要求過高。所以,這類銀行業(yè)務(wù)希望依賴數(shù)據(jù)庫本身獲取一個平衡的數(shù)據(jù)鏡像,即要么讀到事務(wù)操作數(shù)據(jù)前的原始狀態(tài),要么讀取到數(shù)據(jù)被分布式事務(wù)修改后的最終狀態(tài)。
針對分布式場景下的一致性讀問題,早期可以通過加鎖讀,即查詢時強制顯示加排他鎖的方式。加鎖讀在高并發(fā)場景下會有明顯的性能瓶頸,還容易產(chǎn)生死鎖。所以,在分布式下,我們希望以一種輕量的方式實現(xiàn)RR隔離級別,即快照讀的能力。一致性讀即快照讀,讀取到的數(shù)據(jù)一定是“平衡”的數(shù)據(jù),不是處于“中間狀態(tài)”的數(shù)據(jù)。對于業(yè)務(wù)來說,無論是集中式數(shù)據(jù)庫還是分布式數(shù)據(jù)庫,都應(yīng)該做到對業(yè)務(wù)透明且無感知。即集中式可以看到的數(shù)據(jù),分布式也同樣能看到,即都要滿足可重復(fù)讀。
在解決這個問題前,我們首先需要關(guān)注基于MySQL這種分布式架構(gòu)的數(shù)據(jù)庫,在單節(jié)點下的事務(wù)一致性和可見性的原理。
下圖是典型的MVCC模型,活躍事務(wù)鏈表會形成高低水位線,高低水位線決定哪些事務(wù)可見或不可見。如果事務(wù)ID比高水位線還要小,該事務(wù)屬于在構(gòu)建可見性視圖之前就已經(jīng)提交的,那么一定可見。而對于低水位線對應(yīng)的事務(wù)ID,如果數(shù)據(jù)行的事務(wù)ID比低水位線大,那么代表該數(shù)據(jù)行在當(dāng)前可見性視圖創(chuàng)建后才生成的,一定不可見。每個事務(wù)ID都是獨立的序列并且是線性增長,每個數(shù)據(jù)行都會綁定一個事務(wù)ID。當(dāng)查詢操作掃描到對應(yīng)的記錄行時,需要結(jié)合查詢時創(chuàng)建的可見性視圖中的高低水位線來判斷可見性。
圖中兩種隔離級別,RC隔離級別可以看到事務(wù)ID為1、3、5的事務(wù),因為1、3、5現(xiàn)在是活躍狀態(tài),后面變成提交狀態(tài)后,提交狀態(tài)是對當(dāng)前查詢可見。而對于RR級別,未來提交是不可見,因為可重復(fù)讀要求可見性視圖構(gòu)建后數(shù)據(jù)的可見性唯一且不變。即原來可見現(xiàn)在仍可見,原來不可見的現(xiàn)在仍不可見,這是Innodb存儲引擎的MVCC原理。我們先要了解單節(jié)點是怎么做的,然后才清楚如何在分布式下對其進行改造。
在下面的這個轉(zhuǎn)賬操作中,A賬戶扣款,B賬戶增加余額,A、B兩個節(jié)點分別是節(jié)點1和節(jié)點2,節(jié)點1原來的數(shù)據(jù)是0,轉(zhuǎn)賬后變?yōu)?0,A節(jié)點之前的事務(wù)ID是18,轉(zhuǎn)賬后變成22,每個節(jié)點的數(shù)據(jù)都有歷史版本的鏈接,事務(wù)ID隨著新事務(wù)的提交而變大。對B節(jié)點來說,原來存儲的這行數(shù)據(jù)的事務(wù)ID是33,事務(wù)提交后變成了37。A、B兩個節(jié)點之間的事務(wù)ID是毫無關(guān)聯(lián)的,各自按照獨立的規(guī)則生成。
所以,此時一筆讀事務(wù)發(fā)起查詢操作,也是相對獨立的。查詢操作發(fā)往計算節(jié)點后,計算節(jié)點會同時發(fā)往A、B兩個MySQL節(jié)點。這個“同時”也是相對的,不可能達到絕對同時。此時,查詢操作對第一個節(jié)點得到的低水位線是23,23大于22,所以當(dāng)前事務(wù)對22可見。查詢發(fā)往第二個節(jié)點時得到的低水位線是37,事務(wù)ID 37的數(shù)據(jù)行對當(dāng)前事務(wù)也可見,這是比較好的結(jié)果,我們看到數(shù)據(jù)是平的,查到的都是最新的數(shù)據(jù)。
然而,如果查詢操作創(chuàng)建可見性視圖時產(chǎn)生的低水位線為36,此時就無法看到事務(wù)ID為37的數(shù)據(jù)行,只能看到事務(wù)ID為33的上一個版本的數(shù)據(jù)。站在業(yè)務(wù)的角度,同時進行了兩個操作一筆轉(zhuǎn)賬一筆查詢,到達存儲引擎的時機未必是轉(zhuǎn)賬在前查詢在后,一定概率上存在時序上的錯位,比如:查詢操作發(fā)生在轉(zhuǎn)賬的過程中。如果發(fā)生錯位又沒有任何干預(yù)和保護,查詢操作很有可能讀到數(shù)據(jù)的“中間狀態(tài)”,即不平的數(shù)據(jù),比如讀取到總賬是20,總賬是0。
目前面對這類問題的思路基本一致,即采用一定的串行化規(guī)則讓其一致。首先,如果涉及分布式事務(wù)的兩個節(jié)點數(shù)據(jù)平衡,首先要統(tǒng)一各節(jié)點的高低水位線,即用一個統(tǒng)一標(biāo)尺才能達到統(tǒng)一的可見性判斷效果。然后,由于事務(wù)ID在各個節(jié)點間相互獨立,這也會造成可見性判斷的不一致,所以事務(wù)ID也要做串行化處理。
在確立串行化的基本思路后,即可構(gòu)造整體的事務(wù)模型。比如:A和B兩個賬戶分別分布在兩個MySQL節(jié)點,節(jié)點1和節(jié)點2。每個節(jié)點的事務(wù)ID強制保持一致,即節(jié)點1、2在事務(wù)執(zhí)行前對應(yīng)的數(shù)據(jù)行綁定的事務(wù)ID都為88,事務(wù)執(zhí)行后綁定的ID都為92。然后,保持可見性視圖的“水位線”一致。
此時,對于查詢來說要么查到的都是舊的數(shù)據(jù),要么查到的都是新的數(shù)據(jù),不會出現(xiàn)“一半是舊的數(shù)據(jù),一半是新的數(shù)據(jù)”這種情況。到這里我們會發(fā)現(xiàn),解決問題的根本:1、統(tǒng)一事務(wù)ID;2、統(tǒng)一查詢的評判標(biāo)準(zhǔn)即“水位線”。當(dāng)然,這里的“事務(wù)ID”已經(jīng)不是單節(jié)點的事務(wù)ID,而是“全局事務(wù)ID”,所以整體思路就是從局部到全局的過程。
TDSQL全局一致性讀方案
剛剛介紹了為什么分布式下會存在一致性讀的問題,接下來分享TDSQL一致性讀的解決方案:
首先引入了全局的時間戳服務(wù),它用來對每一筆事務(wù)進行標(biāo)記,即每一筆分布式事務(wù)綁定一個全局遞增的序列號。然后,在事務(wù)開始的時候獲取時間戳,提交的時候再獲取時間戳,各個節(jié)點內(nèi)部維護事務(wù)ID到全局時間戳的映射關(guān)系。原有的事務(wù)ID不受影響,只是會新產(chǎn)生一種映射關(guān)系:每個ID會映射到一個全局的GTS。
通過修改innodb存儲引擎,我們實現(xiàn)從局部事務(wù)ID到全局GTS的映射,每行數(shù)據(jù)都可以找到唯一的GTS。如果A節(jié)點有100個GTS,B節(jié)點也應(yīng)該有100個GTS,此外分布式事務(wù)開啟的時候都會做一次獲取時間戳的操作。整個過程對原有事務(wù)的影響不大,新增了在事務(wù)提交時遞增并獲取一次時間戳,事務(wù)啟動時獲取一次當(dāng)前時間戳的邏輯。
建立這樣的機制后,再來看分布式事務(wù)的執(zhí)行過程,比如一筆轉(zhuǎn)賬操作,A節(jié)點和B節(jié)點首先在開啟事務(wù)的時候獲取一遍GTS:500,提交的時候由于間隔一段時間GTS可能發(fā)生了變化,因而重新獲取一次GTS:700。查詢操作也是一個獨立的事務(wù),開啟后獲取到全局GTS,比如500或者700,此時查詢到的數(shù)據(jù)一定是平衡的數(shù)據(jù),不可能查到中間狀態(tài)的數(shù)據(jù)。
看似方案已經(jīng)完整,但是還有個問題:即分布式事務(wù)都存在兩階段提交的情況,prepare階段做了99%以上的工作,commit做剩余不到1%的部分,這是經(jīng)典的兩階段提交理論。A、B兩個節(jié)點雖然都可以綁定全局GTS,但有可能A節(jié)點網(wǎng)絡(luò)較慢,prepare后沒有馬上commit。由于A節(jié)點對應(yīng)的記錄行沒有完成commit,還處于prepare狀態(tài),導(dǎo)致代表其全局事務(wù)狀態(tài)的全局GTS還未綁定。此時查詢操作此時必須等待,直到commit后才能獲取到GTS后進而做可見性判斷。因為如果A節(jié)點的數(shù)據(jù)沒有提交就沒辦法獲取其全局GTS,進而無法知道該記錄行對當(dāng)前讀事務(wù)是否可見。所以,在查詢中會有一個遇到prepare等待的過程,這是全局一致性讀最大的性能瓶頸。
當(dāng)然,優(yōu)化的策略和思路就是減少等待,這個下一章會詳細分析。至此,我們有了全局一致性讀的基本思路和方案,下一步就是針對優(yōu)化項的考慮了。
一致性讀下的性能優(yōu)化
這部分內(nèi)容的是在上述解決方案的基礎(chǔ)上進行的優(yōu)化。
經(jīng)過實踐后,我們發(fā)現(xiàn)全局一致性讀帶來了三個問題:
第一個問題是映射關(guān)系帶來的開銷。引入映射關(guān)系后,映射一定非常高頻的操作,幾乎掃描每一行都需要做映射,如果有一千萬行記錄需要掃描,在極端情況下很可能要進行一千萬次映射。
第二個問題是事務(wù)等待的開銷。在兩階段提交中的prepare階段,事務(wù)沒有辦法獲取最終提交的GTS,而GTS是未來不可預(yù)知的值,必須等待prepare狀態(tài)變?yōu)閏ommit后才可以判斷。
第三個問題是針對非分布式事務(wù)的考慮。針對非分布式事務(wù)是否也要無差別的進行GTS綁定,包括在事務(wù)提交時綁定全局時間戳、在查詢時做判斷等操作。如果采用和分布式事務(wù)一樣的機制一定會帶來開銷,但如果不加干涉會不會有其他問題?
針對這三個問題,我們接下來依次展開分析。
3.1 prepare等待問題
首先,針對prepare記錄需要等待其commit的開銷問題,由于事務(wù)在沒有commit時,無法確定其最終GTS,需要進行等待其commit。仔細分析prepare等待的過程,就可以發(fā)現(xiàn)其中的優(yōu)化空間。
下圖中,在當(dāng)前用戶表里的四條數(shù)據(jù),A、B兩條數(shù)據(jù)是上一次修改的目前已經(jīng)commit,而C、D數(shù)據(jù)最近修改且處于prepare狀態(tài),上一個版本commit記錄也可以通過undo鏈找到,其事務(wù)ID為63。這個事務(wù)開始時GTS是150,最終提交后變?yōu)?81。這個181是已經(jīng)提交的最終狀態(tài),我們回退到中間狀態(tài),即還沒有提交時的狀態(tài)。
如果按照正常邏輯,prepare一定要等,但這時有個問題,這個prepare將來肯定會被commit,雖然現(xiàn)在不知道它的具體值時多少,但是它“將來”提交后一定比當(dāng)前已經(jīng)commit最大的ID還要大,即將來commit時的GTS一定會比179大。此時,如果一筆查詢的GTS小于等于179,可以認為就算C、D記錄將來提交,也一定對當(dāng)前這筆小于等于179的查詢不可見,因此可以直接跳過對C、D的等待,通過undo鏈追溯上一個版本的記錄。這就是對prepare的優(yōu)化的核心思想,并不是只要遇到prepare就等待,而是要跟當(dāng)前緩存最大已經(jīng)提交的GTS來做比較判斷,如果查詢的GTS比當(dāng)前節(jié)點上已經(jīng)提交的最大GTS還要大則需要等待prepare變?yōu)閏ommit。但如果查詢的GTS比當(dāng)前節(jié)點已經(jīng)提交的最大GTS小,則直接通過undo鏈獲取當(dāng)前prepare記錄的上一個版本,無需等待其commit。這個優(yōu)化對整個prepare吞吐量和等待時長的影響非常大,可以做到50%~60%的性能提升。
3.2非分布式事務(wù)問題
針對非分布式事務(wù)的一致性讀是我們需要考慮的另外一個問題。由于非分布式事務(wù)走的路線不是兩階段提交,事務(wù)涉及的數(shù)據(jù)節(jié)點不存在跨節(jié)點、跨分片現(xiàn)象。按照我們前面的分析,一致性讀是在分布式事務(wù)場景下的問題。所以,針對分布式場景下的非分布式事務(wù),是否可以直接放棄對它的特殊處理,而是采用原生的事務(wù)提交方式。
如果放棄處理是否會產(chǎn)生其他問題,我們繼續(xù)分析。下圖在銀行金融機構(gòu)中是常見的交易模型,交易啟動時記錄交易日志,交易結(jié)束后更新交易日志的狀態(tài)。交易日志為單獨的記錄行,對其的更新可能是非分布式事務(wù),而真正的交易又是分布式事務(wù)。如果在交易的過程中伴隨有查詢操作,則查詢邏輯中里很可能會出現(xiàn)這種狀態(tài):即交易已經(jīng)開始了但交易日志還查不到,對于業(yè)務(wù)來說如果查不到的話就會認為沒有啟動,那么矛盾的問題就產(chǎn)生了。
如果要保持業(yè)務(wù)語義連續(xù)性,即針對非分布式事務(wù),即使在分布式場景下一筆交易只涉及一個節(jié)點,也需要像分布式事務(wù)那樣做標(biāo)記、處理。雖然說針對非分布式事務(wù)需要綁定GTS,但是我們希望盡可能簡化和輕量,相比于分布式事務(wù)不需要在每筆commit提交時都訪問一遍全局時間戳組件請求GTS。所以,我們也希望借鑒對prepare的處理方式,可以用節(jié)點內(nèi)部緩存的GTS來在引擎層做綁定。
受prepare優(yōu)化思路的啟發(fā),是否也可以拿最大提交的GTS做緩存。但是如果拿最大已提交GTS做緩存會產(chǎn)生兩個比較明顯的問題:第一,不可重復(fù)讀;第二,數(shù)據(jù)行“永遠不可見”。這兩個問題會給業(yè)務(wù)帶來更嚴(yán)重的影響。
首先是不可重復(fù)讀問題。T1是非分布式事務(wù),T2是查詢事務(wù)。當(dāng)T1沒有提交的時候,查詢無法看到T1對數(shù)據(jù)的修改。如果T1從啟動到提交的間隔時間較長(沒有經(jīng)過prepare階段),且這段時間沒有其他分布式事務(wù)在當(dāng)前節(jié)點上提交。所以,當(dāng)T1提交后當(dāng)前的最大commit GTS沒有發(fā)生變化仍為100,此時綁定T1事務(wù)的GTS為100,但由于查詢類事務(wù)的GTS也是100,所以導(dǎo)致T1提交后會被T2看得到,出現(xiàn)不可重復(fù)讀問題。
其次是不可見的問題。接著上一個問題,如果用最大已提交的GTS遞增值加1是否可以解決上一個不可重復(fù)讀問題,看似可以解決但是會帶來另外一個更嚴(yán)重的問題:該事務(wù)修改的數(shù)據(jù)行可能“永遠”不可見。假如T1非分布式事務(wù)提交之后,系統(tǒng)內(nèi)再無寫事務(wù),導(dǎo)致“一段時間”內(nèi),查詢類事務(wù)的GTS永遠小于T1修改數(shù)據(jù)會綁定的GTS,進而演變?yōu)門1修改的數(shù)據(jù)行“一段時間內(nèi)”對所有查詢操作都不可見。
這時我們就需要考慮,在非分布式場景下需要緩存怎樣的GTS。在下圖的事務(wù)模型中,T1時刻有三筆活躍事務(wù):事務(wù)1、事務(wù)2、事務(wù)3。事務(wù)2是非分布式事務(wù),它的提交我們希望對事務(wù)3永遠不可見。如果對事務(wù)3不可見的話,就必須要比事務(wù)3開啟的GTS大。所以,我們就需要在非分布式事務(wù)提交時,綁定當(dāng)前活躍事務(wù)里“快照最大GTS加1”,即綁定GTS為106后,由于查詢的GTS為105,無論中間開啟后執(zhí)行多少次,一定對前面不可見,這樣就得以保證。
再看第二個時刻,在事務(wù)4和事務(wù)5中,隨著GTS的遞增,事務(wù)5的啟動GTS已經(jīng)到達到106,106大于等于上一次非分布式事務(wù)提交的GTS值106,所以事務(wù)2對事務(wù)5始終可見,滿足事務(wù)可見性,不會導(dǎo)致事務(wù)不可見。
通過前述優(yōu)化,形成了分布式場景下事務(wù)提交的最終方案:事務(wù)啟動時獲取當(dāng)前全局GTS,當(dāng)事務(wù)提交時進行二次判斷。首先判斷它是不是一階段提交的非分布式事務(wù),如果是則需要獲取當(dāng)前節(jié)點的最大快照GTS并加1;如果是分布式事務(wù)則需要走兩階段提交,在commit時重新獲取一遍全局GTS遞增值,綁定到當(dāng)前事務(wù)中。這樣的機制下除了性能上的提升,在查詢數(shù)據(jù)時更能保證數(shù)據(jù)不丟不錯,事務(wù)可見性不受影響。
3.3高性能映射問題
最后是事務(wù)ID和全局GTS的映射問題。這里為什么沒有采用隱藏列而是使用映射關(guān)系呢?因為如果采用隱藏列會對業(yè)務(wù)有很強的入侵,同時讓業(yè)務(wù)對全局時間戳組件產(chǎn)生過度依賴。比如:若使用一致性讀特性,那么必須引入全局的時間戳,每一筆事務(wù)的提交都會將全局時間戳和事務(wù)相綁定,因此,全局時間戳的可靠性就非常關(guān)鍵,如果稍微有抖動,就會影響到業(yè)務(wù)的連續(xù)性。所以我們希望這種特性做到可配置、可動態(tài)開關(guān),適時啟用。所以,做成這種映射方式能夠使上層對底層沒有任何依賴以及影響。
全局映射還需要考慮映射關(guān)系高性能、可持久性,當(dāng)MySQL異常宕機時能夠自動恢復(fù)。因此,我們引入了新的系統(tǒng)表空間Tlog,按照GTS時間戳和事務(wù)ID的方式做映射,內(nèi)部按頁組織管理。通過這種方式對每一個事務(wù)ID都能找到對應(yīng)映射關(guān)系的GTS。
那么怎樣整合到Innodb存儲引擎并實現(xiàn)高性能,即如何把映射文件嵌入到存儲引擎里?下圖中可以看到,改造后對GTS的映射訪問是純內(nèi)存的,即GTS修改直接在內(nèi)存中操作,Tlog在加載以及擴展都是映射到Innodb的緩沖池中。對于映射關(guān)系的修改,往往是事務(wù)提交的時候,此時直接在內(nèi)存中修改映射關(guān)系,內(nèi)存中Tlog關(guān)聯(lián)的數(shù)據(jù)頁變?yōu)榕K頁,同時在redo日志里增加對GTS的映射操作,定期通過刷臟來維護磁盤和內(nèi)存中映射關(guān)系的一致性。由于內(nèi)存修改的開銷較小,而在redo中也僅僅增加幾十字節(jié),所以整體的寫開銷可以忽略不計。
這種優(yōu)化的作用下,對于寫事務(wù)的影響不到3%,而對讀事務(wù)的影響能夠控制在10%以內(nèi)。此外,還需要對undo頁清理機制做改造,將原有的基于最老可見性視圖的刪除方式改為以最小活躍GTS的方式刪除。
GTS和事務(wù)ID的映射是有開關(guān)的,打開可以做映射,關(guān)閉后退化為單節(jié)點模式。即TDSQL可以提供兩種一致性服務(wù),一種是全局一致性讀,即基于全局GTS串行化實現(xiàn),另外一種是關(guān)閉這個開關(guān),只保證事務(wù)最終一致性。由于任何改造都是有代價,并不是全局一致性讀特性打開比不打開更好,而是要根據(jù)業(yè)務(wù)場景做判斷。
開啟一致性讀特性雖然能夠解決分布式場景下的可重復(fù)讀問題,但是由于新引入了全局GTS組件,該組件一定程度上屬于關(guān)鍵路徑組件,如果其故障業(yè)務(wù)會受到短暫影響。除此之外,全局一致性讀對性能也有一定影響。所以,建議業(yè)務(wù)結(jié)合自身場景評估是否有分布式快照讀需求,若有則打開,否則關(guān)閉。