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
在线 DDL Schema 变更不阻塞读写操作

详细解析

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
常用过滤条件做 Tag WHERE 子句中经常出现的条件字段应设为 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 索引
sql 复制代码
-- 为 location 列创建索引(默认已有基础索引,显式创建可加速特定查询模式)
CREATE INDEX idx_location ON meters (location);

-- 查看索引
SHOW INDEXES FROM meters;

-- 删除索引
DROP INDEX idx_location;

3. Tag 值的修改

3.1 修改语法
sql 复制代码
-- 修改子表的 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 列操作语法
sql 复制代码
-- === 添加列 ===
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 操作语法
sql 复制代码
-- === 添加 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 演进场景

sql 复制代码
-- === 初始建模 ===
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 变更历史

sql 复制代码
-- 查看当前超级表结构
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 提供实时分析、可视化、事件管理与报警等功能。

相关推荐
sxgzzn4 小时前
新能源场站数智化转型:基于数字孪生与AI的智慧运维管理平台解析
大数据·运维·人工智能
YOU OU4 小时前
Spring IoC&DI
java·数据库·spring
Muscleheng5 小时前
Navicat连接postgresql时出现‘datlastsysoid does not exist‘报错
数据库·postgresql
清平乐的技术专栏5 小时前
【Flink学习】(二)Flink 本地环境搭建,运行第一个入门程序
大数据·flink
这是程序猿5 小时前
Spring Boot自动配置详解
java·大数据·前端
ws2019075 小时前
AUTO TECH China 2026广州汽车零部件展:从整机集成迈向核心部件的产业跃升
大数据·人工智能·科技·汽车
humors2215 小时前
从数据到决策:汽车使用成本的精细计算指南
大数据·程序人生
大大大大晴天5 小时前
Flink技术实践:RocksDB 状态后端技术解密
大数据·flink
罗超驿5 小时前
18.事务的隔离性和隔离级别:MySQL面试高频考点全解析
数据库·mysql·面试