秒级预扫描,708 个均衡分片,Doris BE OOM 自动"断臂求生"——我写了个导出工具
14 亿行、56 列宽表、数据严重倾斜
708 个均衡分片,51 个 Worker 并行
14 分 44 秒导出完毕,1,412,819,338 行分毫不差,零 OOM
就是一个 5MB 的 JAR,一行命令
一、先说背景
我需要把一个 14 亿行的 Doris 表导出来做离线分析。
你可能也有过类似的经历——在 Doris 里查数据很快,但把大量数据搬出去这件事,原生生态并不完善:
SELECT INTO OUTFILE —— 官方方案,但要 Broker 或 S3。不是每个公司都有这个基础设施。
mysqldump —— 面向 OLTP 的。几百万行还行,上亿行直接 OOM。
自己写脚本 —— 要考虑分片、并发、OOM 重试、数据倾斜、NULL 值处理……看着简单,落地就是一堆坑。LIMIT/OFFSET 分页越往后越慢,Doris 的 MPP 架构下尤其明显。
SeaTunnel / DataX —— 功能很全,但为了导几张表搭一套分布式平台,总觉得像用牛刀杀鸡。
于是我做了一件程序员最擅长的事:自己写一个。
二、自适应引擎——它自己知道该跑多快
这是整个工具最有意思的部分。
图注:工具的整体架构图,分控制层、执行层、传输与统计层。AIMD 模块持续接收执行层的反馈并动态调整并发。
1. 先摸清数据的脾气(密度预扫描)
大多数导出工具的做法是按值等分:MIN 到 MAX 切成 N 段,每段一个 Worker。
这招在均匀分布的数据上没问题。但真实世界的数据几乎都是倾斜的——某个范围集中了 80% 的行,其他范围稀稀拉拉。
结果就是:99% 的 Worker 已经跑完了,等最后一个大分片等半小时。长尾效应。
我的做法:正式导出前先跑 20-50 个 COUNT(*) 探一下数据分布。
Doris 的 zonemap 让这些 COUNT(*) 非常快——几秒钟就摸清了 14 亿行的分布规律。
拿到分布后,按行数等分而不是按值等分来切割分片。效果:
每一个分片的数据量几乎相等,Worker 同时完工,没有谁等谁。
但等值探测还有个隐藏陷阱:桶内也会倾斜。实测那张 14 亿的表,最后一个桶里 620 万宽的键值区间挤了 2.3 亿行——分片键不唯一,平均一个值对应 37 行,粗粒度扫描根本看不出来。所以扫描之后还有一步递归细分:行数超标的段自动再切 20 份重新 COUNT,最多递归 3 层、预算 400 次额外 COUNT,每轮优先细分当前最大的段——预算永远花在倾斜最狠的地方。这一步把有效分片从 18 个提到了 686 个(仅 22 个空分片),长尾直接消失了。
2. 并发控制——像 TCP 拥塞控制一样自适应(AIMD)
分片好了,开始并发导出。
并发开多少?这是个经典问题:
- 开少了,几十个分片要跑很久,浪费带宽
- 开多了,Doris BE 内存扛不住,直接挂
传统的做法是"凭经验设一个值,跑跑看,崩了就改小"。不崩说明设低了,浪费了性能。
我的方案:AIMD(Additive Increase Multiplicative Decrease)——和 TCP 拥塞控制是同一个思路。
不知道 Doris 集群能抗多少并发?没关系,工具自己试出来。
正常情况 → 每成功完成 10 个文件,并发数 +1(试探上限)
OOM 时 → 并发数直接砍半,等 2-3 秒后重试(快速收敛到安全水位)
你不需要配任何参数,设 --max-concurrent-queries=auto 就行。
下面是初版运行中最精彩的一幕——OOM 发生了,但工具自己救回来了:
看日志里的 WARN 行:OOM 发生后,并发数自动减半,稍作等待后重新开始。后续再也没崩过。
这张截图来自初版(3h15m 那次)。定版得益于递归细分——分片削得足够均匀,BE 全程没压力,零 OOM,AIMD 和对切都没触发。
图注:AIMD 算法的执行逻辑。当探测到 OOM 时并发砍半并暂停,稳定后每完成 10 个分片尝试增加 1 个并发,持续逼近集群的性能上限。
这就像 TCP 的拥塞避免——不崩就往上加,崩了就降一半,5 次重试内总能找到稳定水位。
3. 最后的保险——OOM 自动对切
AIMD 管住的是并发数。但如果单个分片本身就大到 BE 扛不住呢?比如细分也切不干净的极端热点,或者 BE 恰好被别的查询挤占了内存。
这就是标题里的"断臂求生":分片查询触发 BE OOM 时,先整体回滚——已上报的行数、文件数、字节数全部回退,已上传的远端文件自动清理——然后取范围中点把分片一分为二,同一个 Worker 就地先左后右重跑。一次不够就再切,深度上限 12 层,一个分片最多能缩小 4096 倍。损失指数收敛,数学上保证自愈。
宁可切掉一半重来,也不让一个巨型分片废掉跑了半天的整表导出。
4. 流式读取——一个坑的教训
Doris 用的是 MySQL 协议,但不支持服务端游标。
这意味着什么?如果你用常规的 JDBC 分页方式,或者设置 useCursorFetch=true,驱动会把整个结果集缓冲到客户端内存。14 亿行的表,内存直接爆掉。
正确的做法:
statement.setFetchSize(Integer.MIN_VALUE);
statement.setResultSetType(TYPE_FORWARD_ONLY);
statement.setConcurrency(CONCUR_READ_ONLY);
这是 MySQL Connector/J 的客户端流式模式——逐行从网络读取,不缓冲全部结果。
这个坑只有踩过才知道。
三、最终效果
同一张 14 亿行表,三次迭代的性能跃迁:
| 版本 | 耗时 | 最大分片 | 有效分片 | OOM |
|---|---|---|---|---|
| 初版(按值等分 + ORDER BY) | 3 小时 15 分 | 2.13 亿行 | 18 / 708 | 2 次 |
| v2(密度扫描 + 200 COUNT 递归细分) | 1 小时 09 分 | 2.32 亿行 | 415 / 708 | 0 |
| 定版(400 COUNT + 最大段优先) | 14 分 44 秒 | 340 万行 | 686 / 708 | 0 |
从 3 小时到 14 分钟——每秒 160 万行的导出吞吐,瓶颈逐个击破。
| 指标 | 数值 |
|---|---|
| 表行数 | 1,412,819,338(14 亿) |
| 列宽 | 56 列 |
| 数据特征 | 严重倾斜(尾热区 620 万宽的键值区间挤了 2.3 亿行) |
| 分片数 | 708 个(输出 1,061 个 .gz 文件,共 163 GB) |
| Worker 数 | 51 个 |
| 总耗时 | 14 分 44 秒 |
| 导出行数 | 1,412,819,338——和 COUNT(*) 分毫不差 |
| OOM 次数 | 0(三层自适应引擎的递归细分把分片削得足够均匀,BE 根本没压力触发 OOM) |
你不需要一个 16 核 64G 的机器跑它。任何一台有 JDK 的服务器都能跑。
四、和同类工具的客观对比
| 对比维度 | 这个工具 | SeaTunnel / DataX |
|---|---|---|
| 定位 | Doris → GZIP/TXT,专一 | 多数据源、ETL、同步平台 |
| 上手成本 | 一个 JAR,一行命令 | 要部署服务、写配置 |
| OOM 处理 | AIMD 自动降速 + 重试 + 并发减半 | 无内置机制,OOM 只能手动调 |
| 数据均衡 | 密度预扫描,倾斜表均分 | 按值等分,倾斜表长尾 |
| SFTP 上传 | 内置通道池,导完即传 | 不支持,要自己写脚本 |
| 多数据源 | 只做 Doris 导出 | 各种输入输出 |
| 部署依赖 | JDK 11+,零其他依赖 | 要部署服务端或 Python 环境 |
说白了:要的就是「Doris 导出到文件」这一个场景的话,这个工具比那俩省事——不用部署、不用配、自动防 OOM、导完直接上传。需要多数据源、ETL、实时同步的话,老老实实上 SeaTunnel 或 DataX。
五、几个设计决策
零依赖
整个项目极简化,构建成一个 ~5MB 的 Fat JAR。有 JDK 的机器就能跑——不需要 Maven、不需要 Python、不需要 Broker、不需要第三方服务。
不丢一行数据
- 分片边界使用
>= lo AND < hi(左闭右开),最后一个分片用<= hi(闭区间),确保 MAX 值也包含在内 - 始终有一个
WHERE key IS NULL的分片,NULL 值不漏 - OOM 发生时先整体回滚——已上报的行数、文件数、字节数全部回退,已上传的远端文件自动清理,不会多算;然后从范围中点把分片一分为二就地重跑,分片指数级变小,总能小到 BE 扛得住
默认不排序
分片查询默认不加 ORDER BY。教训来自实测:对 1.78 亿行的分片排序,BE 要吃掉 17.7GB 查询内存,是 OOM 的头号推手——而批量导出场景几乎没人依赖文件内的行序。真需要的话 --order-by-key 一键恢复。代价是无序数据的 GZIP 压缩率会低一些,用磁盘空间换 BE 安全和速度,值。
文件自动拆分
每个分片可以按行数上限自动拆成多个 .gz 文件。文件名 {表名}_{日期}_{4位序号}.txt.gz,编排清晰。
六、获取方式
项目已开源,Apache 2.0 协议。
GitHub: github.com/GoBeyondYan…
git clone https://github.com/GoBeyondYang/doris-batch-exporter.git
cd doris-batch-exporter
mvn clean package -DskipTests
java -Xmx4g -jar target/doris-batch-exporter.jar --tables=your_table
所有参数可通过 --help 查看,也支持 YAML 配置文件。生产环境建议:
nohup java -Xmx4g -jar doris-batch-exporter.jar \
--tables=your_table \
--doris-url='jdbc:mysql://fe:9030/db' \
--doris-user='root' \
--doris-password='your_password' \
--sftp-enabled=true \
--ssh-host='target.server' \
--ssh-base-dir='/data/export' \
--separator='^' \
--max-concurrent-queries=auto > export.log 2>&1 &
tail -f export.log
写在最后
这个工具不是什么颠覆性的东西。它解决的就是一个具体的问题——把 Doris 里的数据搬出去。
但在这个具体的问题上,我希望它是现存的最趁手的方案。不需要你搭环境、不需要你调参数、不需要你处理那些边界情况——丢一个表名进去,等着收文件就行。
如果你也有类似的场景,欢迎去 GitHub 试试。好用的话给个 Star,有问题提 Issue。