-
一、說在前面的話
一些涉及數據分析處理的系統,常常需要將先將業務系統中關係數據庫內的數據(離線)抽取到自己的數據庫中(當前比較流行的開源MPP數據庫如Greenplum)以便進行後續處理,鑑於每次進行全量數據抽取,全量分析處理代價較大,需要計算同一張表前後兩次的全量數據計算變化量,這種變化量數據包括insert、update、delete等,後續分析處理只針對這些變化量數據進行,由於業務系統中變化的數據要遠遠少於全量數據,那麼只處理變化的數據將會大大加速數據處理的速度。
-
二、已有的方法分析
- 觸發器
在要抽取的表上建立需要的觸發器,一般要建立插入、修改、刪除三個觸發器,每當源表中的數據發生變化,就被相應的觸發器將變化的數據寫入一個臨時表,抽取線程從臨時表中抽取數據,臨時表中抽取過的數據被標記或刪除。觸發器方式的優點是數據抽取的性能較高,缺點是要求業務表建立觸發器,對業務系統有一定的影響。
- 時間戳
它是一種基於快照比較的變化數據捕獲方式,在源表上增加一個時間戳字段,系統中更新修改表數據的時候,同時修改時間戳字段的值。
當進行數據抽取時,通過比較系統時間與時間戳字段的值來決定抽取哪些數據。有的數據庫的時間戳支持自動更新,即表的其它字段的數據發生改變時,自動更新時間戳字段的值。
有的數據庫不支持時間戳的自動更新,這就要求業務系統在更新業務數據時,手工更新時間戳字段。同觸發器方式一樣,時間戳方式的性能也比較好,數據抽取相對清楚簡單,但對業務系統也有很大的傾入性(加入額外的時間戳字段),特別是對不支持時間戳的自動更新的數據庫,還要求業務系統進行額外的更新時間戳操作。另外,無法捕獲對時間戳以前數據的delete和update操作,在數據準確性上受到了一定的限制。
- 日誌分析
通過分析數據庫自身的日誌來判斷變化的數據,例如MySQL數據庫的binlog,可通過使用阿里開源的canal工具接收並將binlog推送至kafka中。
- 全表比對
典型的全表比對的方式是採用MD5校驗碼。
抽取工具事先爲要抽取的表建立一個結構類似的MD5臨時表,該臨時表記錄源表主鍵以及根據所有字段的數據計算出來的MD5校驗碼。每次進行數據抽取時,對源表和MD5臨時表進行MD5校驗碼的比對,從而決定源表中的數據是新增、修改還是刪除,同時更新MD5校驗碼。MD5方式的優點是對源系統的傾入性較小(僅需要建立一個MD5臨時表),但缺點也是顯而易見的,與觸發器和時間戳方式中的主動通知不同,MD5方式是被動的進行全表數據的比對,當數據量大時可能存在碰撞衝突。
-
三、Kettle的變化量計算
最近在研究了kettle這種流行的ETL工具在數據變化量上的計算方法,該方法爲典型的全表比對計算方法。
1、合併記錄組件
Kettle有一個叫"合併記錄"(MergeRows)的組件,該組件用於將兩個不同來源的數據合併,這兩個來源的數據分別爲同一張業務數據表的舊數據和新數據,將舊數據和新數據按照指定的關鍵字匹配、比較、合併。合併後的數據將包括舊數據來源和新數據來源裏的所有數據,對於變化的數據,使用新數據代替舊數據,同時在結果裏用一個標示字段存儲變化狀態,其中幾個重要的輸入信息包括:
標誌字段:設置標誌字段的名稱,標誌字段用於保存比較的結果,比較結果有下列幾種:
- “identical” – 舊數據和新數據一樣
- “changed” – 數據發生了變化;
- “new” – 新數據中有而舊數據中沒有的記錄
- “deleted” –舊數據中有而新數據中沒有的記錄
關鍵字段:用於定位兩個數據源中的同一條記錄。
比較字段:對於兩個數據源中的同一條記錄中,指定需要比較的字段。
2、注意事項
在使用kettle的這個合併記錄組件時需要注意一下幾點:
(1)舊數據和新數據需要事先按照關鍵字段(即主鍵字段)排序。
(2)舊數據和新數據要有相同的字段名稱(新舊數據表結構一致)。
3、隱含條件
對於MySQL等常用的關係數據庫來說,有主鍵的表採用默認的:
select t.a,t.b,t.c,t.d from table_name t
的SQL模式的查詢結果其實是按照主鍵遞增的順序排序;
所以上述的“舊數據和新數據需要事先按照關鍵字段(即主鍵字段)排序”往往可以忽略,但是對於使用Greenplum這種MPP分佈式數據庫時,這種SQL的查詢結果並不是排好序的。
-
四、變化量計算原理
通過閱讀和分析kettle的MergeRow合併記錄組件的源碼,現將核心原理整理如下:
1、樣本數據準備
假設業務表名稱爲stu,表stu_old爲上一次數據抽取的全量快照數據,表stu_new爲本次數據抽取的全量快照數據,表stu_diff爲根據stu_old變化到stu_new數據時的變化量存儲結果表,存儲結果表中不僅要包含stu_old/stu_new(這兩個表結構一樣,都含有主鍵)的所有字段,還應包含有一個標記字段,stu_diff表中將標記字段取名爲diff。具體定義如下:
-- ---------------------------- -- Table structure for stu_old -- ---------------------------- DROP TABLE IF EXISTS `stu_old`; CREATE TABLE `stu_old` ( `id` varchar(128) NOT NULL, `name` varchar(255) NOT NULL, `sex` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- ---------------------------- -- Records of stu_old -- ---------------------------- INSERT INTO `stu_old` VALUES ('tangqi', '唐七', 'girl'); INSERT INTO `stu_old` VALUES ('wangwu', '王五', 'boy'); INSERT INTO `stu_old` VALUES ('zhangsan', '張三', 'boy');
-- ---------------------------- -- Table structure for stu_new -- ---------------------------- DROP TABLE IF EXISTS `stu_new`; CREATE TABLE `stu_new` ( `id` varchar(128) NOT NULL, `name` varchar(255) NOT NULL, `sex` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- ---------------------------- -- Records of stu_new -- ---------------------------- INSERT INTO `stu_new` VALUES ('liliu', '劉六', 'girl'); INSERT INTO `stu_new` VALUES ('tangqi', '唐七', 'boy'); INSERT INTO `stu_new` VALUES ('wangwu', '王五', 'boy'); INSERT INTO `stu_new` VALUES ('zhangsan', '張三', 'girl');
-- ---------------------------- -- Table structure for stu_diff -- ---------------------------- DROP TABLE IF EXISTS `stu_diff`; CREATE TABLE `stu_diff` ( `id` varchar(128) NOT NULL, `name` varchar(255) NOT NULL, `sex` varchar(255) DEFAULT NULL, `diff` varchar(255) NOT NULL, `num` int(11) NOT NULL AUTO_INCREMENT, PRIMARY KEY (`num`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; |
2、變化量計算過程
這裏以在MySQL數據庫計算爲例,其中的計算過程如下:
(1)首先按照主鍵升序查詢新舊兩個表的數據
舊錶:SELECT `id`,`name`,`sex` FROM `stu_old` ORDER BY `id` asc
新表:SELECT `id`,`name`,`sex` FROM `stu_new` ORDER BY `id` asc
說明:這裏使用了數據庫的查詢排序功能,其實也可以不用order by來排序,因爲MySQL數據庫有主鍵表的默認查詢結果就是按照主鍵升序排序的了;但是數據庫如果使用的是Greenplum時帶上order by就是必要的了。
(2)結果集比較算法
比較計算的Java僞代碼如下:
String flagField = null; //用於記錄比較的結果狀態:insert/update/delete
Object[] outputRow; //用於記錄數據結果
Object[] one = getRowData(rsOld.resultset); //從舊錶的結果集中取一條記錄
Object[] two = getRowData(rsNew.resultset); //從新表的結果集中取一條記錄
while (true) {
if (one == null && two == null) { //如果連個結果集都爲空直接退出計算
break;
} else if (one == null && two != null) { // 如果舊錶中沒有的記錄在新表中有
flagField = VALUE_INSERT; // 這裏說明爲新數據插入了
outputRow = two; // 記錄插入的數據內容
two = getRowData(rsNew.resultset); // 取新表的下一條記錄數據
} else if (one != null && two == null) { // 如果舊錶中有的記錄在新表中沒有
flagField = VALUE_DELETED; // 這裏說明爲新數據刪除了
outputRow = one; // 記錄刪除的數據內容
one = getRowData(rsOld.resultset); // 取舊錶的下一條記錄數據
} else { // 到這裏才真正進入到數據比較的地方
int compare = this.compare(one, two, keyNumbers, metaData);//比較主鍵自動
if (0 == compare) { // 如果新舊兩個表記錄的主鍵值相等
//比較除主鍵外的其他字段
int compareValues = this.compare(one, two, valNumbers, metaData);
if (compareValues == 0) { //如果主鍵外的其他字段值都相同說明記錄沒變
flagField = VALUE_IDENTICAL; //記錄數據記錄沒變標記
outputRow = one; //記錄沒變的數據內容
} else { //到這裏說明數據更新了
flagField = VALUE_UPDATE; //記錄數據記錄被更新
outputRow = two; //記錄更新後數據內容
}
// Get a new row from both streams...
one = getRowData(rsOld.resultset); // 取舊錶的下一條記錄數據
two = getRowData(rsNew.resultset);// 取新表的下一條記錄數據
} else { // 如果新舊兩個表記錄的主鍵值不相等
if (compare < 0) { // 舊錶記錄主鍵值< 新表記錄主鍵值
flagField = VALUE_DELETED; //記錄數據記錄被刪除
outputRow = one; //記錄刪除前數據內容
one = getRowData(rsOld.resultset);// 取舊錶的下一條記錄數據
} else {
flagField = VALUE_INSERT; //記錄數據記錄被添加
outputRow = two; //記錄添加的數據內容
two = getRowData(rsNew.resultset);// 取新表的下一條記錄數據
}
}
//到這裏,flagField 和outputRow 兩個變量已經記錄了一條變化量記錄了。
}
算法核心部分說明:
從已經按照主鍵遞增排序的舊錶結果集中取出一條數據記錄one,於此同時從已經按照主鍵遞增排序的新表結果集中也取出一條數據記錄two,然後進入如下循環步驟:
(i)如果one和two中有一個爲null的情況,如果都爲null則說明計算完成,退出循環;否則,則分別根據情況標記爲VALUE_INSERT或VALUE_DELETE狀態。
(ii) 比較one和two中主鍵字段是否相同:
a)如果相同條件下,非主鍵字段也相同,則說明這是兩個表中相同的記錄,標記爲VALUE_IDENTICAL,否則標記爲VALUE_UPDATE,然後one和two均向後取出一條新記錄;
b)如果不同條件下,如果主鍵字段的比較結果爲one<two,標記爲VALUE_DELETE,one向後取出一條記錄,否則標記爲VALUE_INSERT,然後two向後取出一條記錄。
(ii)重複進行(i)到(ii)的循環操作;
(3)算法分析
- 算法可以處理大數據量表情況,不存在OOM的情況,只要從查詢表的結果集中取數據時不發生異常;
- 算法中無需佔用額外的存儲空間,完全在內存中計算,算法的性能主要依賴於字段值的比較上;
- 該方法需要從數據庫裏一條條的取出數據,需要保持與數據庫間的連接。如果在執行算法的過程中出現了與數據庫間連接的異常,將會導致計算失敗,前功盡棄。
3、理解與總結
從原理上看,相當於將一個有序結果集RS0狀態經insert/update/delete操作後變爲另一個有序結果集RS1狀態後,利用有序性很容易找出insert與delete的數據,利用主鍵相同情況下的比較再定位出update的數據。
- 五、幾句尾話
上述全量算變化量的方法,本人已經編寫了一個完整的DEMO程序,支持多主鍵的情況:
使用示例如下:
public class DbchangeExampleApplication {
private static int BATCH_SIZE = 10000;
public static void main(String[] args) {
BasicDataSource dataSource = new BasicDataSource();
dataSource.setDriverClassName("org.postgresql.Driver");
dataSource.setUrl("jdbc:mysql://172.16.10.210:3306/test?useSSL=false");
dataSource.setUsername("tang");
dataSource.setPassword("123456");
dataSource.setInitialSize(5);
dataSource.setMaxIdle(5);
dataSource.setMinIdle(2);
String outSchemaName = "test";
String outTableName = "stu_diff";
String flagFieldName = "diff";
TaskParamBean.TaskParamBeanBuilder taskBuilder = TaskParamBean.builder();
taskBuilder.oldDataSource(dataSource);
taskBuilder.oldSchemaName("test");
taskBuilder.oldTableName("stu_old");
taskBuilder.newDataSource(dataSource);
taskBuilder.newSchemaName("test");
taskBuilder.newTableName("stu_new");
IDatabaseWriter writer = DatabaseWriterFactory.createDatabaseWriter(dataSource);
writer.prepareWrite(outSchemaName, outTableName);
IDatabaseChangeCaculator changeCaculator = new ChangeCaculatorService();
StopWatch watch = new StopWatch();
watch.start("watcher");
changeCaculator.executeCalculate(taskBuilder.build(), new IDatabaseRowHandler() {
private List<Object[]> cache = new LinkedList<Object[]>();
@Override
public void handle(List<String> fields, Object[] record, RecordChangeType flag) {
Object[] item = Arrays.copyOf(record, record.length + 1);
item[item.length - 1] = flag.getStatus();
cache.add(item);
if (cache.size() >= BATCH_SIZE) {
doSave(fields);
}
}
@Override
public void destroy(List<String> fields) {
if (cache.size() > 0) {
doSave(fields);
}
}
private void doSave(List<String> fields) {
List<String> fieldNames = new ArrayList<String>(fields);
fieldNames.add(flagFieldName);
long ret = writer.write(fieldNames, cache);
System.out.println("handle result count: " + ret);
cache.clear();
}
});
watch.stop();
System.out.println("Total elipse :" + watch.getTotalTimeSeconds() + " s");
System.out.println(watch.prettyPrint());
}
}