
2.数据模型 04 Tag 设计哲学与 Schema 变更机制 — 静态属性建模与在线结构演进适用版本TDengine v3.xv3.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在线 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 的子表数但由于在内存中进行非常快支持、、、LIKE、IN等条件支持创建额外的 Tag 索引加速特定列的查询2.3 手动创建 Tag 索引-- 为 location 列创建索引默认已有基础索引显式创建可加速特定查询模式CREATEINDEXidx_locationONmeters(location);-- 查看索引SHOWINDEXESFROMmeters;-- 删除索引DROPINDEXidx_location;3. Tag 值的修改3.1 修改语法-- 修改子表的 Tag 值ALTERTABLEd1001SETTAG locationCalifornia.Oakland;-- 修改多个 Tag需多次执行ALTERTABLEd1001SETTAG locationCalifornia.Oakland;ALTERTABLEd1001SETTAG group_id5;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 列操作语法-- 添加列 ALTERSTABLE metersADDCOLUMNpowerFLOAT;-- 所有子表立即拥有新列历史数据中该列为 NULL-- 删除列 ALTERSTABLE metersDROPCOLUMNphase;-- 已写入的数据不会立即删除后台合并时清理-- 修改列宽度仅变长类型只能加宽 ALTERSTABLE metersMODIFYCOLUMNmemoNCHAR(300);-- 从 200 加宽到 300-- 修改列名 ALTERSTABLE metersRENAMECOLUMNcurrentcurrent_amp;-- 普通表操作 ALTERTABLEcpu_summaryADDCOLUMNgpu_usageFLOAT;ALTERTABLEcpu_summaryDROPCOLUMNdisk_io;4.3 Tag 操作语法-- 添加 Tag ALTERSTABLE metersADDTAG ownerBINARY(32);-- 所有子表新增 Tag 初始值为 NULL-- 删除 Tag ALTERSTABLE metersDROPTAG owner;-- 修改 Tag 名 ALTERSTABLE metersRENAMETAG group_id gid;-- 修改 Tag 类型加宽 ALTERSTABLE metersMODIFYTAG locationBINARY(128);-- 从 64 加宽到 1285. Schema 变更的内部机制5.1 版本号机制Schema 版本控制 超级表创建时schema_version 1 每次 ALTERschema_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值, cNULL (c 不存在于v1) - 文件2读 ts, a, b, c → 输出 ts值, a值, c值 (b被过滤) - 文件3读 ts, a, c → 输出 ts值, a值, c值6.2 写入兼容性添加列后新写入的数据可以包含新列值如果写入请求未包含新列该列自动填充 NULL客户端使用参数绑定STMT时需要刷新 Schema 后重新 prepare7. 超级表与子表的 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 演进场景-- 初始建模 CREATESTABLE meters(tsTIMESTAMP,currentFLOAT,voltageINT)TAGS(locationBINARY(64),group_idINT);-- 第一次演进发现需要记录相位角 ALTERSTABLE metersADDCOLUMNphaseFLOAT;-- 此后写入可以包含 phase 值历史数据 phase 列为 NULL-- 第二次演进增加设备负责人 Tag ALTERSTABLE metersADDTAG ownerBINARY(32);-- 所有子表的 owner Tag 初始为 NULLALTERTABLEd1001SETTAG ownerTeamA;ALTERTABLEd1002SETTAG ownerTeamB;-- 第三次演进location 长度不够需要加宽 ALTERSTABLE metersMODIFYTAG locationBINARY(128);-- 第四次演进发现 voltage 列名拼写不规范 ALTERSTABLE metersRENAMECOLUMNvoltage volt;-- 第五次演进不再需要 group_id Tag ALTERSTABLE metersDROPTAG group_id;查看 Schema 变更历史-- 查看当前超级表结构DESCRIBEmeters;-- 输出-- 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 MBTag 过滤优化建议场景建议点查Tag 固定值性能最优O(1) 哈希查找范围查Tag 值全索引扫描子表多时稍慢LIKE 模糊查询无法利用索引前缀全扫描组合条件AND/ORAND 可短路OR 需合并结果集FAQQ1: 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-《支持数据类型深度解析》关于 TDengineTDengine 专为物联网IoT平台、工业大数据平台设计。其中TDengine TSDB 是一款高性能、分布式的时序数据库Time Series Database同时它还带有内建的缓存、流式计算、数据订阅等系统功能TDengine IDMP 是一款AI原生工业数据管理平台它通过树状层次结构建立数据目录对数据进行标准化、情景化并通过 AI 提供实时分析、可视化、事件管理与报警等功能。