大綱
18.基於Canal和RocketMQ的增量同步
19.增量同步任務的背景介紹
20.增量同步任務查詢與執行緒池提交
21.RocketMQ裡的binlog訊息的消費邏輯分析
22.新增binlog的資料同步邏輯分析
23.binlog基於記憶體佇列的非同步轉發邏輯
24.基於CAS加鎖的讀寫佇列互換機制
25.binlog基於記憶體的merge合併邏輯
26.對merge資料從目標庫裡分批查詢
27.對merge資料基於目標庫資料做過濾
28.將過濾後的merge資料寫入目標庫
29.offset提交執行緒的啟動和邏輯分析
30.增量同步過程中binlog寫入失敗的恢復
31.增量同步過程中的各種失敗場景的恢復機制
32.定時移除已提交的增量同步訊息
33.增量與全量並行執行的場景分析
34.增量與全量併發同步一批資料的衝突
35.同步完成後的資料校驗邏輯分析
36.資料遷移完成後的無損釋出方案
37.分庫分表線上運維與擴容方案
整個分庫分表的流程:
全量同步 + 增量同步 -> 讓多庫多表資料和單庫單表資料持平 -> 資料校驗 -> 無損釋出 -> 老系統下線 -> 線上多庫多表DDL運維 -> 多庫多表再次擴容
18.基於Canal和RocketMQ的增量同步
(1)在全量資料同步的過程中存在的問題
(2)增量同步方案概述
(1)在全量資料同步的過程中存在的問題
剛從源資料庫查出一批資料寫入到目標庫,已經開始去同步下一批資料了,但上一批資料可能又在源資料庫裡進行了修改或刪除操作。為了處理這種問題,就需要引入增量同步方案。
(2)增量同步方案概述
在發起全量資料遷移任務之前,需要基於Canal去監聽源資料庫的增刪改binlog。Canal監聽到binlog後需要將binlog訊息傳送到RocketMQ訊息中介軟體叢集中,接著資料遷移系統上會有一個RocketMQ消費者去消費這些binlog訊息,這樣資料遷移系統就會把源資料庫的增刪改操作往目標庫中進行同步。
19.增量同步方案的整體介紹
(1)全量同步是一個滾動查詢 + 資料插入的過程
(2)增量同步是為了解決全量同步中已同步資料出現的資料變動問題
(3)增量同步的資料高效寫入和資料防止丟失方案
(1)全量同步是一個滾動查詢 + 資料插入的過程
其中會涉及:一批批查 + 設定滾動ID + 資料過濾 + 去重校驗 + 記錄遷移明細 + 中斷恢復 + 進度統計。
(2)增量同步是為了解決全量同步中已同步資料出現的資料變動問題
開啟全量同步之前,都會先開啟增量同步。
增量同步需要分析以下三種情況:
情況一:還沒同步的一批資料發生增刪改
情況二:正在同步的一批資料發生增刪改
情況三:已經同步的一批資料發生增刪改
(3)增量同步的資料高效寫入和資料防止丟失方案
在增量同步中,首先會透過Canal監聽源資料庫中的binlog⽇志,然後Canal再將監聽到的binlog⽇志傳送放到RocketMQ中,接著資料遷移系統會消費RocketMQ中的binlog訊息,把增刪改操作同步到目標資料庫。
問題一:資料遷移系統消費MQ訊息時,如何保證從MQ獲取到的binlog訊息不會丟失
如果源資料庫增刪改操作了,但由於消費異常導致binlog訊息丟失了,那麼目標資料庫中就沒有對應的增量資料操作,這樣源資料庫和目標資料庫的資料就會不⼀致。為了避免消費異常導致binlog訊息丟失,需要設定禁止自動提交訊息。
消費MQ的binlog訊息時,為了提升消費速度,可以採用多執行緒進行消費。比如每消費一條MQ訊息,就向執行緒池提交一個任務,任務執行完才提交訊息。當這些任務的執行速度慢於消費MQ訊息的速度時,執行緒池的阻塞佇列中就會積壓一些任務。如果此時機器釋出重啟,那麼就可能會導致執行緒池中阻塞佇列裡積壓的任務丟失。但是由於禁止訊息自動提交,所以這些丟失任務對應的MQ訊息後續還可以重新被消費,然後再次被提交到執行緒池中進行處理。
為了方便對binlog訊息進行管理和確保binlog訊息不丟失且有記錄可查,這裡引⼊訊息拉取落庫和非同步訊息提交機制,由兩個定時任務來完成。如下所示:
⾸先源資料庫中會有⼀張消費記錄表,定時任務1每次從MQ拉取並消費⼀條訊息時,都會先在消費記錄表中新增⼀條消費記錄,每條消費記錄的初始狀態都為未消費。然後定時任務1再將獲取到的binlog訊息,在目標資料庫中重做對應的binlog⽇志。也就是將舊庫中的增刪改操作,在目標資料庫中重做⼀遍。重做完成後,再來更新剛剛新增的消費記錄的狀態,從未消費更新為已消費狀態。
此時需要注意:定時任務1消費MQ的binlog訊息後,並不是自動向MQ提交訊息,⽽是需要進行⼿動提交。否則如果訊息都沒有消費成功,就自動向MQ提交訊息,則可能會出現訊息丟失的情況。所以為了保證binlog訊息不丟失,不會⾃動提交訊息,⽽是將提交訊息的任務交給定時任務2來處理。
定時任務2會專⻔從消費記錄表中,查詢已消費的那些記錄,然後向MQ提交訊息,這樣下次就不會從MQ中消費到了。向MQ提交完訊息後,同時會將消費記錄表中的記錄狀態,從已消費更新為已提交。⾄此,⼀個訊息的消費流程才算結束。
問題二:如何提高增量同步時的資料寫入效率
為了提高資料寫入目標資料庫的效率,這裡引入了資料合併、過濾、讀寫佇列的機制,讀寫佇列和資料合併流程圖如下:
定時任務1新增完消費記錄後,並不會⻢上把資料寫入目標庫,⽽是把binlog日誌先放到⼀個寫佇列中,與寫佇列相對的還有⼀個讀佇列。讀佇列是專⻔用於提供給定時任務3進行處理訊息寫⼊操作的。
資料合併提升寫入效率:如果源資料庫中的資料在短時間內進⾏了多次操作,其實只需要保留最新的binlog⽇志即可。所以才使用了一個記憶體佇列來存放binlog訊息,而且會每隔15秒批次處理一次記憶體佇列的所有binlog訊息,以此減少同一條資料對應多條binlog的寫入處理。
binlog日誌的處理細節:從合併後的binlog⽇志中獲取主鍵ID,根據主鍵ID到目標庫中查詢對應的資料。
如果目標庫中能查到這條資料,那麼需要和源資料庫的binlog資料進⾏對⽐。只有當源資料庫的更新時間⼤於目標庫的更新時間,才允許更新資料到目標庫中。如果當前的binlog⽇志的操作型別為刪除操作,則可不⽤對⽐更新時間,直接在目標庫中重做這條binlog⽇志,畢竟源資料庫在刪除⼀條資料時不會更新修改時間。
如果源資料庫的⼀條binlog⽇志對應的資料在目標庫中沒有查到,那麼繼續判斷。如果binlog⽇志是刪除操作,那就沒必要在目標庫中重做這條⽇志了,直接過濾掉。目標庫都沒有資料了,就沒必要執⾏刪除操作。如果binlog⽇志的型別為修改操作,那也沒必要執⾏修改操作。因為目標庫沒資料,直接update也不⾏,可以將binlog的操作型別修改為新增操作。畢竟在binlog⽇志中,包含了⼀條訂單資料的所有欄位的值,⾜以滿⾜新增資料需要的所有欄位。
經過以上的資料過濾操作,⼀⽅⾯避免源資料庫中的舊資料覆蓋了目標庫的新資料,另⼀⽅⾯避免了沒必要執⾏的刪除和更新操作也在目標庫中繼續執⾏。
20.增量同步任務查詢與執行緒池提交
CanalConsumeTask繼承自ApplicationRunner,也就是系統啟動的時候,它就會跑起來。
CanalConsumeTask首先會查出當前配置好的需要滾動查詢全量資料的遷移任務。每個滾動查詢全量資料的遷移任務就對應一個增量同步任務。然後建立一個執行緒池,執行緒數量 = 已配置好的滾動查詢全量資料的遷移任務的數量。接著遍歷每一個滾動查詢資料的遷移業務,提交兩個任務到執行緒池中。其中的CanalPullRunner拉取任務,會設定禁止Consumer自動提交offset,只拉取不提交。另外的CanalPullCommitRunner提交任務,此時才會提交offset。
需要注意的是:從RocketMQ裡消費binlog訊息時,需要避免Consumer自動去提交offset。需要精準控制offset提交,當每一條binlog都已經被應用到目標資料裡後,才能對這條offset進行提交。因為RocketMQ的Consumer提交offset預設是自動提交:即會先提交到本地快取,再提交到RocketMQ。而這就可能會導致offset提交時資料還沒被應用到目標資料庫。
@Component
public class CanalConsumeTask implements ApplicationRunner {
//RocketMQ的nameServer地址
@Value("${rocketmq.name-server:127.0.0.1:9876}")
private String nameServerUrl;
//可以從migrateConfigService拿到增量同步配置
//要從RocketMQ裡監聽到binlog變更,需要知道要關注的是哪些庫哪些表的binlog變更
@Autowired
private MigrateConfigService migrateConfigService;
//ApplicationRunner在系統啟動時就會執行run()方法
@Override
public void run(ApplicationArguments args) throws Exception {
//首先查出當前配置好的需要滾動查詢全量資料的遷移任務,每個滾動查詢全量資料的遷移任務就對應一個增量同步任務
List<ScrollDomain> scrollDomainList = migrateConfigService.queryScrollDomainList();
//這裡會建立一個執行緒池,執行緒數量 = 已配置好的滾動查詢全量資料的遷移任務的數量
ExecutorService executors = Executors.newFixedThreadPool(scrollDomainList.size());
for (ScrollDomain scrollDomain : scrollDomainList) {
//接下來會提交兩個任務
if (scrollDomain.getDataSourceType().equals(1)) {
//從RocketMQ裡消費binlog訊息時,需要避免Consumer自動去提交offset
//需要精準控制offset提交,當每一條binlog都已經被應用到目標資料裡後,才能對這條offset進行提交
//因為RocketMQ的Consumer提交offset預設都是自動提交:也就是會先提交到本地快取,再提交到RocketMQ
//而這就可能會導致offset提交時資料還沒被應用到目標資料庫
//執行拉取任務,此時設定Consumer不自動提交offset,只拉取不提交
executors.execute(new CanalPullRunner(scrollDomain.getDomainTopic(), nameServerUrl));
//執行提交任務,此時才會提交offset
executors.execute(new CanalPullCommitRunner(scrollDomain.getDomainTopic(), nameServerUrl));
}
}
}
}
//需要滾動查詢全量資料的遷移任務配置表
@Data
public class ScrollDomain implements Serializable {
//主鍵ID
private Long id;
//所屬系統(會員、訂單、交易)
//需要進行增量同步的表,是來源於哪個業務的,這個業務可以是會員、訂單、交易等
private String domain;
//當資料來源為來源的時候,配置對應的訊息topic
//需要進行同步的表,該表的binlog會被寫入到RocketMQ的哪個topic裡去
private String domainTopic;
//資料來源型別,1需要讀取資料,2需要寫入資料
private Integer dataSourceType;
//是否顯示 ShardingSphere SQL執行日誌
private Integer sqlshow;
//每個邏輯庫中表的數量
private int tableNum;
}
21.RocketMQ裡的binlog訊息的消費邏輯分析
RocketMQ裡的binlog訊息會由執行緒池裡的CanalPullRunner任務來處理。往執行緒池提交CanalPullRunner任務時,會傳入RocketMQ的地址和主題。然後CanalPullRunner任務首先會設定RocketMQ Consumer禁止自動提交offset,讓當前Consumer不要自動去提交offset,防止還沒處理完binlog訊息就提交offset。如果offset已經被自動提交,但是binlog訊息卻處理失敗,那麼RocketMQ就不會讓消費者再次消費了。之後CanalPullRunner會設定Consumer訂閱指定的topic和nameServer地址,然後啟動這個Consumer。
接著會進入while死迴圈中,並不斷透過Consumer從RocketMQ中一批批的Pull訊息出來消費處理。對於Consumer從RocketMQ中Pull到的每一條訊息,會獲取對應的topic、queue、offset、body。然後判斷該訊息是否已被處理過,即是否已經存在於消費記錄表中,如果存在則跳過執行。如果還沒處理過,那麼呼叫processNewMsg()方法進行處理,並往消費記錄表中插入一條記錄。
//binlog訊息拉取任務(只拉不提交)
public class CanalPullRunner implements Runnable {
//訊息主題
private final String topic;
//RocketMQ的NameServer地址
private final String nameServerUrl;
//binlog訊息同步消費記錄表Mapper
private final EtlBinlogConsumeRecordMapper consumeRecordMapper;
//訊息拉取任務構造方法
//@param topic 訊息主題
//@param nameServerUrl RocketMQ的NameServer地址
public CanalPullRunner(String topic, String nameServerUrl) {
this.topic = topic;
this.nameServerUrl = nameServerUrl;
this.consumeRecordMapper = ApplicationContextUtil.getBean(EtlBinlogConsumeRecordMapper.class);
}
@Override
public void run() {
pullRun();
}
//執行訊息拉取
private void pullRun() {
try {
DefaultLitePullConsumer litePullConsumer = new DefaultLitePullConsumer("binlogPullConsumer");
//設定RocketMQ Consumer禁止自動提交offset,讓它不要自動去提交offset,防止還沒完處理完binlog訊息就提交offset
//如果offset已經被自動提交,但是binlog訊息卻處理失敗,那麼RocketMQ就不會讓消費者再次消費了
litePullConsumer.setAutoCommit(false);
litePullConsumer.setNamesrvAddr(nameServerUrl);
litePullConsumer.subscribe(topic, "*");
litePullConsumer.start();
try {
//進入while死迴圈中,透過Consumer從RocketMQ中一批批的Pull訊息出來消費處理
while (true) {
//拉取未消費訊息
List<MessageExt> messageExts = litePullConsumer.poll();
if (CollUtil.isNotEmpty(messageExts)) {
for (MessageExt messageExt : messageExts) {
byte[] body = messageExt.getBody();
String msg = new String(body);
//記錄queueId和offset
int queueId = messageExt.getQueueId();
long offset = messageExt.getQueueOffset();
String topic = messageExt.getTopic();
//topic、queue、offset、msg,四位一體,把所有的資訊都拿到
//判斷該訊息是否已被處理過,即是否已經存在於消費記錄表中,如果存在則跳過執行
EtlBinlogConsumeRecord existsRecord = consumeRecordMapper.getExistsRecord(queueId, offset, topic);
if (null == existsRecord) {
//如果還沒處理過,那麼進行處理,並往消費記錄表中插入一條記錄
processNewMsg(messageExt, msg);
} else {
//處理已經存在的消費記錄
proccessExistsRecord(litePullConsumer, msg, existsRecord);
}
}
} else {
Thread.sleep(5000);
}
}
} finally {
litePullConsumer.shutdown();
}
} catch (InterruptedException | MQClientException e) {
try {
//假設要拉取訊息的主題還不存在,則會丟擲異常,這種情況下休眠五秒再重試
Thread.sleep(5000);
pullRun();
} catch (InterruptedException ignored) {
}
}
}
...
}
22.新增binlog的資料同步邏輯分析
對於新增的binlog資料,會呼叫CanalPullRunner的processNewMsg()方法進行處理。
由於首先拿到的msg是一個字串格式的binlog,所以需要對該字串格式的binlog做一個解析,然後把這個解析後的binlog的資訊封裝到自定義的BinlogData物件裡去。
由於binlog的字串格式是json格式,所以可以把binlog的字串轉成json物件。然後從json物件裡提取一個一個欄位出來,構建BinlogData物件。
拿到一個BinlogData物件之後,接著會封裝binlog消費處理記錄物件EtlBinlogConsumeRecord。此時不會把binlog的資料放到EtlBinlogConsumeRecord裡,但會設定topic、queue、offset等資訊。
之所以關閉自動提交offset的功能,是因為這裡的每一條binlog訊息都將被非同步處理。也就是每一條binlog訊息,都會提交到本地佇列來實現非同步化處理。所以如果非同步化都沒處理完畢,就自動提交了offset告訴MQ已處理成功,那是有問題的。
因此這裡Consumer消費訊息時,會把訊息先放入本地佇列。然後把binlog在RocketMQ裡的topic、queue、offset封裝成Record物件,插入到DB。
當Record物件插入DB後,binlog自己的資料才會提交到本地佇列進行非同步化處理。此時offset還不會提交給Broker,必須等到非同步化處理完該binlog訊息後,才能提交offset。
public class CanalPullRunner implements Runnable {
...
//處理新的訊息
//@param messageExt mq訊息物件
//@param msg 訊息內容
private void processNewMsg(MessageExt messageExt, String msg) {
try {
//由於首先拿到的msg是一個字串格式的binlog,所以需要對該字串格式的binlog做一個解析
//然後把這個解析後的binlog的資訊封裝到自定義的BinlogData物件裡去
BinlogData binlogData = BinlogUtils.getBinlogDataMap(msg);
Boolean targetOperateType = BinlogType.INSERT.getValue().equals(binlogData.getOperateType()) || BinlogType.DELETE.getValue().equals(binlogData.getOperateType()) || BinlogType.UPDATE.getValue().equals(binlogData.getOperateType());
if (!targetOperateType || null == binlogData || null == binlogData.getDataMap()) {
return;
}
//拿到一個BinlogData物件之後,接著會封裝一個binlog消費處理記錄EtlBinlogConsumeRecord
//此時不會把binlog自己的資料放到EtlBinlogConsumeRecord裡,但會設定topic、queue、offset等資訊
//之所以關閉RocketMQ Consumer自動提交offset的功能,是因為這裡的每一條binlog訊息都將被非同步處理
//也就是每一條binlog訊息,都會提交到本地佇列裡,依託本地佇列來實現非同步化處理
//所以對於Consumer來說,如果非同步化都沒處理完畢,就自動提交了offset,告訴MQ已經處理成功了,那是有問題的
//因此這裡Consumer消費訊息時,是先放入佇列處理
//然後把binlog在RocketMQ裡的topic、queue、offset封裝成EtlBinlogConsumeRecord物件,插入到DB裡
EtlBinlogConsumeRecord consumeRecord = new EtlBinlogConsumeRecord();
consumeRecord.setQueueId(messageExt.getQueueId());
consumeRecord.setOffset(messageExt.getQueueOffset());
consumeRecord.setTopic(messageExt.getTopic());
consumeRecord.setBrokerName(messageExt.getBrokerName());
consumeRecord.setConsumeStatus(ConsumerStatus.NOT_CONSUME.getValue());
consumeRecord.setCreateTime(new Date());
consumeRecordMapper.insert(consumeRecord);
//EtlBinlogConsumeRecord物件插入DB後,binlog自己的資料會提交到本地佇列進行非同步化處理
//此時offset還不會提交給Broker的,必須等到非同步化處理完該binlog訊息後,才能提交offset給Broker
LocalQueue.getInstance().submit(binlogData, consumeRecord);
} catch (Exception e) {
log.error("新增消費記錄失敗", e);
}
}
...
}
解析binlog的json字串為物件的邏輯如下:
//MySQL binlog解析工具類
public abstract class BinlogUtils {
...
//解析binlog json字串
//@param binlogStr binlog json字串
//@param dataType 解析後的data的型別(實體類還是map)
//@return BinlogData
//@throws ClassNotFoundException 找不到實體類異常
//@throws InstantiationException 例項化實體類異常
//@throws IllegalAccessException 非法訪問異常
private static BinlogData getBinlogData(String binlogStr, String dataType) throws ClassNotFoundException, InstantiationException, IllegalAccessException {
//isJson方法裡面會判斷字串是不是為空,所以這裡不需要重複判斷
//由於binlog的字串格式是json格式,所以可以把binlog的字串轉成json物件,然後從json物件裡提取一個一個欄位出來
if (JSONUtil.isJson(binlogStr)) {
JSONObject binlogJson = JSONUtil.parseObj(binlogStr);
BinlogData binlogData = new BinlogData();
//表名
String tableName = binlogJson.getStr("table");
binlogData.setTableName(tableName);
//操作型別
String operateType = binlogJson.getStr("type");
binlogData.setOperateType(operateType);
//操作時間
Long operateTime = binlogJson.getLong("ts");
binlogData.setOperateTime(operateTime);
//獲取資料json陣列
JSONArray dataArray = binlogJson.getJSONArray("data");
if (null != dataArray) {
Iterable<JSONObject> dataArrayIterator = dataArray.jsonIter();
//遍歷data節點並反射生成物件
if (null != dataArrayIterator) {
//binlog的data陣列裡資料的型別為實體類
if (DATATYPE_DOMAIN.equals(dataType)) {
//獲取實體類名稱
String domainName = DOMAIN_PATH + '.' + StrUtil.upperFirst(StrUtil.toCamelCase(tableName));
//獲取表對應的實體類(這裡出現異常就丟擲去了,實際使用時應該捕獲並記錄日誌,因為根據表名找不到物件,那麼這個表的所有資料都無法同步,這種情況肯定要記錄日誌並告警的)
Class<?> domainClass = Class.forName(domainName);
List<Object> datas = new ArrayList<>();
while (dataArrayIterator.iterator().hasNext()) {
JSONObject jsonObject = dataArrayIterator.iterator().next();
Field[] fields = domainClass.getDeclaredFields();
//透過反射建立實體類例項,這裡的異常也直接外拋,實際處理時需要記錄這個異常並告警
Object domain = domainClass.newInstance();
for (Field field : fields) {
//根據屬性名稱反向取得對應的表中的欄位名稱,然後根據屬性的型別取得欄位值並透過set方法設定進去
String fieldName = field.getName();
String columnName = StrUtil.toSymbolCase(fieldName, '_');
//因為我們的屬性是私有的,所以這裡需要設定為可訪問方便直接設值
field.setAccessible(true);
Object fieldValue = getFieldValue(field.getType(), columnName, jsonObject);
if (null != fieldValue) {
field.set(domain, fieldValue);
}
}
datas.add(domain);
}
binlogData.setDatas(datas);
} else if (DATATYPE_MAP.equals(dataType)) {
//binlog的data陣列裡資料的型別為Map
List<Map<String, Object>> dataMap = new ArrayList<>();
while (dataArrayIterator.iterator().hasNext()) {
JSONObject jsonObject = dataArrayIterator.iterator().next();
Map<String, Object> data = new HashMap<>();
jsonObject.keySet().forEach(key -> {
data.put(key, jsonObject.get(key));
});
dataMap.add(data);
}
binlogData.setDataMap(dataMap);
}
}
}
return binlogData;
}
return null;
}
...
}
23.binlog基於記憶體佇列的非同步轉發邏輯
CanalPullRunner在processNewMsg()方法處理一條新增的binlog時:首先會封裝一條binlog消費處理記錄寫入到資料庫中,然後再將該binlog訊息提交到自定義的記憶體佇列LocalQueue。
LocalQueue.getInstance().submit(binlogData, consumeRecord)
在往記憶體佇列寫入binlog訊息時,會首先加一個輕量級的Atomic——PutBinlogLock。因為LocalQueue記憶體寫佇列writeQueue是LinkedList,LinkedList並不是執行緒安全的。成功加鎖的執行緒才能把binlog訊息寫入到記憶體佇列LocalQueue的記憶體寫佇列writeQueue裡,然後釋放鎖。
這個輕量級Atomic鎖會進行如下設計:假設多個執行緒同時來進行加鎖,多個執行緒都會去對Atomic變數進行CAS操作。但只有一個執行緒可以把Atomic變數從true變為false,Atomic變數預設就是支援執行緒安全的。也就是隻有一個執行緒可以完成加鎖的邏輯(即在執行compareAndSet()前flag為true,執行後flag為false。而其他執行緒加鎖都會失敗並在進入自旋(因為執行compareAndSet()前flag=flag,執行後flag還是false。
//binlog訊息拉取任務(只拉不提交)
public class CanalPullRunner implements Runnable {
...
private void processNewMsg(MessageExt messageExt, String msg) {
...
consumeRecordMapper.insert(consumeRecord);
LocalQueue.getInstance().submit(binlogData, consumeRecord);
...
}
...
}
//資料快取阻塞佇列類
public class LocalQueue {
private static volatile LocalQueue localQueue;
//提供鎖的例項物件
private final PutBinlogLock lock = new PutBinlogLock();
//資料同步的寫佇列
private volatile LinkedList<BinlogData> writeQueue = new LinkedList<>();
//資料同步的讀佇列
private volatile LinkedList<BinlogData> readQueue = new LinkedList<>();
//由於可能會多執行緒進行併發讀和寫,所以一般定義為volatile型別,來保證執行緒之間的可見性
//isRead始終只有一些相隔15秒的執行緒在寫
private volatile boolean isRead = false;
private LocalQueue() {
}
//構建一個單例模式物件
public static LocalQueue getInstance() {
if (null == localQueue) {
synchronized (LocalQueue.class) {
if (null == localQueue) {
localQueue = new LocalQueue();
}
}
}
return localQueue;
}
//資料寫入佇列
//@param binlogData MySQL的binlog物件
//@param consumeRecord 消費記錄
public void submit(BinlogData binlogData, EtlBinlogConsumeRecord consumeRecord) {
//writeQueue是LinkedList,LinkedList並不是執行緒安全的
lock.lock();
try {
binlogData.setConsumeRecord(consumeRecord);
writeQueue.add(binlogData);
} finally {
lock.unlock();
}
}
...
}
//鎖競爭類物件
//基於Atomic的輕量級鎖
public class PutBinlogLock {
private final AtomicBoolean putMessageSpinLock = new AtomicBoolean(true);
//加鎖
public void lock() {
//假設多個執行緒同時來進行加鎖
boolean flag;
do {
//多個執行緒都會去對Atomic變數進行CAS操作,只有一個執行緒可以把Atomic變數從true變為false,Atomic變數預設就是支援執行緒安全的
//也就是隻有一個執行緒可以完成加鎖的邏輯(此時執行下面compareAndSet前flag=true,執行後flag=false)
//其他執行緒CAS加鎖都會失敗並在這裡進入自旋(此時執行下面compareAndSet前flag=flag,執行後flag還是false,從而自旋)
flag = this.putMessageSpinLock.compareAndSet(true, false);
} while (!flag);
}
//釋放鎖
public void unlock() {
//只有一個執行緒可以成功的執行cas,把false變為true
this.putMessageSpinLock.compareAndSet(false, true);
}
}
24.基於CAS加鎖的讀寫佇列互換機制
對binlog的處理是基於定時批處理的,不是來一條binlog就處理一條binlog。預設會每隔15秒處理記憶體佇列裡writeQueue的binlog資料,從而實現統一的批處理。
所以會有一個定時排程任務IncrementTask,負責定時對增量binlog資料進行處理。該定時排程任務IncrementTask會每隔15秒跑一次處理記憶體佇列writeQueue的binlog資料。也就是定時排程任務IncrementTask會每隔15秒執行一次LocalQueue的doCommit()方法。
當LocalQueue的doCommit()方法對記憶體佇列writeQueue的binlog資料進行處理時,為了提升效能,避免處理記憶體佇列writeQueue的binlog資料時,對writeQueue持有鎖耗時過長。所以設計了一個交換佇列readQueue,會快速將writeQueue的資料複製到readQueue中。只有兩個佇列在交換資料時才會加鎖,避免對writeQueue的長時間操作。
首先會設定一個標記isRead表明是否正在處理binlog,isRead始終只有一些相隔15秒的執行緒在寫。只有isRead為false時,才能對記憶體佇列writeQueue的binlog資料進行批處理。開始進行批處理時,會設定isRead為true。佇列交換前,readQueue是空的,writeQueue是有資料的。交換進行中,writeQueue會被加鎖,等待交換完成才釋放鎖,才能往writeQueue裡寫。當然往writeQueue裡寫binlog資料時,也會加鎖的,寫完之後才釋放鎖。交換完成後,writeQueue是空的,readQueue是有資料的。當後續慢慢處理完readQueue裡的binlog資料了,設定isRead為false。
需要注意:加鎖都是基於CAS輕量級的加鎖,為什麼不去用JDK提供的執行緒併發安全的佇列呢?因為JDK提供的執行緒併發安全的佇列,僅僅是佇列自己內部是執行緒併發安全而已。而這裡我們需要確保多個佇列queue,在同時操作時,也都是執行緒併發安全的。所以這裡才需要我們自己去對佇列writeQueue的操作進行加鎖。
//負責定時對增量資料寫入落地
//對binlog的處理都是基於定時批處理的,不是來一條binlog就處理一條binlog
//預設是收集15s內的資料統一做一個批處理
@Component
public class IncrementTask {
//負責增量資料的寫入動作,每隔15秒跑一次
@Scheduled(fixedDelay = 15000)
void IncrementTask() {
//獲取記憶體佇列單例
LocalQueue localQueue = LocalQueue.getInstance();
//判斷讀佇列的資料是否已被處理完畢
//剛開始localQueue的isRead預設就是false;如果上一次資料匯入操作還在做(isRead會為true),則不做處理
if (!localQueue.getIsRead()) {
log.info("增量資料執行寫入");
//處理讀佇列裡的資料,執行資料寫入
localQueue.doCommit();
}
}
}
//資料快取阻塞佇列類
public class LocalQueue {
private static volatile LocalQueue localQueue;
//提供鎖的例項物件
private final PutBinlogLock lock = new PutBinlogLock();
//資料同步的寫佇列
private volatile LinkedList<BinlogData> writeQueue = new LinkedList<>();
//資料同步的讀佇列
private volatile LinkedList<BinlogData> readQueue = new LinkedList<>();
//由於可能會多執行緒進行併發讀和寫,所以一般定義為volatile型別,來保證執行緒之間的可見性
//isRead始終只有一些相隔15秒的執行緒在寫
private volatile boolean isRead = false;
...
//獲取是否正在讀取資料解析落地
public Boolean getIsRead() {
return this.isRead;
}
//將讀佇列快取的資料,進行資料合併處理,並寫入儲存落地
public void doCommit() {
//標記目前正在讀取讀佇列的binlog資料,進行寫入儲存落地
isRead = true;
//讀取讀佇列裡的binlog資料,並寫入完成後,互動一下讀寫佇列
swapRequests();
if (!readQueue.isEmpty()) {
...
//readQueue裡的binlog資料處理
}
readQueue.clear();
isRead = false;
}
//交換佇列
private void swapRequests() {
//writeQueue是LinkedList,LinkedList並不是執行緒安全的
lock.lock();
//注意:加鎖都是基於CAS輕量級的加鎖,那為什麼不去用JDK提供的預設執行緒併發安全的佇列呢?
//因為JDK提供的預設執行緒併發安全的佇列,僅僅是佇列自己內部是執行緒併發安全而已
//而這裡我們需要確保多個佇列queue,在同時操作時,也都是執行緒併發安全的
//所以這裡才需要我們自己去對佇列操作進行加鎖
//加鎖有三種方式:synchronized、ReentrantLock和CAS
//這裡選擇CAS,是因為它是輕量級的加鎖
//況且JDK自己內部實現加鎖,都是基於CAS加鎖,如果加鎖不成功會進入while(true)自旋
//所以從效能來說,CAS會更好一些
try {
log.info("本次同步資料寫入:" + writeQueue.size() + "條數");
LinkedList<BinlogData> tmp = writeQueue;
writeQueue = readQueue;
readQueue = tmp;
} finally {
lock.unlock();
}
}
...
}
25.binlog基於記憶體的merge合併邏輯
如果對同一條資料,有增刪改多個binlog,比如有insert、update、update、delete4條binlog,那麼這4條binlog其實都沒有必要都放到目標庫裡跑一遍。完全可以對它們合併在一起,直接執行最後一條delete操作即可。
所以LocalQueue的doCommit()方法在處理readQueue裡的binlog資料時,首先會透過MergeBinlogWrite元件來進行合併操作。透過MergeBinlogWrite元件完成binlog資料合併後,會過濾無效的binlog資料。過濾完無效的binlog資料後,再透過MergeBinlogWrite元件完成向目標庫寫入binlog。
MergeBinlogWrite是一個支援合併binlog的目標庫寫入元件,MergeBinlogWrite的mergeBinlog()方法會對資料進行合併處理。
注意:從readQueue拿到的一條資料,可能會包含一條資料的多個binlog。所以需要先對可能的多個binlog進行merge合併,也就是把每條資料的binlog放入一個map裡。
//資料快取阻塞佇列類
public class LocalQueue {
...
//將讀佇列快取的資料,進行資料合併處理,並寫入儲存落地
public void doCommit() {
//標記目前正在讀取讀佇列的binlog資料,進行寫入儲存落地
isRead = true;
//讀取讀佇列裡的binlog資料,並寫入完成後,互動一下讀寫佇列
swapRequests();
if (!readQueue.isEmpty()) {
//如果對同一條資料,有增刪改多個binlog,比如有insert、update、update、delete4條binlog
//那麼這4條binlog其實都沒有必要都放到目標庫裡跑一遍
//完全可以對它們合併在一起,直接執行最後一條delete操作即可
//所以這裡會在記憶體裡進行binlog的merge操作
//MergeBinlogWrite是一個支援合併binlog的目標庫寫入元件
MergeBinlogWrite mergeBinlogWrite = new MergeBinlogWrite();
//遍歷儲存在讀佇列readQueue的binlog資料,然後進行資料合併,保留時間最新的操作
for (BinlogData binlogData : readQueue) {
//對資料進行合併處理
mergeBinlogWrite.mergeBinlog(binlogData);
}
//接著對資料進行校驗,過濾無效的資料,例如已經小於目標庫記錄時間的
//過濾掉那些:merge以後的binlog,它的操作時間比目標庫裡的資料時間舊
mergeBinlogWrite.filterBinlogAging(OperateType.ADD, null);
//資料寫入,按表分組寫入
mergeBinlogWrite.write(OperateType.ADD, null);
}
readQueue.clear();
isRead = false;
}
...
}
//對資料合併、過濾、寫入儲存
//在進行全量資料同步時,會呼叫MergeBinlogWrite元件的load()方法對資料進行過濾
//在進行增量資料同步時,會透過MergeBinlogWrite元件的mergeBinlog()方法對監聽到的binlog進行合併操作
public class MergeBinlogWrite {
//用於儲存過濾最新的資料,binlogDataMap的key是由"每條資料的主鍵ID" + "&" + "表名"組成的
private final Map<String, BinLog> binlogDataMap = new HashMap<>(2048);
//儲存本次需要更新的訊息物件資訊
private List<EtlBinlogConsumeRecord> etlBinlogConsumeRecordList = new ArrayList<>();
...
//對同步的資料進行合併,轉換
//@param binlogData MySQL的binlog物件
public void mergeBinlog(BinlogData binlogData) {
//此時從readQueue拿到的一條資料,可能會包含一條資料的多個binlog
//所以這裡會先對可能的多個binlog進行merge合併,也就是把每條資料的binlog先放在一個map裡
List<Map<String, Object>> dataList = binlogData.getDataMap();
if (CollectionUtils.isEmpty(dataList)) {
return;
}
//獲取binlog對應的表名
String key = MergeConfig.getSingleKey(binlogData.getTableName());
for (Map<String, Object> dataMap : dataList) {
//每次都需要先加入到集合中,用於處理完後,批次更新
etlBinlogConsumeRecordList.add(binlogData.getConsumeRecord());
//先獲取這條同步記錄的唯一標識欄位
//RocketMQ裡有一個topic -> topic裡有一個table -> table裡有一個標識欄位值(訂單編號)
String mergeKey = dataMap.get(key) + SPLIT_KEY + binlogData.getTableName() + SPLIT_KEY + binlogData.getConsumeRecord().getTopic();
//驗證是否在這批同步的資料當中,有相同的更新記錄
BinLog getBinlogData = binlogDataMap.get(mergeKey);
if (!ObjectUtils.isEmpty(getBinlogData)) {
//判斷歷史的記錄的操作時間,是否大於本次同步的操作時間
//上一次放到map裡的binlog是比較新的,此時這條binlog是舊的,不要去做任何處理
//例如:insert update1 update2,那麼如果update2先進來map,update1後進來,就不要去管它了
if (getBinlogData.getOperateTime().compareTo(binlogData.getOperateTime()) > 0) {
continue;
}
}
//將資料轉換為單條log物件
BinLog binLog = buildBinLog(binlogData, dataMap);
//在這裡的merge,其實就是對一條資料的多個binlog,按照時間先後順序,去做一個覆蓋
binlogDataMap.put(mergeKey, binLog); //topic->table->資料標識,binlog,map
}
}
//轉換成單條儲存的sql變更物件
//@param binlogData MySQL的binlog物件
//@param dataMap 單條sql的資訊
//@return binlog物件
private BinLog buildBinLog(BinlogData binlogData, Map<String, Object> dataMap) {
BinLog binLog = new BinLog();
binLog.setDataMap(dataMap);
binLog.setOperateTime(binlogData.getOperateTime());
binLog.setOperateType(binlogData.getOperateType());
binLog.setTableName(binlogData.getTableName());
binLog.setTopic(binlogData.getConsumeRecord().getTopic());
return binLog;
}
...
}
26.對merge資料從目標庫裡分批查詢
在LocalQueue的doCommit()方法中,呼叫MergeBinlogWrite.mergeBinlog()方法完成binlog資料的合併後,接著就會呼叫MergeBinlogWrite的filterBinlogAging()方法過濾無效的binlog資料。也就是過濾掉那些:merge以後的binlog資料,它的操作時間比目標庫裡的資料時間舊。
filterBinlogAging()方法首先會透過batchQuery()方法對合並的資料從目標庫裡進行分批查詢。也就是首先對合並的資料按表進行分組,分組後再進行資料切割,保證每次最多查詢200條資料。
public class MergeBinlogWrite {
...
//對合並後的資料進行驗證是否為過時資料
public void filterBinlogAging(OperateType operateType, String domain) {
//批次查詢資料是否存在於目標庫,並返回匹配的資料集合;也就是根據這500條資料去分庫分表的目標庫中進行查詢
Map<String, Map<String, Object>> respMap = batchQuery(operateType, domain);
...
}
...
//批次查詢已存在目標庫的資料
private Map<String, Map<String, Object>> batchQuery(OperateType operateType, String domain) {
//先獲取本次遷移的全部唯一key
List<String> keyStrList = new ArrayList<>(binlogDataMap.keySet());
binlogDataList = new ArrayList<>(keyStrList.size());
//先對這批資料,按照表名做一個分組
Map<String, List<String>> keyMap = new HashMap<>();
//增量處理和全量處理
if (operateType == OperateType.ADD) {
//增量的資料進行分組處理(按表粒度分組)
//按表為粒度來進行分組,就是一個表的多條資料分為一組
keyMap = groupIncrMentTable(keyStrList);
} else if (operateType == OperateType.ALL) {
//全量資料進行分組(只是模型轉換的概念)
keyMap = groupAllTable(keyStrList);
}
Map<String, Map<String, Object>> targetMap = new HashMap<>();
MigrateService migrateService = ApplicationContextUtil.getBean(MigrateService.class);
List<Map<String, Object>> targetAllList = new ArrayList<>();
for (Map.Entry<String, List<String>> mapEntry : keyMap.entrySet()) {
String dataBaseKey = mapEntry.getKey();
String[] split = dataBaseKey.split(SPLIT_KEY);
//獲取topic和對應的表
String tableName = null;
String topic = null;
if (operateType == OperateType.ADD) {
topic = split[0];
tableName = split[1];
} else {
tableName = split[0];
}
List<String> keyList = mapEntry.getValue();
//資料切割,每次查詢200條資料
//一個表的keyList,可能會有很多條;條數太多了以後,如果去做批次查詢,一次查詢的量可能會太大
//所以需要對keyList做一個切割,按200條為一個單位,切割成多個批次
//分多個批次,把表對應的所有資料從目標庫表裡查詢出來後,還需要做一些對比,即當前binlog是不是比目標庫裡的資料要舊
int limit = countStep(keyList.size());
//切割成多個集合物件
//java8表示式,流處理的方式,按照指定的批次,將keyList拆分為多個批次
List<List<String>> splitList = Stream.iterate(0, n -> n + 1)
.limit(limit)
.parallel()
.map(a -> keyList.stream().skip((long) a * MAX_SEND).limit(MAX_SEND).parallel().collect(Collectors.toList()))
.collect(Collectors.toList());
//獲取對應的業務域滾動查詢物件
RangeScroll scrollConfig = buildRangeScroll(tableName, topic, domain);
//分頁查詢資料
for (List<String> strings : splitList) {
List<Map<String, Object>> targetList = migrateService.findByIdentifiers(scrollConfig, strings, DBChannel.CHANNEL_2.getValue());
targetAllList.addAll(targetList);
}
String keyValue = MergeConfig.getSingleKey(tableName);
for (Map<String, Object> target : targetAllList) {
String mapKey = target.get(keyValue) + "";
targetMap.put(mapKey + SPLIT_KEY + tableName, target);
}
}
return targetMap;
}
...
//增量資料進行分組
private Map<String, List<String>> groupIncrMentTable(List<String> keyStrList) {
Map<String, List<String>> keyMap = new HashMap<>();
//篩選按表為維度的集合
for (String keyStr : keyStrList) {
String[] split = keyStr.split(SPLIT_KEY);
List<String> keyList;
String key = split[0];
String tableName = split[1];
String topic = split[2];
if (keyMap.containsKey(topic + SPLIT_KEY + tableName)) {
keyList = keyMap.get(topic + SPLIT_KEY + tableName);
keyList.add(key);
} else {
keyList = new ArrayList<>();
keyList.add(key);
}
//每一個表對應的多條資料的標識,訂單表
//order_topic + order_info,list<訂單編號, 訂單編號, 訂單編號>
keyMap.put(topic + SPLIT_KEY + tableName, keyList);
}
return keyMap;
}
...
}
27.對merge資料基於目標庫資料做過濾
MergeBinlogWrite的filterBinlogAging()方法的過濾邏輯需要篩選出如下情況的binlog:
情況一:binlog比目標庫的資料要新的,操作時間是新的,則進行update更新操作
情況二:binlog是delete刪除操作,目標庫也有資料,那麼要進行delete刪除操作
情況三:binlog在目標庫中不存在且不是delete操作,則需要執行insert插入操作
public class MergeBinlogWrite {
...
//對合並後的資料進行驗證是否為過時資料
public void filterBinlogAging(OperateType operateType, String domain) {
//批次查詢資料是否存在於目標庫,並返回匹配的資料集合;也就是根據這500條資料去分庫分表的目標庫中進行查詢
Map<String, Map<String, Object>> respMap = batchQuery(operateType, domain);
//開始核對資料是否已經存在庫中,並驗證誰的時間最新過濾失效資料
for (Map.Entry<String, BinLog> entry : binlogDataMap.entrySet()) {
BinLog binLog = entry.getValue();
//當前同步要處理的表名稱
String tableName = binLog.getTableName();
//判斷同步的資料庫中,是否在目標庫中已存在
//本次拿到的binlog,如果在目標庫裡已經存在一條資料了
if (!CollectionUtils.isEmpty(respMap) && respMap.containsKey(entry.getKey())) {
//當前同步的這條記錄
Map<String, Object> binLogMap = binLog.getDataMap();
//目標庫中查詢到的記錄
Map<String, Object> targetMap = respMap.get(entry.getKey());
//處理同步的記錄是否需要執行,如果同步的時間大於目標庫的時間,則代表需要更新,但刪除的資料不比對時間
if (BinlogType.DELETE.getValue().equals(binLog.getOperateType())) {
//第二種情況,如果當前這條binlog是delete刪除操作,那麼這條binlog是一定要處理的
binLog.setOperateType(BinlogType.DELETE.getValue());
} else if (MigrateCheckUtil.comparison(binLogMap, targetMap, tableName)) {
//第一種情況,binlog比目標庫的資料是要新的,即操作時間是更加新的,那麼進行update更新操作
binLog.setOperateType(BinlogType.UPDATE.getValue());
} else {
continue;
}
} else {
//第三種情況,binlog在目標庫中不存在,則說明這條binlog需要執行插入insert操作
//目標庫裡資料不存在,那麼設定operateType=insert;如果是update,則需要手工調整為insert
//如果是delete,直接返回就即可,目標庫中不存在這條資料,此時刪除操作就不用去做了
//資料在目標庫不存在,對多條資料的最後一條結果集的型別為update,需要更正為insert,如果是delete則略過
if (BinlogType.UPDATE.getValue().equals(binLog.getOperateType())) {
binLog.setOperateType(BinlogType.INSERT.getValue());
}
if (BinlogType.DELETE.getValue().equals(binLog.getOperateType())) {
continue;
}
}
//將需要寫入的資料新增到集合中
binlogDataList.add(binLog);
}
}
...
}
28.將過濾後的merge資料寫入目標庫
在LocalQueue的doCommit()方法中,首先呼叫MergeBinlogWrite的mergeBinlog()方法去完成binlog資料的合併,然後呼叫MergeBinlogWrite的filterBinlogAging()方法進行無效binlog資料的過濾,最後呼叫MergeBinlogWrite的write()方法將過濾後的合併資料進行分組寫入到目標庫。
需要注意的是:在執行一批SQL語句的時候,update更新操作需要一條一條執行。但insert和delete操作,可以進行批次執行插入和刪除。最後完成寫入後,就會更新binlog消費處理記錄為已完成未提交,然後清空讀佇列。
public class MergeBinlogWrite {
...
//對資料進行寫入
public void write(OperateType operateType, RangeScroll rangeScroll) {
//先按表,將資料進行分組
Map<String, List<BinLog>> binLogMap = binlogDataList.stream().collect(Collectors.groupingBy(BinLog::getTableName));
boolean isWrite = true;
//遍歷不同寫入表的集合物件
for (Map.Entry<String, List<BinLog>> mapEntry : binLogMap.entrySet()) {
String tableName = mapEntry.getKey();
List<BinLog> binLogList = mapEntry.getValue();
String topic = binLogList.get(0).getTopic();
//全量的是從外部帶入的引數,增量的透過表名和topic構建
if (Objects.isNull(rangeScroll)) {
rangeScroll = buildRangeScroll(tableName, topic, null);
}
MigrateService migrateService = ApplicationContextUtil.getBean(MigrateService.class);
//批次寫入
boolean isFlag = migrateService.migrateBat(rangeScroll, binLogList);
//有一次更新失敗,本批次的offset都不更新狀態
if (!isFlag) {
isWrite = false;
}
}
//這裡的邏輯用於處理增量同步的情形,operateType == OperateType.ALL才是全量同步
//批次更新offset的標誌,如果更新過程中有一個批次是失敗的,都不能更新掉本地同步的offset,待下次拉取的時候更新
if (isWrite) {
if (OperateType.ADD == operateType) {
updateConsumeRecordStatus();
}
} else {
//如果有更新失敗todo 丟擲異常,暫停任務,等排查出問題後繼續進行
throw new BusinessException("全量資料寫入失敗");
}
}
//更新訊息佇列的offset標誌為已完成
private void updateConsumeRecordStatus() {
EtlBinlogConsumeRecordMapper etlBinlogConsumeRecordMapper = ApplicationContextUtil.getBean(EtlBinlogConsumeRecordMapper.class);
Map<Integer, List<EtlBinlogConsumeRecord>> integerListMap = etlBinlogConsumeRecordList.stream().collect(Collectors.groupingBy(EtlBinlogConsumeRecord::getQueueId));
for (Map.Entry<Integer, List<EtlBinlogConsumeRecord>> mapEntry : integerListMap.entrySet()) {
Integer queueId = mapEntry.getKey();
List<EtlBinlogConsumeRecord> etlBinlogConsumeRecordList = mapEntry.getValue();
//批次更新
etlBinlogConsumeRecordMapper.batchUpdateConsumeRecordStatus(queueId, ConsumerStatus.CONSUME_SUCCESS.getValue(), etlBinlogConsumeRecordList);
}
}
...
}
@Service
public class MigrateServiceImpl implements MigrateService {
...
@Override
public boolean migrateBat(RangeScroll scroll, List<BinLog> binLogs) {
log.info("開始執行migrateBat方法,tableName=" + scroll.getTableName() + ",本次操作" + binLogs.size() + "條記錄");
if (!Objects.isNull(scroll) && CollUtil.isNotEmpty(binLogs)) {
try {
//在執行這批sql語句的時候,update更新操作是一條一條來執行
//insert和delete操作,則可以進行批次插入和刪除
//insert into values()(),delete from table where id in(xx, xx)
List<Map<String, Object>> insertMaps = new ArrayList<>();
List<Map<String, Object>> deleteMaps = new ArrayList<>();
for (BinLog binLog : binLogs) {
if (BinlogType.INSERT.getValue().equals(binLog.getOperateType())) {
//新增操作單獨拎出來做批次新增,不然執行效率太低
insertMaps.add(binLog.getDataMap());
} else if (BinlogType.UPDATE.getValue().equals(binLog.getOperateType())) {
//處理一下更新的null異常物件
binLog.setDataMap(MigrateUtil.updateNullValue(binLog.getDataMap()));
update(binLog.getDataMap(), scroll);
} else if (BinlogType.DELETE.getValue().equals(binLog.getOperateType())) {
deleteMaps.add(binLog.getDataMap());
}
}
//批次新增
if (CollUtil.isNotEmpty(insertMaps)) {
MigrateUtil.removeNullValue(insertMaps);
insertBat(insertMaps, scroll);
}
if (CollectionUtils.isNotEmpty(deleteMaps)) {
delete(deleteMaps, scroll);
}
} catch (Exception e) {
log.error("migrateBat () tableName=" + scroll.getTableName(), e);
return false;
}
return true;
}
return false;
}
...
}
29.offset提交執行緒的啟動和邏輯分析
系統啟動時,會針對每個增量同步任務都提交一個CanalPullCommitRunner任務到執行緒池。接著當offset提交執行緒CanalPullCommitRunner一旦啟動,就會建立一個RocketMQ的Consumer,監聽同樣的topic,同時也關閉自動提交offset。CanalPullCommitRunner和CanalPullRunner裡的Consumer,雖然topic一樣但group不一樣。
之後offset提交執行緒,會首先把topic裡的queue拉取過來,這樣就知道有多少queue。然後把topic裡所有的queue,都分配給當前的這個Consumer,以便可以拿到所有queue,之後offset提交執行緒便會進入while(true)迴圈。
在迴圈中,會透過consumeRecordMapper從資料庫獲取所有已消費但未提交的記錄。因為每一條binlog都會對應一個consumeRecord記錄,所以可以把沒有提交的record都查出來,接著遍歷每一個consumeRecord記錄。
由於每個binlog都是RocketMQ裡的一條訊息,它裡面會包含topic、queue、offset、message。基於consumer的seek()定位,可以直接定位到Broker的那個topic -> queue -> offset的位置去。
定位到指定的位置後,做一個poll操作,從指定的位置poll拉取出來一批資料。這一步是必須的,不然手動提交的東西就不對了。
當拉取出一批訊息後,就需要對這批訊息,執行Commit操作。也就是對這批已經處理成功的訊息,進行手動提交offset。
最後更新這個consumeRecord記錄的消費記錄狀態為已提交,也就是把這條訊息消費記錄的狀態修改為committed,認為它對應的binlog訊息已提交成功。
@Component
public class CanalConsumeTask implements ApplicationRunner {
//RocketMQ的nameServer地址
@Value("${rocketmq.name-server:127.0.0.1:9876}")
private String nameServerUrl;
//可以從migrateConfigService拿到增量同步配置
@Autowired
private MigrateConfigService migrateConfigService;
//ApplicationRunner在系統啟動時就會執行run()方法
@Override
public void run(ApplicationArguments args) throws Exception {
//首先查出當前配置好的需要滾動查詢全量資料的遷移任務,每個滾動查詢全量資料的遷移任務就對應一個增量同步任務
List<ScrollDomain> scrollDomainList = migrateConfigService.queryScrollDomainList();
//這裡會建立一個執行緒池,執行緒數量 = 已配置好的滾動查詢全量資料的遷移任務的數量
ExecutorService executors = Executors.newFixedThreadPool(scrollDomainList.size());
for (ScrollDomain scrollDomain : scrollDomainList) {
//接下來會提交兩個任務
if (scrollDomain.getDataSourceType().equals(1)) {
//執行拉取任務,此時設定Consumer不自動提交offset,只拉取不提交
executors.execute(new CanalPullRunner(scrollDomain.getDomainTopic(), nameServerUrl));
//執行提交任務,此時才會提交offset
executors.execute(new CanalPullCommitRunner(scrollDomain.getDomainTopic(), nameServerUrl));
}
}
}
}
//binlog訊息拉取提交任務
public class CanalPullCommitRunner implements Runnable {
//訊息主題
private final String topic;
//RocketMQ的NameServer地址
private final String nameServerUrl;
//binlog訊息同步消費記錄表Mapper
private final EtlBinlogConsumeRecordMapper consumeRecordMapper;
//訊息拉取提交任務構造方法
public CanalPullCommitRunner(String topic, String nameServerUrl) {
this.topic = topic;
this.nameServerUrl = nameServerUrl;
this.consumeRecordMapper = ApplicationContextUtil.getBean(EtlBinlogConsumeRecordMapper.class);
}
@Override
public void run() {
try {
//注意,這裡Consumer的consumerGroup和CanalPullRunner是不一樣的
DefaultLitePullConsumer litePullConsumer = new DefaultLitePullConsumer("binlogCommitConsumer");
//也是關閉自動提交
litePullConsumer.setAutoCommit(false);
litePullConsumer.setNamesrvAddr(nameServerUrl);
litePullConsumer.start();
commitRun(litePullConsumer);
} catch (MQClientException e) {
log.error("訊息提交失敗", e);
}
}
//執行訊息提交
private void commitRun(DefaultLitePullConsumer consumer) {
try {
//執行緒一旦啟動,這裡首先會把topic裡的queue拉取過來,這樣就知道有多少queue
Collection<MessageQueue> messageQueues = consumer.fetchMessageQueues(topic);
//然後把topic裡所有的queue,都分配給當前的這個consumer,這樣當前的consumer是可以拿到所有的queue
consumer.assign(messageQueues);
try {
//這裡進入while(true)迴圈,負責重試
while (true) {
//透過consumeRecordMapper從資料庫中獲取所有已消費未提交的記錄
//因為每一條binlog都會對應一個consumeRecord記錄,所以這裡可以把沒有提交的record都查出來
List<EtlBinlogConsumeRecord> consumedRecords = consumeRecordMapper.getNotCommittedConsumedRecords(topic);
if (CollUtil.isNotEmpty(consumedRecords)) {
//接著遍歷每一個consumeRecord記錄
for (EtlBinlogConsumeRecord consumedRecord : consumedRecords) {
//而每個binlog都是RocketMQ裡的一條訊息,它裡面會包含topic、queue、offset、message
//基於consumer的seek()定位,就可以直接定位到Broker的那個topic、那個queue、那個offset的位置去
consumer.seek(new MessageQueue(consumedRecord.getTopic(), consumedRecord.getBrokerName(), consumedRecord.getQueueId()), consumedRecord.getOffset());
//定位到指定的位置後,做一個poll操作,從指定的位置poll拉取出一批訊息
//這一步是必須的,不然手動提交的東西就不對了
List<MessageExt> messageExts = consumer.poll();
//當拉取出一批訊息後,就需要對這批訊息,執行commit操作
//也就是對這批已經處理成功的訊息,進行手動提交offset
consumer.commitSync();
//最後更新這個consumeRecord記錄的消費記錄狀態為已提交
//也就是把這條訊息消費記錄的狀態,修改為committed,認為它對應的binlog訊息已經提交成功了
consumedRecord.setConsumeStatus(ConsumerStatus.COMMITTED.getValue());
consumeRecordMapper.updateConsumeRecordStatus(consumedRecord);
}
} else {
Thread.sleep(5000);
}
}
} finally {
consumer.shutdown();
}
} catch (MQClientException | InterruptedException e) {
try {
//假設要拉取訊息的主題還不存在,則會丟擲異常,這種情況下休眠五秒再重試
Thread.sleep(5000);
commitRun(consumer);
} catch (InterruptedException interruptedException) {
log.error("訊息拉取服務啟動失敗!", e);
}
}
}
}
30.增量同步過程中binlog寫入失敗的恢復
(1)binlog在增量同步寫入失敗時無法更新訊息消費記錄的狀態為已消費未提交
(2)增量同步執行緒CanalPullRunner重新消費binlog訊息時的處理
在IncrementTask任務對增量同步的對binlog進行批處理的寫入過程中:LocalQueue的doCommit()方法會呼叫MergeBinlogWrite的write()方法去分組寫入合併的binlog。此時只要有一次binlog更新失敗,那麼這一批binlog對應的消費記錄狀態都不會更新。
由於這一批binlog對應的消費記錄狀態不會更新為已完成未提交,自然就不能在offset提交執行緒中獲取出這一批binlog對應的消費記錄出來,進行提交處理。
這樣由於增量同步執行緒CanalPullRunner一開始就關閉了自動提交offset,所以RocketMQ會一直收不到這一批binlog訊息的任何反饋和通知(也就是Commit)。在這種情況下,RocketMQ會自動把這一批訊息又重新投遞給增量同步執行緒的消費者去消費。
CanalPullRunner的消費者重新消費到這一批binlog時會發現已存在對應訊息消費記錄,於是就會透過processExistsRecord()方法呼叫LocalQueue的commit()方法進行重新寫入處理。
(1)binlog在增量同步寫入失敗時無法更新訊息消費記錄的狀態為已消費未提交
public class MergeBinlogWrite {
...
//對資料進行寫入
public void write(OperateType operateType, RangeScroll rangeScroll) {
//先按表,將資料進行分組
Map<String, List<BinLog>> binLogMap = binlogDataList.stream().collect(Collectors.groupingBy(BinLog::getTableName));
boolean isWrite = true;
//遍歷不同寫入表的集合物件
for (Map.Entry<String, List<BinLog>> mapEntry : binLogMap.entrySet()) {
String tableName = mapEntry.getKey();
List<BinLog> binLogList = mapEntry.getValue();
String topic = binLogList.get(0).getTopic();
//全量的是從外部帶入的引數,增量的透過表名和topic構建
if (Objects.isNull(rangeScroll)) {
rangeScroll = buildRangeScroll(tableName, topic, null);
}
MigrateService migrateService = ApplicationContextUtil.getBean(MigrateService.class);
//批次寫入
boolean isFlag = migrateService.migrateBat(rangeScroll, binLogList);
//有一次更新失敗,本批次的offset都不更新狀態
if (!isFlag) {
isWrite = false;
}
}
//這裡的邏輯用於處理增量同步的情形,operateType == OperateType.ALL才是全量同步
//批次更新offset的標誌,如果更新過程中有一個批次是失敗的,都不能更新掉本地同步的offset,待下次拉取的時候更新
if (isWrite) {
if (OperateType.ADD == operateType) {
updateConsumeRecordStatus();
}
} else {
//如果有更新失敗todo 丟擲異常,暫停任務,等排查出問題後繼續進行
throw new BusinessException("全量資料寫入失敗");
}
}
...
}
(2)增量同步執行緒CanalPullRunner重新消費binlog訊息時的處理
public class CanalPullRunner implements Runnable {
...
//執行訊息拉取
private void pullRun() {
try {
DefaultLitePullConsumer litePullConsumer = new DefaultLitePullConsumer("binlogPullConsumer");
//設定RocketMQ Consumer禁止自動提交offset,讓它不要自動去提交offset,防止還沒完處理完binlog訊息就提交offset
//如果offset已經被自動提交,但是binlog訊息卻處理失敗,那麼RocketMQ就不會讓消費者再次消費了
litePullConsumer.setAutoCommit(false);
litePullConsumer.setNamesrvAddr(nameServerUrl);
litePullConsumer.subscribe(topic, "*");
litePullConsumer.start();
try {
//進入while死迴圈中,透過Consumer從RocketMQ中一批批的Pull訊息出來消費處理
while (true) {
//拉取未消費訊息
List<MessageExt> messageExts = litePullConsumer.poll();
if (CollUtil.isNotEmpty(messageExts)) {
for (MessageExt messageExt : messageExts) {
byte[] body = messageExt.getBody();
String msg = new String(body);
//記錄queueId和offset
int queueId = messageExt.getQueueId();
long offset = messageExt.getQueueOffset();
String topic = messageExt.getTopic();
//topic、queue、offset、msg,四位一體,把所有的資訊都拿到
//判斷該訊息是否已被處理過,即是否已經存在於消費記錄表中,如果存在則跳過執行
EtlBinlogConsumeRecord existsRecord = consumeRecordMapper.getExistsRecord(queueId, offset, topic);
if (null == existsRecord) {
//如果還沒處理過,那麼進行處理,並往消費記錄表中插入一條記錄
processNewMsg(messageExt, msg);
} else {
//處理已經存在的消費記錄
processExistsRecord(litePullConsumer, msg, existsRecord);
}
}
} else {
Thread.sleep(5000);
}
}
} finally {
litePullConsumer.shutdown();
}
} catch (InterruptedException | MQClientException e) {
try {
//假設要拉取訊息的主題還不存在,則會丟擲異常,這種情況下休眠五秒再重試
Thread.sleep(5000);
pullRun();
} catch (InterruptedException ignored) {
}
}
}
...
//處理已經存在的消費記錄
private void processExistsRecord(DefaultLitePullConsumer litePullConsumer, String msg, EtlBinlogConsumeRecord existsRecord) {
//已經存在的消費記錄狀態為已提交,說明mq裡的對應訊息修改提交狀態失敗了
//RocketMQ原始碼裡手動提交訊息時,如果失敗了只會記錄日誌不會丟擲異常,因此這裡必須再次嘗試提交訊息防止mq中未處理的訊息和實際情況不符
try {
if (ConsumerStatus.COMMITTED.getValue().equals(existsRecord.getConsumeStatus())) {
litePullConsumer.seek(new MessageQueue(existsRecord.getTopic(), existsRecord.getBrokerName(), existsRecord.getQueueId()), existsRecord.getOffset());
//這一步必須,不然手動提交的東西不對
List<MessageExt> committedFaildmessageExts = litePullConsumer.poll();
//再次提交已消費的訊息
litePullConsumer.commitSync();
} else {
BinlogData binlogData = BinlogUtils.getBinlogDataMap(msg);
if (null == binlogData) {
return;
}
LocalQueue.getInstance().submit(binlogData, existsRecord);
}
} catch (Exception e) {
log.error("訊息重新消費失敗", e);
}
}
}
31.增量同步過程中的各種失敗場景的恢復機制
場景一:增量同步執行緒CanalPullRunner在拉取到訊息後,新增消費記錄時,系統重啟或當機。此時會導致新增消費記錄失敗,但這並不會有什麼影響,等待這批binlog訊息重新消費即可。
場景二:增量同步執行緒CanalPullRunner在拉取到訊息後,新增消費記錄成功了。但對binlog進行批處理的定時任務剛開始跑時,系統重啟或當機,那麼也不影響。等待這批binlog訊息重新消費後,記憶體讀佇列最終還是會出現這批binlog訊息。
場景三:對binlog進行批處理的定時任務在進行讀寫佇列交換、資料合併及過濾時,系統重啟或當機。那麼也不影響,等待這批binlog訊息重新消費後,記憶體寫佇列會重新出現這批binlog訊息,這些對binlog進行批處理的定時任務繼續執行即可。
場景四:對binlog進行批處理的定時任務在對binlog資料寫入目標庫時,系統重啟或當機。這時這批binlog訊息對應的消費記錄不會被更新為已消費未提交,後續增量同步執行緒CanalPullRunner重新消費這批binlog訊息時會進行進行重新提交處理。
場景五:offset提交執行緒查出一批已消費未提交的訊息,在還沒來得及Commit時,系統重啟或當機。那也不影響,由於提交offset後才會更新訊息消費記錄的狀態,重新執行offset提交執行緒重新查即可。
場景六:offset提交執行緒已經提交offset,但沒來得及更新消費記錄狀態,系統重啟或當機。那也不影響,重新執行offset提交執行緒重新更新消費記錄狀態即可。
32.定時移除已提交的增量同步訊息
每隔5分鐘把已經提交的消費記錄進行刪除和清理,避免訊息消費記錄表資料過多。
//定時任務移除掉已提交的增量同步訊息
@Component
public class RemoveBinlogConsumeTask {
@Autowired
private EtlBinlogConsumeRecordMapper consumeRecordMapper;
@Scheduled(cron = "0 0 0 1/1 * ? ")
public void removeBinlogConsumeRecordTask() {
//每次刪除超過當前時間5分鐘的歷史資料
Date updateTime = DateUtils.addMinute(new Date(), -5);
consumeRecordMapper.deleteCommittedConsumedRecords(updateTime);
//每隔5分鐘把已經提交offset的消費記錄進行刪除和清理
//因為每條binlog資料都會在資料庫裡都有一條消費記錄,這樣可能會導致這個資料太多
}
}
33.增量與全量並行執行的場景分析
執行時,首先會執行增量同步的任務,然後才啟動全量同步任務。全量同步任務,會一批一批地查,一批一批地寫。增量同步任務,會對啟動後的所有增刪改進行同步操作。
下面分析全量和增量一起執行時的場景和情況:此時增量裡都是對全量還沒同步到的資料進行增刪改,而全量則是從歷史第一條資料開始進行查詢和同步的。
場景一:增量出現insert插入操作,全量還沒同步到,此時屬於對最新的資料進行插入,但增量同步已經把最新的資料insert操作寫入到目標庫裡了,這時是沒有問題的。後續全量資料同步到這條資料時,從源資料庫把這條資料查詢出來了,準備進行插入操作,但會發現這條資料已經在目標庫中存在,此時全量同步會進行過濾,不會進行重複插入。
場景二:增量出現update更新操作,全量還沒同步到這條資料,增量已拿到更新的binlog。目標庫裡此時還沒有這條資料,增量同步的寫入邏輯裡,會把update操作轉換為insert操作。直接提前插入這條資料,等全量要同步源資料庫這條資料時,再從目標庫查出來對比過濾。
場景三:增量出現delete刪除操作,全量還沒同步到這條資料,增量已拿到刪除的binlog。增量同步對這條資料處理時,發現目標庫裡沒有這條資料,於是會直接返回,不做處理。而當後續全量同步處理到這一條資料時,便會發現這條資料已經被刪除了,於是也不同步到目標庫。
場景四:全量同步已經同步過一批資料,但是這些資料又發生了刪除和修改。當增量同步拿到這些改的binlog,會發現其操作時間比目標庫裡的資料更加新,於是會去更新。當增量同步拿到這些刪的binlog,會發現目標庫裡有這些資料,於是會去進行刪除。
場景五:全量在同步的資料,和增量發生的變更,幾乎是併發同時發生。比如,全量剛查出來一批資料還沒來得及寫入,此時這批資料同時發生修改和刪除,也就是增量和全量併發同步一批資料產生了衝突。
34.增量與全量併發同步一批資料的衝突
(1)增量同步拿到更新binlog的場景
(2)增量同步拿到刪除binlog的場景
全量查詢出來一批資料還沒來得及落庫,增量也收到了這批資料裡最新的修改和刪除的binlog。這個最新的修改和刪除,是全量同步在全量查詢出來後,才對源資料庫的資料發起的。
全量同步查出來一批資料還沒落庫,增量同步接著拿到最新的更新和刪除,產生了衝突。下面分析是增量同步先落庫,還是全量同步先落庫。
(1)增量同步拿到更新binlog的場景
場景一:如果增量同步先落庫,那麼增量同步會把更新轉插入
當後面全量同步再來嘗試插入時,全量同步去目標庫進行查詢查到有資料,會發現全量同步準備插入的資料比較舊,於是就不會進行插入了。
當後面全量同步再來嘗試插入時,全量同步去目標庫進行查詢發現還沒有資料,但查完後準備落庫插入資料時,增量同步已經把更新轉插入寫入目標庫裡先落庫了。這其實沒有關係的,因為此時全量同步是發起插入的,這會導致唯一鍵衝突而插入失敗。
場景二:如果全量同步先落庫,增量同步再來的更新正常進行更新
那麼此時會正常執行更新,增量同步會把資料更新為最新狀態。
場景三:如果全量同步先落庫,增量同步再來的更新轉換為插入
這發生於全量同步正準備落庫時,增量同步拿到更新binlog,查詢發現目標庫沒有資料。於是增量同步就把更新轉插入,然後準備落庫。但是全量同步此時先落庫進行插入了,於是導致增量同步的更新轉插入操作發生唯一鍵衝突,從而導致這次增量同步失敗。但即便這樣也不會有問題,因為這次增量同步失敗,那麼就不會更新訊息消費記錄的狀態為已消費未提交。後續這條更新的binlog會重新被消費到,從而走回正常的增量同步下的更新操作。
(2)增量同步拿到刪除binlog的場景
場景一:如果全量同步先落庫,增量同步再來進行刪除,此時是不影響的。
場景二:如果增量同步先落庫進行刪除,發現目標庫本來就沒有這條資料,跑空了。然後接著全量同步再來落庫,把資料插入進目標庫,則後續再也不會對這條資料刪除了。從而導致目標庫的資料比源資料庫的資料多了。
綜上所述,其實只有一個增量同步空刪除的問題,解決方案如下:在增量同步對刪除的binlog進行落庫時,如果發現目標庫是空的,此時可能是全量同步還沒把資料插入進來,而增量同步先執行刪除了。為了避免增量同步先刪除的問題,可以把這條刪除的binlog重新投遞到topic裡,並設定為延遲訊息進行延遲一定的時間後再消費,如延遲10分鐘、30分鐘。等這條重新投遞的binlog訊息被消費到的時候,全量同步已經把它對應的資料插入了。這時再讓增量同步執行刪除操作,就不會發生空刪除的問題了。
35.全量同步完成後的資料校驗邏輯分析
(1)校驗的整體思路
(2)校驗的定時任務
(1)校驗的整體思路
方法一:按檢查型別分,可以分為資料順序檢查、隨機資料檢查。
方法二:直接源資料庫和目標庫分別檢查資料總量和最大主鍵值,如select max(id)看看能否對上。
(2)校驗的定時任務
會有一個定時任務CheckDataTask每隔120秒跑一次,負責校驗資料。並且同一時刻只能有一個這樣的定時任務在跑,所以任務一開始執行就會加鎖。
首先會查詢出已經全量同步完成的滾動查詢的遷移明細記錄,並且一次最多查100條這種記錄。然後根據遍歷這些記錄封裝成一個一個的滾動查詢任務,再一批一批發起對資料的校驗。
資料校驗的邏輯如下:
步驟一:先從源資料庫按照分頁來獲取一批資料
步驟二:再從目標資料庫也獲取一批資料
步驟三:接著對資料進行核對校驗,然後找出核對不一致的資料
步驟四:然後去目標庫分別處理那些需要更新和新增的不一致的資料
步驟五:最後更新遷移記錄的型別為核對型別以及完成核對的資料量、更新遷移明細記錄
//負責定時核對校驗資料,核對所有已遷移的資料全量核對
@Component
public class CheckDataTask {
private final Lock lock = new ReentrantLock();
private Map<String, RangeScroll> queryEtlProgressMap = new HashMap<String, RangeScroll>();
//負責定時核對校驗資料
//每隔120秒核對一次,同一時刻只能有一個任務來跑
@Scheduled(fixedDelay = 120000)
public void CheckData() {
log.info("資料核對校驗開始");
//加鎖,同一時刻只能有一個任務來跑
if (lock.tryLock()) {
try {
CheckDataProcessor checkDataProcessor = CheckDataProcessor.getInstance();
//查詢已同步完成的批次,未核對的資料進行核對處理
List<RangeScroll> rangeScrollList = checkDataProcessor.queryCheckDataList();
for (RangeScroll rangeScroll : rangeScrollList) {
String key = rangeScroll.getTableName() + rangeScroll.getTicket();
if (!queryEtlProgressMap.containsKey(key)) {
//在這裡會去重,同一個key只會保留一個全量同步任務
queryEtlProgressMap.put(key, rangeScroll);
}
//發起資料校驗
checkDataProcessor.checkData(rangeScroll);
}
//更新核對型別的遷移記錄
checkDataProcessor.updateEtlProgressCheckSuccess(queryEtlProgressMap);
} catch (Exception e) {
log.error("資料核對過程中發生異常 {}", e.getMessage(), e);
} finally {
log.info("資料核對校驗結束");
lock.unlock();
queryEtlProgressMap.clear();
}
}
}
}
//資料核對處理器
public class CheckDataProcessor {
...
//核驗資料
//rangeScroll 要檢查的資料抽取模型
public void checkData(RangeScroll rangeScroll) {
EtlProgress etlProgress = addEtlProgress(rangeScroll);
try {
//1.先獲取源資料庫的一批資料
List<Map<String, Object>> sourceList = querySourceList(rangeScroll);
//2.再獲取目標庫的一批資料
List<Map<String, Object>> targetList = queryTargetList(sourceList, rangeScroll);
//3.對資料進行核對校驗
Map<BinlogType, List<Map<String, Object>>> comparisonMap = comparison(sourceList, targetList, rangeScroll);
//4.對資料進行歸正處理
updateComparisonData(comparisonMap, rangeScroll);
//5.完成資料核對校驗,更改狀態
updateEtlDirtyRecord(etlProgress, EtlProgressStatus.CHECK_SUCCESS.getValue(), rangeScroll, null);
} catch (Exception e) {
//資料核對過程失敗,只記錄資料核對錯誤資訊
updateEtlDirtyRecord(etlProgress, EtlProgressStatus.SUCCESS.getValue(), rangeScroll, e.getMessage());
log.error("資料核對過程中發生異常 {" + e.getMessage() + "}", etlProgress);
}
}
...
}
36.資料遷移完成後的無損釋出方案
老版本系統會去單庫單表進行讀寫操作,新版本系統會去多庫多表進行讀寫操作。
一般來說,不能讓新版本和老版本兩個系統並行來跑,需要停機發布。而且新版本系統啟動時,不能去RPC服務的註冊中心註冊,避免流量直接分發到新版本系統。一般會先讓老系統下線,再手動上線新系統,在很短的時間內把這兩個動作做完。
如果新版本系統釋出時自動註冊到註冊中心,就會有一部分流量去讀寫多庫多表。此時由於老版本系統還沒完全下線,還會有一部分流量去讀寫單庫單表。雖然讀寫單庫單表的binlog最終會同步到多庫多表(最終一致),但是反過來就不是了。即增刪改操作剛在多庫多表完成,然後去單庫單表進行讀取讀不到,出現資料不一致的問題。
所以一般不能讓新版本和老版本兩個系統並行來跑,最好停機發布,然後控制最短時間完成。
如果一定要不停機發布,可以釋出一個帶有開關的新系統,開關預設是關閉的,而且新系統帶有兩種DAO模型。當開關關閉時,會透過DAO模型一往單庫單表進行讀寫資料。當開關開啟時,會透過DAO模型二往多庫多表進行讀寫資料。所以,開關控制的是應用要操作那種型別的DAO模型。
//原庫資料來源配置,操作db1下的DAO
@Configuration
@MapperScan(basePackages = "com.demo.sharding.order.migrate.mapper.db1", sqlSessionTemplateRef = "SqlSessionTemplate01")
public class DataSource1Config extends AbstractDataSourceConfig {
@Autowired
private MigrateConfig migrateConfig;
@Bean(name = "DataSource01")
@Primary
public DataSource dataSource() throws SQLException {
return buildDataSource(migrateConfig.getOriginDatasource());
}
@Bean(name = "SqlSessionFactory01")
@Primary
public SqlSessionFactory sqlSessionFactory(@Qualifier("DataSource01") DataSource dataSource) throws Exception {
SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
bean.setDataSource(dataSource);
bean.setTypeAliasesPackage("com.demo.sharding.order.migrate.domain");
bean.setConfigLocation(new ClassPathResource("mybatis/mybatis-config.xml"));
String locationPattern = CollectionUtils.isNotEmpty(migrateConfig.getOriginDatasource().getTableRules()) ?
"classpath*:mybatis/mapper/db1/sharding/*.xml" : "classpath*:mybatis/mapper/db1/monomer/*.xml";
bean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(locationPattern));
return bean.getObject();
}
@Bean(name = "TransactionManager01")
@Primary
public DataSourceTransactionManager transactionManager(@Qualifier("DataSource01") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
@Bean(name = "SqlSessionTemplate01")
@Primary
public SqlSessionTemplate sqlSessionTemplate(@Qualifier("SqlSessionFactory01") SqlSessionFactory sqlSessionFactory) throws Exception {
return new SqlSessionTemplate(sqlSessionFactory);
}
}
//目標資料來源配置,操作db2下的DAO
@Configuration
@MapperScan(basePackages = "com.demo.sharding.order.migrate.mapper.db2", sqlSessionTemplateRef = "SqlSessionTemplate02")
public class DataSource2Config extends AbstractDataSourceConfig {
@Autowired
private MigrateConfig migrateConfig;
@Bean(name = "DataSource02")
public DataSource dataSource() throws SQLException {
return buildDataSource(migrateConfig.getTargetDatasource());
}
@Bean(name = "SqlSessionFactory02")
public SqlSessionFactory sqlSessionFactory(@Qualifier("DataSource02") DataSource dataSource) throws Exception {
SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
bean.setDataSource(dataSource);
bean.setTypeAliasesPackage("com.demo.sharding.order.migrate.domain");
bean.setConfigLocation(new ClassPathResource("mybatis/mybatis-config.xml"));
bean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath*:mybatis/mapper/db2/*.xml"));
return bean.getObject();
}
@Bean(name = "transactionManager02")
public DataSourceTransactionManager transactionManager(@Qualifier("DataSource02") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
@Bean(name = "SqlSessionTemplate02")
public SqlSessionTemplate sqlSessionTemplate(@Qualifier("SqlSessionFactory02") SqlSessionFactory sqlSessionFactory) throws Exception {
return new SqlSessionTemplate(sqlSessionFactory);
}
}
37.分庫分表線上運維與擴容方案
(1)分庫分表線上運維
如果要線上上執行一些多庫多表下的DDL操作:一般都會基於ShardingSphere開發一個資料庫運維管理工作臺,然後這個工作臺會基於ShardingSphere把DDL命令路由到各個庫和表裡去。
(2)8庫8表擴容到16庫16表
比較穩妥的方案是,先去線上建好一套16庫16表的環境。然後透過資料遷移系統,把8庫8表的資料,透過全量+增量的處理,同步到16庫16表的環境裡。
需要注意:源資料庫的地址此時使用ShardingSphere配置為8庫8表,目標庫的地址此時使用ShardingSphere配置為16庫16表。增量同步前,Canal需要監聽8臺資料庫伺服器,把8個庫的資料增刪改binlog寫到MQ裡。
所以,分庫分表的線上生產實踐,最重要的是資料遷移。完成資料遷移後,才能進行無損釋出,才能進行線上運維、進行庫表擴容。