Sqoop中提供了一個用於合併數據集的工具sqoop-merge
。官方文檔中的描述可以參考我的另一篇博客Sqoop-1.4.5用戶手冊。
Merge的基本原理是,需要指定新數據集和老數據集的路徑,根據某個merge-key,在reduce過程中,優先取出新數據集中的數據,共同合併成新的全量數據。具體的邏輯分析可以稍後通過看Sqoop-1.4.6的源碼來進一步瞭解。
但是,在原生的Sqoop中,目前只支持merge-key爲一個字段的情況,本文通過分析源代碼並對源代碼進行更改,可以在使用Sqoop的Merge功能時支持任意多個merge-key。
一、Sqoop Merge Tool使用示例
在這裏模擬一次數據增量同步到hive中的過程。
1、數據準備
有一張分區表sqoop_merge_all
,分區字段pt,在hdfs上的文件存儲路徑爲:hdfs://m000/user/hive/warehouse/sqoop_merge_all
,表結構如下:
字段 | 類型 |
---|---|
id | int |
type | string |
comments | string |
updatetime | string |
另外有一張增量表sqoop_merge_inc
,該表的hdfs路徑爲hdfs://m000/user/hive/warehouse/sqoop_merge_inc
。
看一下這兩張表中的數據:
sqoop_merge_all (pt=20160810)
假設這裏存的是20160810的全量數據。
sqoop_merge_inc
假設20160811當天,對type=type1
的記錄進行了更新,並且新增了一條type=type3
的記錄。
現在,需要把sqoop_merge_inc
中的兩條記錄與sqoop_merge_all
分區 (pt=20160810)
中的兩條記錄進行合併,合併後的數據存入sqoop_merge_all
的分區 (pt=20160811)
。
按照正常邏輯,id=1
的那條記錄被更新成id=3
的那一條,id=4
的記錄新增,那麼最終sqoop_merge_all
的分區 (pt=20160811)
中應該包含的記錄分別爲id=2,id=3,id=4
這三條。
2、Sqoop Merge操作
完整的sqoop merge命令如下:
sqoop merge \
--new-data hdfs://m000/user/hive/warehouse/sqoop_merge_inc \
--onto hdfs://m000/user/hive/warehouse/sqoop_merge_all/pt=20160810 \
--target-dir hdfs://m000/user/hive/warehouse/sqoop_merge_all/pt=20160811 \
--jar-file /usr/local/sqoop/bindir/sqoop_merge.jar \
--class-name sqoop_merge \
--merge-key type
簡單說明一下,上面這句命令中表示,將新的數據集(參數--new-data
)與已有的數據集(參數--onto
)進行合併,合併後的數據存入(參數--target-dir
)路徑下。需要指定這次合併中使用的表的結構jar包和class。根據type
字段進行合併。
最終結果如下,與上面期望的相一致。
我們看一眼sqoop_merge.jar
以及sqoop_merge.class
的內容,基本上可以理解成這個類是sqoop_merge表的一個java bean定義。
二、Sqoop Merge Tool實現原理
那麼,Sqoop是如何實現上面這個功能的呢?
1、腳本分析
在$SQOOP_HOME/bin
路徑下,有一個sqoop-merge腳本。
(1)sqoop-merge
該腳本主要邏輯就是調用了sqoop merge
命令,並把類似於--onto
之類的參數都傳入。
prgm=`readlink -f $0`
bin=`dirname ${prgm}`
bin=`cd ${bin} && pwd`
exec ${bin}/sqoop merge "$@"
(2)sqoop
該腳本中,對參數進行處理後,最終執行下面這一句。把merge也一併傳入。
source ${bin}/configure-sqoop "${bin}"
exec ${HADOOP_COMMON_HOME}/bin/hadoop org.apache.sqoop.Sqoop "$@"
2、Java源代碼
查看Sqoop源代碼,可以看到Sqoop是使用Java語言實現的。我們首先找到上面腳本中使用的類。由於每個類中的方法調用過程比較麻煩,接下來只分析主要代碼,首先,完整的調用鏈如下所示。
org.apache.sqoop.Sqoop#main
--> org.apache.sqoop.Sqoop#runTool(args)
--> org.apache.sqoop.Sqoop#runTool(args, new Configuration())
--> org.apache.sqoop.Sqoop#runSqoop(Sqoop sqoop, args[1...end])
--> org.apache.hadoop.util.ToolRunner.run()
--> tool.run()
最後這個tool的類型會詳細分析。
(1)org.apache.sqoop.Sqoop
這個類是整個調用鏈的入口,這個類中主要方法的邏輯如下。
在第二個runTool方法調用處,會根據傳入的第一個參數(即我們傳入的”merge”),生成一個SqoopTool
類型的tool對象。
public static int runTool(String [] args, Configuration conf) {
...
String toolName = expandedArgs[0];
Configuration pluginConf = SqoopTool.loadPlugins(conf);
SqoopTool tool = SqoopTool.getTool(toolName);
...
Sqoop sqoop = new Sqoop(tool, pluginConf);
// 除去"merge"參數之外的其他參數,一起傳入runSqoop中
return runSqoop(sqoop, Arrays.copyOfRange(expandedArgs, 1, expandedArgs.length));
}
那麼這個tool對象到底是個什麼呢?跟蹤進入com.cloudera.sqoop.tool#getTool
方法,進入org.apache.sqoop.tool.SqoopTool#getTool
方法。
(2)org.apache.sqoop.tool.SqoopTool
這裏面,是從一個名爲TOOLS的Map中,根據toolName獲取對應的類對象。從下面代碼中我們可以看到Sqoop支持的所有Tool,並且merge對應的Tool是MergeTool
類型的。
private static final Map<String, Class<? extends SqoopTool>> TOOLS;
TOOLS = new TreeMap<String, Class<? extends SqoopTool>>();
...
// registerTool方法最終都會向TOOLS中put一個對象
registerTool("codegen", CodeGenTool.class,
"Generate code to interact with database records");
registerTool("create-hive-table", CreateHiveTableTool.class,
"Import a table definition into Hive");
registerTool("eval", EvalSqlTool.class,
"Evaluate a SQL statement and display the results");
registerTool("export", ExportTool.class,
"Export an HDFS directory to a database table");
registerTool("import", ImportTool.class,
"Import a table from a database to HDFS");
registerTool("import-all-tables", ImportAllTablesTool.class,
"Import tables from a database to HDFS");
registerTool("import-mainframe", MainframeImportTool.class,
"Import datasets from a mainframe server to HDFS");
registerTool("help", HelpTool.class, "List available commands");
registerTool("list-databases", ListDatabasesTool.class,
"List available databases on a server");
registerTool("list-tables", ListTablesTool.class,
"List available tables in a database");
registerTool("merge", MergeTool.class,
"Merge results of incremental imports");
registerTool("metastore", MetastoreTool.class,
"Run a standalone Sqoop metastore");
registerTool("job", JobTool.class,
"Work with saved jobs");
registerTool("version", VersionTool.class,
"Display version information");
最前面那個調用鏈最後那個tool
對象,對應的就是MergeTool類型。那麼接下來我們進入到MergeTool#run
中。
(3)org.apache.sqoop.tool.MergeTool
在這個方法中,生成一個MergeJob對象,然後通過該mergeJob的runMergeJob方法,運行一個MapReduce任務。
public int run(SqoopOptions options) {
try {
// Configure and execute a MapReduce job to merge these datasets.
MergeJob mergeJob = new MergeJob(options);
if (!mergeJob.runMergeJob()) {
LOG.error("MapReduce job failed!");
return 1;
}
} catch (IOException ioe) {
...
}
return 0;
}
(4)org.apache.sqoop.mapreduce.MergeJob
這個類的runMergeJob方法是一個標準的MapReduce程序。我們主要跟蹤其Mapper類和Reducer類。
public boolean runMergeJob() throws IOException {
...
if (ExportJobBase.isSequenceFiles(jobConf, newPath)) {
job.setInputFormatClass(SequenceFileInputFormat.class);
job.setOutputFormatClass(SequenceFileOutputFormat.class);
job.setMapperClass(MergeRecordMapper.class);
} else {
job.setMapperClass(MergeTextMapper.class);
job.setOutputFormatClass(RawKeyTextOutputFormat.class);
}
...
job.setReducerClass(MergeReducer.class);
}
使用的Reducer類是MergeReducer
,根據文件類型分別生成MergeRecordMapper
和MergeTextMapper
類型的Mapper類。但是,不管表文件類型是什麼,這兩個Mapper類,最終共同繼承了MergeMapperBase
類,並且在各自的map方法中,調用了MergeMapperBase#processRecord
方法,map階段的主要邏輯也就在該方法中。
(5)org.apache.sqoop.mapreduce.MergeMapperBase
這裏我們只分析processRecord方法。
在這個方法中我們看到,每一條記錄對應一個MergeRecord對象,這個對象最後會在map的輸出中輸出到reduce階段。fieldMap是一個Map類型,其key就是我們通過參數--merge-key
指定的字段,根據該字段名稱,從fieldMap中取出當前記錄該字段的值,轉化成String後,當作map的輸出,與MergeRecord對象一起交給Reduce來處理。
protected void processRecord(SqoopRecord r, Context c)
throws IOException, InterruptedException {
MergeRecord mr = new MergeRecord(r, isNew);
Map<String, Object> fieldMap = r.getFieldMap();
if (null == fieldMap) {
throw new IOException("No field map in record " + r);
}
Object keyObj = fieldMap.get(keyColName);
if (null == keyObj) {
throw new IOException("Cannot join values on null key. "
+ "Did you specify a key column that exists?");
} else {
c.write(new Text(keyObj.toString()), mr);
}
}
(6)org.apache.sqoop.mapreduce.MergeReducer
這個類中,reduce方法的邏輯如下。
取出MergeRecord集合中相同key的所有記錄,如果新數據集中不包含當前字段值的記錄,則從舊的數據集中取該條記錄。如果新舊數據集中都有該記錄,則從新的數據集中取出該記錄。
public void reduce(Text key, Iterable<MergeRecord> vals, Context c)
throws IOException, InterruptedException {
SqoopRecord bestRecord = null;
try {
for (MergeRecord val : vals) {
if (null == bestRecord && !val.isNewRecord()) {
// Use an old record if we don't have a new record.
bestRecord = (SqoopRecord) val.getSqoopRecord().clone();
} else if (val.isNewRecord()) {
bestRecord = (SqoopRecord) val.getSqoopRecord().clone();
}
}
} catch (CloneNotSupportedException cnse) {
throw new IOException(cnse);
}
if (null != bestRecord) {
c.write(bestRecord, NullWritable.get());
}
}
三、Sqoop Merge Tool源碼修改
從上面源代碼過程分析可以看到,merge過程只能指定一個字段,如果指定多個字段時,會報如下的錯,提示當前指定的字段不存在。
16/08/22 15:54:15 INFO mapreduce.Job: Task Id : attempt_1470135750174_2508_m_000004_2, Status : FAILED
Error: java.io.IOException: Cannot join values on null key. Did you specify a key column that exists?
at org.apache.sqoop.mapreduce.MergeMapperBase.processRecord(MergeMapperBase.java:79)
at org.apache.sqoop.mapreduce.MergeTextMapper.map(MergeTextMapper.java:58)
at org.apache.sqoop.mapreduce.MergeTextMapper.map(MergeTextMapper.java:34)
at org.apache.hadoop.mapreduce.Mapper.run(Mapper.java:145)
at org.apache.hadoop.mapred.MapTask.runNewMapper(MapTask.java:763)
at org.apache.hadoop.mapred.MapTask.run(MapTask.java:339)
at org.apache.hadoop.mapred.YarnChild$2.run(YarnChild.java:162)
at java.security.AccessController.doPrivileged(Native Method)
at javax.security.auth.Subject.doAs(Subject.java:415)
at org.apache.hadoop.security.UserGroupInformation.doAs(UserGroupInformation.java:1491)
at org.apache.hadoop.mapred.YarnChild.main(YarnChild.java:157)
並且我們發現使用merge key對記錄進行合併主要發生在map階段,所以如果需要支持多個字段的merge時我們只需要修改MergeMapperBase#processRecord
方法即可。修改後的代碼如下
在使用sqoop merge時,多個字段用逗號分隔,把每個字段對應的值取出來拼接成新的key。
protected void processRecord(SqoopRecord r, Context c)
throws IOException, InterruptedException {
MergeRecord mr = new MergeRecord(r, isNew);
Map<String, Object> fieldMap = r.getFieldMap();
if (null == fieldMap) {
throw new IOException("No field map in record " + r);
}
Object keyObj = null;
if (keyColName.contains(",")) {
String connectStr = new String(new byte[]{1});
StringBuilder keyFieldsSb = new StringBuilder();
for (String str : keyColName.split(",")) {
keyFieldsSb.append(connectStr).append(fieldMap.get(str).toString());
}
keyObj = keyFieldsSb;
} else {
keyObj = fieldMap.get(keyColName);
}
if (null == keyObj) {
throw new IOException("Cannot join values on null key. "
+ "Did you specify a key column that exists?");
} else {
c.write(new Text(keyObj.toString()), mr);
}
}
上面需要注意的一點是,我的拼接符使用了一個byte的String,這樣可以避免以下這種情況。
假設使用“+”當拼接符,如果存在兩條記錄:
Field a | Field b |
---|---|
a+ | b |
a | +b |
使用字段a,b進行merge時,上面兩條不一樣的記錄最終會被程序認爲是相同的,由此會產生新的數據不準確問題。
有關該問題的更多信息可以參考[SQOOP-3002]。
四、多字段的merge
還是以上面兩張表爲例進行測試,表sqoop_merge_all
使用兩個新的分區pt=20160801,pt=20160802
。
sqoop_merge_all (pt=20160801)
數據
sqoop_merge_inc
數據
指定--merge-key type,comments
進行merge,理論上只有id=1
的那一條記錄被更新成id=5
的那一條。合併後的數據應該包含id=2,id=3,id=4,id=5
這四條記錄。
sqoop merge \
--new-data hdfs://m000/user/hive/warehouse/sqoop_merge_inc \
--onto hdfs://m000/user/hive/warehouse/sqoop_merge_all/pt=20160801 \
--target-dir hdfs://m000/user/hive/warehouse/sqoop_merge_all/pt=20160802 \
--jar-file /usr/local/sqoop/bindir/sqoop_merge.jar \
--class-name sqoop_merge \
--merge-key type,comments
merge後,最終sqoop_merge_all (pt=20160802)
的數據爲: