TDengine Tag 设计哲学与 Schema 变更机制

2.数据模型 > 04 Tag 设计哲学与 Schema 变更机制 — 静态属性建模与在线结构演进

适用版本:TDengine v3.x(v3.3.x / v3.4.x) | 最后更新:2026-05-16
在这里插入图片描述

概述

Tag(标签)是 TDengine 数据模型中区别于传统数据库的核心创新之一。Tag 将设备的静态属性(如位置、型号、楼层)从时序数据中分离出来,既避免了大量重复存储,又提供了高效的多维度过滤和分组能力。

Schema 变更(ALTER TABLE/STABLE)则解决了生产环境中不可避免的"表结构演进"需求——添加新指标列、增删 Tag、修改列宽——且在线执行,不阻塞读写。

本文深入解析:

  1. Tag 的设计哲学与最佳实践
  2. Tag 的存储结构与索引机制
  3. Schema 变更的类型、语法与内部实现
  4. 变更对运行系统的影响

核心概念速查表

概念说明
Tag子表的静态属性,一张子表内所有行共享同一组 Tag 值
Tag 索引VNode 内部对 Tag 值的索引结构,加速 Tag 过滤查询
Schema 版本超级表/表结构变更时递增的版本号,用于兼容新旧数据
列式存储新增列不影响已有数据文件,旧文件中该列视为 NULL
在线 DDLSchema 变更不阻塞读写操作

详细解析

1. Tag 的设计哲学

1.1 数据分类原则

在 TDengine 中,时序数据被明确分为两类:

分类存储方式典型示例
度量值(Metric)列(Column),按时间有序存储温度、电压、CPU 使用率
静态属性(Metadata)标签(Tag),与子表绑定设备型号、安装位置、楼层编号
设计决策树:

  某个数据属性 X →
    │
    ├── X 是否随时间频繁变化?
    │     ├── 是 → 作为列(Column)
    │     └── 否 → 进一步判断
    │
    └── X 是否用于跨设备过滤/分组?
          ├── 是 → 作为标签(Tag)
          └── 否 → 考虑是否需要存储
1.2 Tag vs 列的存储开销对比
场景:10 万张子表,每表每天 8640 行(每 10 秒一条),保留 365 天

如果 "location" 作为列(Column):
  存储 = 10万 × 8640 × 365 × 32字节 ≈ 9.3 TB(压缩前)
  
如果 "location" 作为标签(Tag):
  存储 = 10万 × 32字节 = 3.2 MB
  
  节省比 ≈ 3,000,000:1

这就是 Tag 分离设计的核心价值——将重复的静态信息只存一份

1.3 Tag 的最佳建模实践
实践说明
高区分度属性优先设备 ID、区域编码等区分度高的属性适合做 Tag
常用过滤条件做 TagWHERE 子句中经常出现的条件字段应设为 Tag
避免高基数 × 低查询频率如果某属性几乎不会被用来过滤/分组,不需要设为 Tag
控制 Tag 总数最多 128 个 Tag,但建议 10~20 个以内
选择窄类型Tag 存储在内存索引中,BINARY(32) 优于 NCHAR(100)
避免频繁修改的属性Tag 值变更有开销,不适合每秒都会变的属性
1.4 什么不应该做 Tag
❌ 不适合做 Tag 的情况:

  1. 每秒都在变的值(如 GPS 坐标持续更新)
     → 应该作为列存储
     
  2. 唯一标识每一行的值(如流水号)
     → 这是时间戳的职责
     
  3. 与时间相关的状态(如"当前是否在线")
     → 应该作为列存储,用 LAST() 查最新状态
     
  4. 大文本(如设备说明书全文)
     → Tag 总大小限制 16KB,不适合存大文本

2. Tag 的存储与索引

2.1 Tag 的存储位置
Tag 存储架构:

  MNode(元数据中心):
    └── 超级表定义
          ├── Tag Schema(名称、类型、偏移量)
          └── 不存储具体 Tag 值

  VNode(数据节点):
    └── 元数据引擎(TDB)
          ├── 子表元数据记录
          │     ├── 子表 UID
          │     ├── 所属超级表 UID
          │     └── Tag 值(紧凑二进制格式)
          │
          └── Tag 索引
                ├── 主索引:子表名 → 子表元数据
                └── Tag 值索引:加速 WHERE tag = value 查询
2.2 Tag 索引机制

TDengine 为 Tag 列自动维护索引,不需要手动创建:

