1. 程式人生 > 其它 >硬核乾貨!TDSQL全域性一致性讀技術詳解|

硬核乾貨!TDSQL全域性一致性讀技術詳解|

分散式場景下如何進行快照讀是一個很常見的問題,因為在這種場景下極易讀取到分散式事務的“中間狀態”。針對這一點,騰訊雲資料庫TDSQL設計了全域性一致性讀方案,解決了分散式節點間資料的讀一致性問題。

近日騰訊雲資料庫專家工程師張文就在第十二屆中國資料庫技術大會上為大家分享了“TDSQL全域性一致性讀技術”。以下是分享實錄:

1. 分散式下一致性讀問題

近年來很多企業都會發展自己的分散式資料庫應用,一種常見的發展路線是基於開源MySQL,典型方案有共享儲存方案、分表方案,TDSQL架構是一種典型的分割槽表方案。

以圖例的銀行場景為例,是一種典型的基於MySQL分散式架構,前端為SQL引擎,後端以MySQL作為儲存引擎,整體上計算與儲存相分離,各自實現橫向擴充套件。

銀行的轉賬業務一般是先扣款再加餘額,整個交易為一個分散式事務。分散式事務基於兩階段提交,保證了交易的最終一致性,但無法保證讀一致性

轉賬操作先給A賬戶扣款再給B賬戶增加餘額,這兩個操作要麼都成功,要麼都不成功,不會出現一個成功一個不成功,這就是分散式事務。在分散式資料庫下,各節點相對獨立,一邊做扣款的同時另一邊可能已經增加餘額成功。在某個節點的儲存引擎內部,如果事務沒有完成提交,那麼SQL引擎對於前端仍是阻塞狀態,只有所有子事務全部完成之後才會返回客戶端成功,這是分散式事務的最終一致性原理。但是,如果該分散式事務在返回給前端成功之前,即子事務還在執行過程中,此時,剛好有查詢操作,正好查到這樣的狀態,即A賬戶扣款還沒有成功,但B賬戶餘額已經增加成功,這便出現了分散式場景下的讀一致性的問題。

部分銀行對這種場景沒有苛刻的要求,出報表的時候如果有資料處於這種“中間”狀態,一般通過業務流水或其他方式補償,使資料達到平衡狀態。但部分敏感型業務對這種讀一致性有強依賴,認為補償操作的代價太高,同時對業務的容錯性要求過高。所以,這類銀行業務希望依賴資料庫本身獲取一個平衡的資料映象,即要麼讀到事務操作資料前的原始狀態,要麼讀取到資料被分散式事務修改後的最終狀態。

針對分散式場景下的一致性讀問題,早期可以通過加鎖讀,即查詢時強制顯示加排他鎖的方式。加鎖讀在高併發場景下會有明顯的效能瓶頸,還容易產生死鎖。所以,在分散式下,我們希望以一種輕量的方式實現RR隔離級別,即快照讀的能力。一致性讀即快照讀,讀取到的資料一定是“平衡”的資料,不是處於“中間狀態”的資料。對於業務來說,無論是集中式資料庫還是分散式資料庫,都應該做到對業務透明且無感知。即集中式可以看到的資料,分散式也同樣能看到,即都要滿足可重複讀。

在解決這個問題前,我們首先需要關注基於MySQL這種分散式架構的資料庫,在單節點下的事務一致性和可見性的原理。

MVCC模型,活躍事務連結串列會形成高低水位線,高低水位線決定哪些事務可見或不可見。如果事務ID比高水位線還要小,該事務屬於在構建可見性檢視之前就已經提交的,那麼一定可見。而對於低水位線對應的事務ID,如果資料行的事務ID比低水位線大,那麼代表該資料行在當前可見性檢視建立後才生成的,一定不可見。每個事務ID都是獨立的序列並且是線性增長,每個資料行都會繫結一個事務ID。當查詢操作掃描到對應的記錄行時,需要結合查詢時建立的可見性檢視中的高低水位線來判斷可見性。

兩種隔離級別,RC隔離級別可以看到事務ID為1、3、5的事務,因為1、3、5現在是活躍狀態,後面變成提交狀態後,提交狀態是對當前查詢可見。而對於RR級別,未來提交是不可見,因為可重複讀要求可見性檢視構建後資料的可見性唯一且不變。即原來可見現在仍可見,原來不可見的現在仍不可見,這是Innodb儲存引擎的MVCC原理。我們先要了解單節點是怎麼做的,然後才清楚如何在分散式下對其進行改造。

這個轉賬操作中,A賬戶扣款,B賬戶增加餘額,A、B兩個節點分別是節點1和節點2,節點1原來的資料是0,轉賬後變為10,A節點之前的事務ID是18,轉賬後變成22,每個節點的資料都有歷史版本的連結,事務ID隨著新事務的提交而變大。對B節點來說,原來儲存的這行資料的事務ID是33,事務提交後變成了37。A、B兩個節點之間的事務ID是毫無關聯的,各自按照獨立的規則生成。

