千万级测点不停机迁移:从 InfluxDB 到 DolphinDB 的工业级数据搬迁方案
摘要
把一个时序库的数据搬进另一个时序库,听起来像个体力活——写个脚本,导出、转换、导入,跑一个晚上就完事。数据量在百 GB 以内时,这个想象基本成立,按时间分片导出 CSV、pandas 行转列、分批 tableInsert,一台跳板机跑一个周末就能收工。
但把场景换到电网、水务、化工这类 SCADA 系统,事情完全变样,千万级测点、多年历史数据、源库还在线生产,朴素方案会在四个地方接连翻车,大查询拖垮源库、单线程慢到以周计、乱序写入与目标分区错位、中途失败全部重来。
数据搬迁,恰恰是整个迁移工程里最硬的骨头。查询改写错了,顶多一块看板报错;数据搬丢搬错,是生产事故。
本文专门把这块骨头拆开讲。核心是一套"先分桶、再并行、分批查询、批量写入"的流水线,元数据提取 → HASH 分桶对齐目标分区 → 多 worker 并行调度 → 时间块 × ID 批次查询 → 批量写入 → 批次级日志与定向重试 → 滚动校验推进。这套方案在 DolphinDB 侧已经沉淀成完整的迁移工具,在真实项目环境验证跑通。本文公开全部设计思路与关键参数,完整代码的获取方式见文末。
一、数据搬迁为什么难:朴素方案的四个翻车现场
1.1 先把场景摆出来
本文的案例来自一张很典型的 SCADA 量测表。InfluxDB 1.x 里一张 scada_value measurement,字段不多,time、value、quality、flags,加上 id、station、type 几个 tag。结构简单,但规模不简单,测点 id 数千万个,混着秒级量测和低频状态量,几年历史攒下来,数据量在百亿行这个级别。
更要命的是,搬迁期间源库不能停。生产采集还在往里写,看板还在从里面读,搬迁只能"贴着"在线业务跑。这也是"不停机迁移"的真正含义,不是最后切流那一刻不停机,而是整个历史数据搬迁期间,源库和业务始终在线。
在这个约束下,把中小规模里好用的那套"导出 → 转换 → 灌入"直接搬过来用,会依次撞上四堵墙。
1.2 翻车现场一:大查询拖垮源库
CSV 导出的问题是查询粒度,按时间窗导出,窗口开大,InfluxDB 内存先爆;窗口开小,文件碎成几万个,管理和调度全是灾难。
真正的问题藏在后面:搬迁用的重查询,和生产写入、看板查询挤在同一个 InfluxDB 实例上。1.x 开源版是单机,一个扫千万序列的大查询足以让在线写入延迟和看板加载一起抖起来。搬迁把生产库搬挂了,就是本末倒置。
查询必须拆小、拆准。但拆小之后查询次数爆炸,又回到慢的问题,这是第一对矛盾。
1.3 翻车现场二:单线程慢到以周计
算一笔账。假设历史数据 100 亿行,导入链路(查询 + 转换 + 写入)单线程稳定在每秒 5 万行——这已经是很乐观的数字——连续不断要跑 55 个小时。
注意,这 55 小时是全速理想值。实际跑起来观察日志,瓶颈基本都在源端查询,链路速度由 InfluxDB 每次查询吐数据的速度决定,写入端大部分时间在等。再叠加源库负载高峰要降速、中途失败要重跑,单线程方案的时间预算是以周计的。
窗口期每拉长一天,双跑对账、灰度切换就整体往后顺延一天。慢不只是慢,慢是风险。
1.4 翻车现场三:乱序写入与目标分区错位
DolphinDB 这边的目标表是组合分区,一级按时间 VALUE 分区,二级按测点 id HASH 分成 300 个分区(建表设计见第三节)。
如果导出按纯时间序推进,同一时刻千万个设备的行会混在一个批次里。这批数据写入时要横跨几百个 HASH 分区,小事务满天飞,分区锁冲突不断,实际写入吞吐跟压测时差一个量级。数据量小的时候感觉不到,规模一大就是生死线。
写入端要想快,数据必须提前按目标分区"归好堆"再进,这是第二对矛盾:源端查询想按时间拆,目标端写入想按设备拆。
1.5 翻车现场四:跑到 80% 挂了,全部重来
没有批次记录的搬迁脚本,失败等于灾难。跑到 80% 网络抖一下,进程退出,你面对三个问题:从哪重来(没有断点)、刚才那个批次写没写进去一半(不知道)、重跑会不会把已写入的数据再写一遍(会)。
百万行规模,重跑一晚上也就忍了。百亿行规模,一次"全部重来"就是几天时间,而且没人敢保证第二次跑得完。
1.6 小结:难的不是搬,是搬得快、搬得稳、错了能查
四个翻车现场对应四项工程诉求:源端不能被打垮(限流与拆分)、整体要快(并行)、写入要顺(分区对齐)、失败要能恢复(断点与重试)。
跨库数据迁移的难度经常被低估成"写个脚本跑一晚上"的活。从"能跑"到"能在千万级测点上稳定跑完",中间隔着的是一整套工程体系。这也是为什么各大数据库厂商真正落地项目时,搬迁工具都是标配而不是可选项。
二、总体设计:先分桶、再并行、分批查询、批量写入
2.1 一条流水线,六层结构
方案的整体架构可以概括成十二个字:先分桶、再并行、分批查询、批量写入。展开是六层:
| 层 | 做什么 | 关键产物/机制 |
| 源数据层 | InfluxDB 里的 scada_value,搬迁期间保持在线 |
时间 + id 双维度可查 |
| 元数据提取层 | 提取时间范围内的 id 候选列表 | unique_ids.txt |
| HASH 分桶层 | 按 DolphinDB 分区规则把 id 分成 300 个桶 | bucket_000.txt ~ bucket_299.txt |
| 并行调度层 | 桶分配给多个 worker,各自独立会话 | bucket % workers 分配 |
| 查询与写入层 | 时间块 × ID 批次查询,批量写入目标表 | 转换 + tableInsert 批量写 |
| 日志与重试层 | 批次级日志,失败不中断、可定向重试 | JSON Lines 日志 + 重试链 |
这个架构的核心优势是稳。它避开了大时间范围全表扫描,避开了单线程导入,用与目标端一致的 HASH 分桶保证写入天然按分区聚合,个别批次失败也不影响整体推进,事后按日志缩小范围重试就行。四项工程诉求,一层一层接住。
2.2 第一步:提取元数据,拿到全部测点 id
搬迁的第一个动作不是碰数据,是拿清单。脚本 get_scada_metaData.py 按时间范围从 InfluxDB 提取 id 候选列表,生成 unique_ids.txt:
python get_scada_metaData.py --start "2026-07-01T00:00:00Z" --end "2026-07-02T00:00:00Z"
这里有个值得单独说的细节。InfluxDB 1.x 的 SHOW TAG VALUES ... WHERE time ... 是 shard 级过滤,它返回的是"这个时间范围覆盖到的 shard 里出现过的 id",可能比严格时间范围内的 id 略多一点。
略多一点没关系。这是候选集不是结果集,后面按 id 实际查询,查不到数据的 id 自然就是空批,不影响正确性。用一点点冗余,换来的是元数据提取从全表扫描变成秒级操作,这笔账很划算。
2.3 第二步:HASH 分桶,让源端拆分和目标端分区说同一种语言
整个方案里最关键的一步。用 DolphinDB 的 hashBucket 函数(对 SYMBOL 类型的 id 按 300 取桶)把 unique_ids.txt 拆成 300 个桶文件:
python select_influx_into_ddb.py --prepare-only
为什么分桶必须用 DolphinDB 的 hashBucket(id, 300),而不是顺手写个 Python 内置 hash()?因为分桶的目的不是"把 id 分成 300 堆"这么简单,而是要让每一堆恰好对应目标表的一个 HASH 分区。Python 的 hash() 和 DolphinDB 的哈希函数结果完全对不上,分出来的桶和目标分区是错位的,写入照样横跨分区,还可能数据倾斜,等于白分。
对齐之后,红利立刻显现:一个桶的数据恰好落进一个 HASH 分区,每个 worker 写入的都是连续归属同一分区的数据,无倾斜、无跨分区小事务,写入吞吐直接回到正常水平。
翻车现场三里"源端想按时间拆、目标端想按设备拆"的矛盾,就是这样解的,查询按时间块推进,但查询的 id 集合预先按目标分区归好了堆,两端各得其所。
2.4 第三步:多 worker 并行,各管一摊
导入脚本按 bucket % workers 把 300 个桶分配给若干 worker。每个 worker 拿到一组桶,独立开一个 DolphinDB 会话,互不干扰地跑完自己的"查询 → 转换 → 写入"循环。
独立会话不是多余的细节。多线程共享一个连接,线程间的锁和上下文切换会把吞吐拖垮,还会放大连接异常的影响面。一人一个会话,简单粗暴但有效。
三、目标端与查询端:三个决定成败的细节
3.1 目标表:分区设计与字段映射
目标表按下述方式建(时间范围、库表名按实际环境调整):
// 一级按月 VALUE 分区,二级按测点 HASH 分 300 区(与分桶脚本的 300 严格一致)
dbTime = database("", VALUE, 2022.01M .. 2026.12M)
dbHash = database("", HASH, [SYMBOL, 300])
db = database("dfs://scada", COMPO, [dbTime, dbHash], engine = "TSDB")
createPartitionedTable(
db,
table(1:0, `time`value`quality`flags`id`station`type,
[TIMESTAMP, DOUBLE, INT, INT, SYMBOL, SYMBOL, SYMBOL]),
`scada_value,
sortColumns = `id`time
)
两条纪律。第一,HASH 分区数必须和脚本的 --hash-buckets 参数一致,建表 300、分桶 300,改一处必须同步改另一处。第二,300 这个数字是按千万级测点定的,平均每个桶三万多个 id;如果测点量级不同,可以调,但分区总数要控制在合理范围(时间分区数 × HASH 数),这个属于常规的分区设计话题。
字段映射如下,值得留意的是三个 tag 都转成了 SYMBOL 列,id 兼任 HASH 分区键:
| InfluxDB(scada_value) | DolphinDB(scada_value) | 说明 |
| time | time TIMESTAMP |
时间戳,注意 UTC 与本地时间的转换 |
| value | value DOUBLE |
量测值 |
| quality | quality INT |
质量戳 |
| flags | flags INT |
标志位 |
| id(tag) | id SYMBOL |
测点标识,HASH 分区键 |
| station(tag) | station SYMBOL |
厂站 |
| type(tag) | type SYMBOL |
测点类型 |
3.2 查询策略:时间块 × ID 批次,半开区间
每个 worker 的查询不是"一个时间范围全查",而是两个维度同时切:
-
时间维度,按
--chunk-hours(默认 4 小时)切块 -
id 维度,按
--id-batch-size(默认 100,建议 50 起步)把桶内的 id 拼成小批
单次查询的形态是"time 范围 + id 条件":
SELECT * FROM scada_value
WHERE time >= '2026-07-01T00:00:00Z' AND time < '2026-07-01T04:00:00Z'
AND (id = '...' OR id = '...' OR ...) -- 50 个一批
时间边界一律用半开区间 [start, end),脚本统一取 time >= start AND time < end。别小看这个约定,左闭右开保证了相邻批次不会在边界上重复搬数据,这是"重跑不重复"的前提之一。
两个参数是一对活的权衡:
-
--id-batch-size太小,查询次数爆炸,调度开销吃掉吞吐;太大,InfluxDB 解析一长串 OR 条件会明显变慢甚至超时。50 到 100 是比较稳的起点 -
--chunk-hours越小单次返回越少、越稳,但查询次数越多;越大则相反。4 小时是个经过验证的中间值
调参的大方向记住一句话:瓶颈基本在源端查询。让 InfluxDB 每次查询"吃得下、回得来",比盲目调大写入端参数有用得多。
3.3 写入策略:攒大批,带退避
查询结果转换成目标表结构后,按 --write-batch-size(默认 10 万点)攒批写入。太小会退化成频繁的小 upload + tableInsert,太大则 Python 端内存和目标端写入压力一起涨,10 万是个均衡值,一般不用动。
DolphinDB 端的失败重试自带指数退避:失败后等 10 秒重试,再失败等 20 秒、40 秒、80 秒,最多 5 次。目标端短暂抖动时任务自动扛过去,不需要人工盯守。
3.4 workers:不是越大越好
--workers 控制并行度,默认 8。经验节奏是:
-
先用
--workers 2或4,配上--id-batch-size 50、--chunk-hours 4保守压测 -
观察源库负载和批次日志,稳定后升到 8
-
不建议直接开到 300(一桶一线程)——那会同时压垮 InfluxDB 和 DolphinDB
并行度增加的是"查询 → 转换 → 写入"流水线的并发路数,但 InfluxDB 1.x 单机撑不住无限并发。搬迁是长跑,稳态比峰值重要。
四、跑到 80% 挂了怎么办:日志与重试体系
4.1 批次级日志,一行一条 JSON
每个批次(某个 worker 处理的某个桶的某个时间块 × id 批次)完成或失败,都会写一条 JSON Lines 日志,一行一条 JSON:
{"log_time":"2026-08-12 15:30:12.345","worker_id":0,"bucket":144,"bucket_batch_no":40,
"id_count":200,"first_id":"...switch227_dig","last_id":"...switch284_dig",
"start_time":"2026-07-01T00:00:00.000Z","end_time":"2026-07-01T08:00:00.000Z",
"queried_count":11399,"inserted_count":0,"status":"failed","error":"Traceback ..."}
关键字段是 queried_count 和 inserted_count,查了多少、写进去多少,两个数对不上就是异常批次,直接从日志里筛出来。
为什么用 JSON Lines 而不是 CSV?很实际的原因,error 字段里的 traceback 是多行的,会把 CSV 结构拆得粉碎;JSON Lines 一行一条,按行看、按行筛、按行重放都干净。日志体系里全部分三类:批次明细日志、失败批次日志、控制台日志,重试时自动滚动生成新的重试日志,不覆盖原始记录。
4.2 失败不中断,收尾后定向重试
主流程的设计哲学是"不因噎废食",个别批次失败不会中断整体任务,失败的批次记入 failed_batches.log,跑完之后看汇总:
失败批次数: 31
失败批次日志: failed_batches.log
然后单独重试失败批次:
python select_influx_into_ddb.py --retry-failed-log failed_batches.log
重试后仍有失败,会生成 retry_failed_batches.log,继续喂回去,形成一个可以反复滚动的重试链:
python select_influx_into_ddb.py --retry-failed-log retry_failed_batches.log
如果怀疑是个别 id 或数据点本身有问题(比如某个时间段的数据在源端就是脏的),再加 --split-retry 把失败批次进一步拆分定位:
python select_influx_into_ddb.py --retry-failed-log failed_batches.log --split-retry
--split-retry 会明显增加耗时,所以默认关闭,定位问题的最后手段。
这套设计的巧妙在于,失败处理和主流程解耦。搬迁跑到 99%,31 个失败批次单独补,不用陪着 99% 的成功数据重跑一遍。翻车现场四的问题,到这里闭环。
五、搬完不等于搬对:滚动校验与交付物
5.1 三层校验
搬迁完成后的校验至少做三层:
-
总量校验,InfluxDB 侧查询总条数 vs DolphinDB 侧
select count(*),按同样的时间范围各跑一遍 -
分段校验,按天或按小时分段比对,把"总量对但某一段错了"的情况揪出来
-
抽样校验,随机抽一批 id,逐点比对
time / value / quality / flags / station / type是否一致
三层各有分工,总量说明没丢,分段说明没漏段,抽样说明值没搬错。时区偏移、类型转换、浮点精度这类问题,会在第三层现形。
5.2 大时间范围:滚动执行,完成一段校验一段
几年的历史不要一把跑完。按天或按小时段滚动执行,每完成一个时间段,先校验,通过后再推进下一段:
-
出问题时,影响范围被钉死在最近一段,而不是全量未知
-
每一段的校验记录就是这段数据的"验收凭证"
-
源库负载可以错峰安排,白天降速、夜间全速
配合半开区间边界、批次级日志,整个搬迁过程是"分段推进、段段可查"的状态,这正是源库和业务全程在线的底气。
5.3 交付物清单
一次严肃的搬迁,交付物应该包括:unique_ids.txt(测点清单)、id_buckets/(分桶文件)、批次明细日志、汇总日志、失败批次清单与最终重试记录、三层校验结果,以及目标库中的完整数据。这套材料既是验收依据,也是下一次迁移的模板。
六、完整流程与工具获取
6.1 三条命令跑完全程
把前面的步骤串起来,标准执行流程就三条命令:
# 1. 提取时间范围内的 id 候选列表 → unique_ids.txt
python get_scada_metaData.py --start "2026-07-01T00:00:00Z" --end "2026-07-02T00:00:00Z"
# 2. 按 DolphinDB HASH 规则生成 300 个分桶文件 → id_buckets/
python select_influx_into_ddb.py --prepare-only
# 3. 并行导入(先用保守参数压测)
python select_influx_into_ddb.py --reuse-bucket-files \
--start "2026-07-01T00:00:00Z" --end "2026-07-02T00:00:00Z" \
--workers 4 --id-batch-size 50 --chunk-hours 4
常用参数速查:
| 参数 | 默认值 | 说明 |
--workers |
8 | 并行 worker 数,2/4 压测起步,稳定后升 8 |
--hash-buckets |
300 | HASH 桶数,必须与目标表 HASH 分区数一致 |
--id-batch-size |
100 | 单次查询拼接的 id 数,50~100 起步 |
--chunk-hours |
4 | 时间块大小(小时) |
--write-batch-size |
100000 | 单次写入 DolphinDB 的最大点数 |
--buckets |
全部 | 只处理指定桶,如 0,1,2 或 0-15 |
--retry-failed-log |
无 | 只重试指定失败日志中的批次 |
--split-retry |
false | 失败批次拆分定位,更慢,默认关闭 |
--ddb-retries |
5 | 目标端失败重试次数(10/20/40/80s 指数退避) |
如果只想补某几个桶,--buckets "0-15" 直接指定范围,不用全量重跑。
6.2 工具获取方式
到这里,设计思路、关键参数、执行流程、重试策略都已经摊开了,照着实现一遍是可行的。但把分桶对齐、并行调度、指数退避、JSON Lines 日志、定向重试这些细节都自己写扎实,没有两三周下不来,而且第一版几乎一定会在某个没料到的地方翻车,这套方案里的每个设计,几乎都是真实项目里踩坑踩出来的。
这套工具不需要你自己从零造。get_scada_metaData.py 和 select_influx_into_ddb.py 是 DolphinDB 在真实迁移项目中沉淀下来的内部工具,完整实现了上面全部机制,附配套说明文档,已在实际项目环境验证跑通。
由于内部材料和完整代码目前不对外公开发布,有需要的读者可以查看DolphinDB迁移方案官方推文(https://mp.weixin.qq.com/s/Cdpx94_oU7HmPiEegtq1sg),联系官方获取。如果你正在规划时序库的国产化迁移,建议早点拿去压测,迁移窗口期的排期,从来都是越早开始越从容。
七、总结
回到开头那个想象,迁移为什么远不是"跑一个晚上就完事"的 ETL 活,现在可以给出完整的回答了。
数据搬迁这件事,难就难在,表面上是个 ETL 活,骨子里是一套工程体系:
-
快,来自并行,而并行的前提是源端拆分逻辑与目标端分区逻辑对齐(HASH 分桶)
-
稳,来自克制,查询拆小、参数保守、失败不中断主流程
-
可恢复,来自断点,批次级日志 + 半开区间 + 定向重试
-
可信任,来自对账,三层校验 + 滚动推进,每一段数据都有验收凭证
千万级测点、百亿行历史、源库全程在线,这几个条件叠在一起,就是对"工业级"三个字最实在的注解。
最后留一个朴素但重要的结论,搬迁工具要在动迁之前就位,而不是动迁之后补救

浙公网安备 33010602011771号