不停機分庫分表遷移
不停機分庫分表遷移
本文是好友阿飛寫的,並且經過作者同意發的原創!
阿飛Javaer,轉載請註明原創出處,謝謝!
需求說明
類似訂單表,使用者表這種未來規模上億甚至上十億百億的海量資料表,在專案初期為了快速上線,一般只是單表設計,不需要考慮分庫分表。隨著業務的發展,單表容量超過千萬甚至達到億級別以上,這時候就需要考慮分庫分表這個問題了,而不停機分庫分表遷移,這應該是分庫分表最基本的需求,畢竟網際網路專案不可能掛個廣告牌"今晚10:00~次日10:00系統停機維護",這得多low呀,以後跳槽面試,你跟面試官說這個遷移方案,面試官怎麼想呀?
借鑑codis
筆者正好曾經碰到過這個問題,並借鑑了codis一些思想實現了不停機分庫分表遷移方案;codis不是這篇文章的重點,這裡只提及借鑑codis的地方--rebalance:
當遷移過程中發生資料訪問時,Proxy會發送“SLOTSMGRTTAGSLOT”遷移命令給Redis,強制將客戶端要訪問的Key立刻遷移,然後再處理客戶端的請求。( SLOTSMGRTTAGSLOT 是codis基於redis定製的)
分庫分表
明白這個方案後,瞭解不停機分庫分表遷移就比較容易了,接下來詳細介紹筆者當初對installed_app
表的實施方案;即使用者已安裝的APP資訊表;
1. 確定sharding column
確定sharding column
絕對是分庫分表最最最重要的環節,沒有之一。sharding column直接決定整個分庫分表方案最終是否能成功落地;一個合適的sharding column的選取,基本上能讓與這個表相關的絕大部分流量介面都能通過這個sharding column訪問分庫分表後的單表,而不需要跨庫跨表,最常見的sharding column就是user_id
,筆記這裡選取的也是user_id
;
2. 分庫分表方案
根據自身的業務選取最合適的sharding column後,就要確定分庫分表方案了。筆者採用主動遷移
與被動遷移
相結合的方案:
-
主動遷移就是一個獨立程式,遍歷需要分庫分表的
installed_app
-
被動遷移就是與
installed_app
表相關的業務程式碼自身將資料遷移到分庫分表後對應的表中。
接下來詳細介紹這兩個方案;
2.1 主動遷移
主動遷移就是一個獨立的外掛遷移程式,其作用是遍歷需要分庫分表的installed_app
表,將這裡的資料複製到分庫分表後的目標表中,由於主動遷移
和被動遷移
會一起執行,所以需要處理主動遷移和被動遷移碰撞的問題,筆者的主動遷移
虛擬碼如下:
public void migrate(){
// 查詢出當前表的最大ID, 用於判斷是否遷移完成
long maxId = execute("select max(id) from installed_app");
long tempMinId = 0L;
long stepSize = 1000;
long tempMaxId = 0L;
do{
try {
tempMaxId = tempMinId + stepSize;
// 根據InnoDB索引特性, where id>=? and id<?這種SQL效能最高
String scanSql = "select * from installed_app where id>=#{tempMinId} and id<#{tempMaxId}";
List<InstalledApp> installedApps = executeSql(scanSql);
Iterator<InstalledApp> iterator = installedApps.iterator();
while (iterator.hasNext()) {
InstalledApp installedApp = iterator.next();
// help GC
iterator.remove();
long userId = installedApp.getUserId();
String status = executeRedis("get MigrateStatus:${userId}");
if ("COMPLETED".equals(status)) {
// migration finish, nothing to do
continue;
}
if ("MIGRATING".equals(status)) {
// "被動遷移" migrating, nothing to do
continue;
}
// 遷移前先獲取鎖: set MigrateStatus:18 MIGRATING ex 3600 nx
String result = executeRedis("set MigrateStatus:${userId} MIGRATING ex 86400 nx");
if ("OK".equals(result)) {
// 成功獲取鎖後, 先將這個使用者所有已安裝的app查詢出來[即遷移過程以使用者ID維度進行遷移]
String sql = "select * from installed_app where user_id=#{user_id}";
List<InstalledApp> userInstalledApps = executeSql(sql);
// 將這個使用者所有已安裝的app遷移到分庫分表後的表中(有user_id就能得到分庫分表後的具體的表)
shardingInsertSql(userInstalledApps);
// 遷移完成後, 修改快取狀態
executeRedis("setex MigrateStatus:${userId} 864000 COMPLETED");
} else {
// 如果沒有獲取到鎖, 說明被動遷移已經拿到了鎖, 那麼遷移交給被動遷移即可[這種概率很低]
// 也可以加強這裡的邏輯, "被動遷移"過程不可能持續很長時間, 可以嘗試迴圈幾次獲取狀態判斷是否遷移完
logger.info("Migration conflict. userId = {}", userId);
}
}
if (tempMaxId >= maxId) {
// 更新max(id),最終確認是否遍歷完成
maxId = execute("select max(id) from installed_app");
}
logger.info("Migration process id = {}", tempMaxId);
}catch (Throwable e){
// 如果執行過程中有任何異常(這種異常只可能是redis和mysql丟擲來的), 那麼退出, 修復問題後再遷移
// 並且將tempMinId的值置為logger.info("Migration process id="+tempMaxId);日誌最後一次記錄的id, 防止重複遷移
System.exit(0);
}
tempMinId += stepSize;
}while (tempMaxId < maxId);
}
這裡有幾點需要注意:
-
第一步查詢出max(id)是為了儘量減少max(id)的查詢次數,假如第一次查詢max(id)為10000000,那麼直到遍歷的id到10000000以前,都不需要再次查詢max(id);
-
根據
id>=? and id<?
遍歷,而不要根據id>=? limit n
或者limit m, n
進行遍歷,因為limit效能一般,且會隨著遍歷越往後,效能越差。而id>=? and id<?
這種遍歷方式即使會有一些踩空,也沒有任何影響,且整個效能曲線非常平順,不會有任何抖動;遷移程式畢竟是輔助程式,不能對業務程式有過多的影響; -
根據id區間範圍查詢出來的
List<InstalledApp>
要轉換為Iterator<InstalledApp>
,每迭代處理完一個userId,要remove掉,否則可能導致GC異常,甚至OOM;
2.2 被動遷移
被動遷移就是在正常與installed_app
表相關的業務邏輯前插入了遷移邏輯,以新增使用者已安裝APP為例,其虛擬碼如下:
// 被動遷移方法是公用邏輯,所以與`installed_app`表相關的業務邏輯前都需要呼叫這個方法;
public void migratePassive(long userId)throws Exception{
String status = executeRedis("get MigrateStatus:${userId}");
if ("COMPLETED".equals(status)) {
// 該使用者資料已經遷移完成, nothing to do
logger.info("user's installed app migration completed. user_id = {}", userId);
}else if ("MIGRATING".equals(status)) {
// "被動遷移" migrating, 等待直到遷移完成; 為了防止死迴圈, 可以增加最大等待時間邏輯
do{
Thread.sleep(10);
status = executeRedis("get MigrateStatus:${userId}");
}while ("COMPLETED".equals(status));
}else {
// 準備遷移
String result = executeRedis("set MigrateStatus:${userId} MIGRATING ex 86400 nx");
if ("OK".equals(result)) {
// 成功獲取鎖後, 先將這個使用者所有已安裝的app查詢出來[即遷移過程以使用者ID維度進行遷移]
String sql = "select * from installed_app where user_id=#{user_id}";
List<InstalledApp> userInstalledApps = executeSql(sql);
// 將這個使用者所有已安裝的app遷移到分庫分表後的表中(有user_id就能得到分庫分表後的具體的表)
shardingInsertSql(userInstalledApps);
// 遷移完成後, 修改快取狀態
executeRedis("setex MigrateStatus:${userId} 864000 COMPLETED");
}else {
// 如果沒有獲取到鎖, 應該是其他地方先獲取到了鎖並正在遷移, 可以嘗試等待, 直到遷移完成
}
}
}
// 與`installed_app`表相關的業務--新增使用者已安裝的APP
public void addInstalledApp(InstalledApp installedApp) throws Exception{
// 先嚐試被動遷移
migratePassive(installedApp.getUserId());
// 將使用者已安裝app資訊(installedApp)插入到分庫分表後的目標表中
shardingInsertSql(installedApp);
}
無論是CRUD中哪種操作,先根據快取中MigrateStatus:${userId}
的值進行判斷:
-
如果值為COMPLETED,表示已經遷移完成,那麼將請求轉移到分庫分表後的表中進行處理即可;
-
如果值為MIGRATING,表示正在遷移中,可以迴圈等待直到值為COMPLETED即遷移完成後,再將請求轉移到分庫分表後的表中進行處理處理;
-
否則值為空,那麼嘗試獲取鎖再進行資料遷移。遷移完成後,將快取值更新為COMPLETED,最後再將請求轉移到分庫分表後的表中進行處理處理;
3.方案完善
當所有資料遷移完成後,CRUD操作還是會先根據快取中MigrateStatus:${userId}
的值進行判斷,資料遷移完成後這一步已經是多餘的。可以加個總開關,當所有資料遷移完成後,將這個開關的值通過類似TOPIC的方式傳送,所有服務接收到TOPIC後將開關local cache化。那麼接下來服務的CRUD都不需要先根據快取中MigrateStatus:${userId}
的值進行判斷;
4.遺留工作
遷移完成後,將主動遷移
程式下線,並將被動遷移
程式中對migratePassive()
的呼叫全部去掉,並可以整合一些第三方分庫分表中介軟體,例如sharding-jdbc
,可以參考sharding-jdbc整合實戰
回顧總結
回顧這個方案,最大的缺點就是如果碰到sharding column(例如userId)的總記錄數比較多,且主動遷移正在進行中,被動遷移與主動遷移碰撞,那麼被動遷移可能需要等待較長時間。
不過根據DB效能,一般批量插入1000條資料都是10ms級別,並且同一sharding column的記錄分庫分表後只屬於一張表,不涉及跨表。所以,只要在遷移前先通過sql統計待遷移表中沒有這類異常sharding column即可放心遷移;
筆者當初遷移installed_app
表時,使用者最多也只擁有不超過200個APP,所以不需要過多考慮碰撞帶來的效能問題;沒有萬能的方案,但是有適合自己的方案;
如果有那種上萬條記錄的sharding column,可以把這些sharding column先快取起來,遷移程式在夜間上線,優先遷移這些快取的sharding column的資料,就可以儘可能的降低遷移程式對這些使用者的體驗。當然你也可以使用你想出來的更好的方案。