面試官:系統(tǒng)重構(gòu),舊庫數(shù)據(jù)怎樣平滑地遷到新庫?
在多年的架構(gòu)評審和技術(shù)面試中,秀才發(fā)現(xiàn)“數(shù)據(jù)遷移”是一個能精準衡量工程師技術(shù)深度與廣度的問題,但凡你簡歷上寫了系統(tǒng)重構(gòu),這個問題基本就是必問題。當然許多求職者的簡歷中也不乏“系統(tǒng)重構(gòu)”、“數(shù)據(jù)庫分庫分表”這些亮眼項目,但是對其中的技術(shù)細節(jié)理解往往不夠深入,最常見的答案就是一個“停機遷移”。
不得不說,停機遷移確實是一種方案,但它更像是一種技術(shù)資源或方案設(shè)計不足時的妥協(xié)?,F(xiàn)在的互聯(lián)網(wǎng)業(yè)務(wù)基本都是高性能要求,長時間的服務(wù)中斷是不可接受的。因此,“如何設(shè)計并實施一套完善的不停機數(shù)據(jù)遷移方案,確保過程平滑、數(shù)據(jù)零丟失且最終一致”,已成為架構(gòu)師乃至高級工程師必須掌握的核心能力。
今天,秀才就結(jié)合過往的經(jīng)驗,系統(tǒng)性地介紹一些數(shù)據(jù)遷移過程中應(yīng)該要注意的點,希望能為你在工作和面試中做方案設(shè)計時提供一些參考。
1. 工具選型
存量數(shù)據(jù)遷移是整個數(shù)據(jù)遷移的第一步,針對MySQL,主要有兩個主流的遷移工具:mysqldum和XtraBackup。
- mysqldump:這是 MySQL 官方提供的命令行工具,用于數(shù)據(jù)庫的備份與恢復。它能將數(shù)據(jù)庫的結(jié)構(gòu)、數(shù)據(jù)和關(guān)系導出為一系列的 SQL 語句。這是一種邏輯備份,備份產(chǎn)物是可讀的 SQL 腳本。
- XtraBackup:這是一款由 Percona 公司開發(fā)的高性能物理備份工具。它直接作用于 InnoDB 存儲引擎的底層文件,支持增量備份與恢復,并且在備份過程中對線上業(yè)務(wù)影響極小。這是一種物理備份,可以理解為直接拷貝數(shù)據(jù)庫的數(shù)據(jù)文件。
下表是mysqldump和XtraBackup的一個綜合對比。
因此,在方案設(shè)計之初,我們就需要基對業(yè)務(wù)數(shù)量級、業(yè)務(wù)對性能抖動的容忍度以及可用的遷移窗口進行確認,最初正確的工具選擇,如果實在面試過程中,也需要向面試官展現(xiàn)這個考慮,突出自己思考問題的全面性。
2. 關(guān)鍵參數(shù)
除了工具,數(shù)據(jù)庫自身的一個關(guān)鍵參數(shù)也必須要考慮到,那就是innodb_autoinc_lock_mode。這個參數(shù)控制著InnoDB引擎下自增主鍵的生成策略,它直接關(guān)系到后續(xù)“雙寫”階段數(shù)據(jù)能否保持一致。
innodb_autoinc_lock_mode有三種工作模式,我們需要清楚當前環(huán)境它的配置值。
- 模式0(傳統(tǒng)鎖):采用傳統(tǒng)的表級自增鎖。任何插入操作(無論是單條還是批量)都會鎖住整張表直到語句結(jié)束,并發(fā)性能最差,但能絕對保證任何情況下生成的自增ID都是連續(xù)的。
- 模式1(連續(xù)鎖,默認):這是默認模式,做出了優(yōu)化。對于INSERT ... VALUES這樣的簡單插入,鎖在分配完主鍵后即釋放,大大提升了并發(fā)性;但對于INSERT ... SELECT這類無法預知插入行數(shù)的語句,仍會退化為表鎖,以保證ID的連續(xù)性。
- 模式2(交錯鎖):最為激進,所有類型的插入都在申請主鍵后立刻釋放鎖,并發(fā)性能最好。但代價是,在并發(fā)執(zhí)行多個插入語句時,不同語句獲得的ID會相互交錯,可能導致單個批量插入語句中產(chǎn)生不連續(xù)的主鍵ID。
在面試中提及你對這個參數(shù)的關(guān)注,并解釋它如何影響后續(xù)雙寫方案中對主鍵的處理,會立刻讓面試官感受到你對數(shù)據(jù)庫底層原理的熟悉程度。比如可以補充道:“在雙寫開始前,我們必須對代碼庫進行審查,識別出所有批量插入的業(yè)務(wù)場景。特別是對于INSERT ... SELECT或循環(huán)單條INSERT的邏輯,需要在innodb_autoinc_lock_mode=2的環(huán)境下進行重點測試,評估其主鍵亂序的風險,必要時需進行業(yè)務(wù)代碼改造,統(tǒng)一為INSERT...VALUES的形式,以確保數(shù)據(jù)一致性?!?/p>
3. 環(huán)境確認
除了上面說的遷移工具的選型和關(guān)鍵參數(shù)的設(shè)置外,要設(shè)計一個好的遷移方案,我們還需要確認一系列環(huán)境因素,比如:
- Binlog模式:確認公司的binlog格式是否為ROW模式,STATEMENT模式在高并發(fā)下可能因執(zhí)行順序的不確定性導致主從不一致,而ROW模式會記錄每一行數(shù)據(jù)的變更,是保證數(shù)據(jù)精確同步的基礎(chǔ)。
- 數(shù)據(jù)庫規(guī)范:了解公司是否有統(tǒng)一的數(shù)據(jù)庫設(shè)計規(guī)范,比如,是否所有表都強制包含update_time字段?刪除操作是采用軟刪除還是物理刪除?這些規(guī)范將直接影響增量校驗方案的選擇。
- ORM框架特性:了解項目中所使用的ORM框架,是否提供了AOP(切面)、Interceptor或Hook等機制,有的話可以方便后續(xù)雙寫實現(xiàn)。
4. 面試實戰(zhàn)指南
前面的的三個環(huán)節(jié)都是準備工作,接下來就要進入到核心的方案設(shè)計階段了。
一個成熟的不停機遷移方案,其核心思想在于平滑過渡與風險可控。整個過程可以總結(jié)為三個階段:存量復制 -> 增量同步 -> 驗證切換。
三個階段過程中的詳細步驟可以簡化為下圖中的四個關(guān)鍵階段切換:
1
4.1 第一階段:存量數(shù)據(jù)遷移
數(shù)據(jù)遷移的第一步就是將源表中已有的存量數(shù)據(jù)導入到新表中,這里就要用到我們前面介紹的Mysql遷移工具了。前面也分析過這兩種工具對比,在大多數(shù)情況下,如果我們的業(yè)務(wù)量不大的話,使用 mysqldump 就可以了。雖然說他遷移的速度較慢。而這,恰恰給你提供了展示技術(shù)深度、突出亮點的機會。在面試時,你可以這樣表述:
“在存量數(shù)據(jù)遷移階段,我們選擇使用mysqldump 工具從源表實時導出數(shù)據(jù)。mysqldump 作為一個邏輯備份工具,優(yōu)點是簡單易用,但缺點也很明顯,即在數(shù)據(jù)量巨大時,導出和導入性能都比較差。因此,我針對性地做了一系列優(yōu)化來提升其性能?!?/p>
- 導出端,我們開啟了extended-insert選項,將多行數(shù)據(jù)合并為單條INSERT語句,顯著減少了SQL文件體積和網(wǎng)絡(luò)傳輸壓力。
- 在導入端,我們采取了組合措施:
- 臨時關(guān)閉了binlog記錄(SET SQL_LOG_BIN=0),避免導入過程產(chǎn)生不必要的日志開銷。
- 放寬了redo log的刷盤時機,將innodb_flush_log_at_trx_commit設(shè)置為2。
2
4.2 第二階段:增量同步
存量數(shù)據(jù)開啟完之后,到這里就要進入到整個方案中最復雜、也最關(guān)鍵的核心的增量同步環(huán)節(jié)了。因為業(yè)務(wù)并不是停止的,時時刻刻都在發(fā)生寫入操作,會影響到表里的數(shù)據(jù)。所以這里在這個階段,就需要開啟雙寫,即所有的寫操作在源表和新表中都要寫入一次。
4.2.1 非侵入式的雙寫機制
實現(xiàn)雙寫的過程中,業(yè)務(wù)系統(tǒng)要同時寫入兩個數(shù)據(jù)源,最忌諱的就是侵入式地修改每一處業(yè)務(wù)邏輯代碼,不僅工作量巨大,而且極易引入新的BUG。因此,一個優(yōu)雅的非侵入式方案是架構(gòu)師的首選。我們可以利用所使用ORM框架的AOP(面向切面編程)能力,例如GORM的ConnPool接口或MyBatis的Interceptor,在數(shù)據(jù)訪問的底層實現(xiàn)一個雙寫代理層。
3
這個代理層能夠靜默地攔截所有數(shù)據(jù)庫的寫操作(INSERT/UPDATE/DELETE),并將其復制一份,分別在源表和新表上執(zhí)行。更關(guān)鍵的是,這個雙寫組件還可以是動態(tài)可控的。我們需要為其設(shè)計一個開關(guān),能夠通過配置中心或API接口,實時地調(diào)整其工作策略,例如是從“源表優(yōu)先”切換到“目標表優(yōu)先”,或是徹底關(guān)閉某一方的寫入。這種動態(tài)可控性,是整個遷移方案風險管理的核心。
4
在面試時,你可以結(jié)合自己熟悉的框架來具體闡述,以 Go 語言的 GORM 為例:
“為了在 GORM 中實現(xiàn)雙寫,我最初考慮過使用它的 Hook 機制,比如 BeforeSave, BeforeDelete。但這種方式要求我為每個數(shù)據(jù)模型都注冊一遍 Hook,維護起來比較繁瑣。經(jīng)過深入研究 GORM 的源碼,我發(fā)現(xiàn)可以從更底層的 ConnPool 接口入手。我通過裝飾器模式,封裝了源表和目標表兩個數(shù)據(jù)源的 ConnPool,在執(zhí)行 SQL 語句時,根據(jù)一個動態(tài)的標記位來決定具體的雙寫邏輯,是先寫源表還是先寫目標表?!?/p>
這里可以給出一個偽代碼,讓面試官有一個直觀的感受:
// DoubleWritePool 結(jié)構(gòu)體封裝了源和目標兩個數(shù)據(jù)源
// 并根據(jù)當前模式管理雙寫邏輯
type DoubleWritePool struct {
source *sql.DB // 源數(shù)據(jù)庫連接
target *sql.DB // 目標數(shù)據(jù)庫連接
mode string// 雙寫模式,如 "source_first", "target_first"
}
// ExecContext 攔截數(shù)據(jù)庫寫操作
func (p *DoubleWritePool) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
switch p.mode {
case"source_first":
// 優(yōu)先寫源表
res, err := p.source.ExecContext(ctx, query, args...)
if err != nil {
// 源表寫入失敗,操作終止,不寫目標表
returnnil, err
}
// 源表成功后,嘗試寫入目標表,此處的錯誤可降級處理(如只記錄日志)
// 后續(xù)依賴獨立的校驗修復機制來保證最終一致性
go p.target.ExecContext(ctx, query, args...)
return res, nil
case"target_first":
// 切換到目標表優(yōu)先的邏輯
// ...
default:
// 默認或其他模式
return p.source.ExecContext(ctx, query, args...)
}
}4.2.2 雙寫階段的核心挑戰(zhàn)
- 數(shù)據(jù)一致性問題
只要涉及到雙寫機制,就會帶來兩個必須解決的經(jīng)典問題。首先是數(shù)據(jù)一致性問題。
面試官很可能會追問:“在雙寫過程中,如果寫源表成功了,但寫目標表失敗了,怎么辦?”
我們的核心應(yīng)對原則是,在雙寫階段,允許這種短暫的不一致存在,只保證最終一致性,所以以優(yōu)先寫入的庫為準。然后靠我們后續(xù)部署的獨立的數(shù)據(jù)校驗和修復工具,周期性的掃描和修復這類不一致數(shù)據(jù)。
接著,你可以展示你更深層次的思考,提出一個“基于消息隊列的異步補償方案”的方案,然后分析出這個方案的缺陷,最終放棄,突出方案選型能力。
我們也曾評估過基于消息隊列的異步補償方案,但由于難以精確還原UPDATE...WHERE這類語句的實際影響范圍(例如,一個UPDATE users SET level = 5 WHERE score > 10000語句,我們無法在消息中得知究竟哪些用戶的level被更新了),導致補償邏輯異常復雜,最終選擇了更為通用和穩(wěn)健的校驗修復方案。
5
- 自增主鍵沖突問題
其次是更麻煩的的自增主鍵沖突問題。因為雙寫其實是要保證兩邊數(shù)據(jù)庫的數(shù)據(jù)嚴格一致。那么如果源表使用了自增主鍵,新表的數(shù)據(jù)也要包含自增主鍵。但是在高并發(fā)場景下,如果簡單地依賴兩邊數(shù)據(jù)庫各自生成主鍵,極易因時序問題導致數(shù)據(jù)錯亂。比如,線程A和線程B幾乎同時插入數(shù)據(jù),可能出現(xiàn)A在源表拿到ID 100,B在源表拿到ID 101,但寫入目標表時,B的操作先于A完成,導致B在目標表拿到ID 100,A拿到ID 101,數(shù)據(jù)就此張冠李戴了。
6
正確的解決方案是,在寫入目標表時,必須顯式地指定主鍵值。這意味著,在源表插入成功后,程序需要獲取到數(shù)據(jù)庫為其生成的自增ID,并將這個ID一并用于目標表的插入操作,從而從根本上確保兩邊主鍵的絕對一致。
7
4.2.3 增量校驗與修復機制
在雙寫運行的同時,我們需要一個持續(xù)的任務(wù)機制來發(fā)現(xiàn)并修復兩邊的不一致數(shù)據(jù)。這里主要有兩個方案:基于時間戳輪詢和利用 Binlog。相比之下,Binlog 方案技術(shù)含量更高,也更可靠,是面試時的首選答案。
4.2.3.1 基于時間戳輪詢
我們可以啟動一個定時任務(wù),持續(xù)不斷地輪詢源表中update_time這樣的字段,找到變更的記錄,然后與新表進行比對。一旦發(fā)現(xiàn)不一致,就以原表的數(shù)據(jù)為準,覆蓋新表。
// last_time 記錄上次掃描到的最大時間戳
var last_time = get_initial_timestamp()
for {
// 1. 根據(jù)上次的時間戳,查詢源表中發(fā)生變更的行
// SELECT * FROM source_table WHERE update_time > last_time ORDER BY update_time ASC
rows := findUpdatedRows(last_time)
for _, row := range rows {
// 2. 根據(jù)主鍵,在目標表中查找對應(yīng)的行
tgtRow := findTgt(row.id)
// 3. 比較兩行數(shù)據(jù)是否一致
if !isEqual(row, tgtRow) {
// 4. 如果不一致,用源表數(shù)據(jù)修復目標表數(shù)據(jù)
fixTargetTable(row)
}
}
// 5. 更新時間戳,用于下一次掃描
iflen(rows) > 0 {
last_time = getMaxUpdateTime(rows)
}
// 6. 短暫休眠,避免空輪詢
sleep(1 * time.Second)
}當然,這個方案強依賴于良好的表設(shè)計規(guī)范,即所有表都有update_time字段,且刪除操作為軟刪除。如果存在物理刪除,此方案會失效,因為源表記錄刪除后,基于update_time的輪詢將無法捕獲到這次刪除操作,導致新表殘留臟數(shù)據(jù)。
8
針對這里的第二個前提,其實正好可以作為你的一個亮點。你可以等待面試官追問“那如果業(yè)務(wù)方?jīng)]有實現(xiàn)軟刪除,就是用的物理刪除,有沒有什么方法可以實現(xiàn)一致性呢”,這里你就可以展現(xiàn)出你的亮點方案了。
你可以這樣回答:“對于物理刪除的場景,在這個方案的基礎(chǔ)上還需要一個補救措施:增加一個低頻的反向全量校驗,即從目標表出發(fā),反查源表。如果在源表中找不到對應(yīng)的記錄,就說明該數(shù)據(jù)已被刪除,此時再清理目標表的冗余數(shù)據(jù)?!?/p>
9
4.2.3.2 基于Binlog實時監(jiān)聽
一個更高級、更實時的方案是基于Binlog的實時監(jiān)聽,這也是是面試時的首選答案。其實Mysql的 Binlog (ROW 模式) 本身就包含了變更后的數(shù)據(jù),為什么不直接利用呢?在面試的時候,你可以這樣介紹這個方案。
“在監(jiān)聽 Binlog 的基礎(chǔ)上,我們做了進一步優(yōu)化。當收到一條 Binlog 事件后,我們直接將 Binlog 中包含的數(shù)據(jù)內(nèi)容(即變更后的數(shù)據(jù)鏡像)與目標表的實時數(shù)據(jù)進行比較。如果兩者不一致,我們才會拿著主鍵去查詢源表的最新數(shù)據(jù),用這個最新數(shù)據(jù)來覆蓋目標表。”
10
在實際應(yīng)用中,有一個關(guān)鍵點需要注意:當檢測到數(shù)據(jù)不一致時,修復邏輯應(yīng)當回到源表重新查詢最新數(shù)據(jù),而不是直接依賴 Binlog 的內(nèi)容。原因在于,Binlog 中的事件有可能滯后于真實狀態(tài)——你拿到的可能是一條“舊操作”,而新表的數(shù)據(jù)此時已經(jīng)被后續(xù)更新覆蓋。如果直接使用 Binlog,可能會把較新的正確數(shù)據(jù)覆蓋掉;而通過源表查詢,可以確保修復時拿到的始終是最新版本。
4.2.4 主從同步延遲
在上一步介紹了增量同步過程中必須的一個環(huán)節(jié),增量校驗與修復。如果是單庫模式下都好說,上述方案沒什么大的問題,在實際部署中,為了保證系統(tǒng)的高可用,一般數(shù)據(jù)庫都采用的是主從部署模式,一般讀取數(shù)據(jù)都是讀取的從庫,這里就可能出現(xiàn)主從同步延遲問題了。不管是新表的主從延遲,還是原表的主從延遲,都可能導致我們拿到‘過期’的數(shù)據(jù),從而做出錯誤的判斷。主從同步延遲也是面試官極易挑戰(zhàn)的一個點。
11
解決方案也很簡單,就是再做一次校驗,雙重校驗。面試的時候你可以這樣說:
“為了解決 主從延遲 帶來的數(shù)據(jù)一致性問題,同時避免過度消耗主庫資源,我采用了一種“兩階段校驗”的思路。在第一次校驗時,程序優(yōu)先從從庫讀取并進行比對,如果結(jié)果一致,就無需再繼續(xù)處理。只有在從庫校驗未通過的情況下,才會觸發(fā)第二步:直接訪問主庫,拿到最新的數(shù)據(jù)再做比對。而在數(shù)據(jù)修復環(huán)節(jié),我們始終以主庫的結(jié)果為最終依據(jù)。
這個方案背后的邏輯是:主從延遲與真實不一致通常是低概率事件,因此真正需要訪問主庫的情況也很少。這樣既能保證一致性,又避免了對主庫的頻繁壓力,實現(xiàn)了可靠性與性能之間的平衡。
”
4.3 第三階段:驗證切換
經(jīng)過一段時間的增量同步,并且校驗新舊數(shù)據(jù)源的差異趨于零,且系統(tǒng)穩(wěn)定運行時,我們就可以進入最后的驗證切換階段了。
直接從“源表讀寫”切換到“新表讀寫”,風險是巨大的。一旦切換后新庫出現(xiàn)問題,源庫的數(shù)據(jù)已經(jīng)不再是最新,回滾將變得異常困難,甚至丟失數(shù)據(jù)。
12
所以這里又是一個面試的亮點展示,這里你可以提出一個更穩(wěn)妥的方案。在做最后一步數(shù)據(jù)源切換的時候,你可以這樣說。
“為了確保遷移過程的安全性,我們在方案中設(shè)計了一個中間過渡階段。具體做法是先調(diào)整雙寫策略,改為“新表讀寫”,同時所有的寫請求,源表也會寫一份。這一操作相當于一次“預發(fā)布”,為我們提供了一個可觀察、可回滾的緩沖期。在這段時間內(nèi),新表承擔起權(quán)威數(shù)據(jù)源的角色,而源表依舊保持同步寫入作為備用。如果新表在此期間出現(xiàn)性能下降或潛在缺陷等異常情況,我們能夠立即將業(yè)務(wù)切換回源表,保證業(yè)務(wù)穩(wěn)定運行。正是這份可回退的保障,使得整個方案真正具備了穩(wěn)妥性。”
13
通過這個方案就能保證從源表切換到新表的過程中有一個安全的過渡,即使新表還有問題也不怕,因為我們有備份機制。
5. 小結(jié)
回顧整個遷移過程,要做到平滑的數(shù)據(jù)遷移遠非執(zhí)行一個腳本那么簡單。從前期的工具選型、參數(shù)調(diào)優(yōu),到中期的雙寫與校驗,再到最后的驗證切換,每一步都伴隨著技術(shù)細節(jié)。其核心思想在于通過雙寫機制確保業(yè)務(wù)的連續(xù)性,利用校驗修復工具保證數(shù)據(jù)的最終一致性,并設(shè)計可灰度、可回滾的切換步驟來將風險降至最低。換句話說,遷移的難點從不在于“能不能完成”,而在于“如何安全、平滑地完成”。如果在面試中能夠完整地講清楚這套思路,不僅能體現(xiàn)技術(shù)深度,也能彰顯你作為架構(gòu)師對復雜系統(tǒng)的全局把控力。

2022-03-30 07:28:24




























