Doris 数据导出实战:SeaTunnel、EXPORT 与 OUTFILE 性能对比及 9.5 亿行写入故障复盘

Doris 数据导出实战:SeaTunnel、EXPORT 与 OUTFILE 性能对比及 9.5 亿行写入故障复盘

核心结论

同表同分区、约 2100 万行:SeaTunnel 70 分钟以上,EXPORT 约 5 分钟 。 2.5 亿行导出:EXPORT 从 50 分钟优化到 4 分钟 (Parquet + Tablet 级拆分)。 2.1 亿行 OUTFILE:原分桶 42 分钟 ,改成 128 桶反而 53 分钟 。 9.5 亿行回写:任务被取消,直接触发原因是 Segment 数量超限。

这四组结果指向同一个判断:导出性能由执行链路和分桶设计决定,参数只能让你把已有能力用满,不能凭空创造能力。

脱敏说明 :本文涉及的主机名、IP、域名、库表名、字段名、账号、Kerberos 主体、路径均为示例化处理,与任何真实生产环境无关。文中性能数字来自实际测试记录,但不同 Doris 版本、集群规模、数据分布和负载下结果会明显不同------方法可以参考,数字不要直接套用。


Doris 数据导出实战:SeaTunnel、EXPORT 与 OUTFILE 性能对比及 9.5 亿行写入故障复盘1. 背景2. 测试环境与口径说明2.1 环境2.2 一个必须先说清的口径问题2.3 三个容易混淆的概念3. 组 1:SeaTunnel 与 EXPORT,同表同分区3.1 SeaTunnel 配置与结果3.2 EXPORT 配置与结果3.3 执行链路差异4. 组 2:EXPORT 参数优化,从 50 分钟到 4 分钟4.1 五组对照结果4.2 并发度:1 → 16,接近线性4.3 data_consistency:Tablet 粒度 vs 分区粒度4.4 Parquet:本次收益最大的一项4.5 还有一个隐藏参数:maximum_tablets_of_outfile_in_export4.6 推荐模板5. 组 3:OUTFILE,2.1 亿行实测5.1 测试方法5.2 两组结果5.3 并发参数为什么没有明显收益5.4 为什么加桶反而更慢5.5 OUTFILE 与 EXPORT 的关系5.6 OUTFILE 适用场景6. EXPORT 与 OUTFILE 对比7. 组 4 前置:EXPORT 的异步任务治理7.1 它是异步的,别等7.2 Progress 不能作为判断依据7.3 失败会留下残留文件7.4 delete_existing_files:能清理,但要想清楚7.5 生产可用的幂等编排7.6 三个必须注意的边界8. 9.5 亿行回写失败:too many segments in rowset8.1 现象8.2 报错怎么读8.3 根因一:自动分桶预估值严重偏低8.4 根因二:分桶键存在数据倾斜8.5 根因三:单次写入规模过大8.6 待验证因素:AUTO_INCREMENT8.7 短期方案:按分区/日期分批8.8 长期方案:重建表结构9. 分桶策略:导出和写入的共同地基9.1 分桶少了不行,多了也不一定行9.2 分桶键怎么选9.3 建表评审清单10. 选型建议11. 生产运维检查清单12. 总结参考资料

1. 背景

Doris 在大数据平台里的角色,已经从"在线分析引擎"扩展到"数据交换节点"。越来越多的场景需要把 Doris 中的数据抽取到 HDFS,供 Hive、Spark 继续加工。

目前主流的三条路:

方式 一句话说明
SeaTunnel Doris Source 读 + HDFS Sink 写,中间过 Spark/Flink 引擎
EXPORT Doris 自带的异步导出,直接推送到 HDFS
SELECT INTO OUTFILE 把 SELECT 结果直接写文件,同步执行

三者都能完成"Doris → HDFS",但执行链路、并行机制、资源占用和任务管理方式差异很大。很多"导出慢"的困惑,本质是把三条不同的链路当成同一件事来比较。

本文分四部分:先对比 SeaTunnel 和 EXPORT(同表同分区,最严格的对照),再讲 EXPORT 的参数优化,然后是 OUTFILE 的实测与边界,最后复盘一次 9.5 亿行回写失败。

2. 测试环境与口径说明

2.1 环境

项目 配置
数据库 Apache Doris
Doris 版本 2.1.x(部分测试为 2.1.11)
目标存储 HDFS(HA 部署,Kerberos 认证)
数据格式 CSV、Parquet
相关组件 SeaTunnel、Spark、Hadoop
读取方式 Doris Source / EXPORT / OUTFILE

2.2 一个必须先说清的口径问题

下面四组测试不是在同一张表、同一数据量、同一负载下完成的:

组 场景 数据量 是否严格对照
1 SeaTunnel vs EXPORT 约 2100 万行 ✅ 同表同分区,可算倍数
2 EXPORT 参数优化 2.5 亿行 ✅ 同表同量,可算倍数
3 OUTFILE 分桶对比 2.1 亿行 ✅ 同表同量,可算倍数
4 9.5 亿行回写 9.5 亿行 --- 故障复盘

只有组内可以计算倍数,跨组只能看量级。 这一点在很多性能文章里被忽略,导致"5 分钟 vs 42 分钟 = 8 倍"这类口径错误的结论满天飞(两者根本不是同一份数据)。

2.3 三个容易混淆的概念

