2.数据模型 > 04 Tag 设计哲学与 Schema 变更机制 --- 静态属性建模与在线结构演进
适用版本:TDengine v3.x(v3.3.x / v3.4.x) | 最后更新:2026-05-16
概述
Tag(标签)是 TDengine 数据模型中区别于传统数据库的核心创新之一。Tag 将设备的静态属性(如位置、型号、楼层)从时序数据中分离出来,既避免了大量重复存储,又提供了高效的多维度过滤和分组能力。
Schema 变更(ALTER TABLE/STABLE)则解决了生产环境中不可避免的"表结构演进"需求------添加新指标列、增删 Tag、修改列宽------且在线执行,不阻塞读写。
本文深入解析:
- Tag 的设计哲学与最佳实践
- Tag 的存储结构与索引机制
- Schema 变更的类型、语法与内部实现
- 变更对运行系统的影响
核心概念速查表
| 概念 | 说明 |
|---|---|
| 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 的子表数),但由于在内存中进行,非常快
- 支持
=、>、<、LIKE、IN等条件 - 支持创建额外的 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
参考
系统构架篇
- 01-《TDengine 整体架构全景》
- 02-《集群拓扑深度解析》
- 03-《MNode 内部机制深度解析》
- 04-《RPC 通信层深度解析》
- 05-《VNode 生命周期》
- 06-《RAFT 共识协议》
- 07-《端到端的消息流》
数据模型
- 01-《数据库创建与参数详解》
- 02-《超级表/子表/普通表》
- 03-《支持数据类型深度解析》
关于 TDengine
TDengine 专为物联网IoT平台、工业大数据平台设计。其中,TDengine TSDB 是一款高性能、分布式的时序数据库(Time Series Database),同时它还带有内建的缓存、流式计算、数据订阅等系统功能;TDengine IDMP 是一款AI原生工业数据管理平台,它通过树状层次结构建立数据目录,对数据进行标准化、情景化,并通过 AI 提供实时分析、可视化、事件管理与报警等功能。
