核心目标 :掌握
CREATE STREAM流式计算与 Read Cache 缓存能力,能用 C++ 配合流计算构建实时统计与最新值服务。前置知识:Part 4(窗口查询)、Part 7(订阅);理解触发-计算分离思想。
验证环境 :TDengine 3.4.x(3.4.1.6);taosc;C++17;Docker(服务端);数据来自
seed.cpp。系列导航 :写作计划 · Part 7 数据订阅 → 本篇 → Part 9 集群
0. 本篇问题场景
EMS 的实时统计需求很直接,但实现起来很烦:
- 负荷统计 :每个变电站过去 1 小时的
avg(power)/max(ia),要按小时自动滚动更新,报表直接读; - 状态统计:每台断路器当前"合了多久"、今天"动作了几次";
- 降采样:原始遥测 90 天过期,但"5 分钟均值"要留 3 年------谁来持续做这个压缩?
- 最新值 :监控大屏要"毫秒级"显示每台设备最新遥测------每次都全表查
ORDER BY ts DESC显然不行。
传统方案:定时任务轮询聚合 + Redis 存最新值 + 自己维护调度。组件多、延迟高、容易出 bug。TDengine 把两件事做进了数据库:
- 流计算(Stream):用 SQL 定义"每当数据写入就自动计算并写结果表",降采样、统计、状态计算都归它;
- Read Cache(最新值缓存) :
LAST/LAST_ROW查询直接命中内存缓存,最新值秒回。
本篇的目标:掌握 CREATE STREAM 的窗口/触发/结果语义,配合 Read Cache 搭建"写入 → 流计算 → 查询/订阅"的实时闭环。
1. 心智模型:三个"实时处理"怎么分工
| 能力 | 触发 | 产出 | 典型用途 |
|---|---|---|---|
| 订阅 TMQ(Part 7) | 每条数据变化 | 事件推给应用 | 变位、越限告警 |
| 流计算 | 窗口/事件/周期 | 结果落库 | 降采样、统计、状态计算 |
| 外部流处理(Flink 等) | 事件流 | 任意 | 复杂多源计算------TDengine 是轻量替代 |
关键认知 :流计算的产出是新表里的数据(不是推给应用的事件)。应用侧要么查结果表,要么订阅结果表。所以"写入 → 流计算 → 订阅"可以串成管道。
1.1 新流引擎:触发与计算解耦
3.3.7+ 的新流引擎有两个特点(这是理解全部语法的钥匙):
- 触发源与计算源可以分离 :触发窗口的"表"和参与计算的"数据集"不必是同一个;甚至可以有周期触发(
PERIOD)而没有数据触发; - 结果延迟可调 :用
STREAM_OPTIONS(水位线、过期时间、最大延迟等)在"时效"和"资源"之间平衡。
1.2 流计算 vs 连续查询(2.x)
2.x 的 CREATE CONTINUOUS QUERY 在 3.x 中已被 CREATE STREAM 取代(迁移见 Part 12)。本系列只讲 3.x 的流计算。
1.3 Read Cache:最新值为什么能秒回
建库参数 cachemodel 决定 LAST/LAST_ROW 是否走内存缓存:
cachemodel |
行为 |
|---|---|
none |
不缓存,最新值查询走存储 |
last_row |
缓存每个子表最后一行(LAST_ROW 命中) |
both |
缓存最后一行 + 最后一条(LAST/LAST_ROW 都命中) |
cachesize(MB)是缓存容量上限。命中时查询不触达磁盘,所以"毫秒级"。代价:缓存与数据最终一致(写入即更新),但容量有限,超大子表数时注意预算(Part 11 展开)。
2. 最小可运行示例:两个流任务
项目 src/examples/part8_stream.cpp 完整可编译运行。核心 SQL:
2.1 变电站小时负荷统计
sql
CREATE STREAM IF NOT EXISTS station_hourly INTERVAL(1h)
FROM ems.telemetry PARTITION BY station
STREAM_OPTIONS(FILL_HISTORY)
INTO ems.station_hourly (ts, avg_power, max_ia)
TAGS (station BINARY(32) AS station)
AS SELECT _twstart, station, avg(power), max(ia) FROM %%trows;
逐段拆解:
| 片段 | 含义 |
|---|---|
INTERVAL(1h) |
时间窗口 1 小时,窗口关闭触发(默认 trigger) |
FROM ems.telemetry PARTITION BY station |
计算源为遥测超级表,按站点分组,每组独立触发 |
STREAM_OPTIONS(FILL_HISTORY) |
回填创建前已有历史数据(否则新流引擎默认只处理创建后的数据) |
INTO ems.station_hourly (ts, avg_power, max_ia) |
结果表数据列;TAGS(... AS ...) 声明输出标签 → 结果表是超级表,每组自动建子表 |
AS SELECT _twstart, station, ... |
计算逻辑;_twstart 是窗口起点伪列,%%trows 是该组触发时刻的数据集 |
注意 :输出标签(station)只出现在 TAGS 中,数据列列表不重复声明;标签值通过 AS station 从 SELECT 列取值。
2.2 开关状态持续时长
sql
CREATE STREAM IF NOT EXISTS status_duration STATE_WINDOW(state)
FROM ems.status PARTITION BY tbname
STREAM_OPTIONS(FILL_HISTORY)
INTO ems.status_duration (ts, state, seconds)
TAGS (tbname BINARY(32) AS tbname)
AS SELECT _twstart, tbname, state, stateduration(state) / 1000.0 FROM %%trows;
注意:超级表配 STATE_WINDOW 必须 PARTITION BY tbname(按子表分组,每个设备独立的状态机)。
2.3 管理语句
sql
SHOW STREAMS; -- 查看所有流任务
DROP STREAM IF EXISTS station_hourly;-- 删除流任务(结果表保留)
START STREAM station_hourly; -- 恢复(STOP 后持久生效)
STOP STREAM station_hourly; -- 暂停
2.4 C++ 侧:StreamManager
src/ems_lab/stream_setup.h/.cpp 把流任务管理封装成三个方法(create_hourly_load / create_status_duration / show / drop),内部就是 TdConn::exec + TdResult。流计算本身是 SQL 能力,C++ 不需要特殊 API------这是它轻量的原因。
cpp
StreamManager sm(conn);
sm.create_hourly_load();
sm.show();
// 结果表像普通表一样查询
TdResult res(conn.query("SELECT station, avg_power, max_ia FROM station_hourly "
"ORDER BY ts DESC LIMIT 10"));
2.5 Read Cache 查询
cpp
// cachemodel='both' 时命中内存缓存
TdResult last(conn.query("SELECT LAST_ROW(*) FROM telemetry WHERE tbname = 'd_0001'"));
3. 机制拆解:触发、结果与一致性
3.1 窗口触发(TRIGGER)
| 触发类型 | 语法 | 触发时机 |
|---|---|---|
| 窗口关闭 | INTERVAL(n) [SLIDING(m)](默认) |
窗口结束(可配水位线提前/推后) |
| 窗口开启 | TRIGGER WINDOW_START |
窗口开始 |
| 非窗口周期 | PERIOD(d) |
按固定周期触发 |
| 状态/事件/计数窗口 | STATE_WINDOW / EVENT_WINDOW / COUNT_WINDOW |
状态变化/事件匹配/计数达标 |
水位线 WATERMARK(d):允许迟到数据在 d 内仍计入当前窗口;超过则算入下一窗口。这是"乱序数据在流里怎么办"的答案------用水位线兜底,而不是无限等待。
3.2 结果写入语义(必须背下来)
结果表写入按主键(时间戳)规则:
- 主键为 NULL 的结果被丢弃;
- 同一输出子表、相同时间戳:后写覆盖前写(幂等重算友好);
- 不同时间戳:追加。
配合 DELETE_RECALC(重算窗口时先删旧结果再写)可以做到"流重算不产生脏数据"。
3.3 Read Cache 一致性
- 写入即更新缓存(数据可见性与缓存同步);
cachemodel与cachesize是建库/改库参数,改后对后续生效;- 缓存存的是"每个子表最后一行/条",大屏实时值用它;历史曲线仍走存储查询。
3.4 资源与失败
- 每个流任务占用 snode 资源;窗口数、滑窗粒度影响内存(窗口内存随并发窗口数增长);
- 流任务失败会重试/告警,
SHOW STREAMS看状态;FILL_HISTORY可回填历史窗口。
4. ems-lab 工程实战:实时统计闭环
telemetry/status(写入,Part 6)
→ CREATE STREAM(station_hourly / status_duration,落结果表)
→ 结果表查询(报表 / 大屏)
→ 结果表订阅(Part 7 的 TdSubscriber 订阅结果表 topic → 推送)
→ Read Cache(LAST_ROW 秒回最新值)
演进要点:
- 降采样 :原始遥测
keep 90,聚合结果keep 1095(3 年)------用两个库/两张表 + 流计算实现分级存储; - 统计口径一致 :流计算与 Part 4 的窗口查询用同一套 SQL 语义,结果可互相校验(双路径对比是验收手段);
- 缓存兜底 :大屏最新值走
LAST_ROW(缓存),报表历史走聚合表,各取所需。
5. 失败实验与根因
5.1 窗口边界与迟到数据
实验:数据在窗口关闭后才到达(乱序迟到)。
现象:迟到数据可能被算进下一窗口(无水位线时),或在水位线允许范围内补进当前窗口。根因:窗口触发是"时间推进"模型,迟到数据需要 WATERMARK 或重算机制兜底。教训 :延迟敏感统计必须配置水位线;不能接受误差时用 DELETE_RECALC 重算。
5.2 滑窗内存暴涨
实验:INTERVAL(1m) SLIDING(1s) 在大量设备上跑。
现象:snode 内存随活跃窗口数增长,峰值明显。根因:滑动窗口同时保有多个窗口状态。教训:滑窗粒度与设备数决定内存,先算预算再上线(Part 11 有估算思路)。
5.3 结果表重复/覆盖不符预期
实验:流重算或迟到数据导致结果表出现"两条同时间戳不同值"。
现象:查出来只有一条(后写覆盖)。根因:同子表同 ts 覆盖是结果写入语义。教训:这是特性不是 bug;要保留多版本就换时间戳精度/加版本列,不要指望流自动去重。
5.4 缓存与数据不一致
实验:cachemodel 从 none 改成 both 后立即查询。
现象:部分子表 LAST_ROW 仍走存储(缓存未建立)。根因:缓存按需/渐进建立,超大表首次查询仍较慢。教训 :缓存生效是"逐渐的",容量有限;关键实时表才开 both。
6. 版本与环境差异
| 维度 | 说明 |
|---|---|
| 新流引擎 | 3.3.7+ 引入(触发-计算解耦);3.3.6 LTS 不含新流引擎,为旧流引擎,Part 8 正文以 3.4.x 的 CREATE STREAM 语法为准 |
_twstart/_twend |
新流引擎窗口伪列(3.4.x);旧版流/查询窗口用 _wstart/_wend,注意区分 |
STREAM_OPTIONS |
水位线/过期/最大延迟等选项随版本演进,以官方 Stream Syntax 页为准 |
CREATE CONTINUOUS QUERY |
2.x 能力,3.x 已由 CREATE STREAM 取代 |
| Read Cache | cachemodel/cachesize 3.x 建库参数;与 2.x 行为基本一致 |
发布前核对 :流语法细节(trigger 名、option 名)以所装版本官方文档为准;升级后重跑 part8_stream 与结果校验。
7. 测试与验收
7.1 自动化断言
- 造数后创建流任务(
FILL_HISTORY回填 2026-08-01 的历史数据),断言结果表能被查询、行结构与预期一致; - 双路径校验:
station_hourly的某窗口avg_power与 Part 4 同窗口查询结果一致(误差在浮点容差内); - 状态窗口:
status_duration的seconds与seed_status的变位时间差一致; - 幂等重算:重跑流任务(
DELETE_RECALC)后结果表行数不变(同 ts 覆盖)。
7.2 本篇验收清单
- 能画出"写入 → 流计算 → 结果表 → 查询/订阅"的闭环图;
-
StreamManager并入 ems-lab,part8_stream编译运行,两个流任务创建成功并可查询结果; - 复现 4 个失败实验(迟到数据、滑窗内存、结果覆盖、缓存渐进生效)并解释根因;
- 能说清
WATERMARK、DELETE_RECALC、结果写入三规则(NULL 丢弃/同 ts 覆盖/追加); - 知道 3.3.6 LTS 与 3.4.x 的流引擎差异;
- 大屏最新值用
LAST_ROW(缓存),并知道cachemodel/cachesize如何配置。
8. 常见误区
| 误区 | 事实 |
|---|---|
| "流计算和订阅一样,都是推给应用" | 流计算的产出是结果表数据,应用要查/订阅结果表 |
| "流里迟到数据会自动归正确窗口" | 需要 WATERMARK/重算机制;默认可能进下一窗口 |
| "滑动窗口越细越精确" | 滑窗内存随窗口数增长,先算预算 |
| "结果表重复了,帮我修" | 同子表同 ts 覆盖是语义;要保留多版本需换主键设计 |
| "开了缓存最新值立刻全秒回" | 缓存渐进建立、容量有限;超大表首次查询仍走存储 |
| "流计算是独立组件,要单独部署" | 3.x 流引擎内置于 taosd(snode 角色),无独立进程 |
9. 本篇小结
流计算把"持续聚合"从应用搬进了数据库:
- 模型 :
CREATE STREAM ... INTO ... AS SELECT,窗口触发(INTERVAL/STATE/EVENT/COUNT/PERIOD),STREAM_OPTIONS调时效与资源; - 结果语义:主键 NULL 丢弃、同 ts 覆盖、追加------幂等重算的基石;
- 水位线 :
WATERMARK是迟到数据的答案; - Read Cache :
cachemodel/cachesize让LAST_ROW秒回,配大屏最新值; - 闭环:写入(Part 6)→ 流计算(本篇)→ 订阅(Part 7)/查询,全部在 TDengine 内完成。
至此,单机上的存取、查询、订阅、流计算全部打通。下一步跳出单机:集群架构与高可用------数据怎么分片、副本怎么同步、节点挂了怎么办。