写这篇文章时反复遇到概念混用,先澄清:

  • 分桶数 = 单分区的 Tablet 数 :BUCKETS 128 表示每个分区切成 128 个桶,每个桶对应一个 Tablet;若副本数为 3,实际 Replica 数是 384。所谓"Tablet 级并行",就是按这 128 个 Tablet 拆分任务。
  • 异步任务 ≠ 失败重试 ≠ 断点续传 :EXPORT 是异步任务(提交即返回),失败后可以重新提交,但不支持断点续传------已写出的文件不会自动清理,也不会从中断处继续。这三件事不能混为一谈。
  • parallelism ≠ 实际线程数:它只是一个上限,最终并行度还受 Tablet 数量、FE 调度参数和 BE 资源限制。

3. 组 1:SeaTunnel 与 EXPORT,同表同分区

这是全文最有参考价值的一组:为排除表结构和数据分布差异,两者操作的是同一张测试表的同一个分区,约 2100 万行。

3.1 SeaTunnel 配置与结果

ini 复制代码
env {
  parallelism = 128
  job.mode = "BATCH"
​
  spark.yarn.keytab = "/home/etl/etl.keytab"
  spark.yarn.principal = "etl/host.example.com@EXAMPLE.COM"
}
​
source {
  Doris {
    fenodes = "192.168.1.100:8030"
    username = "root"
    password = "******"
    doris.request.query.timeout.s = "7200"
​
    table_list = [
      {
        database = "ods"
        table = "order_detail_m_test"
        doris.filter.query = "DAY = 30"
        scan.parallelism = 128
        doris.batch.size = "102400"
        doris.exec.mem.limit = "4294967296"
      }
    ]
  }
}
​
transform {}
​
sink {
  HdfsFile {
    fs.defaultFS = "hdfs://nameservice1"
    path = "/user/hive/warehouse/ods.db/order_detail_m"
    file_format_type = "parquet"
    hdfs_site_path = "/opt/hadoop/etc/hadoop/hdfs-site.xml"
    kerberos_principal = "etl/host.example.com@EXAMPLE.COM"
    kerberos_keytab_path = "/home/etl/etl.keytab"
    custom_filename = true
    file_name_expression = "${transactionId}_${now}"
    filename_time_format = "yyyy.MM.dd"
    is_enable_transaction = true
  }
}
css 复制代码
../bin/start-seatunnel-spark-3-connector-v2.sh \
  --master yarn --deploy-mode cluster \
  --config ./doris2hdfs_spark_3.config \
  --queue etl --name "Doris2HDFSJob"

结果:约 2100 万行,70 分钟以上,成功。

需要说明一点:parallelism = 128 只是 Spark 侧的并行度上限,不代表"读 Doris、Spark 处理、写 HDFS"三段都能达到 128 路有效并发。实际有效并行度取决于:源表 Tablet 数、Doris 的 Scan 实例数、Spark Task 调度、数据传输带宽、Parquet 编码吞吐、HDFS 写入能力。任何一环成为瓶颈,整体就退化成串行。

3.2 EXPORT 配置与结果

ini 复制代码
EXPORT TABLE ods.order_detail_m_test
PARTITION (P31)
TO "hdfs://nameservice1/tmp/export/order_detail_m/"
PROPERTIES
(
    "label" = "export_test_001",
    "format" = "parquet",
    "timeout" = "3600"
)
WITH HDFS
(
    "hadoop.security.authentication" = "kerberos",
    "hadoop.kerberos.principal" = "hdfs/nn01.example.com@EXAMPLE.COM",
    "hadoop.kerberos.keytab" = "/opt/doris/be/conf/hdfs.keytab"
);

结果:约 2100 万行,约 5 分钟,成功。

同量数据,70 分钟 vs 5 分钟,耗时缩短约 92.9%,相差约 14 倍。

3.3 执行链路差异

SeaTunnel 的链路:

复制代码
Doris 集群
    │
    ▼
Doris Source(Scan)
    │
    ▼
数据读取与网络传输
    │
    ▼
Spark 执行引擎(Shuffle / 缓存 / 反序列化)
    │
    ▼
Parquet 编码
    │
    ▼
HDFS Sink(多文件写出 + 事务提交)
    │
    ▼
HDFS

EXPORT 的链路:

sql 复制代码
Doris FE
    │  创建 EXPORT 任务(异步返回)
    ▼
拆分为 N 个 SELECT INTO OUTFILE 子任务
    │
    ├──────────┬──────────┬──────────┐
    ▼          ▼          ▼          ▼
   BE-1       BE-2       ...        BE-N
(各自读取本地 Tablet,并行写出文件)
    │
    ▼
Parquet 文件
    │
    ▼
HDFS

关键差异在于中间是否经过外部计算引擎。

SeaTunnel 走的是"读出来 → 过引擎 → 再写出去",数据要完整经过 Spark 的内存与网络,这一段在大批量搬运时成本极高。

EXPORT 则由 Doris 自己组织任务,在 BE 侧完成读取和文件写出,数据不经过外部引擎。

所以结论很明确:只要需求是"把 Doris 里的原始数据搬到 HDFS",没有复杂转换,就优先 EXPORT。

4. 组 2:EXPORT 参数优化,从 50 分钟到 4 分钟

确认 EXPORT 在纯搬运场景更优之后,继续在它内部做参数对照。同表、同量(2.5 亿行),只调整三件事:格式、并发数、拆分粒度。

4.1 五组对照结果

# 格式 并发数 拆分粒度 耗时
1 CSV 1 分区级 50 分钟
2 CSV 16 分区级 18 分钟
3 CSV 16 Tablet 级 15 分钟
4 Parquet 16 Tablet 级 4 分钟
5 Parquet 16 分区级 7 分钟

从 50 分钟到 4 分钟,12.5 倍。这个优化过程可以拆成三个独立变量来看。

4.2 并发度:1 → 16,接近线性

并发数 耗时
1 50 分钟
16 18 分钟

