首页 / 资讯中心 / 文章详情

从关系库到TDengine:时序数据建库建表与写入查询实践

从关系库到TDengine:时序数据建库建表与写入查询实践 ★ FEATURED ARTICLE
1. 从关系库迁到 TDengine我为什么做这个决定TDengine 时序数据库这个名字我第一次接触是在一个设备数据采集项目里。当时整套系统跑在 MySQL 上单表每天新增两千万行左右的设备读数三个月后查询开始肉眼可见地变慢磁盘也快撑不住了。那段时间我几乎每周都要去翻慢查询日志加索引、改分表、删历史数据折腾到最后发现关系型数据库处理这种写多读少、按时间聚合的数据本质上就是在用螺丝刀砸钉子——能干但特别费劲。后来我把这套采集链路整体换到了 TDengine 上同样的数据量写入吞吐上去了磁盘占用下来了按时间窗口做聚合查询的响应时间也从十几秒降到几百毫秒。这篇东西写给三类人看一类是手里正攒着海量设备数据、还没想好往哪存的人一类是已经用了时序数据库但只是当个能写进去的库在用、没真正发挥它能力的人还有一类是刚接手别人搭好的 TDengine 环境、需要快速搞清楚建库建表逻辑和常见故障排查的人。我不会讲太多底层存储引擎的论文式内容主要讲我实际怎么建库、怎么设计表结构、怎么写数据、怎么查数据以及中间踩过的那些坑。文中涉及的命令和参数都以 3.x 版本为准不同版本之间参数名和语法有差异你自己动手前最好先确认一下手头的版本号。1.1 时序数据把关系库压垮的那三个月先说说我遇到的具体场景这样后续讲设计取舍时你能对上号。这套系统的数据来源是几百台设备每台设备每隔几秒上报一条记录字段包括时间戳、电流、电压、功率、温度、运行状态等。听起来不多但设备数量乘上采集频率一天下来就是千万级。这些数据的访问特征非常鲜明几乎不更新、不删除单条永远是按时间追加查询基本都带时间范围而且大概率还要做聚合比如最近一小时每台设备的平均电流昨天全天每小时的最大功率。关系库在这种场景下有三个绕不过去的坎。第一是写入放大的问题每张表都要维护索引每插入一行 B 树都要调整数据量一大写入延迟就上来了。第二是单表容量几亿行之后即使有索引范围扫描的回表开销也不小。第三是存储成本关系库的行式存储加上索引同样一份数据占的空间通常是列式时序库的好几倍。我当时算过一笔账一年数据在 MySQL 上要占掉将近两个 T迁到 TDengine 上压缩后只用了不到三百个 G这个差距主要来自列式存储和针对时间戳的压缩算法。另一个容易被忽略的点是数据生命周期。设备数据一般只关心最近几个月更早的要么归档要么直接删掉。关系库删历史数据是个很痛的操作要么按时间分区然后 DROP PARTITION要么写个批量删除脚本慢慢跑前者要求你提前设计好分区后者会带来大量碎片。时序库一般把这件事做成了内建能力建库时指定保留时长过期数据自动清理这对运维来说省心太多了。1.2 选型时我比较过的三条路当时摆在面前的选择其实不止一个我大概比较了三类方案最后才落到 TDengine 上。把这些对比写出来是因为我见过太多人一拍脑袋选了个库用半年发现不合适再迁移成本比一开始多花两周调研高得多。方案优势主要顾虑我的判断关系库继续分表团队熟悉生态成熟写入延迟随数据量线性变差存储成本高只能续命半年后还得再迁通用分布式 KV写入吞吐高扩展方便聚合查询要自己实现开发量大适合做底层不适合直接做业务查询专用时序数据库写入、压缩、时间聚合都是原生能力生态相对小SQL 方言有差异场景高度匹配值得投入学习成本在专用时序库里我重点看了三件事一是写入模型支不支持批量写入、支不支持无模式写入这决定了采集端改造的工作量二是查询语言是不是类 SQL如果是一门全新的查询语言团队的学习曲线会很陡三是部署运维单机能不能跑起来集群扩容是不是平滑有没有配套的导入导出和压测工具。TDengine 在这三点上给出的答案是批量 SQL 和行协议都支持、查询基本是标准 SQL 加时间窗口函数、单机一个进程就能起、集群按节点水平扩展、自带数据导出和压测工具。对一个不想在基础设施上投入太多人力的团队来说这套组合是比较务实的。注意选型时不要只看官方 benchmark 的峰值数字那些数字往往是在最优批次、最优硬件、最简表结构下跑出来的。真正该看的是你的数据模型下、你的批次大小下、你的查询模式下它的表现。有条件的话拿自己业务的一份真实数据做一次小规模压测比看十篇评测文章都管用。1.3 动手前先把数据模型想清楚我见过不少团队上来就建库建表跑了两周发现标签设计不合理想改就得重建整个超级表数据还得重新灌一遍。所以在动手之前我花了大概两天时间把数据模型捋了一遍。核心问题是三个哪些字段是采集值哪些字段是设备的静态属性设备的层级关系怎么表达未来会不会有新的设备类型接入。采集值是随时间变化的量比如电流、电压、温度它们构成表的列。静态属性是设备一旦注册就基本不变的信息比如设备所属的场站、设备型号、安装位置、分组编号这些在 TDengine 里就是标签。这个划分非常关键因为标签的取值组合决定了子表的数量而子表的划分方式直接影响到查询时能不能高效地按设备维度过滤。我当时犯过一个错把设备型号这种只有五六种取值的字段做成了列结果每行都存一遍白白浪费空间查询时还得 GROUP BY 去重改成标签之后存储和查询都清爽了。层级关系这块TDengine 用的是超级表 标签的扁平模型没有真正的嵌套结构。如果你的设备是场站—机组—传感器三层那就在标签里把这三层的标识都放进去查询时用 WHERE 组合过滤。这种扁平化设计看起来简陋但在实际使用中反而更灵活因为你可以按任意标签组合去聚合不受层级约束。2. 部署与建库建表地基打不牢后面全是坑2.1 单机还是集群先看数据量和并发写入部署形态这件事我的建议是先单机跑通业务再考虑集群。听起来像废话但确实有很多人一上来就要搭三节点集群结果连单机的写入参数都没调明白集群一出问题就抓瞎。单机版安装非常简单下载对应平台的安装包解开之后执行安装脚本服务端进程就会以系统服务的方式跑起来默认监听 6030 端口给客户端连接6041 端口提供 REST 接口和行协议写入。装完之后用命令行客户端连上去能看到版本号和服务器状态就说明通了。什么时候该上集群我给自己的判断标准是两条同时满足单机写入已经接近磁盘 IO 瓶颈或者单节点的数据量超过了单盘容量规划。如果只是查询慢很多时候调索引、调缓存、优化 SQL 就能解决不必急着上集群。集群部署的复杂度主要在网络配置和数据副本上节点之间要能互相通信副本数设置要考虑一致性和可用性的平衡这些在单机阶段完全不用操心。需要提醒的是磁盘类型的选择。时序数据写入是顺序追加为主但合并、查询、排序都会产生随机读所以我一般会用 SSD 或者 NVMe 做数据盘机械盘虽然便宜但合并阶段的延迟会明显上升。另外数据目录和日志目录最好分开避免日志刷盘影响数据写入。2.2 建库语句里每个参数都在花钱建库是整个系统里最重要的一个动作因为库级参数一旦定下来后续调整虽然可以但有些参数改了之后只对新数据生效老数据不会重算。我用的建库语句大概是这个样子CREATE DATABASE sensor_data KEEP 365 DURATION 10 PRECISION ms BUFFER 256 CACHEMODEL last_row WAL_LEVEL 1 MINROWS 100 MAXROWS 4096;逐条说下我为什么这么设。KEEP 365是数据保留天数超过一年的数据自动删除。这个值要结合业务来定有些合规场景要求保留三年那就写 1095但要注意存储成本会翻三倍。DURATION 10是数据文件的分组时长单位是天意思是每 10 天的数据放在一组文件里。这个参数直接影响到按时间范围查询的效率——如果你的查询经常跨月DURATION 设得太小会导致要扫描很多文件组如果查询基本只看最近几天DURATION 小一点反而利于过期清理。我一般按最常用的查询时间跨度来设比如业务上主要看最近一周就设 7 或者 10。PRECISION ms是时间戳精度可选毫秒、微秒、纳秒。采集端如果只到秒级其实用默认的毫秒就够了没必要上微秒精度越高时间戳占的存储空间越大。BUFFER 256是每个虚拟节点组的写入缓存大小单位 MB这个值影响写入吞吐内存充裕的话可以往上调但要注意总内存占用是 BUFFER 乘以虚拟节点数。CACHEMODEL last_row是缓存每张子表最新一行这个对查设备当前状态这类查询特别有用能从缓存直接返回而不扫盘。WAL_LEVEL 1是预写日志级别1 表示只写 WAL 不强制落盘写入性能最好代价是极端情况下可能丢少量数据如果是金融、计费这类不能丢数据的场景就调到 2。MINROWS和MAXROWS控制的是数据块的行数区间这两个参数比较微妙。块太小元数据开销大块太大查询时扫描的无效数据多。默认值在很多场景下够用我一般在写入量大、查询范围窄的场景下把 MAXROWS 调小一点让块更细粒度。注意KEEP和DURATION是配合使用的DURATION 不能大于 KEEP。曾经有个同事把 DURATION 设成了 30 而 KEEP 设成了 7建库直接报错排查了半天才发现是参数约束问题。2.3 超级表、子表、标签到底怎么划分TDengine 的数据模型是三层库、超级表、子表。超级表定义的是同一类设备的列结构也就是所有子表共享的采集字段子表对应具体的一台设备通过标签区分彼此。我建表的时候是这么写的CREATE STABLE meters ( ts TIMESTAMP, current FLOAT, voltage INT, power FLOAT, temperature FLOAT ) TAGS ( station_id INT, device_type VARCHAR(16), location VARCHAR(32) );建完超级表之后不需要预先创建子表写入时带上标签就会自动建。比如INSERT INTO dev_1001 USING meters TAGS (3, meter, north-hall) VALUES (2024-05-01 10:00:00.000, 10.32, 220, 2270.4, 36.5);这条语句的意思是往dev_1001这张子表写一行如果子表不存在就用meters的结构和给定标签创建它。这个自动建表的能力非常省事采集端不用维护一份设备清单再去建表新设备接入只要开始上报数据就行。关于标签怎么划分我的经验是标签数量控制在五个以内每个标签的取值基数不要太高。标签太多会让元数据膨胀取值基数太高比如把设备编号做成标签则失去了分组聚合的意义因为每个子表的标签组合都不一样按标签聚合等同于按子表聚合。比较合理的标签是场站、设备类型、区域这类维度取值数量在几十到几百之间。还有一点容易被忽略超级表不是必须的。如果你的数据只有一类设备、字段完全统一直接用普通表也能跑。但一旦有多类设备超级表的价值就出来了——一次查询可以跨所有子表做聚合比如统计所有场站过去一小时的总功率不用写 UNION。所以我现在的习惯是只要预估未来会接入第二种设备类型一律用超级表。3. 数据写入三条路子和我踩过的批次坑3.1 SQL 批量写入的正确姿势写入方式的选择取决于采集端的形态。如果是 Java 或者 Go 写的采集服务一般用官方连接器如果是 Python 脚本或者临时导入用 REST 接口或者命令行更顺手。不管用哪种核心原则只有一个一定要批量写千万别一条一条插入。我做过对比测试同样一百万行数据单条插入耗时接近二十分钟改成每批五百行之后降到了不到一分钟差距全靠网络往返和事务开销。SQL 批量写入的写法是在一条 INSERT 里跟多个 VALUES 组INSERT INTO dev_1001 VALUES (2024-05-01 10:00:00.000, 10.32, 220, 2270.4, 36.5) (2024-05-01 10:00:05.000, 10.35, 221, 2287.4, 36.6) (2024-05-01 10:00:10.000, 10.29, 219, 2253.5, 36.4);如果一次要写多台设备可以把多条 INSERT 拼在一条语句里用子表名分隔INSERT INTO dev_1001 VALUES (2024-05-01 10:00:00.000, 10.32, 220, 2270.4, 36.5) dev_1002 VALUES (2024-05-01 10:00:00.000, 8.71, 219, 1907.5, 35.8) dev_1003 VALUES (2024-05-01 10:00:00.000, 12.05, 222, 2675.1, 37.1);用连接器的时候更推荐参数绑定加批处理的方式。以 Python 的 REST 连接器为例大致流程是建连接、准备语句、循环填参、攒够一批执行一次import taosrest conn taosrest.connect(urlhttp://127.0.0.1:6041, userroot, passwordyour_password) cursor conn.cursor() batch [] for row in generate_rows(): batch.append(row) if len(batch) 500: sql INSERT INTO ? USING meters TAGS (?, ?, ?) VALUES (?, ?, ?, ?, ?) cursor.executemany(sql, batch) batch.clear()参数绑定的好处是省掉了 SQL 字符串拼接的开销也避免了时间戳格式转义的问题。不过要注意绑定模式下每条记录都要带上标签批次内的标签组合最好一致否则会自动拆成多条语句执行。3.2 行协议与无模式写入有些场景下采集端不方便拼 SQL比如设备端的固件只能发简单的文本报文或者你手上有一堆用其他时序库行协议写的旧代码。这时候可以用行协议接口它的格式是这样的curl -u root:your_password \ -d meters,station_id3,device_typemeter,locationnorth-hall current10.32,voltage220,power2270.4 1714564800000 \ http://127.0.0.1:6041/influxdb/v1/ib/write前面是测量名和标签中间是字段最后是毫秒时间戳。这个接口的好处是无模式第一次写入时会自动根据字段类型建表后续字段增减也能自适应。但它也有代价自动推断类型有时候会推错比如某个字段一直是整数某天突然来了个小数就可能出现类型不匹配的问题另外无模式写入的性能比预建表要低一些因为每次都要做元数据检查。我的做法是核心业务表预建边缘设备用无模式兜底。主链路的数据采集用预建超级表加批量 SQL保证性能和类型确定一些临时接入的第三方设备或者调试阶段的数据走行协议快速落地。这样既不牺牲主链路的性能也不至于为了接一个新设备就改一次表结构。注意行协议的时间戳默认按毫秒解析如果你的采集端给的是秒级时间戳记得在建库时把精度设对或者在报文里做单位换算。我曾经因为这个问题导致所有数据都落在 1970 年附近查了半天才发现是时间戳单位搞错了。3.3 批次大小、并发度和写入顺序的实测批次大小不是越大越好。我实测下来单条 SQL 的批次在 300 到 1000 行之间比较合适太小了网络往返占比高太大了单条语句解析和内存占用会上升而且失败重试的成本变高。具体设多少要看单行数据的大小字段多、字符串长的表批次就往下压字段少、全是数值的表批次可以往上提。并发度方面我一般按写入线程数不超过虚拟节点组数来配。单机默认是一个虚拟节点组起太多写入线程反而会因为锁竞争变慢。有一个简单的方法可以找到合适的并发数从单线程开始逐步加倍观察每秒写入行数的变化当加倍后吞吐不再明显提升甚至下降时就说明到瓶颈了。写入顺序上有一个细节值得说同一张子表的数据尽量按时间顺序写。TDengine 对乱序数据是有处理能力的但乱序写入会触发额外的合并操作写入放大的倍数会上升。如果采集端本身就有乱序比如多路数据汇聚到一个写入队列可以在写入前做一次按时间排序的缓冲代价不大收益不小。4. 查询与计算把时序数据真正用起来4.1 时间窗口聚合与降采样时序数据最常见的查询就是降采样也就是把秒级数据聚合成分钟级、小时级。TDengine 提供的INTERVAL子句就是干这个的配合_wstart、_wend能看到窗口的起止时间SELECT _wstart AS window_start, AVG(current) AS avg_current, MAX(voltage) AS max_voltage, COUNT(*) AS sample_count FROM meters WHERE ts NOW - 1h PARTITION BY tbname INTERVAL(1m);这条查询的意思是取最近一小时的数据按子表分组每分钟一个窗口算出平均电流、最大电压和采样点数。PARTITION BY tbname保证每台设备单独出结果如果不需要区分设备去掉这个子句就是全局聚合。INTERVAL还有一个兄弟叫SLIDING用来做滑动窗口。比如每 30 秒算一次过去 5 分钟的平均值就写成INTERVAL(5m) SLIDING(30s)。滑动窗口在计算移动平均、检测趋势突变时特别好用但要注意窗口重叠会带来额外的计算量滑动步长越小、窗口越长计算开销越大。还有一个函数我经常用就是FILL。时间窗口聚合有个天然问题如果某个窗口内没有数据默认就不输出这一行导致前端画图时曲线断掉。加上FILL(PREV)可以用前一个窗口的值填充FILL(VALUE, 0)填零FILL(LINEAR)做线性插值。设备离线导致的数据空洞用FILL(NULL)明确标记出来反而比强行插值更诚实这点在做数据质量分析时很重要。4.2 连续查询与流式计算如果某个降采样结果是高频使用的比如前端大屏每分钟都要拉一次过去 24 小时每小时的均值每次都实时算一遍其实很浪费。TDengine 提供了流式计算的能力可以把聚合结果持续写入另一张表CREATE STREAM hourly_stat INTO hourly_meters AS SELECT _wstart AS ts, tbname AS device_name, AVG(current) AS avg_current, MAX(power) AS max_power FROM meters PARTITION BY tbname INTERVAL(1h);建好之后新写入的数据会自动触发计算结果落到hourly_meters表里。前端查大屏的时候直接查这张结果表响应时间从秒级降到毫秒级。这个机制本质上是用写入时的少量计算换查询时的大量计算对于读多写少的报表场景非常划算。流计算的坑主要在重启和补数据上。如果流任务停了几个小时再启动中间的数据能不能补算取决于流任务的配置和版本行为。我的做法是在创建流的时候明确指定触发方式并且对结果表保留一段时间的原始数据万一流算错了还能回溯重算。另外结果表的 KEEP 设置要跟流任务的保留策略对齐否则可能出现流还在跑但结果表数据被清掉的情况。4.3 数据生命周期管理数据保留这件事在建库时通过KEEP定了大概但运行过程中可能需要调整。比如业务上突然要求历史数据保留两年那就得改库参数ALTER DATABASE sensor_data KEEP 730;这个改动只影响后续的数据清理策略已经删掉的数据找不回来所以调整前最好先确认一下归档有没有做。我现在的标准做法是双保险库上设 KEEP 做自动清理同时定期用导出工具把冷数据备份到对象存储。导出工具支持按时间范围导出我一般按月导命名带上年月需要回溯的时候再按需导入。还有一个小技巧是分区表的手动清理。TDengine 的数据文件是按时间分组的如果某一批数据确认是脏数据需要立刻删掉可以用DELETE FROM按时间条件删但要注意这个操作会重写数据文件数据量大的时候比较慢最好在业务低峰期做。日常的过期清理交给 KEEP 就够了不要频繁手动删。5. 排障实录那些年我对着日志发呆的夜晚5.1 连接、认证与端口问题刚接触的时候最容易卡在连接上。第一个要分清的是端口6030 是客户端原生连接用的6041 是 REST 和行协议用的两个端口都要在防火墙放行。我遇到过用 REST 连接器去连 6030 的情况报错信息不太直观排查了半天才反应过来是端口搞错了。第二个是认证。默认用户名是 root初始密码在安装时设定如果忘了可以用命令行工具重置。REST 接口的认证信息放在请求头或者 URL 里用 curl 测试的时候记得带上-u参数。有一个容易忽略的点是密码里包含特殊字符拼在 URL 里会被转义导致认证失败这种情况建议改用请求头传认证信息。第三个是版本兼容。客户端连接器的版本和服务端版本差太多的时候可能出现协议不匹配。我的习惯是服务端和客户端连接器尽量用同一个大版本升级的时候先升服务端再升客户端中间留一段观察期。现象可能原因排查动作连接超时端口未放行或服务未启动检查服务进程状态用 telnet 测端口连通性认证失败密码错误或特殊字符转义改用请求头传认证用命令行工具验证密码连接被重置版本协议不匹配核对服务端与客户端连接器版本时断时续网络质量或连接池配置不当检查连接池最大连接数和空闲回收时间5.2 写入异常与时间戳相关的坑写入报错里最常见的是表不存在和类型不匹配。表不存在一般发生在没有用USING子句指定超级表、又直接往一个不存在的子表写数据的时候。类型不匹配则多出现在行协议场景自动推断的类型和实际值冲突。解决方式是预建超级表把字段类型明确下来行协议只作为辅助通道。时间戳的问题更隐蔽。有一次我发现查出来的数据总是少一截排查下来是采集端用的是本地时间字符串而服务端按 UTC 解析两边差了八小时。后来统一改成传毫秒时间戳问题消失。还有一次是精度设置的问题建库时用了毫秒精度但采集端给的是微秒时间戳结果所有数据被截断到毫秒同一毫秒内的多条数据互相覆盖。这类问题的排查方法是随机抽几条原始报文手工换算时间戳跟库里查出来的值对一下很快就能定位。注意时间戳是时序库的命门写入前一定要确认三件事——单位是秒、毫秒还是微秒时区是本地还是 UTC精度和建库时设置的是否一致。这三件事任何一件对不上数据就是错的而且错得很安静不会报错。5.3 查询变慢与资源占用的排查查询变慢的原因通常有三类扫描的数据量太大、聚合计算太重、结果集太大。第一类最典型的表现是查询没带时间条件或者时间范围太宽。我见过有人写了个不带 WHERE 的 COUNT 查询直接把整库扫了一遍机器差点没扛住。时序库的查询一定要带时间范围这是铁律。第二类是聚合计算太重。比如在千万级数据上做COUNT(DISTINCT)或者用了太多嵌套函数。优化思路是先降采样再计算用INTERVAL把数据先聚到分钟级再在外层做二次聚合。另外PARTITION BY的维度不要太细按几千个子表分区计算中间结果的内存占用会很可观。第三类是结果集太大。有人一次查出几十万行明细往前端推前端渲染卡死还以为是数据库慢。这种场景应该在后端做分页或者只返回聚合结果。TDengine 支持LIMIT和OFFSET但要注意深分页的效率问题更好的做法是用时间游标翻页。资源占用方面内存是最容易出问题的。建库时的BUFFER乘以虚拟节点数就是写入缓存的基本开销再加上查询时的中间结果内存如果机器内存不够就会出现频繁换页表现为整体变慢。我一般会留出至少百分之三十的余量并且监控写入缓存的使用率和查询的内存峰值接近上限就提前扩容或者调整参数。6. 几条不怎么写在文档里的经验6.1 关于批次、并发和压测的实践建议压测这件事我强烈建议在上线前做一次哪怕规模很小。用官方的压测工具造一批模拟数据跑一遍写入和查询你能提前知道这套配置大概能扛多少。压测的时候要注意两点模拟数据的分布要接近真实比如子表数量、单表行数、标签基数都要对上否则压出来的数字没有参考价值查询也要压很多人只压写入不压查询结果上线之后写入没问题查询把 CPU 打满了。批次和并发的调优没有万能公式我的方法是固定其他变量单变量扫描。先固定并发为一批次从一百开始翻倍记录吞吐找到吞吐拐点之后固定批次并发从一翻倍再找拐点。整个过程大概半小时比凭感觉设参数可靠得多。6.2 表结构演进时怎么少走弯路业务是会变的设备类型会增加采集字段会增减这些都是常态。TDengine 支持给超级表增加列语法是ALTER STABLE ... ADD COLUMN但已有的子表不会自动补这一列新写入的数据会带新列老数据该列为空。这个行为本身没问题但如果你在查询里用了SELECT *不同子表返回的列数可能不一致前端解析就会出错。所以我的习惯是查询永远显式列出字段不用星号。标签的修改要更谨慎。标签值可以更新但标签的结构数量和类型改动代价很大基本等同于重建超级表。所以第一次设计标签的时候宁可多留一个冗余字段也不要在上线三个月后再加。如果确实要加稳妥的做法是建一张新的超级表用INSERT INTO ... SELECT把老数据迁过去再切换写入和查询中间留一段双写过渡期。这套东西用下来我最大的感受是时序数据库的价值不在于它能存多少数据而在于它把按时间聚合这件事变成了原生能力省掉的是你在应用层反复造轮子的时间。但前提是你得把数据模型设计对、把写入方式用对否则它就是个写入快一点的普通库该慢的地方还是慢。我见过有人把 TDengine 当成关系库用每个字段都做成标签最后子表数量爆炸查询慢得离谱然后得出结论说这时序库不行——问题从来不在库在用法。
阅读完成 · 觉得有帮助?
咨询建站