核心目标:理解 TDengine 解决什么、不解决什么;看懂"客户端 → taosd(mnode/vnode)→ WAL → 内存表 → 落盘"的写入链路;用 C++ 完成一次可观察、可验证、可清理的最小读写闭环。
前置知识:C++11/17(RAII、异常、STL);基础 SQL;了解 TCP 客户端-服务器模型。
验证环境:TDengine 3.4.x(正文基线 3.4.1.6,文档 v3.4.2);taosc(taos.h);C++17;CMake ≥ 3.20;Docker(服务端)或 Linux/Windows 本机安装。
0. 本篇问题场景
你负责一套 EMS(能量管理系统)的数据存储:变电站里上千台设备,每台每秒上报一组电压、电流、功率,一年下来是千亿级的数据点。之前用 MySQL 一张大表硬扛,几个月后:
- 写入越来越慢,采集端开始积压;
- 查询"某台主变最近 1 小时负荷曲线"要扫全表,报表接口动不动超时;
- 磁盘占用失控,压缩率惨不忍睹。
于是你想换一个"时序数据库"。但选择很多:InfluxDB、OpenTSDB、TimescaleDB、Prometheus、TDengine......每个都说自己快。
本篇不急着比较基准测试,先回答三个更基础的问题:
- 时序数据到底特殊在哪,为什么 MySQL 会天然吃亏;
- TDengine 的定位与组件是什么,它凭什么在"写多、按时间查、只追加"的场景里占优;
- 一条数据从 C++ 程序发出到落盘,中间发生了什么------这条链路将贯穿整个系列。
最后我们用 C++ 跑通第一个最小闭环:连接 → 建库 → 建超级表 → 写入 → 查询。
1. 心智模型与关键概念
1.1 时序数据三要素
任何一条时序数据都可以拆成三个部分:
| 要素 | 含义 | EMS 示例 |
|---|---|---|
| 时间戳 timestamp | 数据产生/采集的时刻 | 2026-08-01 00:00:00.123 |
| 指标 metric | 连续或离散的测量值 | 电压 110.2kV、有功 4.32MW |
| 标签 tag | 描述"谁产生的"静态属性 | 设备 d_0001、站点 华东-站A、电压等级 110kV |
三要素的角色完全不同:时间戳是主键和排序键,指标是被压缩和聚合的对象,标签是过滤和分组的维度。所有时序数据库的优化都建立在这个结构之上。
1.2 TDengine 定位:不是"更快一点的 MySQL"
把 TDengine 和几个常被拿来对比的系统放在一张表里看:
| 系统 | 核心定位 | 数据模型 | 强项 | 弱项 |
|---|---|---|---|---|
| MySQL/PostgreSQL | 关系型 OLTP | 表 + 行 + 强约束 | 事务、复杂关联、灵活 DDL | 海量时序写入/压缩/时间聚合吃力 |
| Redis | 内存键值/缓存 | key-value | 低延迟缓存、计数器 | 容量、持久化成本高 |
| InfluxDB | 时序数据库 | measurement + tag + field | 生态成熟、查询灵活 | 集群版闭源,高基数场景弱 |
| OpenTSDB | 时序数据库(HBase 之上) | metric + tag | 大规模扩展 | 运维复杂、查询延迟高 |
| Prometheus | 监控指标库 | 指标 + label,拉取模型 | 服务发现、告警、生态 | 存储/查询是监控场景专用,不适合业务数据 |
| ClickHouse | 分析型列存 | 表 + 分区 | 极快分析扫描、压缩 | 单行写入/更新弱,运维重 |
| TDengine | 时序数据库(写入优先) | 库 + 超级表 + 子表 | 超高写入吞吐、时序压缩、库内订阅/流计算/缓存 | 生态仍在成长,不适合复杂事务 |
TDengine 的关键判断是:时序数据是"写多读少、只追加、按时间有序、按设备分片"的。它把每个采集点(设备)的数据按时间有序存放、单独压缩,从根上避免了 MySQL"随机写 + 全表扫描"的困境。
1.3 3.x 组件全景
TDengine 3.x 的安装包里不止一个数据库引擎:
┌─────────────────────────────────────────────────────────┐
│ 应用(C++ / 其他语言客户端) │
│ native 连接(6030,taosc 直连 taosd) │
│ WebSocket / REST(6041,经 taosAdapter) │
└─────────────────────────┬───────────────────────────────┘
│
┌─────────────────────────▼───────────────────────────────┐
│ taosd(核心服务,单进程) │
│ mnode 元数据 │ vnode 数据 │ qnode 查询 │ snode 流/订阅 │
│ WAL → 内存表 → 落盘(时间分区 + 列存 + 压缩) │
└──────────────┬──────────────────────────────┬───────────┘
│ │
taosKeeper(监控采集) taosAdapter(REST/WebSocket/兼容协议)
taosExplorer(Web 管理,企业版) taosX(数据管道,企业版)
taos CLI(taos shell) taosBenchmark / taosdump / taosgen(工具)
- taosd:一切数据操作的核心守护进程。3.x 在一个进程里按角色拆分:mnode 管元数据(库/表/标签定义),vnode 管数据分片与落盘,qnode 管查询执行,snode 管流计算与订阅。后面 Part 9 会展开集群形态。
- taosAdapter:应用与集群之间的"桥",提供 RESTful 与 WebSocket 接口(端口 6041),并兼容 InfluxDB/OpenTSDB 写入协议。
- taosKeeper:采集 taosd 运行指标并写入监控库,配合 Grafana 面板 TDinsight 使用。
- taos CLI(
taos) :官方命令行,本系列最重要的观察与验证工具(不是主线语言,但每个结论都要用它验证)。 - taosBenchmark / taosdump / taosgen:压测、备份恢复、造数工具。
端口约定 :6030(taosd native)、6041(taosAdapter REST/WebSocket)。记住这两个端口,排查连接问题时最先看它们。
1.4 执行链路:一条数据的一生
写入路径(重点,后面每个性能结论都从这里推导):
C++ 客户端
│ taos_query(conn, "INSERT ...") / taos_stmt 批量绑定
▼
taosd(native 6030 或经 taosAdapter 6041)
│ 鉴权 → SQL 解析
▼
mnode 路由:子表属于哪个 vgroup
▼
vnode(数据分片)
│ ① 写 WAL(预写日志,可配置 fsync 策略)
│ ② 写内存表(memtable)
▼
异步合并:内存表达到阈值 → 落盘为时间分区内的列存文件 → 后台合并/压缩
查询路径:
C++ 客户端 → taosd → qnode 生成执行计划
→ vnode 并行扫描(时间范围裁剪 → 列裁剪 → 预计算过滤)
→ 聚合/排序 → 结果回传客户端
从这条链路能立刻推导出三条铁律,后面会反复用到:
- 时间戳必须是首列主键------因为存储和扫描都按时间有序组织,没有它一切优化都不成立;
- 查询必须尽量带时间范围------没有时间裁剪的查询等于全量扫描;
- 写入是"顺序追加 + 合并"------所以乱序数据(迟到的时间戳)会打断有序性,付出额外代价(Part 6 详细量化)。
2. 最小可运行示例
2.1 用 Docker 起一个 TDengine
本系列服务端统一用官方 Docker 镜像(可复现、便于后续多节点编排):
bash
docker run -d --name tdengine -p 6030:6030 -p 6041:6041 \
tdengine/tdengine:3.4.1.6
# 进入 taos CLI 验证
docker exec -it tdengine taos
在 taos 里核对版本并试一条 SQL:
sql
SELECT SERVER_VERSION();
CREATE DATABASE demo KEEP 90 PRECISION 'ms';
USE demo;
CREATE STABLE meters (ts TIMESTAMP, current FLOAT, voltage FLOAT)
TAGS (location BINARY(24));
INSERT INTO d_1001 USING meters TAGS ('beijing') VALUES (NOW, 10.2, 219.9);
SELECT LAST_ROW(*) FROM meters;
看到结果后 QUIT 退出。注意:taos CLI 只是验证手段,系列主线始终是 C++。
Windows 用户:官方提供 Windows 安装包(含服务端与 taos.h 客户端库),可直接本机开发验证;行为差异见第 6 节。
2.2 第一个 C++ 程序
本系列代码都在 ems-lab 项目里(tdengine/ems-lab/),先看最小的闭环 src/examples/part1_minimal.cpp 的核心逻辑:
cpp
#include "ems_lab/td_conn.h"
using namespace ems_lab;
int main() {
try {
TdConn conn(config_from_env()); // 1) 连接(环境变量注入配置)
conn.exec("CREATE DATABASE IF NOT EXISTS ems KEEP 90 DAYS 30 BUFFER 256 "
"WAL_LEVEL 1 PRECISION 'ms' CACHEMODEL 'both' CACHESIZE 1 VGROUPS 4");
conn.exec("USE ems"); // 2) 建库
conn.exec("CREATE STABLE IF NOT EXISTS telemetry (ts TIMESTAMP, "
"ua FLOAT, ub FLOAT, uc FLOAT, ia FLOAT, ib FLOAT, ic FLOAT, "
"power FLOAT, freq FLOAT) "
"TAGS (station BINARY(32), device_type BINARY(24), "
"voltage_level BINARY(16), vendor BINARY(32))"); // 3) 建遥测超级表
conn.exec("INSERT INTO d_0001 USING telemetry TAGS "
"('华东-站A', 'main_transformer', '110kV', 'ACME') "
"VALUES (NOW, 110.2, 109.8, 110.0, 12.5, 12.6, 12.4, 4.32, 50.01)"); // 4) 写入
TAOS_RES* res = conn.query("SELECT LAST_ROW(*) FROM telemetry"); // 5) 查询最新一条
TAOS_ROW row;
while ((row = taos_fetch_row(res)) != nullptr) { /* 按列类型打印 */ }
taos_free_result(res); // 6) 释放结果集
} catch (const std::exception& e) {
std::fprintf(stderr, "错误: %s\n", e.what());
return 1;
}
return 0;
}
编译与运行:
bash
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
export EMS_HOST=localhost EMS_PORT=6030 EMS_USER=root EMS_PASS=taosdata EMS_DB=ems
./build/part1_minimal
预期输出(列名 + 一行数据):
text
ts ua ub uc ia ib ic power freq station device_type voltage_level vendor
1785542400000 110.200 109.800 110.000 12.500 12.600 12.400 4.320 50.010 华东-站A main_transformer 110kV ACME
最小闭环完成
对照看一遍这条链:taos_connect(TCP 连接 6030)→ CREATE DATABASE(经 mnode 建元数据)→ CREATE STABLE(建超级表模板)→ INSERT ... USING ... TAGS(自动建子表 d_0001 并写入)→ SELECT LAST_ROW(*)(取每个设备最新一行)→ taos_free_result(释放)。
2.3 三个 API 事实(3.x 与 2.x 不同)
写代码前必须知道三件 3.x 特有的 API 事实(Part 5 会展开):
taos_query直接返回结果集TAOS_RES*。2.x 里要先taos_query再taos_use_result取结果;3.x 没有taos_use_result,一条 SQL 对应一个结果集指针。- SQL 失败时
taos_query不返回 NULL ,而是返回一个"带错误的结果集"------必须用taos_errno(res) != 0判断,用taos_errstr(res)取信息。 - 连接失败返回 NULL ,错误信息通过
taos_errno(NULL)/taos_errstr(NULL)获取。
这正是 TdConn::query() 的封装逻辑:
cpp
TAOS_RES* TdConn::query(const std::string& sql) const {
TAOS_RES* res = taos_query(conn_, sql.c_str());
const int code = taos_errno(res);
if (code != 0) {
const char* msg = taos_errstr(res);
taos_free_result(res);
throw TdException("SQL 执行失败: " + std::string(msg ? msg : "unknown") +
" | SQL: " + sql, code);
}
return res;
}
3. 机制拆解:链路、时间主键与资源代价
3.1 写入链路逐层看
一条 INSERT 经过的每一层都回答一个问题"这一步花了什么代价":
| 层 | 做什么 | 代价/可配置项 |
|---|---|---|
| 客户端 | 组 SQL / 参数绑定、网络发送 | 网络往返(RTT)------所以批量写入远快于逐条写 |
| taosd | 鉴权、SQL 解析、路由 | CPU |
| vnode | 写 WAL → 写内存表 | WAL 落盘策略(wal_level/fsync)决定数据安全与吞吐的取舍 |
| 后台 | 内存表阈值触发 → 落盘 → 合并压缩 | 决定写入峰值后多少数据在内存、多久落盘 |
理解这个分层后,几个现象就说得通了:
- 批量写入为什么快:10 万行分 100 批 vs 逐条 10 万次网络往返,后者的 RTT 开销是前者的上千倍(Part 6 给量化数据);
wal_level调低为什么写入更快但更险:WAL 不落盘/延迟落盘时,进程崩溃可能丢最近数据;- 刚写入的数据为什么能马上查到:数据在内存表里就可读,落盘是后台异步行为。
3.2 时间戳主键:硬约束还是特性?
TDengine 里每张表的第一个列必须是 TIMESTAMP。这不是限制,而是整个存储设计的地基:
- 数据按时间有序写入 → 追加友好,天然支持 LSM 式合并;
- 按时间分区(
days)→ 过期数据整块淘汰(keep),查询按时间裁剪; - 时间戳做索引 → 范围查询 O(log n + 结果集) 而非全表扫。
代价也要知道:同一张表内时间戳必须唯一,重复时间戳写入会覆盖旧值(Part 3 验证);乱序时间戳会触发排序合并,性能下降(Part 6 量化)。
3.3 建库参数:先理解再照抄
CREATE DATABASE ems KEEP 90 DAYS 30 BUFFER 256 WAL_LEVEL 1 PRECISION 'ms' CACHEMODEL 'both' CACHESIZE 1 VGROUPS 4 里每个参数都有明确含义:
| 参数 | 本例 | 含义 |
|---|---|---|
KEEP 90 |
90 天 | 数据保留期,过期数据被自动清理 |
DAYS 30 |
30 天 | 每个数据文件覆盖的时间跨度(时间分区) |
BUFFER 256 |
256 MB | 每个 vnode 内存表缓冲上限 |
WAL_LEVEL 1 |
1 | WAL 落盘策略(0/1/2,Part 6/11 详解) |
PRECISION 'ms' |
毫秒 | 时间戳精度 ms/us/ns,建库后不可改 |
CACHEMODEL/CACHESIZE |
both/1 | 最新值缓存(Read Cache),Part 8 详解 |
VGROUPS 4 |
4 | 数据分片数,Part 9 集群篇展开 |
Part 2 会逐个讲清楚"何时该改、改了有什么代价"。
4. ems-lab 工程实战:项目骨架与第一个封装
4.1 项目结构
按写作计划的 4.2 约定,ems-lab 从本篇开始逐步长成完整形态,当前只有最小骨架:
text
ems-lab/
├── CMakeLists.txt # TAOS_HOME 定位 taos.h/libtaos;core 库 + 示例 + 测试目标
├── README.md # Docker 验证环境、编译运行说明
├── src/
│ ├── ems_lab/
│ │ ├── settings.h # 环境变量名(EMS_HOST/PORT/USER/PASS/DB)
│ │ ├── td_conn.h # ConnConfig / TdException / TdConn
│ │ └── td_conn.cpp
│ └── examples/
│ └── part1_minimal.cpp # 本篇最小闭环
├── scripts/
│ └── seed.cpp # 四遥数据模拟器(Part 2 引入)
└── tests/
└── test_conn.cpp # 集成测试(需要真实服务)
4.2 TdConn 封装的三条原则
TdConn 是系列里所有代码的地基,封装时坚持三条原则(也是 Part 5 的主题):
- RAII 管生命周期 :构造即连接、析构即关闭,禁止拷贝、支持移动。忘记
taos_close的后果是连接句柄泄漏(Part 5 有失败实验); - 失败即异常 :连接失败抛
TdException(带错误码),SQL 失败同样抛------调用方不需要记住"先查 errno 再 free"的顺序,封装里处理干净; - 配置走环境变量 :
config_from_env()从EMS_*读取连接信息,代码与文档里不出现硬编码凭据。
4.3 用 taos CLI 做对照验证
C++ 程序跑完后,用 taos CLI 验证同一件事,建立"代码 ↔ 真实数据库"的信任:
sql
USE ems;
SHOW STABLES; -- 应看到 telemetry
SHOW TABLES; -- 应看到自动建出的 d_0001
DESCRIBE telemetry; -- 列定义与标签
SELECT COUNT(*) FROM telemetry; -- 1
建议习惯:每篇的每个结论都用 CLI 输出留档,这是系列"可复验"要求的落地方式。
5. 失败实验与根因
5.1 连接不存在的端口
cpp
ConnConfig bad = config_from_env();
bad.port = 6031; // 故意连错
TdConn conn(bad); // 抛 TdException
现象:TdException 携带错误码(网络不可达类)与信息 连接 TDengine 失败: ... (host=..., port=6031)。
根因与排查:taos_connect 返回 NULL。依次检查------服务是否启动(docker ps)、端口映射是否生效(6030)、防火墙是否放行。这也是项目里 test_conn.cpp 的第一个用例:错误端口必须抛异常,而不是静默返回。
5.2 未建库直接查询
cpp
TdConn conn(cfg); // 登录时 db 为空
conn.query("SELECT * FROM telemetry"); // 抛异常
现象:错误信息形如 Database not specified(未指定数据库)。
根因:telemetry 属于 ems 库,但当前会话没有 USE ems,解析器找不到该表。修法:连接时带 EMS_DB,或执行 USE ems,或在 SQL 里写全限定名 ems.telemetry。
5.3 时间戳缺失 / 重复
sql
-- 缺时间戳:VALUES 里不写 ts 但表要求首列为 ts → 语法/列数错误
INSERT INTO d_0001 USING telemetry TAGS (...) VALUES (110.2, 109.8, ...);
-- 重复时间戳:同一子表写两次相同 ts → 第二次覆盖第一次
INSERT INTO d_0001 USING telemetry TAGS (...) VALUES (NOW, ...);
INSERT INTO d_0001 USING telemetry TAGS (...) VALUES (NOW, ...); -- 覆盖!
根因:时间戳是主键。第一例违反"首列必须为时间戳";第二例触发"同表同 ts 覆盖"语义。这个覆盖语义在生产里是把双刃剑:重放数据时能幂等,但也意味着业务侧必须保证"同一设备同一时刻只有一个值"(Part 3 详述)。
6. 版本与环境差异
| 维度 | 说明 |
|---|---|
| 3.4.x(主线) | 正文基线 3.4.1.6;3.4.0 起社区版/企业版客户端驱动不兼容,需按发行版安装对应 taosc |
| 3.3.6(LTS) | 官方标注 LTS,生产可选;与 3.4.x 的差异(新函数、流引擎等)在对应篇目标注 |
| 2.x | 已不在本系列主线;taos_use_result、taos_field_count 等 2.x API 与 3.x 不同,迁移见 Part 12 |
| Docker | 推荐验证方式,tdengine/tdengine:3.4.1.6 镜像,多节点编排 Part 9 |
| Linux | 官方包安装:/usr/local/taos(include/ + driver/libtaos.so),systemd 管理 taosd |
| Windows | 官方安装包含服务端与客户端;taos.h 位于安装目录 include/,链接 taos.lib;本机验证可用,生产建议 Linux |
发布前核对 :文中版本号、API 与行为以官方文档与所装版本的 taos.h 为准;升级版本后先重跑 test_conn。
7. 测试与验收
7.1 自动化测试(test_conn.cpp)
集成测试需要真实服务(README 的 Docker 方式):
bash
ctest --test-dir build --output-on-failure
测试覆盖两条路径:
- 失败路径 :错误端口连接必须抛
TdException(且错误码非 0); - 成功路径 :建独立库
ems_test→ 建超级表 → 写 2 行 →COUNT(*)断言 = 2 →DROP DATABASE ems_test清理。
7.2 本篇验收清单
- Docker 启动 TDengine 3.4.x,
taosCLI 能SELECT SERVER_VERSION(); -
part1_minimal编译运行,输出 LAST_ROW 结果与预期一致; - 用 taos CLI 复验:
SHOW STABLES/SHOW TABLES/DESCRIBE telemetry/COUNT(*); - 3 个失败实验全部复现并能解释根因(连接失败 / 未建库 / 时间戳覆盖);
-
test_conn全部 PASS(含错误端口用例); - 能画出并口头解释"写入链路"与"查询链路"两张图。
8. 常见误区
| 误区 | 事实 |
|---|---|
| "TDengine 是更快一点的 MySQL,可以直接迁移表结构" | 数据模型完全不同:必须"时间戳主键 + 一表一设备 + 标签",Part 2 专门讲 |
"taos_query 失败返回 NULL,判空就行" |
3.x 返回带错误的结果集,必须 taos_errno(res) 判断 |
| "连接用完不 close,进程退出会自动回收" | 长连接服务必须显式关闭,否则句柄泄漏,最终连接数打满(Part 5 验证) |
| "建库参数照抄官方示例就行" | KEEP/DAYS/PRECISION/VGROUPS/WAL_LEVEL 每个都影响成本与行为,Part 2/11 逐个讲 |
| "时序库就是不停 INSERT,不用管顺序" | 同表同 ts 会覆盖;乱序写入有性能代价 |
| "taos CLI 能跑通,代码一定没问题" | CLI 与代码走同一协议,但连接管理、错误处理、资源释放只有代码才体现 |
9. 本篇小结
回到开篇的三个问题:
- 时序数据特殊在"三要素":时间戳是主键、指标被压缩聚合、标签做过滤分组。MySQL 的随机写 + 行存 + 全表扫描在这个形态下天然吃亏;
- TDengine 的定位:写优先的时序数据库,靠"时间有序 + 列存 + 时序压缩 + 库内订阅/流计算/缓存"换取写入吞吐与存储效率;组件上记住 taosd(6030)与 taosAdapter(6041)两个端口;
- 写入链路:客户端 → taosd → mnode 路由 → vnode(WAL → 内存表)→ 异步落盘合并。从链路推导出"时间戳主键""查询带时间范围""批量写入"三条铁律,整个系列都会复用。
你已经在 C++ 里跑通了最小闭环,并掌握了 3.x 的 API 事实(taos_query 直接返回结果集、错误检查 taos_errno(res)、连接失败查 taos_errno(NULL))。下一步是系列最重要的一课:数据模型设计------四遥数据到底该建几张超级表、标签怎么放、精度怎么选。