一台设备、两台设备用 MySQL 存数据可能毫无感觉。等设备数量过了千级慢查询、磁盘告警、主从延迟会接二连三地出现最后你发现最大的瓶颈不是应用代码而是当初“用 MySQL 存设备采集数据”这个决定。这篇文章就从这个场景切入把设备接入量上来之后时序库选型这件事重新捋一遍。想先给出一个判断MySQL 本身不是不能用但设备上报数据本质上是时序数据和订单、用户这类事务型数据有完全不同的写入和查询特征。把它当成普通业务表设计几千台设备就拖垮数据库其实是必然结果。这篇文章会先从根因讲起再说明时序数据库在存储、压缩、查询和生命周期上到底改了什么然后给出主流时序库的选型对比最后走一遍从 MySQL 迁到时序库的落地路径和常见坑。1. 为什么几千台设备就能拖垮 MySQL1.1 设备上报数据的模型和业务数据完全不同设备数据有几个非常明显的特征持续追加设备按周期上报数据只增不减几乎没有“更新某一条历史数据”的需求。时间有序读多集中在最近几小时、最近几天查询几乎总是带时间范围。高频聚合业务经常要看“过去 5 分钟平均电压”“今天峰值电流”“某台设备最近 24 小时曲线”。生命周期明确很久以前的数据大概率没人再查但 MySQL 不会自动淘汰表只会越来越胖。拿一个保守的例子来算。假设有 3000 台设备每 30 秒上报一次每次上报一条记录一天的数据量是86400 / 30 * 3000 8,640,000一天 864 万行一年就是 31 亿行。如果设备内部还有多个测点一条上报拆成多行写数据量直接翻几倍。这个量级对 MySQL 并不是天文数字真正的问题在于MySQL 表越大写入和查询的互相干扰越严重最后形成恶性循环。1.2 MySQL 在大表场景下的三座大山第一座山是索引膨胀。InnoDB 每次插入不仅要写聚簇索引还要同步维护所有二级索引。为了支撑“按设备查”“按时间查”“按站点查”你往往建了多个索引每条写入都被放大好几倍。设备点数越多、上报越频繁写入放大越明显。第二座山是查询退化。设备数据查询几乎总是“某个设备 某个时间范围 统计聚合”这在 MySQL 里通常要写长 SQLSELECT FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(ts) / 300) * 300) AS bucket, AVG(voltage) AS avg_voltage, MAX(current_m) AS max_current FROM device_reading WHERE device_id dev_0001 AND ts 2024-01-01 00:00:00 GROUP BY bucket ORDER BY bucket;功能上没问题但时间窗口聚合要自己用函数算分钟级、小时级聚合在几十亿行的大表上扫起来非常痛苦加上设备维度过滤SQL 很快就变成全表和半表扫描。第三座山是数据清理困难。时序数据留存到一定时间后必须淘汰但 MySQL 里删除上亿行历史数据是灾难级的操作大量 binlog、undolog、主从复制延迟删除完还有碎片问题。很多人最后不敢删于是表越堆越大最终拖垮磁盘和备份。1.3 真正的问题数据模型从一开始就选错了MySQL 为事务型业务设计表结构面向“当前状态”和“频繁更新”而设备数据是“事件流”在时间轴上不断追加查询也围绕时间窗口展开。拿事务型数据库存储事件流就像用衣柜装粮食不是衣柜不好而是用错了地方。格式上可以这样对比维度事务型业务数据设备时序数据写入增删改都有几乎只追加不改老数据查询按主键/业务条件精确查按设备、按时间范围聚合存储增长相对可控只增不减规模大冷热数据无明显冷热旧数据长期无人访问生命周期在线使用需要按时间自动淘汰当你意识到设备数据是时序数据选型思路马上就清晰了应该找专门为“时间戳 标签 字段”设计的存储而不是继续在关系型数据库里硬扛。2. 时序数据库到底解决了什么2.1 时序数据的基本模型几乎所有时序数据库都是同一个抽象模型时间戳记录数据产生的时间。标签 / Tag描述数据来源比如设备 ID、站点 ID、型号标签用于过滤会建索引。字段 / Field实际数值比如电压、电流、功率字段一般不做索引。设备数据写成这个模型业务语义更自然我要查“站点 1001 下设备 dev_0001 在过去 1 小时的电压均值”就是过滤site_id1001、device_iddev_0001再按时间窗口聚合。2.2 存储引擎层面的优化时序库针对“只追加 时间范围查询”做了几件关键事。一是有序追加写。数据按时间到达时序库把写入设计成追加式顺序写配合 WAL 和类 LSM 结构写入路径比随机更新的关系型数据库高效得多。二是列式存储 时序编码压缩。设备数据的相邻记录往往数值接近时间戳也规律递增。列式存储可以分别对时间列、设备标签列、数值列做编码压缩比如时间戳增量编码、浮点 Gorilla 压缩。压缩率高磁盘占用和 IO 压力一起降下来。三是时间分区。时序库通常按天、按小时把数据切到不同分区或存储文件查询时自动裁剪掉无关分区。清理历史数据也从“DELETE 上亿行”变成“直接丢弃最早的分区”成本完全不是一个量级。四是标签索引分离。时序库只为标签建索引字段值不建索引查询时先通过标签定位设备集合再扫描时间范围索引不会无限膨胀。2.3 查询模型和生命周期管理时序库把“按时间窗口聚合”做成了原生的查询能力。同样是查过去 24 小时每 5 分钟的电压均值TimescaleDB 写作SELECT time_bucket(5 minutes, ts) AS bucket, device_id, AVG(voltage) AS avg_voltage FROM device_metrics WHERE ts now() - interval 1 day GROUP BY bucket, device_id ORDER BY bucket;TDengine 写作SELECT _wstart, AVG(voltage) FROM meter WHERE ts now - 1d INTERVAL(5m);传统 MySQL 要手写时间桶函数时序库直接内置窗口语义查询代码的维护成本低很多。生命周期管理同样重要。时序库普遍支持保留策略和降采样规则数据过了留存期自动删除或者被聚合成小时级、天级数据后再存储。对几千台设备的场景来说这几乎是刚需。3. 选型前先搞清楚这五件事选型之前不要先看榜单先回答下面五个问题。第一写入规模和峰值。要分清“几千台设备”只是设备数量选型看的是每秒写入点数。每秒点数 设备数 × 每台设备测点数 × 上报频率。另外要问峰值设备重启、批量升级、重新连网时会不会集中上报峰值吞吐决定了集群规模和是否能接受单机方案。第二真实查询模式。需要明确是查最近 5 分钟看板还是查一年前的历史趋势是按设备查还是按站点聚合查是精确取原始点还是只要分钟级降采样结果查询模式直接决定要不要选有分布式和冷热分层能力的库。第三数据留存时间。原始数据保留几天降采样后保留几年有没有审计类需求要求原始数据不能丢如果要把原始数据长期归档还需要考虑冷存储方案而不只是时序库本身的容量。第四部署环境和团队经验。公司能不能上云生产环境是 K8s 还是裸机有没有专职 DBA团队是否熟悉 SQL如果一个库只要写 SQL 就能上手另一个需要学新查询语言这个差异在实战中影响很大。第五成本和许可证边界。开源版本、商业版本、集群版是否收费、许可证限制是什么都需要提前确认。不要等数据起来后再因为版本边界被动迁移。4. 主流时序库梳理与选型判断4.1 InfluxDB生态完善验证场景最方便InfluxDB 是很多团队最早接触的时序库。它有完整的写入协议、查询语法、保留策略和连续查询能力文档多社区教程多用来做快速验证很顺手。数据模型为“measurement tag field”写入通过 line protocol。InfluxDB 单机版适合中小规模大规模分布式场景需要评估企业版或 3.x 新架构。另外 InfluxDB 有自己的一套查询语言体系和 SQL 不完全一样对习惯 MySQL 的团队有一点学习成本。4.2 TDengine物联网高频写入场景更对口TDengine 的定位更偏向物联网、工业采集、车联网这类“设备上报”场景核心设计处理写入吞吐和存储压缩。它保留 SQL 语法并提供超级表模型同一类设备共用一张超级表每台设备一张子表标签和字段分离。这个模型和设备数据结构贴合度很高从 MySQL 迁移过来的团队上手相对快。需要留意的是TDengine 的建库参数、查询函数在不同版本之间有过调整生产环境要按官方文档核对版本细节。表结构设计也讲究不是把它当成 MySQL 照样建表就能完全发挥性能。4.3 TimescaleDB能保留 PostgreSQL 心智的扩展方案TimescaleDB 不是独立数据库而是 PostgreSQL 的扩展。它把普通表转成 hyper 表自动按时间分区同时保留 PostgreSQL 的完整 SQL、事务、JOIN 能力。如果团队已经有 PG 经验TimescaleDB 能大幅降低迁移门槛很多原本复杂的业务查询可以原样保留。代价是它本质上还是在 PostgreSQL 体系内优化性能上限和资源开销取决于 PG 的部署方式需要主动开启压缩和数据保留策略否则优势体现不出来。4.4 Prometheus VictoriaMetrics适合监控指标不适合业务设备数据Prometheus 的模型是“指标名 标签”配合采集器拉取服务指标是现代监控体系的主流选择。VictoriaMetrics 作为存储后端在写入和压缩上做了很多优化。但要区分设备业务数据和监控指标不是一回事。监控指标关心服务在线状态、CPU 使用率、请求延迟数据由采集任务周期性拉取。设备上报数据往往要落地到业务系统参与计费、告警、报表甚至后续分析强套 Prometheus 模型会非常别扭标签基数和高基数问题是常见坑。4.5 IoTDB工业场景有专门设计IoTDB 是面向工业物联网场景的时序数据库对复杂测点、数据导入导出、TsFile 格式有专门设计适合工厂、能源、装备这类对采集链路和文件交换有要求的场景。资料和社区规模相对小一些学习曲线偏高但如果你就是在工业采集场景里选型它是值得认真评估的对象。4.6 ClickHouse不是时序库但常用于时序分析ClickHouse 本质是分析型数据库列式存储 高压缩率 优秀的聚合性能让它经常出现在时序分析场景。但它不是为“设备高频点写入”设计的写入后的数据可见性和合并机制需要结合工程经验处理。很多团队的架构其实是双轨时序库负责实时采集和近实时查询ClickHouse 或数据湖负责大规模历史分析。不要指望一个组件解决所有问题。4.7 常见选型思路小结产品核心定位数据模型适合场景要注意的点InfluxDB通用时序库measurement tag field中小规模、快速验证集群与授权边界TDengine物联网时序库SQL 超级表设备上报、工业采集版本差异、表设计TimescaleDBPostgreSQL 时序扩展hypertable已有 PG 体系的团队需主动开启压缩和保留策略Prometheus VictoriaMetrics监控指标存储指标名 标签线上监控不适合业务设备数据IoTDB工业时序库TsFile 文件组工业复杂采集社区和资料相对少ClickHouse分析型数据库列式表大规模历史分析不适合高频点写入从实际工程角度看最务实的一句话建议是如果数据来源是设备定期上报、查询以设备和时间窗口为主TDengine 和 TimescaleDB 是最值得优先做压测的如果还要保留完整关系型查询优先 TimescaleDB如果已经很明确只需监控指标Prometheus 体系就够。5. 从 MySQL 迁到时序库的落地路径5.1 迁移不是换连接串而是重建数据模型很多人以为迁移就是改数据源连接实际上必须重新设计表。以 TDengine 为例建库和建超级表可以这样写-- TDengine 3.x 示例不同版本参数名可能有差异请以官方文档为准 CREATE DATABASE iot_data KEEP 365 DURATION 10 BUFFER 64; USE iot_data; CREATE STABLE meter ( ts TIMESTAMP, voltage INT, current FLOAT, power FLOAT ) TAGS ( site_id INT, device_id NCHAR(32) );KEEP 365表示数据保留 365 天DURATION 10表示每 10 天一个存储分片。超级表meter定义了字段和时间戳站点和设备信息放在 TAGS 中作为查询过滤条件。写入单台设备时可以用自动建表语法INSERT INTO d_0001 USING meter TAGS (1, dev_0001) VALUES (now, 220, 3.2, 705.6);如果这台设备第一次出现TDengine 会按 TAGS 自动建子表这个设计非常适合“不同批次设备动态上线”的场景。5.2 TimescaleDB 的建表方式如果团队保留 PG 体系TimescaleDB 的迁移路径更平滑。先创建普通表再转成超表-- 文件路径init.sql CREATE EXTENSION IF NOT EXISTS timescaledb; CREATE TABLE device_metrics ( ts TIMESTAMPTZ NOT NULL, device_id TEXT NOT NULL, site_id TEXT, voltage DOUBLE PRECISION, current DOUBLE PRECISION, power DOUBLE PRECISION ); SELECT create_hypertable(device_metrics, ts); CREATE INDEX idx_device_ts ON device_metrics (device_id, ts DESC);create_hypertable会按ts自动分区之后的 SQL 还是标准 PostgreSQL业务层代码改动比换库小很多。5.3 写入代码改造用批量替代逐条无论选哪个时序库写入端都必须避免逐条插入。设备数据高频到达逐条 insert 会白白浪费大量网络和解析开销。推荐在应用层做缓冲攒一批后再写。如果是 InfluxDB单条写入格式如下device_metrics,site_id1001,device_iddev_0001 voltage220.5,current3.2,power705.6 1699999999999999999逗号前是标签空格后是字段时间戳默认纳秒。批量写入时把多行拼成一个请求体发送吞吐差距会非常大。如果使用 JDBC 写 TimescaleDB标准批量接口足够// 文件路径src/main/java/com/example/iot/TsdbWriter.java import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.Timestamp; import java.time.Instant; public class TsdbWriter { private static final String URL jdbc:postgresql://127.0.0.1:5432/iot; private static final String USER postgres; private static final String PASSWORD your_password; public void writeBatch() throws Exception { try (Connection conn DriverManager.getConnection(URL, USER, PASSWORD)) { String sql INSERT INTO device_metrics(ts, device_id, site_id, voltage, current, power) VALUES (?, ?, ?, ?, ?, ?); PreparedStatement ps conn.prepareStatement(sql); // 演示批量写入生产环境建议由队列按批聚合 for (int i 0; i 1000; i) { ps.setTimestamp(1, Timestamp.from(Instant.now())); ps.setString(2, dev_ (i % 100)); ps.setString(3, site_1); ps.setDouble(4, 220.5); ps.setDouble(5, 3.2); ps.setDouble(6, 705.6); ps.addBatch(); if (i % 500 499) { ps.executeBatch(); } } ps.executeBatch(); System.out.println(batch write ok); } } }代码里最关键是addBatch()executeBatch()配合连接池和异步队列写入吞吐会远好于一条一条 insert。5.4 历史数据回填与双写校验生产环境迁移建议分四步走。第一步新数据双写。应用同时写 MySQL 和时序库查询暂时仍然走 MySQL。双写阶段校验时序库是否可用但不影响线上查询。第二步读新库对比。跑定时任务对比 MySQL 和时序库的数据量与关键聚合结果。对比时注意排除最后一条写入的延迟窗口否则会因为缓冲区还没落库而出现短暂不一致。第三步切读。通过配置开关把查询切到新库保留回滚开关。发现异常可以立即切回 MySQL。第四步停写旧库。确认新库稳定后再停掉 MySQL 写入并把 MySQL 中的历史数据归档而不是马上粗暴 delete。历史数据回填时也要批量导入并注意数据校验。校验维度至少包括-- 对比某天数据量 SELECT count(*) FROM old_db.device_reading WHERE ts_day 2024-06-01; SELECT count(*) FROM new_db.device_metrics WHERE time_bucket(1 day, ts) 2024-06-01;再对比关键指标总量SELECT sum(power), count(*) FROM old_db.device_reading WHERE ts_day 2024-06-01; SELECT sum(power), count(*) FROM new_db.device_metrics WHERE time_bucket(1 day, ts) 2024-06-01;偏差超过阈值时不要切流先定位是时区问题、时间精度问题还是数据漏导。5.5 切流与回滚切流必须做成快速可回滚的开关比如配置中心里的读写标记。建议顺序是先切写、确认写入正常再切读。回滚窗口至少保留一周。任何切流操作都要提前备份不要在没有任何回滚方案的情况下直接删 MySQL 数据。6. 常见问题与排查思路问题现象可能原因排查方式解决方案应用连接时序库报错驱动版本不匹配、端口不对、认证失败查看应用日志、用客户端工具测试连接统一客户端驱动版本核对连接参数写入速率上不去逐条插入、无批量缓冲、连接池过小观察写入吞吐和网络包数应用层批量 flush增大连接池和队列缓冲查询突然变慢标签基数过高、时间范围过大、分区裁剪失效查看查询执行计划和慢日志限制时间范围优先按标签过滤优化表结构磁盘空间膨胀过快保留策略未生效、压缩未开启、字段模型设计冗余查看库表空间占用配置保留周期开启压缩重新设计字段数量迁移数据总对不上时区不一致、时间精度不同、重复写入未去重抽样对比原始记录统一用 UTC 存储统一时间精度增加幂等逻辑数据写完查不到写入缓冲未落盘、副本或合并延迟确认写入返回状态等待窗口合理设置批量 flush 周期避免查询刚写入的秒级窗口这里最常被忽略的是时区问题。MySQL 很多团队喜欢存本地时间但时序库通常建议统一存 UTC 或相对固定的时区否则跨时区设备、夏令时场景会导致聚合结果错乱。迁移前就约定时间精度和时区能省掉大量对账时间。7. 最佳实践与工程建议7.1 数据模型设计标签字段不要滥用。设备 ID、站点 ID、厂商、型号适合做标签电压、电流、功率、温度这类持续变化的数值适合做字段。不要把每个测点都拆成一个独立标签否则标签基数会失控。字段粒度也要设计好。如果一条上报包含 10 个测点可以拆成多个字段放同一行记录而不是一条记录只存一个字段。时序库对多字段的压缩效果通常优于多标签模型。7.2 写入链路设计写入端一定要批量缓冲建议至少攒到几百条或 1 秒刷一次再写。同时要用异步队列解耦采集端和存储端避免采集进程被写库阻塞。如果峰值流量非常大还要考虑在应用和时序库之间加一层消息队列削峰库跟不上时先落消息再异步消费。7.3 容量规划与监控上线前先做容量估算日均写入行数、单行大小、保留天数、压缩系数、副本数。按估算结果留至少 1.5 到 2 倍余量。监控项至少要覆盖写入速率、查询延迟、慢查询数、磁盘水位、分区数量、主从或副本同步延迟。务必提前配置好告警尤其是磁盘增长。时序库磁盘写满是运维事故里最常出现的一种一旦磁盘写满写入阻塞会立刻反压到采集链路。7.4 生产环境安全与升级数据库账号要遵循最小权限原则应用只分配读写所需权限不要用超级用户连接业务。升级时序库版本前要在测试环境先验证不同大版本的建库参数、查询函数、存储格式可能有变化不能直接沿用旧文档。生产环境的备份和恢复演练一定要做时序库数据量很大恢复方案不演练真到故障时会手忙脚乱。8. 最后说点实在的MySQL 不是罪魁祸首问题核心是数据模型和场景错配。设备数据是典型的时序数据就应当交给面向时序设计的存储来处理。几千台设备只是一个临界点设备接入量继续增长时选型思考的优先级会比具体产品更重要。我的建议是选定两三个候选时序库后用真实设备数据、真实查询模式和一周左右的数据量做小规模压测不要只看官方跑分。官方压测通常在理想硬件和理想模型下你的字段设计、批量大小、查询模式都会影响最终结果。如果在迁移前能把数据模型、保留策略和读写链路都设计好换数据库只是工程动作如果模型没理清换到哪个时序库都只是把 MySQL 的坑搬了一次家。先花时间把数据模型和查询模式摸清楚这是整个选型里最值得投入的环节。建议先把本文提到的五类问题对照自己的场景列一遍再拿着真实数据去跑一轮压测。等到设备数据真正涨起来之后你会感谢当初在选型阶段多花的这一天时间。