Tag 过滤查询流程:

  SELECT * FROM meters WHERE location = 'Beijing' AND group_id > 5
       │
       ▼
  各 VNode 并行执行 Tag 过滤:
       │
       ▼
  ① 扫描本 VNode 的子表 Tag 索引
  ② 对每张子表的 Tag 值进行条件匹配
  ③ 返回满足条件的子表列表
       │
       ▼
  只对这些子表执行时序数据扫描

Tag 索引的性能特点

  • Tag 数据量小(每子表只有一组 Tag 值),通常全部驻留内存
  • Tag 过滤是 O(N)扫描(N = 本 VNode 的子表数),但由于在内存中进行,非常快
  • 支持 =><LIKEIN 等条件
  • 支持创建额外的 Tag 索引加速特定列的查询
2.3 手动创建 Tag 索引
-- 为 location 列创建索引(默认已有基础索引,显式创建可加速特定查询模式)
CREATE INDEX idx_location ON meters (location);

-- 查看索引
SHOW INDEXES FROM meters;

-- 删除索引
DROP INDEX idx_location;

3. Tag 值的修改

3.1 修改语法
-- 修改子表的 Tag 值
ALTER TABLE d1001 SET TAG location = 'California.Oakland';

-- 修改多个 Tag(需多次执行)
ALTER TABLE d1001 SET TAG location = 'California.Oakland';
ALTER TABLE d1001 SET TAG group_id = 5;
3.2 修改的内部流程
Tag 值修改流程:

  ALTER TABLE d1001 SET TAG location = 'NewValue'
       │
       ▼
  ① 客户端将请求发往 d1001 所在 VNode
  ② VNode 更新元数据引擎中的 Tag 值
  ③ 更新 Tag 索引
  ④ 通过 Raft 复制到其他副本
  ⑤ 返回成功
  
  注意:
  - 不涉及时序数据的修改(数据文件不动)
  - 但修改后,查询历史数据时也会使用新的 Tag 值
  - 不是原子操作:多个 SET TAG 之间有短暂的中间状态
3.3 Tag 修改的影响
方面影响
当前查询新 Tag 值立即生效
历史查询也使用新值(不记录 Tag 变更历史)
写入不受影响
缓存客户端 Catalog 需要刷新(自动)
性能少量开销(索引更新),偶尔执行无问题

4. Schema 变更(列操作)

4.1 支持的变更类型
操作超级表子表普通表
添加列❌(跟随超级表)
删除列
修改列宽度(加宽)
修改列名
添加 Tag
删除 Tag
修改 Tag 名
修改 Tag 类型(加宽)

核心规则:子表的列 Schema 完全跟随超级表,不能独立变更。如需变更列结构,必须 ALTER 超级表。

4.2 列操作语法
-- === 添加列 ===
ALTER STABLE meters ADD COLUMN power FLOAT;
-- 所有子表立即"拥有"新列,历史数据中该列为 NULL

-- === 删除列 ===
ALTER STABLE meters DROP COLUMN phase;
-- 已写入的数据不会立即删除,后台合并时清理

-- === 修改列宽度(仅变长类型,只能加宽) ===
ALTER STABLE meters MODIFY COLUMN memo NCHAR(300);  -- 从 200 加宽到 300

-- === 修改列名 ===
ALTER STABLE meters RENAME COLUMN current current_amp;

-- === 普通表操作 ===
ALTER TABLE cpu_summary ADD COLUMN gpu_usage FLOAT;
ALTER TABLE cpu_summary DROP COLUMN disk_io;
4.3 Tag 操作语法
-- === 添加 Tag ===
ALTER STABLE meters ADD TAG owner BINARY(32);
-- 所有子表新增 Tag 初始值为 NULL

-- === 删除 Tag ===
ALTER STABLE meters DROP TAG owner;

-- === 修改 Tag 名 ===
ALTER STABLE meters RENAME TAG group_id gid;

-- === 修改 Tag 类型(加宽) ===
ALTER STABLE meters MODIFY TAG location BINARY(128);  -- 从 64 加宽到 128

5. Schema 变更的内部机制

5.1 版本号机制
Schema 版本控制:

  超级表创建时:schema_version = 1
  每次 ALTER:schema_version++
  
  数据文件记录:创建时的 schema_version
  
  读取旧数据时的兼容逻辑:
    if 数据文件的 schema_version < 当前 schema_version:
       新增的列 → 填充 NULL
       删除的列 → 跳过(不读取)
       加宽的列 → 旧数据按原宽度读取