并发从 1 提到 16,耗时从 50 分钟降到 18 分钟,接近线性。

原因是 EXPORT 会把导出拆成多个独立的 OUTFILE 执行任务,由多个任务并行处理不同数据范围。

但并发不是越高越好。并发过高会增加:

  • BE 磁盘 IO 竞争
  • HDFS 网络写入压力
  • CPU / 内存占用
  • 与在线查询的资源争抢

建议从 8 或 16 开始压测,逐步上调,观察 BE 的 IO util 和查询延迟。

4.3 data_consistency:Tablet 粒度 vs 分区粒度

这是 EXPORT 最容易被误解的一个参数。

ini 复制代码
-- 默认值:按分区粒度拆分,同一分区由同一个任务处理,保证版本一致
"data_consistency" = "partition"
​
-- 改为 Tablet 粒度:同一分区可以拆给多个任务并行处理
"data_consistency" = "none"
参数 拆分粒度 特点
partition(默认) Partition 同一分区由一个任务处理,读取一致的数据版本
none Tablet 同一分区的不同 Tablet 可分给多个任务,并行度更高

在 16 并发下:

  • 分区级 CSV:18 分钟
  • Tablet 级 CSV:15 分钟
  • Tablet 级 Parquet:4 分钟

重要前提:none 不是"无条件开启最高并发"。

它的语义是"不保证跨 Tablet 的一致性快照"。当同一个分区里不同 Tablet 被不同任务读取时,如果这个分区正在被写入,不同任务可能读到不同版本的数据,最终导出的文件里就混有不同时刻的状态。

由此得出一条很实用的判断规则:

表是实时表(持续写入)→ 只能用 partition,因为一致性优先。 表是历史表(分区已冻结)→ 可以用 none,换取 Tablet 级并行。

这条线一旦划清,选型就没有纠结了:绝大多数"T+n 批量出仓"的场景,目标分区在导出时早已冻结,none 完全安全;而"准实时表当天抽取"的场景,必须老老实实用 partition,此时提升性能应该靠增加分区数量(一次导多个分区)而不是提高单分区内的并行度。

4.4 Parquet:本次收益最大的一项

同 16 并发、同 Tablet 级拆分:

格式 耗时
CSV 15 分钟
Parquet 4 分钟

CSV → Parquet 带来约 3.75 倍提升,比"并发 1→16"和"分区级→Tablet 级"任何一项都大。

原因是列式存储 + 压缩大幅降低了写出字节数,网络和 HDFS 的压力同步下降。而且 Parquet 对下游 Hive/Spark 更友好------只要下游不强制要求 CSV,就应该用 Parquet。

需要注意:Parquet 的编码压缩本身要消耗 CPU,收益大小取决于数据类型、压缩算法和集群 IO 能力,不是所有数据集都必然有同等提升。

4.5 还有一个隐藏参数:maximum_tablets_of_outfile_in_export

官方文档中提到,EXPORT 底层会拆成多个 SELECT INTO OUTFILE 子任务,单个子任务能负责多少 Tablet,受 FE 参数 maximum_tablets_of_outfile_in_export 限制。

这意味着:即使你把 parallelism 设成 16,如果单个子任务被允许负责的 Tablet 数很小,实际切出来的子任务数量可能远多于 16,也可能因为 Tablet 总数不足而根本切不出 16 份。

所以调参的正确顺序是:先看 Tablet 总数,再看单子任务 Tablet 上限,最后定 parallelism。 反过来调(先拍一个 16 上去)大概率白调。

4.6 推荐模板

ini 复制代码
EXPORT TABLE ods.example_table
PARTITION (p20251031)
TO "hdfs://nameservice1/tmp/export/example_table/"
PROPERTIES
(
    "label" = "export_example_20251031_v1",
    "format" = "parquet",
    "data_consistency" = "none",   -- 仅限已冻结的历史分区
    "parallelism" = "16",
    "max_file_size" = "512MB",
    "timeout" = "7200",
    "delete_existing_files" = "false"
)
WITH HDFS
(
    "hadoop.security.authentication" = "kerberos",
    "hadoop.kerberos.principal" = "hdfs/nn01.example.com@EXAMPLE.COM",
    "hadoop.kerberos.keytab" = "/opt/doris/be/conf/hdfs.keytab"
);
参数 作用 建议
format 文件格式 优先 Parquet
data_consistency 拆分粒度 冻结分区用 none,实时表用 partition
parallelism 并发上限 从 8 / 16 开始压测
max_file_size 单文件大小 按下游读取习惯定,256MB~1GB 常见
timeout 任务超时 按量级给足
delete_existing_files 导出前是否清空目标目录 默认 false,开之前先确认目录归属

5. 组 3:OUTFILE,2.1 亿行实测

OUTFILE 不能简单理解为"EXPORT 的替代品"------它最大的价值在于直接导出 SELECT 的结果,可以做过滤、列裁剪、表达式计算、聚合和关联。这是 EXPORT 做不到的(EXPORT 只能整表/整分区/带 WHERE)。

所以问题不是"要不要用 OUTFILE",而是"什么量级、什么场景下用"。

5.1 测试方法

同表、同一天(day = '31')、约 2.1 亿行,只改分桶数:

ini 复制代码
SET query_timeout = 7200;   -- 默认只有 900 秒,不设置会被掐断
​
SELECT *
FROM ods.example_table
WHERE day = '31'
INTO OUTFILE "hdfs://nameservice1/user/etl/test_export/example_"
FORMAT AS PARQUET
PROPERTIES
(
    "hadoop.security.authentication" = "kerberos",
    "hadoop.kerberos.principal" = "etl/host.example.com@EXAMPLE.COM",
    "hadoop.kerberos.keytab" = "/opt/doris/conf/etl.keytab",
​
    "fs.defaultFS" = "hdfs://nameservice1/",
    "hadoop.username" = "etl",
​
    "dfs.nameservices" = "nameservice1",
    "dfs.ha.namenodes.nameservice1" = "nn1,nn2",
    "dfs.namenode.rpc-address.nameservice1.nn1" = "nn01.example.com:8020",
    "dfs.namenode.rpc-address.nameservice1.nn2" = "nn02.example.com:8020",
    "dfs.client.failover.proxy.provider.nameservice1" =
        "org.apache.hadoop.hdfs.server.namenode.ha.ConfiguredFailoverProxyProvider",
​
    "dfs.namenode.kerberos.principal" = "hdfs/_HOST@EXAMPLE.COM"
);

注意:HDFS HA 相关属性里的 nameservice 名称必须与目标集群实际配置一致,这里用 nameservice1 只是示例。

5.2 两组结果

组 分桶数 数据量 格式 耗时 结果
3-a 原表配置 约 2.1 亿行 Parquet 42 分钟 成功
3-b 128 约 2.1 亿行 Parquet 53 分钟 成功

分桶数增加后,整体耗时反而多了约 11 分钟,增幅约 26%。

这个结果本身比"快了多少"更有价值,因为它推翻了一个常见的朴素认知:分桶越多越快。

5.3 并发参数为什么没有明显收益

为了进一步提速,也调整了查询侧并行参数:

ini 复制代码
SET enable_parallel_outfile = true;        -- 开启并行写出
SET parallel_pipeline_task_num = 16;       -- Pipeline 并行度
​
SELECT /*+ SET_VAR(parallel_fragment_exec_instance_num=16) */ *
FROM ods.example_table
WHERE day = '31'
INTO OUTFILE "..."
FORMAT AS PARQUET;

结果是:暂时没有观察到明显改善。

这里需要澄清三个参数各自作用的位置,否则很容易误判:

参数 作用环节
enable_parallel_outfile 是否允许多个 Writer 并行写出文件
parallel_pipeline_task_num Pipeline 引擎的任务并行度
parallel_fragment_exec_instance_num 查询 Fragment 的执行实例并行度

它们作用于不同环节,不是"都设成 16 就有 16 路有效写入"。 举例来说:查询实例开得再多,如果 Writer 数量受引擎版本限制仍然上不去,最终还是会排队写文件。

另外,Doris 2.1 的 Pipeline 引擎对 OUTFILE 并行导出存在版本层面的限制。是否真正并行,要看返回结果中的 FileNumber,而不是看参数设了多少------这是一个很实用的自检方法。

5.4 为什么加桶反而更慢

OUTFILE 的总耗时可以拆成六段:

复制代码
OUTFILE 总耗时
    ├── 查询计划生成
    ├── Tablet 数据扫描
    ├── 过滤与计算
    ├── 查询结果传输(Fragment → Writer)
    ├── 文件编码与写出
    └── HDFS 文件关闭与任务收尾

分桶数增加,只会让第 2 段(Tablet 扫描)的并行单元变多。但如果真正的瓶颈在第 4、5 段(结果传输、文件写出),那多出来的扫描并行度不但没用,还会同时推高调度开销、版本处理和资源竞争------于是总耗时上升。

这就像收费站:车流堵在出口,你多开几条入口车道,排队只会更长。

必须强调:这组结果只能说明"在当前环境下,128 桶没有带来收益",不能推断"所有 OUTFILE 都不适合增加分桶"。 要确认瓶颈到底在哪一段,需要看 Query Profile、BE CPU、磁盘 IO、网络流量、HDFS 写入吞吐和实际 Writer 数量。没有这些指标,任何关于瓶颈的判断都只是猜测。

5.5 OUTFILE 与 EXPORT 的关系

理解这一点,前面所有现象就都能串起来了:

EXPORT 在实现上,就是把一个导出任务拆成多个 SELECT INTO OUTFILE 子任务并行执行。

也就是说:

  • 单个 OUTFILE = 一个导出任务,Writer 并行度受引擎和参数限制,写不快;
  • EXPORT = N 个 OUTFILE 子任务并发,把"一个慢任务"变成"多个并行任务"。

这解释了三件事:

  1. 为什么 EXPORT 比 OUTFILE 快得多------不是算法更优,而是任务被拆开了;
  2. 为什么 OUTFILE 调并发参数收效有限------单个任务内部的并行上限就在那;
  3. 为什么 EXPORT 的 parallelism 和 maximum_tablets_of_outfile_in_export 才是关键------它们决定能拆成几份。

5.6 OUTFILE 适用场景

场景 建议
简单查询结果导出(千万行以内) ✅ 推荐
需要 WHERE 过滤 ✅ 推荐
需要列裁剪、表达式计算 ✅ 推荐
需要聚合、关联查询 ✅ 推荐
亿级纯搬运 ⚠️ 优先评估 EXPORT
长时间后台批量任务 ⚠️ 优先评估 EXPORT
需要同步拿到结果 ✅ 可以用

两个必须知道的坑:

  • query_timeout 默认 900 秒 。2 亿行这种量级,不先 SET query_timeout 就直接被掐断,而报错往往只显示连接中断,极易误判成网络问题。
  • 同步阻塞,且失败不清理 。跑 40 分钟的 SQL 依赖一条客户端长连接,一次抖动就前功尽弃;已经写到 HDFS 的文件不会被 Doris 自动清理,需要人工检查。

6. EXPORT 与 OUTFILE 对比