所以,此時一筆讀事務發起查詢操作,也是相對獨立的。查詢操作發往計算節點後,計算節點會同時發往A、B兩個MySQL節點。這個“同時”也是相對的,不可能達到絕對同時。此時,查詢操作對第一個節點得到的低水位線是23,23大於22,所以當前事務對22可見。查詢發往第二個節點時得到的低水位線是37,事務ID 37的資料行對當前事務也可見,這是比較好的結果,我們看到資料是平的,查到的都是最新的資料。

然而,如果查詢操作建立可見性檢視時產生的低水位線為36,此時就無法看到事務ID為37的資料行,只能看到事務ID為33的上一個版本的資料。站在業務的角度,同時進行了兩個操作一筆轉賬一筆查詢,到達儲存引擎的時機未必是轉賬在前查詢在後,一定概率上存在時序上的錯位,比如:查詢操作發生在轉賬的過程中。如果發生錯位又沒有任何干預和保護,查詢操作很有可能讀到資料的“中間狀態”,即不平的資料,比如讀取到總賬是20,總賬是0。

目前面對這類問題的思路基本一致,即採用一定的序列化規則讓其一致。首先,如果涉及分散式事務的兩個節點資料平衡,首先要統一各節點的高低水位線,即用一個統一標尺才能達到統一的可見性判斷效果。然後,由於事務ID在各個節點間相互獨立,這也會造成可見性判斷的不一致,所以事務ID也要做序列化處理。

在確立序列化的基本思路後,即可構造整體的事務模型。比如:A和B兩個賬戶分別分佈在兩個MySQL節點,節點1和節點2。每個節點的事務ID強制保持一致,即節點1、2在事務執行前對應的資料行繫結的事務ID都為88,事務執行後繫結的ID都為92。然後,保持可見性檢視的“水位線”一致。此時,對於查詢來說要麼查到的都是舊的資料,要麼查到的都是新的資料,不會出現“一半是舊的資料,一半是新的資料”這種情況。到這裡我們會發現,解決問題的根本:1、統一事務ID;2、統一查詢的評判標準即“水位線”。當然,這裡的“事務ID”已經不是單節點的事務ID,而是“全域性事務ID”,所以整體思路就是從區域性到全域性的過程。

2. TDSQL全域性一致性讀方案

剛剛介紹了為什麼分散式下會存在一致性讀的問題,接下來分享TDSQL一致性讀的解決方案

首先引入了全域性的時間戳服務,它用來對每一筆事務進行標記,即每一筆分散式事務繫結一個全域性遞增的序列號。然後,在事務開始的時候獲取時間戳,提交的時候再獲取時間戳,各個節點內部維護事務ID到全域性時間戳的對映關係。原有的事務ID不受影響,只是會新產生一種對映關係:每個ID會對映到一個全域性的GTS。

通過修改innodb儲存引擎,我們實現從區域性事務ID到全域性GTS的對映,每行資料都可以找到唯一的GTS。如果A節點有100個GTS,B節點也應該有100個GTS,此外分散式事務開啟的時候都會做一次獲取時間戳的操作。整個過程對原有事務的影響不大,新增了在事務提交時遞增並獲取一次時間戳,事務啟動時獲取一次當前時間戳的邏輯。

建立這樣的機制後,再來看分散式事務的執行過程,比如一筆轉賬操作,A節點和B節點首先在開啟事務的時候獲取一遍GTS:500,提交的時候由於間隔一段時間GTS可能發生了變化,因而重新獲取一次GTS:700。查詢操作也是一個獨立的事務,開啟後獲取到全域性GTS,比如500或者700,此時查詢到的資料一定是平衡的資料,不可能查到中間狀態的資料。

看似方案已經完整,但是還有個問題:即分散式事務都存在兩階段提交的情況,prepare階段做了99%以上的工作,commit做剩餘不到1%的部分,這是經典的兩階段提交理論。A、B兩個節點雖然都可以繫結全域性GTS,但有可能A節點網路較慢,prepare後沒有馬上commit。由於A節點對應的記錄行沒有完成commit,還處於prepare狀態,導致代表其全域性事務狀態的全域性GTS還未繫結。此時查詢操作此時必須等待,直到commit後才能獲取到GTS後進而做可見性判斷。因為如果A節點的資料沒有提交就沒辦法獲取其全域性GTS,進而無法知道該記錄行對當前讀事務是否可見。所以,在查詢中會有一個遇到prepare等待的過程,這是全域性一致性讀最大的效能瓶頸。

當然,優化的策略和思路就是減少等待,這個下一章會詳細分析。至此,我們有了全域性一致性讀的基本思路和方案,下一步就是針對優化項的考慮了。

