ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从MySQL迁移到TDengine:工业时序数据场景的完整实践与性能优化

从MySQL迁移到TDengine:工业时序数据场景的完整实践与性能优化 去年接了一个挺折腾的活儿把一家大型工厂的数据库从 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跑一段双写观察期通过校验任务确认数据一致再把读流量切换到 TDengineMySQL 归档只读这套方案的好处是每走一步都有验证出问题可以随时回退不会出现“切了之后发现数据对不上又不知道问题出在哪”的情况。3.2 全量导出的实操细节全量导出的本质是“读 MySQL、写 TDengine、中间完成数据格式转换”。我们当时的做法是按 MySQL 表的主键范围分批读取数据避免一次性 loading 太多数据导致源库出问题每批数据在内存中转换时间格式、字段类型通过 RESTful 接口或原生连接写入 TDengine采用批量提交每批 5000 到 1 万条记录每批读取的最大时间戳作为增量同步的起点这里给一个简化的 Python 伪代码思路可以参考import pymysql from taos import TaosConnection # 连接 MySQL src pymysql.connect( hostmysql-host, userroot, password***, databasefactory, charsetutf8mb4, cursorclasspymysql.cursors.DictCursor ) # 连接 TDengine dest TaosConnection(hosttdengine-host, userroot, passwordtaosdata) # 分批读取加入 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 万条/minCPU/IO 接近极限峰值约 150 万条/minCPU 还有余量查询某设备一周温度曲线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 时间戳所有转换代码里明确指定timezoneUTC查询展示层再按业务时区做转换。这个坑必须是全局约定任何一块代码都不能例外。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 合适得多的选择。如果重新来一次我会先花更多时间把测点字典、标签字典、保留策略梳理清楚再决定建几个超级表而不是边导数据边补模型。最后再提醒一句碰到这类迁移别急着停掉旧系统。让新旧两套系统并行跑一段时间用真实流量去验证数据一致性比任何测试环境里的演练都可靠。等你自己确认新库确实稳了再去斩断旧库也不算迟。
返回列表