近日,TDSQL新敏態(tài)引擎重磅發(fā)布。該引擎可完美解決對于敏態(tài)業(yè)務(wù)發(fā)展過程中業(yè)務(wù)形態(tài)、業(yè)務(wù)量的不可預(yù)知性,實現(xiàn)PB級存儲的Online DDL,可以實現(xiàn)大幅提升表結(jié)構(gòu)變更過程中的數(shù)據(jù)庫吞吐量,有效應(yīng)對業(yè)務(wù)變化;其獨有的數(shù)據(jù)形態(tài)自動感知特性,使數(shù)據(jù)能根據(jù)業(yè)務(wù)負載情況實現(xiàn)自動遷移,打散熱點,降低分布式事務(wù)比例,獲得極致的擴展性和性能。
與此同時,TDSQL新敏態(tài)引擎還具有對分布式事務(wù)完整支持的特性,支撐了上層計算引擎多主讀寫架構(gòu)的實現(xiàn),并與計算引擎結(jié)合實現(xiàn)了計算下推、分布式事務(wù)一階段優(yōu)化等多維度優(yōu)化,進一步實現(xiàn)分布式數(shù)據(jù)庫系統(tǒng)性能極致提升,有效適配企業(yè)新敏態(tài)業(yè)務(wù)需求。在騰訊內(nèi)部業(yè)務(wù)實踐中,TDSQL新敏態(tài)引擎可支撐業(yè)務(wù)在保持高性能且連續(xù)服務(wù)的基礎(chǔ)上,一個月內(nèi)完成高達1000次表結(jié)構(gòu)在線變更。
在高頻的表結(jié)構(gòu)變更過程中,如何減少對在線業(yè)務(wù)請求的影響,甚至使得用戶能夠以原生、不阻塞業(yè)務(wù)的方式進行,這就成為了TDSQL新敏態(tài)引擎面對的技術(shù)挑戰(zhàn)。本期將由騰訊云數(shù)據(jù)庫高級工程師趙東志,為大家深度解讀TDSQL新敏態(tài)引擎OnlineDDL的原理與實現(xiàn)。以下是分享實錄:
Instant DDL
下圖所示為TDSQL新敏態(tài)引擎的核心架構(gòu)。SQLEngine是計算層,主要負責(zé)SQL的解析、分發(fā),包括數(shù)據(jù)查詢,將SQL轉(zhuǎn)為KV,再將KV收集的結(jié)果轉(zhuǎn)化為SQL能獲取到的結(jié)果,最后傳輸?shù)娇蛻舳说拳h(huán)節(jié)。其中,DDL也是計算層負責(zé)的部分之一。
過去在單機系統(tǒng)下DDL的執(zhí)行方式分為兩種:MySQL對支持的部分Online DDL,不支持的部分則通過外部組件pt工具對每個DB節(jié)點做DDL。在集群規(guī)模比較大時,運維會變得更加復(fù)雜,需要用外部工具保證多個節(jié)點之間DDL的原子性,每個節(jié)點還需要預(yù)留兩倍存儲空間。
基于上述原因,TDSQL新敏態(tài)引擎的設(shè)計目標(biāo)定為三個方面:
·保證Online性質(zhì),做到不阻塞業(yè)務(wù)讀寫請求。
·保證多節(jié)點緩存一致性,使得Crash-safe等在TDSQL系統(tǒng)中自治。
·兼容MySQL,方便業(yè)務(wù)遷移。
我們以加列為例來介紹Instant DDL。下圖中的Client,包含一個表結(jié)構(gòu),是一條映射語句。在F1列的基礎(chǔ)上,插入一個pk=10、F1=1的數(shù)據(jù)行。插入后再進行加列操作,加入F2列,加列后的表如下圖TDstore所示。這時如果需要讀取前述兩行數(shù)據(jù),就會遇到問題。在讀取pk=11這行時,可以用最新的表結(jié)構(gòu)直接解析數(shù)據(jù)行,但在讀取pk=10這行時,我們需要知道該數(shù)據(jù)行里是否包含F(xiàn)2列。
為此我們在表結(jié)構(gòu)中引入版本號概念。比如初始列表的版本為1,做加列操作后,schema變?yōu)?,插入時再將版本2寫入到value字段中。讀取數(shù)據(jù)時需要先判斷數(shù)據(jù)行的版本,如果數(shù)據(jù)行版本為2,就用當(dāng)前的表結(jié)構(gòu)解析;如果數(shù)據(jù)行版本為1,比當(dāng)前版本小,確定F2列不在該版本的schema中后,可直接填充默認值再返回到客戶端。
通過版本號概念的引入,在整個加列過程中,只需要更改元數(shù)據(jù),即schema的信息并未更改數(shù)據(jù)行,加列過程變得更加快速高效。同樣的方式也可作用于Varchar擴展長度等無損數(shù)據(jù)類型的轉(zhuǎn)換,Index invisible等其他DDL。但并不是所有的DDL都可以僅修改元數(shù)據(jù),部分DDL還需要生成部分數(shù)據(jù)才能實現(xiàn),比如加索引操作。因為索引的生成是從無到有的過程,因此必須要生成部分數(shù)據(jù),無法通過直接修改表結(jié)構(gòu)來實現(xiàn)。
Add/Drop Index
以加索引為例,下圖左邊所示為TDSQL新敏態(tài)引擎索引數(shù)據(jù)庫存儲結(jié)構(gòu)。圖中有兩行數(shù)據(jù),有一個主鍵和一個索引。在TDStore中,每個索引都會有全局唯一的index ID,比如主鍵為index1,二級索引為index2。主鍵數(shù)據(jù)由index ID+pk組成,形成key,value為其他字段。在索引中,它的組成為索引indexID+索引信息+主鍵信息。
如果要進行alter table、add index操作,從無索引狀態(tài)變?yōu)樗饕?,則需要掃主鍵數(shù)據(jù),組建索引的KV形式,插入index中進行掃描,再修改元數(shù)據(jù),以完成索引的添加。如果在掃描主鍵、修改元數(shù)據(jù)的同時,存在并發(fā)事務(wù)如delete或insert等操作,就會產(chǎn)生掃描回填的索引過程與用戶事務(wù)并發(fā)之間的問題。
針對DDL和用戶請求的并發(fā)問題我們可以將DML分為delete、insert、update三種來加以討論。
對于delete,我們可以scan任意一行數(shù)據(jù),再按索引形式將其插回到TDstore中。假設(shè)存在一個并發(fā),兩數(shù)據(jù)行為同一行,刪除操作相當(dāng)于插入一個類型為delete的key。目標(biāo)是在主鍵上刪除該數(shù)據(jù)行,在索引上也刪除該數(shù)據(jù)行。如果不計后果直接插入,就會遇到問題。比如刪除后,又插入到該數(shù)據(jù)行后,最終的結(jié)果是,key被刪除后在索引上再次出現(xiàn)。
為解決上述問題,我們引入了托馬斯寫機制,在插入時先查看版本,看是否存在更加新的寫入,如果有更加新的寫入,則該條key就不能再被寫入。這里采用時間戳的比較機制。在scan時,基于TDStore提供的全局一致性讀,我們在讀取時會獲取一個時間戳,比如1。在事務(wù)中插入時,其時間戳也通過TDStore來獲取,讀取數(shù)據(jù)所用時間戳也會帶進去,即在該時間戳讀,寫時也用同一時間戳,TS為1。在同一條key中,如果發(fā)現(xiàn)存在比自己更大的ts,說明該key已被用戶更改過,則put不生效,以此來解決并發(fā)問題。
對于insert,如果插入一條新數(shù)據(jù),與當(dāng)前數(shù)據(jù)行無沖突,即當(dāng)前數(shù)據(jù)行無該條數(shù)據(jù),這時只需要在索引上也插入該行數(shù)據(jù)即可。update相當(dāng)于delete+insert的組合,在delete和insert問題解決后,update問題也會自然解決。我們通過托馬斯寫規(guī)則機制解決回填索引與用戶事務(wù)的并發(fā)問題。
在分布式系統(tǒng)中我們還會面臨另一個問題,即多個計算節(jié)點之間的緩存一致性問題。因為在TDSQL中,上層計算節(jié)點可以有很多個,且每個計算節(jié)點還會有自身的緩存。以索引為例,假設(shè)某個DDL在SQLEngine1上執(zhí)行一個add index idx_f1,此時SQLEngine1上并發(fā)的執(zhí)行一個插入操作,則會在主鍵,索引上分別插入一行kv,如果這時另一個計算節(jié)點SQLEngine2由于緩存更新不及時,獲取到的表結(jié)構(gòu)沒有idx_f1,如果接到刪除請求,在解析完該表結(jié)構(gòu)后,該計算節(jié)點只會刪除主鍵上的數(shù)據(jù),而不會刪除該條索引記錄,最終導(dǎo)致主鍵上和索引上的數(shù)據(jù)不一致。
單機系統(tǒng)一般不會出現(xiàn)上述問題。假設(shè)將兩個節(jié)點想象成兩個線程,比如thread1、thread2,線程1想要進行表的元數(shù)據(jù)修改,可以獲取一個的元數(shù)據(jù)鎖,將所有的請求先擋住,再到內(nèi)存中的表結(jié)構(gòu)??梢钥闯鰡螜C系統(tǒng)依靠mutex可以實現(xiàn)多線程互斥,不存在兩個線程使用不同版本的t1的情況。
一個簡單的想法是將單機系統(tǒng)中的鎖擴展成分布式鎖。這種做法在原理上可行,但會存在時耗不可控的問題。以下圖為例,假設(shè)sqlengine1想發(fā)起申請鎖的請求,它可以在自身節(jié)點申請,也可以在其他節(jié)點如sqlengine2、sqlengine3上申請。但由于分布式系統(tǒng)中網(wǎng)絡(luò)不太可控,sqlengine數(shù)量非常多,可能會存在網(wǎng)絡(luò)異常問題,比如sqlengine3存在網(wǎng)絡(luò)異常,回復(fù)時間就會比較慢。網(wǎng)絡(luò)時間的延遲導(dǎo)致不可控問題。如果等到所有節(jié)點都申請成功,再去做更改,用戶請求的阻塞時間就會被拉長。
分布式鎖的實現(xiàn)還有很多方案,比如引入超時機制,但同樣也會存在其他問題,例如超時時間定義為多長?太長對用戶業(yè)務(wù)會有影響,太短則可能存在誤判。我們進一步思考,能否不依賴分布式鎖達到同樣的目的。
我們采用GoogleF1論文中引入的過渡態(tài)的思想。前述問題出現(xiàn)的原因是有的計算節(jié)點無法感知到該索引,有的計算節(jié)點感知到該索引并去寫索引,這就產(chǎn)生了數(shù)據(jù)不一致問題。F1的基本思想是在分布式系統(tǒng)中,在沒有鎖的情況下,無法同時從某個狀態(tài)遷移到下一個狀態(tài),這時就可以引入中間狀態(tài)。比如某個節(jié)點可以先進入到下一個狀態(tài),但該狀態(tài)與上一個狀態(tài)相互兼容。如圖所示,假設(shè)目前為v1狀態(tài),先進入v2,但v2與v1可以兼容,相當(dāng)于還有部分節(jié)點處于v1狀態(tài),兩者可以并存一段時間,等所有節(jié)點都進入v2后,再進入v3,狀態(tài)兩兩兼容,最終推進到完整的過程。但如何保證兩兩之間不超過兩個狀態(tài)也成為了一個新的問題?假設(shè)有個節(jié)點1先進入到v2,節(jié)點2在v1,過段時間后節(jié)點1想進入v3,但要如何確定是否所有節(jié)點都進入v2呢?
F1中還提到lease機制。假設(shè)sqlengine是一個執(zhí)行DDL的節(jié)點,如果想進入下一個狀態(tài),就需要等2t的時間。所有sqlengine節(jié)點,每隔一個t周期,都會看自己的schema是否過期,如果過期就會重新加載,通過2t和t的交叉,保證推進時其他節(jié)點必定將新schema加入進來。如果部分節(jié)點加載不上來出現(xiàn)異常,就會主動下線。但如果單純的lease還是不可靠的。
比如在下圖中,節(jié)點1間隔2t時間進入v2,再間隔2t進入v3。假設(shè)節(jié)點2在v1時進行put key操作,但該請求在存儲層面執(zhí)行的時間較久,剛好遇到了io 100%,阻塞時間較長,比如阻塞5T的時間才把請求寫下去。這時存在一個節(jié)點,在間隔2t后誤以為其他節(jié)點都已經(jīng)進入新狀態(tài),因此進入到v3。這就違反前述規(guī)則,即同一時刻不能有兩個相鄰版本以外的寫入并存。即使v2知道自身超過lease選擇主動下線也沒有用,因為寫入請求已經(jīng)發(fā)到存儲層,該寫入的生命周期已經(jīng)由存儲層來控制。對于上述問題,F(xiàn)1中也提到可以引入deadline時間來控制,但是目前我們并沒有這種機制,而是采用了一種版本判定機制來解決這個問題。
從本質(zhì)上來看,這個問題屬于計算層與存儲層聯(lián)動的問題,因為該請求已經(jīng)發(fā)到TDStore,我們需要在推進版本前讓TDStore感知到相關(guān)情況,具體流程如下:在進入下一狀態(tài)前,需要先推一個版本下去。推下去后,存儲層會感知到該節(jié)點想要進入v2。與此同時,存儲層發(fā)現(xiàn)v1狀態(tài)下還有一個請求未完成,等該請求寫完后存儲層再返回同意。如果存儲層中一旦存在舊版本請求沒有完成,它會等到完成后再反饋。
在這種約束機制下,只要push版本成功,說明存儲層里已經(jīng)沒有比v2更小的寫入,即此時任意節(jié)點都沒有過期版本正在寫入,可以進入v3狀態(tài)。同時在該機制下,存儲層不會接受后續(xù)請求中比v2小的讀寫請求。在極端異常的場景中,假設(shè)某一節(jié)點在push已經(jīng)成功的情況下,發(fā)送仍處于v1狀態(tài)的請求,這時存儲層就會發(fā)現(xiàn)該請求比當(dāng)前版本的v2要小,只能拒絕。通過存儲層的版本校驗機制,進一步保證了系統(tǒng)中任意時刻的有效寫入只能在兩個相鄰的狀態(tài)之間。
最后對緩存與執(zhí)行進行總結(jié)。我們采用F1的思想引入過渡態(tài),將Add Index分成多個階段,每相鄰的兩個階段兩兩兼容,這樣就無需依賴全局的分布式鎖。在存儲層進行該版本的有效性檢驗,進一步保證每時每刻的有效寫入只能位于兩個相鄰狀態(tài)之間。大多數(shù)情況下,我們可以認為該版本檢驗無效。因為每個節(jié)點都能加載新的表結(jié)構(gòu),且能用新的表結(jié)構(gòu)進行讀寫,版本檢驗僅適用于預(yù)防階段場景,為防止此類極端場景對數(shù)據(jù)造成一致性的破壞,保證整體算法運行的正確性。整體過程為:由計算層直接向下推送版本,演變?yōu)橄认騎DStore push當(dāng)前版本,再進入下一狀態(tài),通過此類方式來完成整體的變更操作。
刪索引則相對容易,可以看成加索引的反向操作,具體過程如下圖。
通用Online DDL
在Instant DDL中,僅需更改表結(jié)構(gòu)、修改元數(shù)據(jù)即可。在Varchar擴展長度等無損數(shù)據(jù)類型的轉(zhuǎn)換中,還需要生成部分數(shù)據(jù)才能實現(xiàn)。要如何使得更廣泛的其他DDL通過Online方式執(zhí)行,這就成了新的挑戰(zhàn)。
為此我們結(jié)合了pt-online-schema-change的思想。pt的原理為:在執(zhí)行OnlineDDL時,會生成一個新的表結(jié)構(gòu)即臨時表,再將舊表數(shù)據(jù)拷貝到新表中,過程中還會進行建觸發(fā)器等操作,保證拷表過程中的增量同步。在TDSQL新敏態(tài)引擎的設(shè)計中我們借鑒了上述拷表思想??奖磉^程中的新表的過程可以想象成在原表上加一個特殊的索引,即回歸到托馬斯寫問題,針對拷表過程中的問題我們也設(shè)計了過渡態(tài)問題的解決方案。
以上圖為例,圖中的舊表為status0,建立一張臨時表為tmp1,狀態(tài)為delete only。我們會在內(nèi)部建立一張新表,將舊表與新表進行關(guān)聯(lián),并且會將表status0上的刪除相關(guān)的操作同步臨時表tmp1,接下來進入write only狀態(tài)。write only的過程與加索引過程相同,會在執(zhí)行過程中將delete、update、insert等新的增量同步到tmp1上。
準(zhǔn)備開始thoma write回填數(shù)據(jù)之前,需要在存儲層推版本,確保當(dāng)前沒有處于delete only狀態(tài)的節(jié)點,保證任何新的請求都會增量同步到新的臨時表中。之后再進行thomas write操作按照加索引的方式,從MC獲取時間戳,再用時間戳掃數(shù)據(jù),從老表上將舊數(shù)據(jù)回遷到新表,thomas write機制可以保證整體回遷過程與原表事務(wù)并發(fā)的正確性,最后再進行臨時表命名。
在此之前,我們還會進行其他的檢查操作,比如檢查舊表與新表數(shù)據(jù)的一致性。因為在這種拷表方式中,如果alter影響到主鍵,就容易引起數(shù)據(jù)方面的問題。假設(shè)原表的主鍵為一個Varchar,屬于大小寫敏感類型,上面有A和a兩條數(shù)據(jù)。如果變更字符序,將其變?yōu)榇笮懖幻舾?,在新表中A和a就會變成一條數(shù)據(jù),從而覆蓋掉原始數(shù)據(jù)。我們需要通過類似的二次檢查來確定是否存在該種情況,避免拷貝過程中的數(shù)據(jù)遺失。
檢查完成后,我們會進行rename操作,更改舊表表名,再將新表替換成原表表名,相當(dāng)于將整個原表替換到新表的狀態(tài)。我們還會進行反向同步操作,因為可能有部分節(jié)點仍處于status2,此時原表上還有讀請求,我們需要將這些請求轉(zhuǎn)發(fā)到這張表上,保證處于該狀態(tài)的計算節(jié)點仍能讀到這些新增的數(shù)據(jù)請求。在這些請求轉(zhuǎn)移完成后,再取消關(guān)聯(lián),將版本推掉,最終將舊表用異步方式進行清理。
結(jié)合pt-online-schema-change的思想,我們將拷表的過程想象成添加一個特殊的索引,從而進一步推廣到支持MySQL所有類型的DDL。
Online DDL原子性
在TDSQL新敏態(tài)引擎中,所有計算節(jié)點為無狀態(tài),持久化操作通過存儲層來實現(xiàn),DDL的發(fā)起操作則在計算節(jié)點中進行。如果某一計算節(jié)點在執(zhí)行DDL過程中掛掉,就會面臨中間狀態(tài)由誰來負責(zé)推進的問題。
實際操作中,每個計算節(jié)點在執(zhí)行前,會在存儲層持久化一個DDL任務(wù)隊列。每次開展DDL任務(wù)時,就會將該DDL任務(wù)插入到DDL任務(wù)隊列中。如果正常結(jié)束,就會將該任務(wù)刪除。如果非正常結(jié)束如異步掛掉,其他的計算節(jié)點,會感知到任務(wù)隊列中有未完成的任務(wù),根據(jù)該任務(wù)當(dāng)前執(zhí)行信息,再去界定該DDL任務(wù)的下一步操作,例如繼續(xù)推進或回滾。
在上述過程中,恢復(fù)線程與工作線程之間通過MC的lock來互斥。這看似引入了分布式鎖,但實際上該鎖只作用于DDL之間。因為TDSQL新敏態(tài)引擎的整體設(shè)計原則是DML優(yōu)先,在DDL過程中盡量避免影響DML。
總結(jié)
綜上所述,TDSQL新敏態(tài)引擎Online DDL核心技術(shù)可以總結(jié)為四個方面:
·Instant DDL:通過多版本的解析規(guī)則,使得加列或varchar擴展長度等無損類型變更這些只需修改元數(shù)據(jù)的DDL瞬間完成。
·Add/Drop Index:通過托馬斯寫機制,解決生成索引數(shù)據(jù)和用戶事務(wù)的并發(fā)問題;采用F1過渡態(tài)+存儲層版本交驗機制,解決多個節(jié)點間緩存一致性問題。
·通用Online DDL:抽象出適用于所有DDL的copy table流程,進一步將Online DDL推廣到可支持絕大多數(shù)MySQL的DDL。
·DDL原子性:通過任務(wù)隊列+恢復(fù)線程的工作機制,保證DDL整體的原子性。