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 子任务并发,把"一个慢任务"变成"多个并行任务"。
这解释了三件事:
- 为什么 EXPORT 比 OUTFILE 快得多------不是算法更优,而是任务被拆开了;
- 为什么 OUTFILE 调并发参数收效有限------单个任务内部的并行上限就在那;
- 为什么 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"
它的行为是:在写入数据前清空目标目录。
所以它解决的是"重跑前先清干净",而不是"失败后自动回滚" 。开它之前必须确认两件事:
- 目标目录是本次任务专属的一次性目录,不含任何其他数据;
- 目录路径拼装没有 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 三个必须注意的边界
- 行数一致 ≠ 数据一致。
totalRows对得上只能排除"漏文件",不能排除"内容错位"。重要数据建议再加分区条件、关键字段统计、主键去重、校验和。 hdfs dfs -mv不是绝对原子。 同一文件系统内的目录 rename 是元数据操作,接近原子;但如果目标目录已存在且非空、或跨文件系统,行为会不同,不能直接假设"一次 rename 完成原子替换"。- 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。优化之前先定位瓶颈在哪一层------扫描、汇聚还是写出,然后决定是调参数还是换方案。盲目加并发和加桶,往往只是在堵的地方多开了几条车道。