5.2 添加列的实现
ADD COLUMN 内部流程:

  ALTER STABLE meters ADD COLUMN power FLOAT
       │
       ▼
  ① MNode 处理:
     - 检查列名不冲突、类型合法
     - 更新超级表 Schema(追加新列定义)
     - 递增 schema_version
     - 通过 Raft 持久化
     │
     ▼
  ② MNode → 所有 VNode 广播 Schema 变更通知
     │
     ▼
  ③ 各 VNode 执行:
     - 更新本地超级表 Schema 副本
     - 标记 schema_version 变更
     - 不修改任何已有数据文件
     │
     ▼
  ④ 客户端 Catalog 感知变更:
     - 下次查询时发现版本不一致
     - 自动拉取最新 Schema
     
  关键特性:
  ✓ 不重写数据文件(零 I/O 开销)
  ✓ 不阻塞读写
  ✓ 立即生效(新写入可携带新列)
  ✓ 历史数据中新列为 NULL(读取时填充)
5.3 删除列的实现
DROP COLUMN 内部流程:

  ALTER STABLE meters DROP COLUMN phase
       │
       ▼
  ① MNode 处理:
     - 检查不是时间戳列、不是最后一个数据列
     - 从 Schema 定义中标记该列已删除
     - 递增 schema_version
     │
     ▼
  ② 各 VNode:
     - 更新本地 Schema
     - 不立即删除数据文件中的旧数据
     │
     ▼
  ③ 读取旧数据时:
     - 按旧 schema_version 解码数据块
     - 跳过被删除的列(不返回给用户)
     │
     ▼
  ④ 后台合并(Compaction)时:
     - 重写数据块时不包含已删除的列
     - 磁盘空间逐步回收
5.4 变更限制
限制原因
不能删除时间戳列(第一列)时间戳是表的主键
不能修改列类型(只能加宽变长类型)避免数据转换和兼容性问题
不能减小列宽度已有数据可能超出新宽度
不能修改 Tag 类型(只能加宽)同上
子表不能独立变更列必须通过超级表统一管理
JSON Tag 不能与其他 Tag 操作混合JSON Tag 的特殊性

6. Schema 变更对查询的影响

6.1 读取兼容性
Schema 多版本读取示例:

  时间线:
  ──────────────────────────────────────────→
  Schema v1:       Schema v2:         Schema v3:
  [ts, a, b]       [ts, a, b, c]      [ts, a, c]  (删了b,加了c)
  │                │                   │
  数据文件1        数据文件2            数据文件3
  (v1 格式)       (v2 格式)           (v3 格式)
  
  查询 SELECT * FROM 表 时:
  - 文件1:读 ts, a, b → 输出 ts=值, a=值, c=NULL (c 不存在于v1)
  - 文件2:读 ts, a, b, c → 输出 ts=值, a=值, c=值 (b被过滤)
  - 文件3:读 ts, a, c → 输出 ts=值, a=值, c=值
6.2 写入兼容性
  • 添加列后,新写入的数据可以包含新列值
  • 如果写入请求未包含新列,该列自动填充 NULL
  • 客户端使用参数绑定(STMT)时需要刷新 Schema 后重新 prepare

7. 超级表与子表的 Schema 同步

Schema 同步机制:

  超级表 meters (v3):
    Columns: [ts, current, voltage, power]
    Tags: [location, group_id, owner]
         │
         │ Schema 变更自动传播
         ▼
    ┌─────────────────────────────────┐
    │ 所有子表自动继承新 Schema          │
    │                                 │
    │ d1001: Columns = 与超级表相同     │
    │        Tags = [值1, 值2, NULL]   │
    │                                 │
    │ d1002: Columns = 与超级表相同     │
    │        Tags = [值3, 值4, NULL]   │
    │        (新 Tag 'owner' 初始为NULL) │
    └─────────────────────────────────┘
    
  子表 ≠ 独立实体,子表 = 超级表的实例化

代码示例

典型的 Schema 演进场景

-- === 初始建模 ===
CREATE STABLE meters (
  ts TIMESTAMP,
  current FLOAT,
  voltage INT
) TAGS (
  location BINARY(64),
  group_id INT
);

-- === 第一次演进:发现需要记录相位角 ===
ALTER STABLE meters ADD COLUMN phase FLOAT;
-- 此后写入可以包含 phase 值,历史数据 phase 列为 NULL

-- === 第二次演进:增加设备负责人 Tag ===
ALTER STABLE meters ADD TAG owner BINARY(32);
-- 所有子表的 owner Tag 初始为 NULL
ALTER TABLE d1001 SET TAG owner = 'TeamA';
ALTER TABLE d1002 SET TAG owner = 'TeamB';

