背景
最近正在项目上做Poc,使用到了Datax作为数据集成工具,客户给了20多张表进行抽数性能测试。服务器配置64G 32C,目前部署了我们的服务大概占用了20G,调度平台使用DolphinScheduler1.3.9。相当于Datax可用的内存也大概在40G左右。
客户提供的20多张表,数据规模从几条数据到2亿条数据不等。从单并发2亿条数据需要54m才能同步完成,优化后只需要16m。
Datax相关概念
在讨论调优手段的时候,需要全面了解Datax架构和执行流程
Datax设计思想
DataX本身作为离线数据同步框架,采用Framework + plugin架构构建。将数据源读取和写入抽象成为Reader/Writer插件,纳入到整个同步框架中。
- Reader:Reader为数据采集模块,负责采集数据源的数据,将数据发送给Framework。
- Writer: Writer为数据写入模块,负责不断向Framework取数据,并将数据写入到目的端。
- Framework:Framework用于连接reader和writer,作为两者的数据传输通道,并处理缓冲,流控,并发,数据转换等核心技术问题。
架构设计
- DataX完成单个数据同步的作业,我们称之为Job,DataX接受到一个Job之后,将启动一个进程来完成整个作业同步过程。DataX Job模块是单个作业的中枢管理节点,承担了数据清理、子任务切分(将单一作业计算转化为多个子Task)、TaskGroup管理等功能。
- DataXJob启动后,会根据不同的源端切分策略,将Job切分成多个小的Task(子任务),以便于并发执行。Task便是DataX作业的最小单元,每一个Task都会负责一部分数据的同步工作。
- 切分多个Task之后,DataX Job会调用Scheduler模块,根据配置的并发数据量,将拆分成的Task重新组合,组装成TaskGroup(任务组)。每一个TaskGroup负责以一定的并发运行完毕分配好的所有Task,默认单个任务组的并发数量为5。
- 每一个Task都由TaskGroup负责启动,Task启动后,会固定启动Reader—>Channel—>Writer的线程来完成任务同步工作。
- DataX作业运行起来之后, Job监控并等待多个TaskGroup模块任务完成,等待所有TaskGroup任务完成后Job成功退出。否则,异常退出,进程退出值非0
Datax调优设计
相关配置
我们一开始测试抽数效率的时候,跑的是默认配置,即Datax使用的jvm内存默认1G,从datax.py文件可以看到
DATAX_HOME = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATAX_VERSION = 'DATAX-OPENSOURCE-3.0'
if isWindows():
codecs.register(lambda name: name == 'cp65001' and codecs.lookup('utf-8') or None)
CLASS_PATH = ("%s/lib/*") % (DATAX_HOME)
else:
CLASS_PATH = ("%s/lib/*:.") % (DATAX_HOME)
LOGBACK_FILE = ("%s/conf/logback.xml") % (DATAX_HOME)
## 这里默认1g
DEFAULT_JVM = "-Xms1g -Xmx1g -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=%s/log" % (DATAX_HOME)
DEFAULT_PROPERTY_CONF = "-Dfile.encoding=UTF-8 -Dlogback.statusListenerClass=ch.qos.logback.core.status.NopStatusListener -Djava.security.egd=file:///dev/urandom -Ddatax.home=%s -Dlogback.configurationFile=%s" % (
DATAX_HOME, LOGBACK_FILE)
ENGINE_COMMAND = "java -server ${jvm} %s -classpath %s ${params} com.alibaba.datax.core.Engine -mode ${mode} -jobid ${jobid} -job ${job}" % (
DEFAULT_PROPERTY_CONF, CLASS_PATH)
REMOTE_DEBUG_CONFIG = "-Xdebug -Xrunjdwp:transport=dt_socket,server=y,address=9999"
Datax Job相关默认配置,可以从core.json文件里查看到
{
"entry": {
"jvm": "-Xms1G -Xmx1G",
"environment": {}
},
"common": {
"column": {
"datetimeFormat": "yyyy-MM-dd HH:mm:ss",
"timeFormat": "HH:mm:ss",
"dateFormat": "yyyy-MM-dd",
"extraFormats":["yyyyMMdd"],
"timeZone": "GMT+8",
"encoding": "utf-8"
}
},
"core": {
"dataXServer": {
"address": "http://localhost:7001/api",
"timeout": 10000,
"reportDataxLog": false,
"reportPerfLog": false
},
"transport": {
"channel": {
"class": "com.alibaba.datax.core.transport.channel.memory.MemoryChannel",
"speed": {
"byte": -1,
"record": -1
},
"flowControlInterval": 20,
"capacity": 512,
"byteCapacity": 67108864
},
"exchanger": {
"class": "com.alibaba.datax.core.plugin.BufferedRecordExchanger",
"bufferSize": 32
}
},
"container": {
"job": {
"reportInterval": 10000
},
"taskGroup": {
"channel": 5
},
"trace": {
"enable": "false"
}
},
"statistics": {
"collector": {
"plugin": {
"taskClass": "com.alibaba.datax.core.statistics.plugin.task.StdoutPluginCollector",
"maxDirtyNumber": 10
}
}
}
}
}
与性能相关的配置如下:
- core.transport.channel.class:channal实现类,Datax默认只有MemoryChannel实现,底层基于队列
- core.transport.channel.capacity:channel容量,即队列大小,默认512
- core.transport.channel.byteCapacity:channel数据大小容量,默认为64Mb
- core.transport.channel.speed.byte:channel限速,默认不限制
- core.transport.channel.speed.record:channel限制数据条数,默认不限制
- core.transport.exchanger.class:reader和writer缓冲区实现类,Datax默认只有com.alibaba.datax.core.plugin.BufferedRecordExchanger实现
- core.transport.exchanger.bufferSize:缓冲区大小,默认32条数据。
- core.container.taskGroup.channel: 每个taskGroup内部并行task的数量,默认是5.
全局性能配置如下:
- job.setting.speed.channel:切分任务,默认1。
- job.setting.speed.byte:全局限速配置,默认没有这个key,总的job不限制速度。
- job.setting.speed.record: 全局限速配置,默认没有这个key,总的job不限制速度。
基于这样的配置,我们在跑一个2亿条数据量的抽数任务时,花费一个小时才同步完成,这远远不能满足业务需求,所以需要修改相关配置,进行任务调优。但在修改之前,需要了解这些全局配置与局部配置到底如何组合起来使用的
并行度
Job是如何切分Task,Task又是如何组装成TaskGroup的
参考代码如下:com.alibaba.datax.core.job.JobContainer#split
/**
* 执行reader和writer最细粒度的切分,需要注意的是,writer的切分结果要参照reader的切分结果,
* 达到切分后数目相等,才能满足1:1的通道模型,所以这里可以将reader和writer的配置整合到一起,
* 然后,为避免顺序给读写端带来长尾影响,将整合的结果shuffler掉
*/
private int split() {
// 判断并行度
this.adjustChannelNumber();
// 默认并行度为1
if (this.needChannelNumber <= 0) {
this.needChannelNumber = 1;
}
// 生成reader配置
List<Configuration> readerTaskConfigs = this
.doReaderSplit(this.needChannelNumber);
int taskNumber = readerTaskConfigs.size();
// 生成writer配置
List<Configuration> writerTaskConfigs = this
.doWriterSplit(taskNumber);
// 生成transformer配置
List<Configuration> transformerList = this.configuration.getListConfiguration(CoreConstant.DATAX_JOB_CONTENT_TRANSFORMER);
LOG.debug("transformer configuration: "+ JSON.toJSONString(transformerList));
/**
* 输入是reader和writer的parameter list,输出是content下面元素的list
* 合并reader,writer,transformer配置
*/
List<Configuration> contentConfig = mergeReaderAndWriterTaskConfigs(
readerTaskConfigs, writerTaskConfigs, transformerList);
LOG.debug("contentConfig configuration: "+ JSON.toJSONString(contentConfig));
this.configuration.set(CoreConstant.DATAX_JOB_CONTENT, contentConfig);
return contentConfig.size();
}
判断并行度的逻辑如下:com.alibaba.datax.core.job.JobContainer#adjustChannelNumber
private void adjustChannelNumber() {
int needChannelNumberByByte = Integer.MAX_VALUE;
int needChannelNumberByRecord = Integer.MAX_VALUE;
// 是否配置了字节限速
boolean isByteLimit = (this.configuration.getInt(
CoreConstant.DATAX_JOB_SETTING_SPEED_BYTE, 0) > 0);
if (isByteLimit) {
// job.setting.speed.byte为全局限速配置
long globalLimitedByteSpeed = this.configuration.getInt(
CoreConstant.DATAX_JOB_SETTING_SPEED_BYTE, 10 * 1024 * 1024);
// 在byte流控情况下,单个Channel流量最大值必须设置,否则报错!
// 单个channal限速配置,core.transport.channel.speed.byte
Long channelLimitedByteSpeed = this.configuration
.getLong(CoreConstant.DATAX_CORE_TRANSPORT_CHANNEL_SPEED_BYTE);
if (channelLimitedByteSpeed == null || channelLimitedByteSpeed <= 0) {
throw DataXException.asDataXException(
FrameworkErrorCode.CONFIG_ERROR,
"在有总bps限速条件下,单个channel的bps值不能为空,也不能为非正数");
}
// 计算所需要的channel数量
needChannelNumberByByte =
(int) (globalLimitedByteSpeed / channelLimitedByteSpeed);
needChannelNumberByByte =
needChannelNumberByByte > 0 ? needChannelNumberByByte : 1;
LOG.info("Job set Max-Byte-Speed to " + globalLimitedByteSpeed + " bytes.");
}
// 是否配置了记录数限速
boolean isRecordLimit = (this.configuration.getInt(
CoreConstant.DATAX_JOB_SETTING_SPEED_RECORD, 0)) > 0;
if (isRecordLimit) {
// 全局记录数限速,job.setting.speed.record
long globalLimitedRecordSpeed = this.configuration.getInt(
CoreConstant.DATAX_JOB_SETTING_SPEED_RECORD, 100000);
// 单个channel记录数限速,core.transport.channel.speed.record
Long channelLimitedRecordSpeed = this.configuration.getLong(
CoreConstant.DATAX_CORE_TRANSPORT_CHANNEL_SPEED_RECORD);
if (channelLimitedRecordSpeed == null || channelLimitedRecordSpeed <= 0) {
throw DataXException.asDataXException(FrameworkErrorCode.CONFIG_ERROR,
"在有总tps限速条件下,单个channel的tps值不能为空,也不能为非正数");
}
needChannelNumberByRecord =
(int) (globalLimitedRecordSpeed / channelLimitedRecordSpeed);
needChannelNumberByRecord =
needChannelNumberByRecord > 0 ? needChannelNumberByRecord : 1;
LOG.info("Job set Max-Record-Speed to " + globalLimitedRecordSpeed + " records.");
}
// 取较小值
this.needChannelNumber = needChannelNumberByByte < needChannelNumberByRecord ?
needChannelNumberByByte : needChannelNumberByRecord;
// 如果从byte或record上设置了needChannelNumber则退出
if (this.needChannelNumber < Integer.MAX_VALUE) {
return;
}
// 如果配置了byte或者record限速,即使配置了job.setting.channel,也不生效
boolean isChannelLimit = (this.configuration.getInt(
CoreConstant.DATAX_JOB_SETTING_SPEED_CHANNEL, 0) > 0);
if (isChannelLimit) {
this.needChannelNumber = this.configuration.getInt(
CoreConstant.DATAX_JOB_SETTING_SPEED_CHANNEL);
LOG.info("Job set Channel-Number to " + this.needChannelNumber
+ " channels.");
return;
}
throw DataXException.asDataXException(
FrameworkErrorCode.CONFIG_ERROR,
"Job运行速度必须设置");
}
分配任务算法如下:com.alibaba.datax.core.container.util.JobAssignUtil#assignFairly
这段代码主要解释如何将多个task公平地整合成多个taskgroup。
public static List<Configuration> assignFairly(Configuration configuration, int channelNumber, int channelsPerTaskGroup) {
Validate.isTrue(configuration != null, "框架获得的 Job 不能为 null.");
List<Configuration> contentConfig = configuration.getListConfiguration(CoreConstant.DATAX_JOB_CONTENT);
Validate.isTrue(contentConfig.size() > 0, "框架获得的切分后的 Job 无内容.");
Validate.isTrue(channelNumber > 0 && channelsPerTaskGroup > 0,
"每个channel的平均task数[averTaskPerChannel],channel数目[channelNumber],每个taskGroup的平均channel数[channelsPerTaskGroup]都应该为正数");
int taskGroupNumber = (int) Math.ceil(1.0 * channelNumber / channelsPerTaskGroup);
Configuration aTaskConfig = contentConfig.get(0);
String readerResourceMark = aTaskConfig.getString(CoreConstant.JOB_READER_PARAMETER + "." +
CommonConstant.LOAD_BALANCE_RESOURCE_MARK);
String writerResourceMark = aTaskConfig.getString(CoreConstant.JOB_WRITER_PARAMETER + "." +
CommonConstant.LOAD_BALANCE_RESOURCE_MARK);
boolean hasLoadBalanceResourceMark = StringUtils.isNotBlank(readerResourceMark) ||
StringUtils.isNotBlank(writerResourceMark);
if (!hasLoadBalanceResourceMark) {
// fake 一个固定的 key 作为资源标识(在 reader 或者 writer 上均可,此处选择在 reader 上进行 fake)
for (Configuration conf : contentConfig) {
conf.set(CoreConstant.JOB_READER_PARAMETER + "." +
CommonConstant.LOAD_BALANCE_RESOURCE_MARK, "aFakeResourceMarkForLoadBalance");
}
// 是为了避免某些插件没有设置 资源标识 而进行了一次随机打乱操作
Collections.shuffle(contentConfig, new Random(System.currentTimeMillis()));
}
// parseAndGetResourceMarkAndTaskIdMap方法,主要是为了获取任务配置列表
// 按照task配置的reader.parameter.loadBalanceResourceMark和writer.parameter.loadBalanceResourceMark,分别对任务进行分组,选择分组数最高的那组,作为任务分组的源。
LinkedHashMap<String, List<Integer>> resourceMarkAndTaskIdMap = parseAndGetResourceMarkAndTaskIdMap(contentConfig);
// 正式分配任务配置
List<Configuration> taskGroupConfig = doAssign(resourceMarkAndTaskIdMap, configuration, taskGroupNumber);
// 调整 每个 taskGroup 对应的 Channel 个数(属于优化范畴)
adjustChannelNumPerTaskGroup(taskGroupConfig, channelNumber);
return taskGroupConfig;
}
划分任务
/**
* /**
* 需要实现的效果通过例子来说是:
* <pre>
* a 库上有表:0, 1, 2
* b 库上有表:3, 4
* c 库上有表:5, 6, 7
*
* 如果有 4个 taskGroup
* 则 assign 后的结果为:
* taskGroup-0: 0, 4,
* taskGroup-1: 3, 6,
* taskGroup-2: 5, 2,
* taskGroup-3: 1, 7
*
* </pre>
*/
private static List<Configuration> doAssign(LinkedHashMap<String, List<Integer>> resourceMarkAndTaskIdMap, Configuration jobConfiguration, int taskGroupNumber) {
// 获取content配置列表
List<Configuration> contentConfig = jobConfiguration.getListConfiguration(CoreConstant.DATAX_JOB_CONTENT);
Configuration taskGroupTemplate = jobConfiguration.clone();
taskGroupTemplate.remove(CoreConstant.DATAX_JOB_CONTENT);
List<Configuration> result = new LinkedList<Configuration>();
List<List<Configuration>> taskGroupConfigList = new ArrayList<List<Configuration>>(taskGroupNumber);
for (int i = 0; i < taskGroupNumber; i++) {
taskGroupConfigList.add(new LinkedList<Configuration>());
}
// 获取TaskGroup中任务最多的数量
int mapValueMaxLength = -1;
// 存储taskgroup name
List<String> resourceMarks = new ArrayList<String>();
for (Map.Entry<String, List<Integer>> entry : resourceMarkAndTaskIdMap.entrySet()) {
resourceMarks.add(entry.getKey());
if (entry.getValue().size() > mapValueMaxLength) {
mapValueMaxLength = entry.getValue().size();
}
}
int taskGroupIndex = 0;
// 轮询mapValueMaxLength次,每次都要再次轮询resourceMarks
for (int i = 0; i < mapValueMaxLength; i++) {
for (String resourceMark : resourceMarks) {
if (resourceMarkAndTaskIdMap.get(resourceMark).size() > 0) {
// 取出第一个task的id
int taskId = resourceMarkAndTaskIdMap.get(resourceMark).get(0);
// 轮询的向taskGroupConfigList插入值
taskGroupConfigList.get(taskGroupIndex % taskGroupNumber).add(contentConfig.get(taskId));
taskGroupIndex++;
// 删除第一个
resourceMarkAndTaskIdMap.get(resourceMark).remove(0);
}
}
}
Configuration tempTaskGroupConfig;
// 每个taskgroup添加配置
for (int i = 0; i < taskGroupNumber; i++) {
tempTaskGroupConfig = taskGroupTemplate.clone();
tempTaskGroupConfig.set(CoreConstant.DATAX_JOB_CONTENT, taskGroupConfigList.get(i));
tempTaskGroupConfig.set(CoreConstant.DATAX_CORE_CONTAINER_TASKGROUP_ID, i);
result.add(tempTaskGroupConfig);
}
return result;
}
上面已经把任务划分成多个组,为了每个组能够均匀的分配channel,还需要调整。算法原理是,当channel总的数目,不能整除TaskGroup的数目时。多的余数个channel,从中挑选出余数个TaskGroup,每个多分配一个。
比如现在有13个channel,然后taskgroup确有5个。那么首先每个组先分 13 / 5 = 2 个。那么还剩下多的3个chanel,分配给前面个taskgroup。
private static void adjustChannelNumPerTaskGroup(List<Configuration> taskGroupConfig, int channelNumber) {
int taskGroupNumber = taskGroupConfig.size();
int avgChannelsPerTaskGroup = channelNumber / taskGroupNumber;
int remainderChannelCount = channelNumber % taskGroupNumber;
// 表示有 remainderChannelCount 个 taskGroup,其对应 Channel 个数应该为:avgChannelsPerTaskGroup + 1;
// (taskGroupNumber - remainderChannelCount)个 taskGroup,其对应 Channel 个数应该为:avgChannelsPerTaskGroup
int i = 0;
for (; i < remainderChannelCount; i++) {
taskGroupConfig.get(i).set(CoreConstant.DATAX_CORE_CONTAINER_TASKGROUP_CHANNEL, avgChannelsPerTaskGroup + 1);
}
for (int j = 0; j < taskGroupNumber - remainderChannelCount; j++) {
taskGroupConfig.get(i + j).set(CoreConstant.DATAX_CORE_CONTAINER_TASKGROUP_CHANNEL, avgChannelsPerTaskGroup);
}
}
切分键
切分键一般选用整型类型的字段,最好的是主键或者是有索引的列,因为表主键通常情况下比较均匀,不容易出现某些切片出现数据倾斜的情况。
暂时不支持字符串,日期,浮点等其他类型作为切分键。
对于关系型数据库,切分逻辑如下:假设切分键为主键id
- 查询出最大,最小的id值
select min(id), max(id) from <table_name>;
- 根据上述JobContainer.split方法,判断出来的channel number来切分执行sql,假设最小id=1,最大id为1000000,channelNumber = 5;
-- 根据min,max进行划分
select * from <table_name> where id >= 1 and id < 200000;
select * from <table_name> where id >= 200000 and id < 400000;
select * from <table_name> where id >= 400000 and id < 600000;
select * from <table_name> where id >= 600000 and id < 800000;
select * from <table_name> where id >= 800000 and id <= 1000000;
-- 最后再统计一下id为null的情况。
select * from <table_name> where id is null;
- 详细代码在:com.alibaba.datax.plugin.rdbms.reader.util.SingleTableSplitUtil
- 对于Mongodb,Datahub等分布式数据库,按照shard数进行切分,任务并发上限不超过shard数。
- 对于Hdfs等分布式文件系统来说,任务并发数不能超过文件数。
总结
Datax调优手段,主要分为三块内容
- Datax启动时候配置的jvm参数,可以指定堆内存大小。
- 指定core.json里面的初始化配置
- core.transport.channel.class:channal实现类,Datax默认只有MemoryChannel实现,底层基于队列
- core.transport.channel.capacity:channel容量,即队列大小,默认512
- core.transport.channel.byteCapacity:channel数据大小容量,默认为64Mb
- core.transport.channel.speed.byte:channel限速,默认不限制
- core.transport.channel.speed.record:channel限制数据条数,默认不限制
- core.transport.exchanger.class:reader和writer缓冲区实现类,Datax默认只有com.alibaba.datax.core.plugin.BufferedRecordExchanger实现
- core.transport.exchanger.bufferSize:缓冲区大小,默认32条数据。
- core.container.taskGroup.channel: 每个taskGroup内部并行task的数量,默认是5.
- 指定job相关配置
- job.setting.speed.channel:切分任务,默认1。
- job.setting.speed.byte:全局限速配置,默认没有这个key,总的job不限制速度。
- job.setting.speed.record: 全局限速配置,默认没有这个key,总的job不限制速度。
- splitPk:切分键