1. 为什么水务数据非得上时序数据库
做了八年多的水利水务信息化项目,说实话,数据量从来不是一开始就吓人的那种大,而是温水煮青蛙式地涨上来的。前年我们给嘉环科技做智慧水务平台的底层数据层改造,压力点、流量计、水质监测站、泵站设备一张张表叠进来,MySQL 从刚上线时的轻轻松松,到半年后慢查询日志里全是SELECT AVG(pressure) ... GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d %H:'),我就知道这架构差不多到头了。
1.1 先看一组把我们压垮的真实数据量
一个区县级水务项目,规模不算夸张,大概是这样:压力监测点 300 个,流量计 150 个,水质在线监测站 40 个,泵站和阀门状态点约 200 个。采集频率分别是压力点 1 分钟一次、流量计 15 分钟一次累计值、水质 1 小时一次、设备状态秒级心跳。
算下来一天多少条?
- 压力点:300 点位 × 1440 条/天 = 43.2 万条
- 流量计:150 × 96 = 1.44 万条
- 水质站:40 × 24 = 0.1 万条
- 设备状态:200 × 86400(秒级) = 1728 万条,这个量级我们后来直接做了预处理,只在状态变化时落一条,降到每天约 2 万条
- 加总:大约每天 45 万条,一年 1.6 亿条
MySQL 放进 1.6 亿行以后,单条按时间范围查询还能忍,但一旦涉及全点位聚合、同比环比、小时级降采样,查询延迟直接奔着十几秒去。更痛苦的是存储,同样的数据量,MySQL 要占将近 2TB,因为每行还得存主键索引、create_time 索引、device_id 索引。而时序数据库天生按列存储、按时间分区,同样的量在 TDengine 里做完压缩,连十分之一都不到。
1.2 时序数据难搞的三个特点
很多人觉得水务数据不复杂,无非是压力、流量、水位、水质。但这类数据有三个共性特点,正好是传统关系库最不擅长的地方:
第一,写入模式极其单一。全是按时间顺序追加,极少更新。你基本不会改一条昨天的压力记录,只会查它、聚合它。MySQL 的 B+ 树索引在这种场景下是负资产,每次写入都要随机维护多棵索引树,而 TDengine 的写入模型是顺序追加,写入吞吐量天然高一个量级。
第二,查询全是范围扫描加聚合。业务方要的从来不是“查某一条”,而是“这个测点过去 24 小时的平均压力”“这条管线所有点位的最高水位”“过去一个月每天的流量累计”。这类查询在 MySQL 里全靠扫索引回表,再在应用层写循环;TDengine 里一句INTERVAL(1h)就完成了降采样。
第三,数据生命周期明显。一周前的原始数据就没人再看了,但日报月报还得能出,所以需要自动降采样和过期清理。TTL 保留策略在 MySQL 里要自己写定时任务 DELETE,在 TDengine 里是数据库参数自带的能力。
1.3 时序数据库与传统关系库的核心差异
拿我们自己的对比测试来说,同样 1 亿条数据,同样查“最近 7 天每个监测点的小时均值”,结果差距不是一点半点。
| 对比项 | MySQL(InnoDB) | TDengine |
|---|---|---|
| 写入吞吐 | 约 5000 条/秒(单机) | 约 20 万+ 条/秒(单机) |
| 1 亿条占用空间 | 约 180GB | 约 10GB(压缩后) |
| 7 天小时均值查询 | 8~15 秒 | 毫秒到秒级 |
| 自动过期 | 无,需任务删除 | KEEP 参数控制 |
| 降采样 | 应用层实现 | 连续查询/流式计算 |
所以当嘉环科技那边提出来要“平台扛住五年数据、查询秒级返回”时,我几乎没有犹豫就把 TDengine 列进了方案。不是因为它最流行,而是这个场景它就是最顺手的工具。后面的事实也证明,整个数据层替换下来,最耗时的不是写入和查询,反而是模型的重新设计。
2. 嘉环智慧水务的整体链路:TDengine 到底负责哪一段
很多同行容易把时序数据库理解成“一个大容量 MySQL 替换品”,其实时序库在智慧水务项目里承担的是一个明确的职责边界,理解这个边界比学会建表重要得多。
2.1 从采集到应用的完整链路
嘉环科技这个智慧水务平台,数据链路分成五层:
终端感知层:压力变送器、电磁流量计、超声波液位计、在线水质仪、泵站工控 PLC。这些设备通过 4G/NB-IoT/有线网络把数据传到前置机。
接入汇聚层:前置采集服务(我们自己写的采集网关)负责协议解析。水务行业协议五花八门,MODBUS-RTU、DL/T 645、HJ 212 都有,这一层统一转成 JSON 格式,打上时间戳和设备编号标签。
数据处理层:Kafka 做缓冲削峰,数据清洗服务做异常值剔除(负压力、超量程数据直接丢弃或者标记),然后把有效数据落到 TDengine。
存储计算层:TDengine 负责所有时序数据的存储、降采样、连续查询。我们后来还把设备状态量的实时判断逻辑也挪到了 TDengine 的流计算里,不再单独部署一套流处理引擎。
应用呈现层:Web 端的大屏、移动端的巡检工单、数据报表系统,全部通过 REST API 从 TDengine 或者其他业务库里取数。
TDengine 的位置非常清晰:只要是带时间戳的、点位维度产生的测量值,一律进 TDengine;业务属性数据比如用户档案、工单、设备台账,仍然留在 MySQL/PostgreSQL。这个分工一开始就定死,后面才不会乱。
2.2 时序库在其中的职责边界
有的团队会把设备的配置信息也塞进时序库的标签里,我建议不要做过头。TDengine 的标签适合存查询维度的属性,比如测点编号、所属水厂、管径、安装位置;不适合存频繁变化的属性,比如设备维修状态、责任人、保养记录。标签在 TDengine 里是按值存储的,频繁修改会导致元数据重复写,而且历史数据的标签取值会被覆盖成最新值,这点我们后面踩过坑,会在第四节细讲。
另外,时序库不应该承担复杂关联查询。水务业务经常要查“某个片区的压力均值与这个片区同时段供水量的关系”,这种关联如果硬在 TDengine 里做,跨库 JOIN 很难受。我们的做法是:TDengine 只输出各点位的时间序列聚合结果,业务层拿到结果后在应用内存里做二次加工,或者干脆把聚合结果再同步到 MySQL 的业务宽表里。分工明确以后,TDengine 的性能优势才能完全发挥出来。
2.3 超级表 STable 解决建模痛点
TDengine 里最有价值也最需要理解的概念就是超级表(STable)。简单说,它就是一类设备的模板,每个具体物理点位是一张子表,点位固化的属性是标签(TAG),采集的数据是普通列。
同样的压力监测场景,如果建一张 MySQL 大表,要靠WHERE device_id = xxx AND create_time BETWEEN ...来过滤。而 TDengine 里,每个压力点是一张独立的子表,查询单点就是查单表,查询一组同类点位就是按超级表过滤标签。物理上表按设备隔离,查询时又按标签批量圈选,这个模型一上来就是贴合 IoT 场景的。
CREATE STABLE TABLE pressure_1min ( ts TIMESTAMP, pressure FLOAT, status INT ) TAGS ( area VARCHAR(20), pipe_dn INT, station VARCHAR(50) );
对应某台具体设备上线时,我们一般不会手动逐张建子表,而是直接在写入时让 TDengine 自动建表:
INSERT INTO p_014_001 USING pressure_1min TAGS ('城东片区', 300, '城南加压泵站') VALUES (now, 0.42, 0);
写入即建表,运维上省了很大一块工作量。新增点位再也不用先跑一遍建表 DDL 了。这也是后面我们能把 300 多个压力点顺利接进来的原因之一。
3. 数据建模与容量估算:开工前不算清账会被数据追着跑
接手嘉环项目的时候,我发现他们原来的时序数据表存在一个共性问题:不管什么类型的数据全都堆在一张表里,压力、流量、水质、设备状态全混着,靠type字段区分。这种做法在 TDengine 里非常吃亏,因为不同采集频率的数据混在同一张子表里,会导致时间线错乱、压缩率下降。所以第一件事就是分域建模。
3.1 建表规范与字段设计
我们的建表规范按“采集频率 + 数据类型”拆成多张超级表,而不是一个大宽表:
| 超级表名 | 适用对象 | 主字段 | 采集频率 |
|---|---|---|---|
| pressure_1min | 管网压力点 | pressure, status | 1 分钟 |
| flow_15min | 流量计 | total_flow, instant_flow | 15 分钟 |
| water_quality_1h | 水质站 | ph, turbidity, residual_chlorine, temperature | 1 小时 |
| pump_status | 泵站设备状态 | current, vibration, temperature | 状态变化/心跳 |
每个超级表里,测量值列尽量用 FLOAT 或 INT,不要用 VARCHAR。时序数据大多数是数值型,TDengine 对数值类型的压缩算法最成熟。像开关状态这种只有 0/1 的字段,直接用 BOOL 或者 INT,不要学别人用 VARCHAR 存 "ON"/"OFF",纯属浪费空间。
另外,每张超级表我都会加一列quality_flag INT,用于标记数据质量。原始采集数据经常有毛刺,比如压力突然从 0.4 跳到 1.2,这可能是传感器瞬态干扰,也可能是真的爆管。我们不能随手删掉异常数据,但可以在写入时打上质量标签,后续分析时排除掉quality_flag != 0的测点。这个字段在真实项目中帮了大忙,尤其是做爆管定位模型的时候,异常值本身就是信号。
3.2 容量估算的实操算法
这里给一套可以直接抄作业的估算公式,适用于绝大多数智慧水务场景:
总行数 = 点位数量 × 每天采集条数 × 保留天数
存储空间 = 总行数 × 平均单行压缩后字节数
不同数据类型的压缩后单行字节数不太一样。我们在嘉环项目实测的数据:
- 压力点(FLOAT 压力值 + INT 状态 + 时间戳):压缩后约 12 字节
- 流量计(两个 FLOAT + 时间戳):压缩后约 18 字节
- 水质站(5 个 FLOAT + 时间戳):压缩后约 36 字节
按 5 年保留期算:
- 300 个压力点:300 × 1440 × 365 × 5 × 12 字节 ≈ 9.5GB
- 150 个流量计:150 × 96 × 365 × 5 × 18 字节 ≈ 0.47GB
- 40 个水质站:40 × 24 × 365 × 5 × 36 字节 ≈ 0.63GB
- 全部加起来也就 10GB 出头
这个数字当时让项目组的人挺惊讶的,因为 MySQL 时代同样数据量要占 300GB 左右。TDengine 的列式存储 + 压缩算法在这个量级确实优势巨大。当然如果采集频率提高到秒级,或者加了振动传感器这类高频数据,估算公式不变,但结果会指数级上升,要预留足够的磁盘余量。
3.3 保留策略与降采样设计
时序库不能无限制囤原始数据,成本和查询效率都受不了。我们的策略是两级:
热数据:原始数据保留 30 天,满足实时监控和近期的异常追溯。 温数据:用连续查询(Continuous Query)自动每小时把原始数据聚合成 1 小时均值,保留 5 年。
CREATE DATABASE IF NOT EXISTS db_water KEEP 30 DURATION 10 BUFFER 64 WAL_LEVEL 2;
CREATE TABLE IF NOT EXISTS avg_pressure_1h AS SELECT _wstart AS ws, tbname, AVG(pressure) AS avg_pressure FROM pressure_1min INTERVAL(1h);
然后给连续查询设好调度周期,每小时跑一次,把结果写入avg_pressure_1h这张历史聚合表。这样大屏上展示“过去一年每小时压力趋势”时,不用去扫 30 天前的原始数据,查询速度依然非常快。
4. 从 MySQL 迁到 TDengine 的四个坑:时间、窗口、填值、标签
写这部分之前,我要先如实说一句:任何数据库迁移,真正的坑都不在“导入数据”这一步,而在应用层那些你以为写对了的 SQL。从 MySQL 切换到 TDengine,我们踩了四个比较有代表性的坑,每个都花了不少时间排查。
4.1 第一个坑:时间戳到底该用字符串还是 BIGINT
MySQL 里大家习惯用DATETIME存时间,迁到 TDengine 时想当然地也想继续用字符串。测试阶段看不出来,等数据量上来就出问题了:字符串时间戳无法直接参与时间运算,INTERVAL聚合完全失效,界面上的趋势曲线全乱了。
TDengine 的时间戳主键必须是 TIMESTAMP 类型,底层用 BIGINT 毫秒存储。所以迁移时第一步,把原来的DATETIME统一转换成毫秒时间戳。这里有个小坑:要特别小心时区。MySQL 的DATETIME不带时区,如果原始数据存的是北京时间(UTC+8),转换时得显式加上时区偏移,否则从 TDengine 里读出来的时间会整体少 8 小时。
我们当时的转换 SQL 大致长这样:
-- MySQL 侧 SELECT device_id, UNIX_TIMESTAMP(create_time) * 1000 AS ts, pressure FROM raw_pressure WHERE create_time >= '2025-01-01 00:00:00';
-- TDengine 侧接收时用毫秒时间戳直接写入
这个操作本身不复杂,但要命的是如果原库里混着不同时区写入的数据(采集网关的服务器时区不一致),转出来的时间戳会参差不齐。后来我们加了一步校验:取每个点位数据的时间戳,算相邻两条的间隔,超过采集周期 3 倍的点位单独列出来排查。这一步能筛出大部分时间错乱问题。
4.2 第二个坑:INTERVAL 聚合的窗口边界
TDengine 里最常用的降采样查询是INTERVAL(1h),但这个窗口边界跟 MySQL 的GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d %H:')有一个隐蔽的差别,不知道的人很容易被带到沟里。
TDengine 的INTERVAL是按数据库的时间分区边界对齐窗口的。如果你的数据保留了 10 天(DURATION 10),库内部是每 10 天一个分区文件,默认窗口从分区起始时刻对齐。如果采集端的时间戳不是整点对齐的(比如压力点采集周期是 1 分钟,但第一台设备的启动时间是 09:00:30),那么一个小时窗口可能是从 09:00:30 到 10:00:30,而不是你直觉上的 09:00:00 到 10:00:00。
排查这个问题时,我们一度以为是聚合结果错乱,后来发现窗口边界整体偏移了采集的起始相位。解决方法不复杂:应用层统计时统一用_wstart作为时间标签,而不是假设窗口一定对齐到自然小时。如果业务非要按自然时间对齐,处理办法是洗数据时先把秒级偏移抹掉,或者用时间截断函数把采集时间规整到整分钟/整点后再写入。
4.3 第三个坑:FILL 填值并不总是你想要的
做报表的时候,经常遇到某个时段没数据。比如压力点断线 20 分钟,那这段时间的小时均值就没有。MySQL 的做法通常是应用层判断查到 None 就填 0 或者填上一个值。TDengine 提供了FILL语法,用起来方便,但容易产生误导。
SELECT _wstart, AVG(pressure) AS avg_pressure FROM pressure_1min WHERE tbname = 'p_014_001' AND ts >= '2025-06-01 00:00:00' AND ts < '2025-06-02 00:00:00' INTERVAL(1h) FILL(PREV);
FILL(PREV)会用前一个有效窗口的值填充当前空窗口。看起来挺好用,但如果你采集终端本身就存在长期离线、前一天有值当天没值的情况,这个“ PREV”会把 24 小时前的老旧数据填充进去,报表上看起来一切正常,实际上全是假数据。我们的做法是:核心指标不用 PREV 填充,直接用 NULL 表示缺失,由业务层决定要不要插值;就算要插值,也只允许线性插值并打上“估算值”标记,不允许静默填充。
4.4 第四个坑:标签值覆盖与设备台账不同步
最后这个坑最隐蔽,而且很容易被忽略。之前说过,修改标签会覆盖历史数据上的标签取值。我们的业务场景里,一个片区泵站被合并到另一个管理所时,维护人员直接执行了ALTER TABLE p_014_001 SET TAG area = '新片区'。结果所有历史压力数据的 area 都变成了‘新片区’,统计 2023 年各个片区压力分布的时候数据整体偏了。
这类问题的根因是:我们把“设备现在的属性”和“设备当时的属性”混为一谈了。时序数据记录的是历史事实,标签如果表示的是设备当前位置(物理事实),改一改无可厚非;但如果你要用标签做历史分域统计,那就要么把归属变化单独建一张映射表,要么在数据里新增一列area_snapshot,在写入时固化当时的归属信息。
最终我们的方案是:业务映射表存动态属性,时序标签只存稳定属性(设备编号、型号、安装位置),行政区划/管理归属全部外置到 MySQL 的业务表里,查询时按时间区间关联。这样既保住了 TDengine 的标签过滤性能,又不会污染历史数据。
5. 部署调优与版本选择:社区版能撑起多大场面
项目进入部署阶段后,我们其实做了一轮比较充足的选型和部署验证。这一节把结果和细节都贴出来,给准备接手类似项目的团队一些可以直接参考的配置。
5.1 安装与部署形态
官方网站有详细的安装包,支持 Linux RPM/DEB、Windows 安装包、Docker 等。像“TDengine 安装 window 教程”这种搜索热度一直很高,说明很多团队习惯先在 Windows 上做验证。我们的经验是:Windows 装个社区版做功能验证完全没问题,装完启动 taosd、执行taos命令行即可;但生产环境一定要放 Linux,性能释放更充分,运维手段也更成熟。
一套集群的常规部署建议:
| 配置项 | 开发测试环境 | 生产环境 |
|---|---|---|
| 节点数 | 1 | 3(1 主 2 从)或 3 数据节点 |
| CPU | 4 核 | 16 核以上 |
| 内存 | 8GB | 64GB |
| 磁盘 | 100GB HDD | 2TB NVMe SSD |
| OS | CentOS 7 / Ubuntu 22.04 均可 | 同左,建议统一 |
启动以后先别急着写数据,先建库设参数。其中DURATION(数据落盘文件的时间跨度)、WAL_LEVEL(预写日志级别)、CACHE(内存块大小)这几个参数,建库之后有些很难在线调整,最好一开始就规划好。我们的库建库 SQL 大概是这样:
CREATE DATABASE db_water KEEP 30 DURATION 10 BUFFER 64 WAL_LEVEL 2 WAL_FSYNC_PERIOD 3000 VGROUPS 8;
VGROUPS决定了数据分多少个虚拟存储组。我们当时 300 多个压力点,8 个组就够了。如果点位有几千个,可以适当调大,但不要创建后频繁调整,迁移数据很麻烦。
5.2 实测有效的参数调优
固定调优清单如下,不同的场景按需取用:
- 压力点这种高频小数据量点位多的情况,把
BUFFER调到 64MB 以上,能有效降低磁盘 IOPS。 WAL_LEVEL 0性能最高但有丢数据风险,我们不允许丢数据,用的WAL_LEVEL 2。- 历史聚合查询较多的,建议打开查询缓存或者直接把常用连续查询结果物化。
- 如果要把 TDengine 数据接入 Grafana 大屏,建议用官方的 plug-in,版本要跟服务端匹配,早期踩过因为插件版本不对导致查询报错的坑。
5.3 社区版和商业版的现实考量
经常有人问“TDengine 太贵了,有没有替代方案”。这里我说下我们的认知:TDengine 社区版功能上覆盖了时序写入、超级表、连续查询、流式计算、多级存储这些核心能力,我们在嘉环项目中用到的全部功能,社区版都提供了,没有功能上的瓶颈。
商业版更多解决的是运维管控、多集群统一管理、企业级技术支持这类问题。对于区县级智慧水务平台这种规模,团队自己有能力维护一套集群的话,社区版完全够用。真正要花钱的地方,其实是你的时间成本和技术兜底能力。预算充足、项目 SLA 要求高、手上没有专职 DBA 的话,商业版本给你带来的运维提效是真实的;但如果说“功能不够所以要买商业版”,那是误解,绝大多数场景下社区版撑起一个中等规模项目绰绰有余。
部署完成以后,我们还做了一轮压测,写入稳定在每秒 10 万条数据不丢不堵,查询 P99 延迟在 200ms 以内。这个性能表现,当时的 MySQL 架构是完全不可能达到的。
最后一个忠告
在嘉环科技这个智慧水务项目结束以后,我的体会是:换时序数据库解决的是“数据存储和查询”这一层问题,但不等于整套系统就变“智能”了。水务数据真正的难关,是那些 0.1% 的脏数据、断线补传、时钟漂移和口径不一致。TDengine 给了我们一个极其趁手的时序底座,但上层的数据治理和业务模型设计,才是决定智慧水务平台最终能走多远的关键。时序库是骨架,数据质量才是血液。这套话虽然不新鲜,但亲身做一遍,真能体会到什么叫“工欲善其事,必先利其器”。