对比维度 EXPORT SELECT INTO OUTFILE
执行方式 异步任务 同步 SQL
数据来源 表 / 指定分区 SELECT 查询结果
数据加工 不支持(仅 WHERE) 完整 SQL 能力
并行机制 parallelism + data_consistency enable_parallel_outfile 等
一致性 可选 partition / none 单任务内一致
任务管理 SHOW EXPORT SQL 执行结果
客户端连接 提交后可断开 需等待执行完成
失败残留 需人工清理 需人工清理
适用场景 大批量搬运 查询结果导出

选型不是"谁替代谁",而是看需求:整块搬,用 EXPORT;要变形,用 OUTFILE。

7. 组 4 前置:EXPORT 的异步任务治理

这一节解决实践的痛点------EXPORT 快,但异步语义带来三个坑。

7.1 它是异步的,别等

提交 EXPORT 后,Doris 创建任务并立即返回,不会一直等文件写完。

sql 复制代码
SHOW EXPORT;                                  -- 看全部
SHOW EXPORT WHERE LABEL = 'export_test_001';  -- 按 label 查

关键字段:

字段 说明
JobId 任务 ID
Label 任务标识(幂等依据)
State 任务状态
Progress 进度(参考值)
TaskInfo 任务配置(含实际 parallelism、data_consistency)
Path 目标路径
CreateTime / StartTime / FinishTime 时间线
ErrorMsg 错误信息
OutfileInfo 导出文件信息(行数、大小、文件数)

7.2 Progress 不能作为判断依据

实测中经常遇到:

yaml 复制代码
State: EXPORTING
Progress: 0%
OutfileInfo: NULL

任务其实在正常导出,只是进度上报粒度很粗。所以:

唯一可靠的判据是 State = FINISHED。 Progress 只当装饰看。

7.3 失败会留下残留文件

EXPORT 不走"先写临时目录再原子重命名"的流程,而是边导边写目标目录。由此产生两个后果:

  • 任务失败或超时被 CANCELLED 时,已写出的文件不会自动删除;
  • 重跑时文件名带 uuid 前缀,不会覆盖,而是新增一批文件 → HDFS 上出现重复数据。

这就是"EXPORT 重跑会产生重复数据"的完整成因:目录直写 + uuid 文件名 + 无事务,三者叠加的必然结果,不是 bug。

7.4 delete_existing_files:能清理,但要想清楚

重跑前清理目标目录,可以用:

ini 复制代码
"delete_existing_files" = "true"

它的行为是:在写入数据前清空目标目录。

所以它解决的是"重跑前先清干净",而不是"失败后自动回滚" 。开它之前必须确认两件事:

  1. 目标目录是本次任务专属的一次性目录,不含任何其他数据;
  2. 目录路径拼装没有 bug(路径拼接错误 + 这个开关 = 一次删库级事故)。

更稳的做法是根本不用它:每次导出到独立目录(见 7.5),天然不需要清理。

7.5 生产可用的幂等编排

思路:EXPORT 只负责把数据倒进一次性沙箱目录,落地由脚本用原子 rename 完成。

sql 复制代码
提交 EXPORT(唯一 label + 独立目录)
        │
        ▼
轮询 SHOW EXPORT,等 State = FINISHED
        │
        ▼
校验 OutfileInfo.totalRows 与源表 COUNT(*)
        │
        ▼
校验 HDFS 文件数 / 大小
        │
        ▼
hdfs dfs -mv 到正式分区目录(同文件系统内接近原子)
        │
        ▼
MSCK REPAIR TABLE,更新分区元数据
        │
        ▼
下游校验,完成发布

对应脚本:

