2亿条数据量如何在一刻钟左右完成同步

2,397 阅读10分钟

背景

最近正在项目上做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,作为两者的数据传输通道,并处理缓冲,流控,并发,数据转换等核心技术问题。

架构设计

  1. DataX完成单个数据同步的作业,我们称之为Job,DataX接受到一个Job之后,将启动一个进程来完成整个作业同步过程。DataX Job模块是单个作业的中枢管理节点,承担了数据清理、子任务切分(将单一作业计算转化为多个子Task)、TaskGroup管理等功能。
  2. DataXJob启动后,会根据不同的源端切分策略,将Job切分成多个小的Task(子任务),以便于并发执行。Task便是DataX作业的最小单元,每一个Task都会负责一部分数据的同步工作。
  3. 切分多个Task之后,DataX Job会调用Scheduler模块,根据配置的并发数据量,将拆分成的Task重新组合,组装成TaskGroup(任务组)。每一个TaskGroup负责以一定的并发运行完毕分配好的所有Task,默认单个任务组的并发数量为5。
  4. 每一个Task都由TaskGroup负责启动,Task启动后,会固定启动Reader—>Channel—>Writer的线程来完成任务同步工作。
  5. 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

  1. 查询出最大,最小的id值
select min(id), max(id) from <table_name>;
  1. 根据上述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;
  1. 详细代码在:com.alibaba.datax.plugin.rdbms.reader.util.SingleTableSplitUtil
  2. 对于Mongodb,Datahub等分布式数据库,按照shard数进行切分,任务并发上限不超过shard数。
  3. 对于Hdfs等分布式文件系统来说,任务并发数不能超过文件数。

总结

Datax调优手段,主要分为三块内容

  1. Datax启动时候配置的jvm参数,可以指定堆内存大小。
  2. 指定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.
  1. 指定job相关配置
  • job.setting.speed.channel:切分任务,默认1。
  • job.setting.speed.byte:全局限速配置,默认没有这个key,总的job不限制速度。
  • job.setting.speed.record: 全局限速配置,默认没有这个key,总的job不限制速度。
  • splitPk:切分键