3. 一致性讀下的效能優化

這部分內容的是在上述解決方案的基礎上進行的優化。

經過實踐後,我們發現全域性一致性讀帶來了三個問題:

第一個問題是對映關係帶來的開銷。引入對映關係後,對映一定非常高頻的操作,幾乎掃描每一行都需要做對映,如果有一千萬行記錄需要掃描,在極端情況下很可能要進行一千萬次對映。

第二個問題是事務等待的開銷。在兩階段提交中的prepare階段,事務沒有辦法獲取最終提交的GTS,而GTS是未來不可預知的值,必須等待prepare狀態變為commit後才可以判斷。

第三個問題是針對非分散式事務的考慮。針對非分散式事務是否也要無差別的進行GTS繫結,包括在事務提交時繫結全域性時間戳、在查詢時做判斷等操作。如果採用和分散式事務一樣的機制一定會帶來開銷,但如果不加干涉會不會有其他問題?

針對這三個問題,我們接下來依次展開分析。

3.1 prepare等待問題

首先,針對prepare記錄需要等待其commit的開銷問題,由於事務在沒有commit時,無法確定其最終GTS,需要進行等待其commit。仔細分析prepare等待的過程,就可以發現其中的優化空間。

下圖中,在當前使用者表裡的四條資料,A、B兩條資料是上一次修改的目前已經commit,而C、D資料最近修改且處於prepare狀態,上一個版本commit記錄也可以通過undo鏈找到,其事務ID為63。這個事務開始時GTS 是150,最終提交後變為181。這個181是已經提交的最終狀態,我們回退到中間狀態,即還沒有提交時的狀態。

如果按照正常邏輯,prepare一定要等,但這時有個問題,這個prepare將來肯定會被commit,雖然現在不知道它的具體值時多少,但是它“將來”提交後一定比當前已經commit最大的ID還要大,即將來commit時的GTS一定會比179大。此時,如果一筆查詢的GTS小於等於179,可以認為就算C、D記錄將來提交,也一定對當前這筆小於等於179的查詢不可見,因此可以直接跳過對C、D的等待,通過undo鏈追溯上一個版本的記錄。這就是對prepare的優化的核心思想,並不是只要遇到prepare就等待,而是要跟當前快取最大已經提交的GTS來做比較判斷,如果查詢的GTS比當前節點上已經提交的最大GTS還要大則需要等待prepare變為commit。但如果查詢的GTS比當前節點已經提交的最大GTS小,則直接通過undo鏈獲取當前prepare記錄的上一個版本,無需等待其commit。這個優化對整個prepare吞吐量和等待時長的影響非常大,可以做到50%~60%的效能提升。

3.2 非分散式事務問題

針對非分散式事務的一致性讀是我們需要考慮的另外一個問題。由於非分散式事務走的路線不是兩階段提交,事務涉及的資料節點不存在跨節點、跨分片現象。按照我們前面的分析,一致性讀是在分散式事務場景下的問題。所以,針對分散式場景下的非分散式事務,是否可以直接放棄對它的特殊處理,而是採用原生的事務提交方式。
如果放棄處理是否會產生其他問題,我們繼續分析。下圖在銀行金融機構中是常見的交易模型,交易啟動時記錄交易日誌,交易結束後更新交易日誌的狀態。交易日誌為單獨的記錄行,對其的更新可能是非分散式事務,而真正的交易又是分散式事務。如果在交易的過程中伴隨有查詢操作,則查詢邏輯中裡很可能會出現這種狀態:即交易已經開始了但交易日誌還查不到,對於業務來說如果查不到的話就會認為沒有啟動,那麼矛盾的問題就產生了。

如果要保持業務語義連續性,即針對非分散式事務,即使在分散式場景下一筆交易只涉及一個節點,也需要像分散式事務那樣做標記、處理。雖然說針對非分散式事務需要繫結GTS,但是我們希望儘可能簡化和輕量,相比於分散式事務不需要在每筆commit提交時都訪問一遍全域性時間戳元件請求GTS。所以,我們也希望借鑑對prepare的處理方式,可以用節點內部快取的GTS來在引擎層做繫結。

受prepare優化思路的啟發,是否也可以拿最大提交的GTS做快取。但是如果拿最大已提交GTS做快取會產生兩個比較明顯的問題:第一,不可重複讀;第二,資料行“永遠不可見”。這兩個問題會給業務帶來更嚴重的影響。

首先是不可重複讀問題。T1是非分散式事務,T2是查詢事務。當T1沒有提交的時候,查詢無法看到T1對資料的修改。如果T1從啟動到提交的間隔時間較長(沒有經過prepare階段),且這段時間沒有其他分散式事務在當前節點上提交。所以,當T1提交後當前的最大commit GTS沒有發生變化仍為100,此時繫結T1事務的GTS為100,但由於查詢類事務的GTS也是100,所以導致T1提交後會被T2看得到,出現不可重複讀問題。