bash 复制代码
#!/usr/bin/env bash
# 批量导出指定表的若干分区到 HDFS,校验通过后落地到 Hive 分区目录
set -euo pipefail
​
FE_HOST="192.168.1.100"; FE_PORT="9030"
DB="ods"; TBL="order_detail_m"
PARTS=("p20251029" "p20251030" "p20251031")
BASE="hdfs://nameservice1/tmp/export"
WAREHOUSE="/warehouse/ods.db/order_detail_m"
RUN_ID="$(date +%Y%m%d%H%M%S)"
MAX_PARALLEL=8
POLL_INTERVAL=15
​
mysql_q() {
  mysql -h"${FE_HOST}" -P"${FE_PORT}" -uroot -p"${DORIS_PASS}" -N -B -e "$1"
}
​
submit_export() {
  local part="$1"
  local label="exp_${TBL}_${part}_${RUN_ID}"
  local target="${BASE}/${TBL}/${RUN_ID}/${part}/"
  mysql_q "EXPORT TABLE ${DB}.${TBL} PARTITION (${part}) TO "${target}"
    PROPERTIES (
      "label"="${label}",
      "format"="parquet",
      "data_consistency"="none",
      "parallelism"="16",
      "max_file_size"="512MB",
      "timeout"="7200",
      "delete_existing_files"="false"
    )
    WITH HDFS (
      "hadoop.security.authentication"="kerberos",
      "hadoop.kerberos.principal"="etl/host.example.com@EXAMPLE.COM",
      "hadoop.kerberos.keytab"="/opt/doris/be/conf/etl.keytab"
    );" >/dev/null
  echo "[submit] ${part} -> ${label}"
}
​
wait_label() {
  local label="$1"
  while :; do
    # SHOW EXPORT 列顺序:JobId Label State Progress ...
    local state
    state="$(mysql_q "SHOW EXPORT FROM ${DB} WHERE LABEL = '${label}'" | awk 'NR==1{print $3}')"
    case "${state}" in
      FINISHED)  echo "[ok]   ${label} FINISHED"; return 0 ;;
      CANCELLED) echo "[fail] ${label} CANCELLED"; return 1 ;;
      "")        echo "[warn] ${label} 不在任务列表(FE 重启会丢任务历史)"; return 1 ;;
    esac
    sleep "${POLL_INTERVAL}"
  done
}
​
verify_and_land() {
  local part="$1" label="$2"
  local rows_src rows_out dir
​
  rows_src="$(mysql_q "SELECT COUNT(*) FROM ${DB}.${TBL} PARTITION (${part})")"
  rows_out="$(mysql_q "SHOW EXPORT FROM ${DB} WHERE LABEL = '${label}'" \
              | grep -o '"totalRows": *"[0-9]*"' | head -1 | grep -o '[0-9]*')"
​
  if [[ -z "${rows_out}" || "${rows_src}" != "${rows_out}" ]]; then
    echo "[abort] ${part} 行数校验失败: src=${rows_src} out=${rows_out}"
    return 1
  fi
​
  dir="${BASE}/${TBL}/${RUN_ID}/${part}"
  hdfs dfs -test -e "${dir}" || { echo "[abort] 目录不存在 ${dir}"; return 1; }
  hdfs dfs -mkdir -p "${WAREHOUSE}/dt=${part#p}"
  hdfs dfs -mv "${dir}"/* "${WAREHOUSE}/dt=${part#p}/"
  echo "[land] ${part} -> ${WAREHOUSE}/dt=${part#p} (${rows_src} 行)"
}
​
main() {
  local pids=()
  for p in "${PARTS[@]}"; do
    while (( $(jobs -rp | wc -l) >= MAX_PARALLEL )); do sleep 2; done
    ( submit_export "$p" \
        && wait_label "exp_${TBL}_${p}_${RUN_ID}" \
        && verify_and_land "$p" "exp_${TBL}_${p}_${RUN_ID}" ) &
    pids+=($!)
  done
  local fail=0
  for pid in "${pids[@]}"; do wait "$pid" || fail=1; done
  exit "${fail}"
}
​
main

7.6 三个必须注意的边界

  1. 行数一致 ≠ 数据一致。 totalRows 对得上只能排除"漏文件",不能排除"内容错位"。重要数据建议再加分区条件、关键字段统计、主键去重、校验和。
  2. hdfs dfs -mv 不是绝对原子。 同一文件系统内的目录 rename 是元数据操作,接近原子;但如果目标目录已存在且非空、或跨文件系统,行为会不同,不能直接假设"一次 rename 完成原子替换"。
  3. FE 重启会丢任务历史。 SHOW EXPORT 查的是内存中的任务,重启后查不到不等于任务失败。生产上要把 label 和状态记进自己的元数据表。

8. 9.5 亿行回写失败:too many segments in rowset

前面讲导出,这一段讲写入。虽然场景不同,但根因落在同一个地方:分桶设计。

8.1 现象

源表约 9.5 亿行,执行全量 INSERT INTO SELECT 写入目标表:

sql 复制代码
INSERT INTO analytics.target_table
(cust_id, day_id, region_id, action_id, channel_id, amount, predict_amt, day)
SELECT
cust_id, day_id, region_id, action_id, channel_id, amount, predict_amt, day
FROM analytics.source_table;

任务被系统取消,关键报错:

yaml 复制代码
[CANCELLED] tablet 310347574 failed on majority backends:
​
[E-238] too many segments in rowset.
  tablet_id:            310347574
  max:                  1500
  _num_segment:         1885
  _segcompacted_point:  1885
  _num_segcompacted:    1600
  rowset_num_rows:      475133858
​
Please check if the bucket number is too small or if the data is skewed.

8.2 报错怎么读

信息 含义
rowset_num_rows: 475133858 这一个 Rowset 里写了 4.75 亿行
_num_segment: 1885 产生了 1885 个 Segment 文件
max: 1500 单 Rowset 的 Segment 数量上限
[E-238] 超过上限,触发保护性取消

结论很清楚:单次写入的 Segment 数量超过阈值,被系统主动拦下。

最后那句 Please check if the bucket number is too small or if the data is skewed,已经把排查方向写出来了------桶太少,或者数据倾斜。

8.3 根因一:自动分桶预估值严重偏低

目标表建表属性:

vbnet 复制代码
"estimate_partition_size" = "1G"
DISTRIBUTED BY HASH(day_id) BUCKETS AUTO

实际情况:单分区真实数据量约 7 GB,而预估值是 1 GB。

在自动分桶机制下,Doris 会参考这个预估值(以及集群资源)计算初始分桶数。预估值只有实际的 1/7,最终只分配了 2 个 Bucket。

2 个桶承接 4.75 亿行,等于每条写入路径上要生成近千万行对应的 Segment。

关于这个参数,有个很重要的治理经验:它不该由业务方来填。

业务同学通常无法判断"这个表的分区大概多大",很多人干脆统一填 1G,而实际分区往往远超 1G。结果就是大量表的桶数被系统性低估。

更合理的做法是:由平台/DBA 侧统一设定,或直接不开放这个配置 ,按 SHOW PARTITIONS 里各分区的实际 DataSize 校准后写入建表模板。这属于"默认值选错会批量制造事故"的参数,不适合交给不掌握信息的人。

8.4 根因二:分桶键存在数据倾斜

scss 复制代码
DISTRIBUTED BY HASH(day_id)

业务是按日期批次写入的,本次写入的所有记录 day_id 完全相同。哈希之后,所有数据必然落进同一个桶。

后果是连锁的:

  • 写入并发退化成 1(再多 BE 也只有一台在干活)
  • 单 Tablet 承载亿级行 → Segment 数量爆炸
  • Compaction 压力全部集中在一个桶上,长期拖累查询

这里有个容易忽略的点:分桶键是否合理,不能只看整张表的基数,要看单次写入批次的分布。

day_id 在整张表里有几百个取值,看起来"基数不低",但在"按天回写"这个写入模式下,单批次只有 1 个取值。低基数不是字段的属性,是字段 + 写入模式的共同属性。

所以日期、状态、类型这类列,在按批次写入的场景下都不适合做唯一分桶键。

8.5 根因三:单次写入规模过大

sql 复制代码
FROM analytics.source_table;   -- 无任何过滤条件

一次性写 9.5 亿行,同时给这些环节施压:Tablet 写入、Segment 生成、Rowset 构建、Compaction、磁盘 IO、内存。

在分桶不足 + 数据倾斜的前提下,大事务是压垮系统的最后一根稻草。

8.6 待验证因素:AUTO_INCREMENT

目标表有自增主键:

sql 复制代码
virtual_id BIGINT AUTO_INCREMENT

亿级批量写入时,自增值分配确实会带来额外开销。但仅凭 too many segments in rowset 这个报错,不能认定自增主键就是本次故障的原因,也不能直接断定它引发了全局字典锁竞争。

要下这个结论,需要看写入 Profile、BE 日志,以及对应版本自增列的具体实现机制。

这一点必须写清楚:把未证实的推测当根因写进复盘,是最容易误导人的。现有证据能确定的只有 Segment 超限,其他都是"需要进一步验证的可疑因素"。

如果业务允许,可以考虑在上游生成稳定唯一的业务 ID,绕开 ID 生成开销------但这是优化建议 ,不是故障归因。

8.7 短期方案:按分区/日期分批

sql 复制代码
INSERT INTO analytics.target_table
(cust_id, day_id, region_id, action_id, channel_id, amount, predict_amt, day)
SELECT
cust_id, day_id, region_id, action_id, channel_id, amount, predict_amt, day
FROM analytics.source_table
WHERE day = '20251031';    -- 单日约 3000 万行,在安全水位内

逐天执行,同时观察:单批耗时、Tablet 分布、BE 内存与 IO、Segment 数量、Compaction 积压。

注意:分批只是降低单次压力,原表的分桶问题并没有解决。

8.8 长期方案:重建表结构

sql 复制代码
CREATE TABLE analytics.target_table
(
    virtual_id     BIGINT       NOT NULL COMMENT '逻辑主键,写入时生成',
    cust_id        BIGINT       NOT NULL COMMENT '客户ID',
    day_id         INT          NOT NULL COMMENT '日期',
    region_id      INT          NULL,
    action_id      BIGINT       NULL,
    channel_id     INT          NULL,
    amount         DECIMAL(18,2) NULL,
    predict_amt    DECIMAL(18,2) NULL,
    day            VARCHAR(20)  NULL
)
ENGINE = OLAP
DUPLICATE KEY(cust_id, day_id)
PARTITION BY RANGE(day_id) ()
DISTRIBUTED BY HASH(cust_id) BUCKETS AUTO
PROPERTIES
(
    "replication_num" = "3",
    "estimate_partition_size" = "10G"
);

两处关键调整:

① estimate_partition_size 对齐真实量级 。单分区实际 7 GB,从 10 G 起评估,留出增长余量。但这绝不是"所有表都改成 120G 就好了" ------预估值远超实际,只会白白增加元数据和 Compaction 开销。它应该按表的真实分区量级设定。

② 分桶键从 day_id 改为 cust_id 。前提是 cust_id 在实际写入批次里具有足够基数和均匀分布。不能因为"它是客户 ID"就默认适合------还要检查热点值、空值比例。

另外:改建表属性不会自动重建历史 Tablet。 已有分区需要单独的迁移或重建方案,这部分成本要提前评估。

9. 分桶策略:导出和写入的共同地基

把前面的测试和故障放在一起,会发现分桶策略同时影响两件事:导出时的扫描并行度 ,和写入时的 Segment 数量。

9.1 分桶少了不行,多了也不一定行

分桶过少 分桶过多
Tablet 不足,扫描并行度受限 Tablet 增多,元数据管理成本上升
写入集中,容易触发 Segment 超限 小 Tablet 增多,Compaction 与调度开销增加
单 Tablet 数据量大,查询易长尾 调度和版本处理开销可能超过收益

中间那个"合适值"没有通式,取决于分区数据量、BE 数量、磁盘资源和数据分布。

9.2 分桶键怎么选

候选字段 风险
日期 单日批量写入时全部落一个桶(本次事故)
状态 / 类型 低基数,必然倾斜
客户 ID 通常高基数,但仍需验证热点与空值
业务流水 ID 通常高基数,适合评估
复合键 需注意哈希后仍可能集中

判断标准只有一条:在"单次写入批次"这个粒度上,该字段的 distinct 值数量是否 ≥ 目标分桶数。

9.3 建表评审清单

新建大数据量表时,这几项应该被强制检查:

sql 复制代码
-- 1. 看建表语句里的分桶和估算参数
SHOW CREATE TABLE analytics.target_table;
​
-- 2. 看分区实际数据量(用来校准 estimate_partition_size)
SHOW PARTITIONS FROM analytics.target_table;
​
-- 3. 看实际 Tablet 数量与分布
SHOW TABLETS FROM analytics.target_table;
​
-- 4. 看查询实际扫多少 Tablet
EXPLAIN SELECT COUNT(*) FROM analytics.target_table;

检查要点:

  • DISTRIBUTED BY HASH(...) 用的字段,在单批次写入中是否高基数
  • estimate_partition_size 是否与实际分区量级匹配
  • 是否走了 BUCKETS AUTO(显式指定分桶时该参数无效)
  • 单 Tablet 大小是否落在合理区间(通常 1~10 GB)
  • 是否存在明显的数据倾斜

10. 选型建议

业务需求 推荐方式 原因
整表 / 整分区搬到 HDFS EXPORT 异步、并行度高、文件形态好
亿级历史数据抽取 EXPORT 可 Tablet 级拆分
导出时需 WHERE 过滤 EXPORT 或 OUTFILE 看是否需要复杂 SQL
需 JOIN / 聚合 / 窗口函数 OUTFILE 完整 SELECT 能力
Doris → PostgreSQL / MySQL SeaTunnel 多种源端和目标端
多源整合、复杂 ETL SeaTunnel 具备转换能力
流式、持续采集 SeaTunnel 适合长跑任务

三条推荐路径:

sql 复制代码
纯搬运:  Doris ── EXPORT ──▶ HDFS ──▶ Hive / Spark
有加工:  Doris ── SELECT INTO OUTFILE ──▶ HDFS
跨系统:  Doris ── SeaTunnel ──▶ PG / MySQL / Kafka / ...

不要为了追求导出速度,把所有同步任务都改成 EXPORT。 需要 SQL 加工的场景,OUTFILE 才是对的;需要跨库的场景,只有 SeaTunnel 能做。

11. 生产运维检查清单

EXPORT

  • 优先 Parquet 格式
  • 根据 Tablet 数量设 parallelism,从 8 / 16 起压测
  • 实时表用 partition,冻结分区才用 none
  • 判断完成只看 State = FINISHED
  • 不用 Progress 作为唯一判据
  • 每次导出用唯一 label
  • 每次任务用独立 HDFS 目录
  • 完成后校验 OutfileInfo.totalRows
  • 失败后检查并清理残留文件
  • delete_existing_files=true 只在专属目录上开
  • 多个大批量导出不要同时打满 BE IO

OUTFILE

  • 大数据量先 SET query_timeout
  • 只在需要 SQL 加工时使用
  • 开并行写出后,检查返回的 FileNumber 是否真的增加
  • 用 Query Profile 定位瓶颈,而不是凭参数猜
  • 失败后检查 HDFS 残留文件
  • 避免长任务依赖客户端长连接

表结构

  • 检查 DISTRIBUTED BY 字段在单批次写入中的基数
  • 检查 estimate_partition_size 是否匹配实际分区量级
  • 确认是否 BUCKETS AUTO
  • 检查实际 Tablet 数与单 Tablet 大小
  • 检查是否存在数据倾斜
  • 禁止无条件全表一次性大写入
  • 监控 Segment 数量与 Compaction 积压

12. 总结

1. 纯数据搬运,优先 EXPORT。 同表同分区 2100 万行:SeaTunnel 70 分钟以上,EXPORT 约 5 分钟。2.5 亿行场景通过格式 + 并发 + 拆分粒度优化,从 50 分钟降到 4 分钟。

2. Parquet 和 Tablet 级拆分是这次优化的主要来源。 CSV → Parquet 单项带来约 3.75 倍提升;data_consistency = none 让并行单元从分区细化到 Tablet。但 none 有前提:实时表不能用,必须用 partition。

3. OUTFILE 不是 EXPORT 的替代品。 它胜在完整 SELECT 能力,输在单任务并行上限。EXPORT 本质上就是它的多任务并发版本------理解这层关系,很多参数就调得明白了。

4. 分桶数增加不等于效率提升。 OUTFILE 实测:原分桶 42 分钟,128 桶 53 分钟。分桶只在"瓶颈位于扫描层"时才有收益,加错地方只会更慢。

5. 大批量写入必须重视分桶设计和批次控制。 9.5 亿行回写失败的确定原因是 Segment 数量超限(1885 > 1500)。自动分桶预估值偏低、低基数分桶键、单次写入过大,是三个相互放大的因素;自增主键的影响则需要进一步验证。

最终建议:纯搬运用 EXPORT,需加工用 OUTFILE,跨系统用 SeaTunnel。优化之前先定位瓶颈在哪一层------扫描、汇聚还是写出,然后决定是调参数还是换方案。盲目加并发和加桶,往往只是在堵的地方多开了几条车道。


参考资料

  1. Apache Doris 官方文档:EXPORT --- 链接
  2. Apache Doris 官方文档:SELECT INTO OUTFILE --- 链接
  3. Apache Doris 官方文档:数据分桶与自动分桶 --- 链接
  4. Apache SeaTunnel 官方文档 --- 链接
相关推荐
isfox1 小时前
MapReduce 作业慢?别乱调参数,先找到瓶颈在哪
大数据
玩美数据3 小时前
市场调研数据分析与数据集驱动的行业格局变革深度解析
大数据·人工智能
微订校园外卖系统4 小时前
校园代取快递系统选型:现成系统与定制开发的技术核对项
大数据·运维·人工智能·外卖系统·本地生活·微订
挖掘狂人6 小时前
Token 都上双十一货架了,AI 电脑的账该怎么算?
大数据·程序员·ai编程
Black蜡笔小新6 小时前
消防控制室/配电房/中控室...视频汇聚平台EasyCVR岗位AI智能监测方案守住关键岗位
大数据·音视频·easycvr
大大大大晴天20 小时前
每天认识一个组件:Apache Spark
大数据
七夜zippoe1 天前
Agent 上线第一天就炸了:6 个生产事故的根因分析与修复记录
大数据·ai·agent·根因分析·修复记录
字节跳动数据平台1 天前
不重拍,也能换道具、换商品、换画风?短剧转漫、电商换款新解法来了
大数据
武子康1 天前
攻击动作做对了,游戏规则就对了吗?H3-World 与三条控制路线的分工
大数据·人工智能·agent