去年接了一个挺折腾的活儿:把一家大型工厂的数据库从 MySQL 迁到 TDengine。这家工厂的产线上有几万台设备,每台设备下面的传感器测点加起来超过十万个,数据是秒级采集的,一天就能攒出上亿条记录。起初这套数据是存在 MySQL 里的,几个业务系统都在上面读写,到了后期已经明显“跑不动”了——归档查询动不动要等十几秒,存储空间每个月都在告急,DBA 天天在处理慢查询和锁等待。
先说结论,整个迁移做完之后,写入性能提升了一个数量级不止,历史曲线类查询从秒级、十几秒的级别压到了百毫秒上下,存储占用只有原来的三分之一左右,硬件成本直接砍掉一大半。这篇文章就是这次迁移的完整复盘,不聊空理论,重点讲数据模型怎么设计、全量和增量数据怎么切、双写校验怎么做,以及我们在现场踩过的一些坑。如果你也在用 MySQL 硬扛时序类数据,这篇文章应该能帮你省下不少弯路。
1. 为什么非迁不可:MySQL 在工厂时序场景下的硬伤
1.1 先搞清楚工业数据是什么长相
工厂里的数据和互联网应用的数据差别其实非常大。互联网应用的核心对象是“用户”,一个用户一行记录,更新频繁,字段之间的关系复杂,需要 JOIN 查询。而工厂里的核心对象是“设备”,数据长这样:一个设备 ID、一个测点、一个采集时间、一个数值,比如温度、压力、流量、电流。它的特征是:
- 写入几乎是纯追加的,每条记录只写一次,很少 UPDATE 或 DELETE
- 数据量极大,十万个测点,1 秒采一次,一天就是 8.64 亿条记录,MySQL 根本招架不住
- 查询模式高度固定,基本就是“某一台设备某一段时间内的曲线”或者“某一批设备在某一时刻的状态快照”
- 数据有生命周期,老数据最终要归档,没有必要一直躺在热库里
坦白讲,这类数据存到 MySQL 里面,一开始可能是图方便,因为团队熟、业务系统直接接就好。但当数据量上来之后,MySQL 在架构上就是先天吃亏的,问题不是调几个参数就能解决。
1.2 MySQL 在时序数据上的几个“死穴”
我见过太多团队在这个阶段和 MySQL 硬磕,慢查询、分库分表、读写分离、主从复制,一套组合拳打下来,短期能顶一阵,长期还是不行。MySQL 在工厂时序场景下面的硬伤很典型:
写入放大和索引维护成本高。MySQL 的 InnoDB 是 B+ 树结构,每次插入都要维护索引,数据量一大,随机 IO 就上来了。高并发写入时,大量 insert 会堆积,很快会看到锁等待、buffer pool 打满、磁盘 IO 撑爆。我们把 IoT 网关的数据写进 MySQL 时,经常遇到连接池被打满的情况,业务侧反映“数据上不来”。
单表数据量过万之后,查询性能断崖式下降。不是 MySQL 不行,而是它的场景不适合。一张表存了几亿条记录,即使加了索引,范围查询还是要走回表,扫一遍索引树再回表查数据,时间消耗巨大。更别提如果查询条件稍微复杂一点,优化器选错索引,整个查询直接卡死。
存储成本太高。MySQL 的行式存储对时序数值型数据非常不友好,浮点数、时间戳重复存储,压缩率很低。我们当时的温度、压力数据,在 MySQL 里一周就能吃掉几百 GB,冷数据归档又麻烦,备份恢复时间也长得让人崩溃。
运维复杂度滚雪球式上涨。主从复制延迟、表碎片整理、慢查询优化、分区表维护……这些工作会随着数据量增长不断膨胀。当时团队的大半精力都花在“让 MySQL 别出问题”上面,真正投入业务分析的精力少得可怜。
1.3 为什么选 TDengine 而不是其他时序数据库
其实我们也评估过 InfluxDB、IoTDB、OpenTSDB 这些。InfluxDB 生态也不错,但它的存储引擎在超大规模数据模型上会有一定限制,部署运维相对复杂;IoTDB 在工业场景做得细,但学习成本不低;OpenTSDB 依赖 HBase,光一个 Hadoop 集群就够呛。
最后选择 TDengine,核心原因有三点:
- 超级表模型和 SQL 语法:TDengine 通过超级表、子表、标签这套模型,把设备的元信息和测点数据天然分离,同时支持标准 SQL,团队迁移成本低,不用重新学一套查询语言
- 部署运维足够简单:一个二进制文件就能跑,生产环境三节点集群也只要半天就能搭好,不需要额外依赖任何第三方组件
- 成本优势明显:社区版免费,功能在这个场景下已经足够用,性能也远超 MySQL
说白了,对一个工厂的 IT 团队来说,选用 TDengine 是“低风险、高回报”的选择,这一点在后来的实际运行中也得到了验证。
2. 迁移前的设计:超级表、子表和标签的正确姿势
2.1 先理解 TDengine 的存储模型
在动手迁移之前,团队必须先把 TDengine 的存储模型想明白,这决定了后续业务能不能顺畅。TDengine 里有几个核心概念:
- 超级表(STable):相当于一组同类设备测点数据的“模板”,定义时间戳和所有字段
- 子表(Child Table):在超级表下,每台设备对应一张子表,子表继承超级表的字段定义,同时通过标签(Tags)来区分设备属性
- 标签(Tags):是设备的静态属性,比如设备型号、所属车间、产线编号等,查询时可以用标签做过滤,效率极高
这个模型最大的价值在于:数据是分散在每台设备自己的子表里的,但查询时却能按标签一次性跨所有子表处理。你可以把它理解成一个文件夹,里面每个设备一个小本子,想查“3 号车间所有设备的温度均值”时,直接按标签筛一遍就能把所有相关的小本子拎出来算。
2.2 一个工厂场景的模型设计实例
我们当时的数据模型设计大概是这样的:
- 温度类测点、压力类测点、流量类测点分别建立不同的超级表,因为它们的字段和保留策略不太一样
- 每台物理设备按设备 ID 建一张子表
- 标签统一设置为:车间编号(zone)、设备类型(device_type)、设备型号(model)
- 测点数据全部作为列放在超级表里
建表语句类似这样:
-- 温度超级表 CREATE STABLE temperature ( ts TIMESTAMP, value FLOAT, quality INT ) TAGS (device_id BINARY(64), zone BINARY(32), device_type BINARY(32), model BINARY(32)); -- 为某一台设备创建子表 CREATE TABLE therm_001 USING temperature TAGS ('TH-001', 'A区', '加热炉', 'RX-200');这个设计的优势是:查询某个车间一段时间的平均温度,一条 SQL 就搞定了,而且不需要维护额外的索引,标签过滤是引擎自动做的。
这里有一个非常关键的设计取舍:千万不要把测点本身拆成一张张表。有些人刚接触 TDengine,会觉得“一个测点一张表”最直观。但这样的话,表数量会爆炸式增长,十万个测点就是十万张表,元数据管理、查询效率、运维成本都会失控。正确的做法是“一台设备一张子表”,把一个设备的所有测点都作为列放进去。如果涉及的设备测点差异太大,那就分多个超级表,而不是一张超级表套到底。
2.3 从 MySQL 表结构到 TDengine 超级表的自动映射
很多团队问过我一个问题:MySQL 里现成的表结构,怎么自动转成 TDengine 的超级表加子表?我们当时写了一个简单的元数据映射工具,核心逻辑不复杂,规则如下:
| MySQL 元素 | TDengine 映射规则 |
|---|---|
| 业务表(如 temperature_record) | 对应一个超级表,表名可以保持一致 |
| 时间字段(datetime 类型) | 转成 TIMESTAMP 类型,写入时统一转成 UTC 毫秒时间戳 |
| 设备标识字段(如 device_id) | 移出普通字段,作为子表的标签,同时设备 ID 作为子表名的后缀 |
| 数值字段(温度、压力、流量等) | 保留为普通字段,数据类型按精度选择 FLOAT 或 DOUBLE |
| 其他设备属性字段(车间、型号) | 全部转成标签,减少冗余存储 |
| 普通索引 | 不需要手工建索引,时间戳主键和标签过滤由引擎内部处理 |
比如 MySQL 里这张表:
CREATE TABLE temp_data ( id BIGINT PRIMARY KEY, device_id VARCHAR(64), ts DATETIME, temp FLOAT, pressure FLOAT, zone VARCHAR(32), model VARCHAR(32) );转成 TDengine 后就是:
CREATE STABLE temp_data ( ts TIMESTAMP, temp FLOAT, pressure FLOAT ) TAGS (device_id BINARY(64), zone BINARY(32), model BINARY(32));这里有个容易踩的坑:MySQL 的 DATETIME 是带时区歧义的,而 TDengine 的 TIMESTAMP 本质上是 UTC 时间戳。转换时如果不统一时区处理,后面查出来的曲线会出现整体偏移 8 小时一类的诡异现象。我们后来是在写入侧做了强制约定:统一把时间转成 UTC 毫秒,查询时再按业务时区展示。
3. 迁移执行:全量、增量、双写、校验四步走
3.1 整体迁移策略
数据库迁移最怕的就是“一把梭”,直接把旧库停掉,导入新库,然后冒风险切流量。如果线上数据量很大,这种方法大概率会翻车。我们采用的是“全量导入 + 增量同步 + 双写过渡 + 一致性校验”四步方案,整体流程是:
- 先把 MySQL 里的历史数据全量导出并转换,导入 TDengine
- 在全量导入期间,MySQL 里还在不断产生新数据,所以需要一个增量同步任务,把新数据实时或准实时地同步到 TDengine
- 两端数据基本对齐后,让业务系统同时写 MySQL 和 TDengine,跑一段双写观察期
- 通过校验任务确认数据一致,再把读流量切换到 TDengine,MySQL 归档只读
这套方案的好处是每走一步都有验证,出问题可以随时回退,不会出现“切了之后发现数据对不上,又不知道问题出在哪”的情况。
3.2 全量导出的实操细节
全量导出的本质是“读 MySQL、写 TDengine、中间完成数据格式转换”。我们当时的做法是:
- 按 MySQL 表的主键范围分批读取数据,避免一次性 loading 太多数据导致源库出问题
- 每批数据在内存中转换时间格式、字段类型
- 通过 RESTful 接口或原生连接写入 TDengine,采用批量提交(每批 5000 到 1 万条)
- 记录每批读取的最大时间戳,作为增量同步的起点
这里给一个简化的 Python 伪代码,思路可以参考:
import pymysql from taos import TaosConnection # 连接 MySQL src = pymysql.connect( host="mysql-host", user="root", password="***", database="factory", charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor ) # 连接 TDengine dest = TaosConnection(host="tdengine-host", user="root", password="taosdata") # 分批读取,加入 where 条件避免一次扫全表 batch_size = 5000 last_id = 0 while True: with src.cursor() as cursor: cursor.execute( "SELECT device_id, ts, temp, pressure, zone, model " "FROM temp_data WHERE id > %s ORDER BY id LIMIT %s", (last_id, batch_size) ) rows = cursor.fetchall() if not rows: break # 转换时间戳为毫秒,组装写入语句 values = [] for row in rows: ts_ms = int(row["ts"].timestamp() * 1000) # 统一转 UTC 毫秒 tag_device = row["device_id"] tag_zone = row["zone"] tag_model = row["model"] values.append( f"('{ts_ms}', {row['temp']}, {row['pressure']}, " f"'{tag_device}', '{tag_zone}', '{tag_model}')" ) # 批量写入 TDengine(注意转义和特殊字符处理) sql = ( "INSERT INTO temp_data " "USING temp_data TAGS " f"('{values[0][3]}', '{values[0][4]}', '{values[0][5]}') " "VALUES " + ",".join(values) ) dest.execute(sql) last_id = rows[-1]["id"] print(f"已处理到 ID: {last_id}")几个实操经验:
- 全量导出时建议给 MySQL 开只读账号,只做 SELECT,不要影响在线业务
- 大批量写入 TDengine 时,如果发现 taosadapter 或服务端 CPU 飙高,可以降低批次大小,从 1 万调到 5000 左右
- 脚本要支持断点续跑,记录下当前的 last_id 或时间戳,避免中途失败后从头再导
3.3 增量同步的两种路线
增量同步我们评估了两种方案,各有适用场景。
方案一:按业务时间戳轮询(简单方案)
如果 MySQL 表里有一个明确的采集时间字段(ts),而且这个字段是随数据写入递增的,最简单的做法就是定时任务:每次从上次同步的时间点往后拉新数据,写入 TDengine。代码逻辑就是在全量导入脚本的基础上,把“按 ID 分批”改成“按时间范围分批”,每 5 分钟跑一次即可。
这种方案的缺点是:如果业务侧有历史数据的回填或修改,靠时间戳轮询是捕获不到的。但如果业务场景只是单纯的采集上报,那这个问题可以忽略。我们现场的采集数据就是纯追加的,所以用的就是这种方案,够用、简单、不易出错。
方案二:基于 binlog 的流式同步(完整方案)
如果 MySQL 表存在修改、删除,或者需要很低延迟的同步,那就需要解析 binlog 了。经典的方案是 Canal 或 Debezium 订阅 binlog,把变更事件实时转发给 TDengine 写入程序。这个方案能保证数据变更完整落地,但架构复杂度会上升不少,需要额外维护 Canal 服务,配置解析规则,还要处理 DDL 变更同步的问题。我们是在后期做数据回填功能时才引入了这种方案。
给个建议:如果你的场景只是 A 表数据单向追加,别一上来就上 binlog 方案。时间戳轮询大概率够了,而且好维护得多。只有在需要检测修改、删除时才需要升级方案。
3.4 双写过渡与一致性校验
增量同步追平之后,并不是马上就能切流量。我们安排了一周的双写过渡期:业务服务在写 MySQL 的同时,把同样的数据写入 TDengine。这一步不能只是“写了就完”,还要做一致性校验。
校验的维度很简单,但非常有效:
- 总数核对:两边表的总记录数是否一致,增量期间每 10 分钟比对一次
- 最新值核对:对每个设备子表,分别取 MySQL 和 TDengine 的最新时间戳和最新值,看是否一致
- 关键指标抽样:随机抽几个设备、几个时间段,用聚合函数对比 SUM、AVG、MAX 是否在精度允许误差范围内
校验 SQL 示例,比如查最新值:
-- TDengine 侧 SELECT last(ts), last(temp) FROM temp_data WHERE device_id = 'TH-001'; -- MySQL 侧 SELECT MAX(ts), SUBSTRING_INDEX(GROUP_CONCAT(temp ORDER BY ts DESC), ',', 1) FROM temp_data WHERE device_id = 'TH-001';当然这种 SQL 在 MySQL 上跑起来不轻松,所以抽样要挑数据量可控的时间窗口来做,没必要全量校验。
3.5 切换后的收尾动作
校验通过后,我们把读流量切到了 TDengine,但 MySQL 并没有立刻销毁。这里我的经验是:留一个退路,别急着删。我们把 MySQL 的归档库设置成只读,保留了两周,确认业务侧没有任何依赖 MySQL 的查询之后再释放存储。期间如果发现 TDengine 侧有问题,随时可以切回去。
4. 上线效果:性能、成本与运维体验
4.1 写入和查询性能的实测数据
迁移后我们对线上场景做了几轮压测,用同一批数据、同样的查询条件来对比:
| 场景 | MySQL 表现 | TDengine 表现 |
|---|---|---|
| 单条采集数据写入(高并发 200 路网关) | 峰值约 20 万条/min,CPU/IO 接近极限 | 峰值约 150 万条/min,CPU 还有余量 |
| 查询某设备一周温度曲线(60480 个点) | 约 6~18 秒,经常超时 | 约 80~200 毫秒 |
| 查询某车间全部设备当天均值聚合 | MySQL 已经无法在合理时间内完成 | 亚秒级返回 |
| 3 年历史数据存储占用(含索引) | 约 2.1 TB | 约 720 GB |
性能提升的背后,本质是架构差异:TDengine 是列式存储,写入走追加模型,时间主键天然有序,范围查询只需要顺序扫描相关数据块,不需要像 MySQL 那样回表、随机 IO、排序合并。MySQL 在时序场景下的慢,不是某一个参数调得不对,而是存储引擎的设计方向不对。
4.2 硬件成本和授权成本到底省在哪
先算硬件账。原来的 MySQL 方案是 4 台高性能物理机组成的集群,还要搭配 SSD 阵列和单独的备份存储。迁移后,我们用的是 3 台普通配置的服务器做 TDengine 集群,存储从原来的全 SSD 换成了普通 SAS 盘,照样扛得住。硬件采购成本和机房功耗、运维工时都明显下降。我们粗略算过,三年期的总体拥有成本(硬件 + 运维 + 存储)下降了六成左右。
再说“TDengine 太贵”这个说法。我猜很多朋友是把“官方企业版”和“社区版”搞混了。TDengine 的社区版是免费的,功能覆盖我们这种工业时序场景完全够用,也没有数据量限制。企业版收费主要对应原厂技术支持、智能运维等增值服务,相当于买保险。大团队如果预算充足,买企业版图个省心也合理,但中小团队完全可以从社区版起步,先跑起来再说性价比。我们这次用的就是社区版,全程没有额外授权费用。
4.3 迁移后运维日常的简化
以前的运维日常是:看慢查询日志、kill 长时间锁、调 buffer pool、重建索引、扩容磁盘。迁移之后,TDengine 集群的运维基本上就是“确认服务活着、磁盘没满、数据能按时归档”。因为它是专门为时序场景设计的,自带数据过期和分层存储机制,我们可以给不同超级表设置不同的保留时长(比如温度数据保留 1 年、压力数据保留半年),老数据自动清理,不需要人工做归档脚本。
团队的人力彻底释放出来了,DBA 终于有空做点真正有价值的数据分析,而不是天天救火。
5. 现场踩过的坑:常见问题与排查实录
5.1 时间整体偏移 8 小时,差点以为是 bug
这是全量导入后最先遇到、也最容易迷惑人的问题。查询某台设备的温度曲线,横轴时间整体往后(或往前)偏移了 8 小时。一开始怀疑是 TDengine 写错了,后来排查才发现是时区处理不一致。
MySQL 的 DATETIME 不带时区,Python 读取时如果用本地时区解析,生成的时间戳就带了东八区的偏移;而 TDengine 内部统一按 UTC 时间保存,查询展示时客户端又自动转一次本地时区,一来一回就会出现 8 小时的偏差。解决办法就是在写入侧统一用 UTC 时间戳,所有转换代码里明确指定timezone="UTC",查询展示层再按业务时区做转换。这个坑必须是全局约定,任何一块代码都不能例外。
5.2 表数量膨胀导致元数据压力过大
我们的采集系统一开始有个历史包袱:MySQL 阶段是“一个测点一张表”的设计,迁移时如果完全照搬,TDengine 里就会有几十万张表。实践证明,表数量过多后,元数据管理的开销和超级表查询性能都会受影响。
解决办法是拆超级表 + 合并测点列:把温度、压力、流量拆到三个超级表下,每个超级表内部的子表按设备维度建,把该设备所有同类型测点都作为列。表数量从几十万降到了几千,查询反而更快了。
这里有个设计原则:子表的粒度是“一台设备”,不是“一个测点”。如果一台设备下面测点特别多,字段数超出限制,再考虑按测点分组拆超级表,而不是拆成一张表一个测点。
5.3 “写入后马上查询查不到”是什么情况
有同事反馈,数据明明写入成功了,马上查询却查不到。这块和 TDengine 的写入接口有关。如果用同步接口(如 Python 原生连接执行 INSERT),写入完成后立即查询是可以查到的;但如果用了异步接口或客户端批量缓冲模式,数据可能还留在客户端缓冲区里,并没有真正发到服务端。
网上那个高频问题“TDengine 保存临时数据马上读取”就是典型的异步缓冲未 flush。解决办法是按官方文档要求,在需要保证可见性时调用 flush 接口,或者在写完一批后隔一小段时间再查。我们在现场把采集端改为同步写入,客户端设置合理的 flush 周期,这个问题再没有出现过。
5.4 多个子表之间怎么做到“时序一致”
工厂里经常要同时看多个设备的同一时间段,比如“3 号车间的 50 台设备的压力曲线放一起对比”。在 TDengine 里,可以通过超级表查询直接完成,不需要像 MySQL 那样 join。SQL 可以这么写:
SELECT ts, device_id, pressure FROM pressure_data WHERE zone = 'A区' AND ts >= '2026-01-01 00:00:00' AND ts <= '2026-01-01 01:00:00' PARTITION BY device_id ORDER BY ts, device_id;这个查询天然会按每个子表分别取出数据,再按时间轴输出,子表之间的时间戳并不强制一致,各自按真实采集时间返回。如果需要严格的时间对齐(比如算多个设备同一时刻的平均值),可以用 TDengine 的 INTERVAL 语法配合填充策略:
SELECT _wstart, AVG(pressure) FROM pressure_data WHERE zone = 'A区' AND ts >= '2026-01-01 00:00:00' AND ts <= '2026-01-01 01:00:00' INTERVAL(10s) FILL(LINEAR);这种写法会自动把不同设备、不同时间点的数据按 10 秒窗口对齐,缺失值按线性插值填充,非常贴近工业分析场景。
5.5 C++ 客户端和连接池的注意点
采集端如果是 C++ 写的,连 TDengine 时有两个选择:一是用官方原生客户端库(libtaos),二是走 RESTful 接口。原生客户端性能更高、延迟更低,适合高频写入;RESTful 接口则胜在跨语言、部署简单,适合低频查询或脚本工具。
直接用原生 C++ API 的时候,要注意连接参数里的taosConnect超时设置。我们在现场遇到过连接池里的连接在服务端已经过期,但客户端仍继续使用,导致写入报错的情况。解决办法是设置合理的连接超时和重连机制,同时定期拉取服务端连接状态。
如果采集端只想快速接入,不追求极限性能,RESTful 其实更省心。TDengine 自带的 taosAdapter 组件开放 6041 端口,任何语言只要会 HTTP 请求,就能执行标准 SQL 写入数据,格式是 JSON 或 line protocol。
5.6 高频问题速查表
| 现象 | 可能原因 | 快速解决办法 |
|---|---|---|
| 查询曲线时间整体偏移 8 小时 | 写入时本地时区转 UTC 环节漏了,或重复转换 | 统一在写入侧转 UTC 时间戳,展示侧再转本地时区 |
| 写完立刻查不到数据 | 用了异步批量写入,客户端缓冲未 flush | 改为同步写入,或写入后主动 flush |
| 表数量过多,超级表查询变慢 | 子表粒度太细,把测点当子表 | 调整为按设备建子表,测点放列 |
| 多个设备时间轴无法对齐 | 各子表真实采集时间天然不一致 | 用 INTERVAL 窗口 + FILL 填充实现时间对齐 |
| 客户端连接频繁超时 | 连接池未处理过期连接 | 设置连接超时与重连机制 |
| 写入速度慢,CPU 很高 | 批次太小,每条一次提交 | 调整批量大小到 5000 条左右,权衡内存占用 |
最后说点实在的
这个项目做下来,我最大的体会是:数据库迁移能不能成功,七成取决于数据模型设计是否认真,三成才取决于执行工具和脚本。TDengine 不是万能银弹,但在“大量设备、高频采集、时间维度查询、数据有生命周期”这类场景里,它确实是比 MySQL 合适得多的选择。如果重新来一次,我会先花更多时间把测点字典、标签字典、保留策略梳理清楚,再决定建几个超级表,而不是边导数据边补模型。
最后再提醒一句:碰到这类迁移,别急着停掉旧系统。让新旧两套系统并行跑一段时间,用真实流量去验证数据一致性,比任何测试环境里的演练都可靠。等你自己确认新库确实稳了,再去斩断旧库也不算迟。