其次是不可見的問題。接著上一個問題,如果用最大已提交的GTS遞增值加1是否可以解決上一個不可重複讀問題,看似可以解決但是會帶來另外一個更嚴重的問題:該事務修改的資料行可能“永遠”不可見。假如T1非分散式事務提交之後,系統內再無寫事務,導致“一段時間”內,查詢類事務的GTS永遠小於T1修改資料會繫結的GTS,進而演變為T1修改的資料行“一段時間內”對所有查詢操作都不可見。

這時我們就需要考慮,在非分散式場景下需要快取怎樣的GTS。在下圖的事務模型中,T1時刻有三筆活躍事務:事務1、事務2、事務3。事務2是非分散式事務,它的提交我們希望對事務3永遠不可見。如果對事務3不可見的話,就必須要比事務3開啟的GTS大。所以,我們就需要在非分散式事務提交時,綁定當前活躍事務裡“快照最大GTS加1”,即繫結GTS 為106後,由於查詢的GTS為105,無論中間開啟後執行多少次,一定對前面不可見,這樣就得以保證。

再看第二個時刻,在事務4和事務5中,隨著GTS的遞增,事務5的啟動GTS已經到達到106,106大於等於上一次非分散式事務提交的GTS值106,所以事務2對事務5始終可見,滿足事務可見性,不會導致事務不可見。

通過前述優化,形成了分散式場景下事務提交的最終方案:事務啟動時獲取當前全域性GTS,當事務提交時進行二次判斷。首先判斷它是不是一階段提交的非分散式事務,如果是則需要獲取當前節點的最大快照GTS並加1;如果是分散式事務則需要走兩階段提交,在commit時重新獲取一遍全域性GTS遞增值,繫結到當前事務中。這樣的機制下除了效能上的提升,在查詢資料時更能保證資料不丟不錯,事務可見性不受影響。

3.3 高效能對映問題

最後是事務ID和全域性GTS的對映問題。這裡為什麼沒有采用隱藏列而是使用對映關係呢?因為如果採用隱藏列會對業務有很強的入侵,同時讓業務對全域性時間戳元件產生過度依賴。比如:若使用一致性讀特性,那麼必須引入全域性的時間戳,每一筆事務的提交都會將全域性時間戳和事務相繫結,因此,全域性時間戳的可靠性就非常關鍵,如果稍微有抖動,就會影響到業務的連續性。所以我們希望這種特性做到可配置、可動態開關,適時啟用。所以,做成這種對映方式能夠使上層對底層沒有任何依賴以及影響。

全域性對映還需要考慮對映關係高效能、可永續性,當MySQL異常宕機時能夠自動恢復。因此,我們引入了新的系統表空間Tlog,按照GTS時間戳和事務ID的方式做對映,內部按頁組織管理。通過這種方式對每一個事務ID都能找到對應對映關係的GTS。

那麼怎樣整合到Innodb儲存引擎並實現高效能,即如何把對映檔案嵌入到儲存引擎裡?下圖中可以看到,改造後對GTS的對映訪問是純記憶體的,即GTS修改直接在記憶體中操作,Tlog在載入以及擴充套件都是對映到Innodb的緩衝池中。對於對映關係的修改,往往是事務提交的時候,此時直接在記憶體中修改對映關係,記憶體中Tlog關聯的資料頁變為髒頁,同時在redo日誌裡增加對GTS的對映操作,定期通過刷髒來維護磁碟和記憶體中對映關係的一致性。由於記憶體修改的開銷較小,而在redo中也僅僅增加幾十位元組,所以整體的寫開銷可以忽略不計。

這種優化的作用下,對於寫事務的影響不到3%,而對讀事務的影響能夠控制在10%以內。此外,還需要對undo頁清理機制做改造,將原有的基於最老可見性檢視的刪除方式改為以最小活躍GTS的方式刪除

GTS和事務ID的對映是有開關的,開啟可以做對映,關閉後退化為單節點模式。即TDSQL可以提供兩種一致性服務,一種是全域性一致性讀,即基於全域性GTS序列化實現,另外一種是關閉這個開關,只保證事務最終一致性。由於任何改造都是有代價,並不是全域性一致性讀特性開啟比不開啟更好,而是要根據業務場景做判斷。開啟一致性讀特性雖然能夠解決分散式場景下的可重複讀問題,但是由於新引入了全域性GTS元件,該元件一定程度上屬於關鍵路徑元件,如果其故障業務會受到短暫影響。除此之外, 全域性一致性讀對效能也有一定影響。所以,建議業務結合自身場景評估是否有分散式快照讀需求,若有則開啟,否則關閉。