-- === 第三次演进:location 长度不够,需要加宽 ===
ALTER STABLE meters MODIFY TAG location BINARY(128);

-- === 第四次演进:发现 voltage 列名拼写不规范 ===
ALTER STABLE meters RENAME COLUMN voltage volt;

-- === 第五次演进:不再需要 group_id Tag ===
ALTER STABLE meters DROP TAG group_id;

查看 Schema 变更历史

-- 查看当前超级表结构
DESCRIBE meters;

-- 输出:
-- Field     | Type      | Length | Note
-- ts        | TIMESTAMP | 8      |
-- current   | FLOAT     | 4      |
-- volt      | INT       | 4      |
-- phase     | FLOAT     | 4      |
-- location  | BINARY    | 128    | TAG
-- owner     | BINARY    | 32     | TAG

性能考量

Schema 变更操作的代价

操作耗时对读写的影响
ADD COLUMN毫秒级(只改元数据)不阻塞
DROP COLUMN毫秒级不阻塞(空间后续回收)
MODIFY COLUMN(加宽)毫秒级不阻塞
ADD TAG毫秒级不阻塞
DROP TAG毫秒级不阻塞
SET TAG(修改值)毫秒级不阻塞

所有 Schema 变更都是在线操作,不需要停机维护。

Tag 数量对内存的影响

Tag 内存估算:

  每张子表的 Tag 存储 ≈ Tag 总宽度 + 管理开销
  
  示例:
    Tags = [location BINARY(64), group_id INT, owner BINARY(32)]
    Tag 宽度 ≈ 64 + 4 + 32 + 管理开销 ≈ 150 字节/子表
    
  100 万子表 × 150 字节 ≈ 150 MB(单 VNode)
  分布到 4 个 VGroup → 每 VNode 约 37.5 MB

Tag 过滤优化建议

场景建议
点查(Tag = 固定值)性能最优,O(1) 哈希查找
范围查(Tag > 值)全索引扫描,子表多时稍慢
LIKE 模糊查询无法利用索引前缀,全扫描
组合条件(AND/OR)AND 可短路,OR 需合并结果集

FAQ

Q1: Tag 值修改后,能否查到修改前的历史值?

不能。TDengine 不维护 Tag 值的变更历史。如果需要追踪属性变化,应该将该属性作为列(Column)存储,并在每次变化时写入一条新记录。

Q2: 超级表的列数上限是多少?

最多 4096 列(含时间戳列)。但实际建议控制在数百列以内。列数过多会:

  • 增大 Schema 元数据体积
  • 增加查询解析开销
  • 如果大部分列为 NULL,不如拆分为多张超级表

Q3: ALTER STABLE 是否需要停写?

不需要。Schema 变更是非阻塞的在线操作。变更过程中:

  • 正在执行的写入使用旧 Schema(不含新列)
  • 变更完成后的新写入使用新 Schema
  • 两者在读取时通过 Schema 版本号自动兼容

Q4: 为什么不能修改列的数据类型(如 INT → FLOAT)?

类型变更涉及:

  • 已有数据的格式转换(海量数据重写)
  • 压缩编码方式变化
  • 可能的精度丢失或溢出

代价过高且风险大。替代方案:添加新类型的列 → 应用层切换到新列 → 后续删除旧列。

Q5: JSON Tag 加了之后能再加普通 Tag 吗?

不能。JSON Tag 是排他性的——如果超级表使用了 JSON Tag,就不能再添加其他普通 Tag。反之亦然。这是设计约束,在建模时需要提前选择:

  • 使用多个结构化 Tag(类型明确,查询优化好)
  • 使用单个 JSON Tag(灵活但查询优化受限)

Q6: 删除列后磁盘空间什么时候释放?

删除列后磁盘空间不会立即释放。已有数据文件中仍然物理存储着被删除列的数据(只是查询时不再返回)。空间在以下时机释放:

  • 后台 Compaction(合并)时重写数据块
  • 数据过期删除(KEEP 到期删除整个文件)
  • 手动触发 COMPACT

参考

系统构架篇

数据模型

关于 TDengine

TDengine 专为物联网IoT平台、工业大数据平台设计。其中,TDengine TSDB 是一款高性能、分布式的时序数据库(Time Series Database),同时它还带有内建的缓存、流式计算、数据订阅等系统功能;TDengine IDMP 是一款AI原生工业数据管理平台,它通过树状层次结构建立数据目录,对数据进行标准化、情景化,并通过 AI 提供实时分析、可视化、事件管理与报警等功能。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

TDengine (老段)

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值