这是一份油田 SCADA 迁移指南:原地将传感器宽表重构为窄超表加元数据表,同时保持仪表盘运行。
现场每投用一种新仪表,就会带来一次模式变更:井场新增套管压力变送器,集输管线上安装流量计算机,已有多年读数的遥测宽表便多出一个带索引的列。你知道结构不合适,却被修复成本阻挡:重构这么大的表仿佛要重新摄入全部历史,而上层仪表盘又不能停机。
本指南介绍原地从时间序列宽表迁移到窄模式,保留历史、持续提供仪表盘服务。它不讨论标签描述信息随时间变化的更复杂场景,也承接原文链接的 Time-Series Cardinality,不再重复论证结构选择。
如果你同时控制摄入和查询,可以选择更温和的方法:在旧表旁边向新表并行摄入,再逐一切换查询。这能拆成小而独立可逆的步骤,条件允许时值得采用。本文针对无法重复摄入、或无法改写查询的常见情形。
Tiger Cloud Enterprise 计划提供迁移团队,可根据实际表设计迁移方案。
开始之前
最终,读数进入 (recorded_at, tag_id, value) 窄超表。现场新仪表只增加元数据表的一行,而不再给多年历史增加一列。原先每次读数重复的描述信息只在标签上保存一次。旧表名与旧列通过新结构上的视图保留,让不能停机的仪表盘继续工作。
原作者在一亿条生成读数上,整个过程机器时间不到7分钟,大部分用于步骤2的重构。测试数据干净,且没有实时摄入争用磁盘;这是原文硬件结果,不能视作生产时间保证。
安排迁移前,需满足:
- 源表是 TimescaleDB 超表,本文始终基于此假设。
- 迁移窗口内可控制新标签注册,实际意味着暂时冻结新增井场或仪表;步骤2会解释原因。
- 步骤4能找到短暂、相对空闲的切换时机。
下面是典型源表。这种表通常不是来自 PI 等将标签元数据与读数分离、且不存入 SQL 的商业历史数据库,而是来自自建摄入:项目从少量油井和手写 INSERT 开始,随后随井场和仪表增长。请将你的列映射到示例名称,后续均使用这些名称。
CREATE TABLE sensor_readings (
recorded_at TIMESTAMPTZ NOT NULL,
tag_id TEXT NOT NULL, -- tag path, e.g. 'Pad07/Well3/TubingPressure'
device_id TEXT NOT NULL,
site TEXT NOT NULL,
line TEXT NOT NULL,
firmware_ver TEXT NOT NULL,
unit TEXT NOT NULL,
value DOUBLE PRECISION NOT NULL
) WITH (tsdb.hypertable, tsdb.partition_column = 'recorded_at');
CREATE INDEX ON sensor_readings
(tag_id, device_id, site, line, firmware_ver, recorded_at DESC);
DDL 使用当前的表选项形式。源表可能通过 create_hypertable() 创建,这不会影响以下步骤。如果源表是普通 PostgreSQL 表而非超表,有两处更简单:步骤3可以使用 CREATE INDEX CONCURRENTLY,步骤4是普通 PostgreSQL 重命名,其余相同。
设计目标模式
触碰源表前,先创建目标。元数据表管理描述信息,窄超表管理事实:
CREATE TABLE tag_metadata (
tag_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tag_name TEXT NOT NULL UNIQUE, -- 'Pad07/Well3/TubingPressure'
device_id TEXT NOT NULL,
site TEXT NOT NULL,
line TEXT NOT NULL,
firmware_ver TEXT NOT NULL,
unit TEXT NOT NULL,
ts_start TIMESTAMPTZ,
ts_end TIMESTAMPTZ,
ts_last_seen TIMESTAMPTZ
);
CREATE TABLE sensor_readings_narrow (
recorded_at TIMESTAMPTZ NOT NULL,
tag_id BIGINT NOT NULL,
value DOUBLE PRECISION NOT NULL
) WITH (
tsdb.hypertable,
tsdb.partition_column = 'recorded_at',
tsdb.create_default_indexes = false,
tsdb.segmentby = 'tag_id'
);
结构是常见模式,元数据最佳实践解释了原因,基数文章测量了收益。DDL 中三项选择决定收益:
- 用窄的 BIGINT 代理键作为
tag_id。文本标签路径只在元数据行的tag_name保存一次;每个事实行都重复较大的 TEXT 键,会抵消大部分优势。 - 在摄入层保证引用完整性。事实表指向元数据表的外键会给高频摄入增加逐行校验,因此此处未声明。如仍需外键,受支持的方向是“超表引用普通表”,与本例一致。
- 按
tag_id分段。按标签分段的压缩能进一步放大窄模式优势,来源标识通常适合做 segmentby。
这样创建表后,列存默认开启,TimescaleDB 自动创建列存策略,每日将超过一个块间隔的块转换。即将加载的历史切片都超过这个期限,策略可能在步骤2写入时同时转换。
官方回填与转换指南建议暂停策略,避免转换与同一块上的并发写入争锁。现在暂停,核验通过后重新启用:
SELECT alter_job(job_id, scheduled => false) FROM timescaledb_information.jobs WHERE proc_name = 'policy_compression' AND hypertable_name = 'sensor_readings_narrow';
DDL 故意不建索引,tsdb.create_default_indexes = false 禁止默认索引,步骤3再建复合索引。单个 value DOUBLE PRECISION 列假定每个标签都是浮点数。工业标签也可能是布尔、状态码或字符串,文末给出多类型列变体及三个需要修改的地方。重构前作出选择。
步骤0:检查标签描述信息是否稳定
迁移依赖一个假设:描述信息函数依赖于标签,即同一标签始终属于同一设备、场站、管线,使用同一单位。这个检查决定迁移是半天工作还是一个项目。只需扫描一次,原作者对一亿行检查约用一分钟。
SELECT tag_id, COUNT(*) FROM ( SELECT DISTINCT tag_id, device_id, site, line, firmware_ver, unit FROM sensor_readings ) d GROUP BY tag_id HAVING COUNT(*) > 1;
结果为空,说明每个标签历史上只有一套描述信息,可以直接执行步骤1和后续流程。
有结果,说明部分标签生命周期中改变了描述信息。井场上很常见:修井使变送器从一口井移到另一口井,仪表更换改变标签路径对应设备,RTU 固件升级改变其所有标签的 firmware_ver。
这些标签需要有效时间窗口:元数据中的 ts_start、ts_end 为每套描述信息建立有时间边界的行,ts_last_seen 追踪当前行。这样步骤2的连接依赖时间,明显更慢,也属于更长的迁移,不只是更换 INSERT。本文不覆盖,后续文章将介绍。如果步骤0返回记录,应停下重新界定迁移范围,不能强套本文流程。
步骤1:回填元数据
扫描宽表,每个标签一行;文本路径写入 tag_name,自动生成代理键。
INSERT INTO tag_metadata (tag_name, device_id, site, line, firmware_ver, unit) SELECT DISTINCT s.tag_id, s.device_id, s.site, s.line, s.firmware_ver, s.unit FROM sensor_readings s WHERE NOT EXISTS ( SELECT 1 FROM tag_metadata m WHERE m.tag_name = s.tag_id );
保护条件使用 NOT EXISTS,而不是 ON CONFLICT ... DO NOTHING,两者差别很重要。它们都支持安全重跑,但只有 NOT EXISTS 保留步骤0的安全网:若标签仍意外有两套描述信息,tag_name 的 UNIQUE 约束会明确报错;ON CONFLICT 则会悄悄忽略,保留先看到的一套。
步骤2:按可恢复的时间切片重构事实
使用控制表,每个时间切片一个事务,同时写入事实与完成标记。面对多 TB 超表,不应执行一个无边界的 INSERT SELECT:长事务可能持锁数小时、产生超过副本消化能力的 WAL,并且在完成90%时失败后无法恢复。
CREATE TABLE migration_slices ( slice_start TIMESTAMPTZ PRIMARY KEY, slice_end TIMESTAMPTZ NOT NULL, completed_at TIMESTAMPTZ NOT NULL DEFAULT now(), row_count BIGINT NOT NULL );
用控制表记录完成,而不对 (tag_id, recorded_at) 建唯一约束,因为这种规模下约束昂贵,现场遥测也可能不满足唯一性。一周是合理切片宽度,但应按硬件选择,使事务持续数分钟。
BEGIN; WITH moved AS ( INSERT INTO sensor_readings_narrow (recorded_at, tag_id, value) SELECT r.recorded_at, m.tag_id, r.value FROM sensor_readings r JOIN tag_metadata m ON m.tag_name = r.tag_id WHERE r.recorded_at >= TIMESTAMPTZ '2024-01-01' AND r.recorded_at < TIMESTAMPTZ '2024-01-08' RETURNING 1 ) INSERT INTO migration_slices (slice_start, slice_end, row_count) SELECT TIMESTAMPTZ '2024-01-01', TIMESTAMPTZ '2024-01-08', count(*) FROM moved; COMMIT;
slice_start 主键覆盖两种重复执行情形。重试已完成切片时,控制行插入因主键失败,同一事务中的事实一起回滚,不重复数据,只浪费一次切片工作。切片中途终止时,事务全部回滚,不写控制行,重试从干净状态开始。
内连接会无声丢弃元数据表中没有的标签。步骤1之后首次出现的新标签正是这种情况,因此前提要求冻结注册。如果无法冻结,应在每个切片前重跑步骤1;NOT EXISTS 保护支持这样做。
驱动循环也是陷阱。应向控制表查询缺失窗口,不能用 MAX(slice_end) 高水位,因为它会跨过并行切片或崩溃留下的空洞。
SELECT g.slice_start, g.slice_start + INTERVAL '7 days' AS slice_end FROM generate_series(TIMESTAMPTZ '2024-01-01', now(), INTERVAL '7 days') AS g(slice_start) WHERE NOT EXISTS ( SELECT 1 FROM migration_slices s WHERE s.slice_start = g.slice_start ) ORDER BY g.slice_start;
把结果交给切片执行器,可以并行;无结果时循环结束。但应停在当前时间所在切片之前,因为切换前摄入仍写宽表,当前切片天然不完整。切换后最后执行它,以 sensor_readings_old 为源;此时旧表不再接收写入,切片最终完整。
不要在未先删除对应事实和控制行时重跑已完成切片,否则该窗口的事实可能重复。
步骤3:加载后建索引
批量加载后再建索引:加载无需维护索引,明显更快,最终 B-tree 也更紧密。超表不支持常见的 CREATE INDEX CONCURRENTLY;替代方案是每块一个事务:
CREATE INDEX sensor_readings_narrow_tag_time_idx ON sensor_readings_narrow (tag_id, recorded_at DESC) WITH (timescaledb.transaction_per_chunk);
构建某块索引时,其他块仍可写入。如果查询只按时间扫描而没有标签条件,也应在此步骤加入普通 (recorded_at DESC) 索引,因为默认索引已禁用。
如果构建因取消、崩溃或错误中断,根索引会标为无效,部分块有索引、部分没有。它仍工作,并适用于新块,只是漏建的块无索引。为保证覆盖完整,查找、删除并重建:
SELECT indexrelid::regclass FROM pg_index WHERE indisvalid IS FALSE;
然后对 sensor_readings_narrow 与 tag_metadata 执行 ANALYZE。兼容视图的读取路径即将加入连接,规划器需要新统计信息。
步骤4:在兼容视图后切换
PostgreSQL 按 OID 而非名称标识表,因此 ALTER TABLE RENAME 是元数据操作,与表大小无关,超表也是如此。但仅重命名并不能实现无破坏切换:新窄表只有三列,旧表有八列,查询 site 或 line 会立即失败。
以旧名称创建视图,恢复旧结构,仪表盘无需感知迁移:
BEGIN; SET LOCAL lock_timeout = '5s'; ALTER TABLE sensor_readings RENAME TO sensor_readings_old; CREATE VIEW sensor_readings AS SELECT r.recorded_at, m.tag_name AS tag_id, m.device_id, m.site, m.line, m.firmware_ver, m.unit, r.value FROM sensor_readings_narrow r JOIN tag_metadata m USING (tag_id); COMMIT;
lock_timeout 必须设置。重命名取得 ACCESS EXCLUSIVE 锁;若前面有长查询,重命名排队,其后读取又排在重命名之后,元数据操作便可能变成停机。原作者的双会话测试中,3秒锁超时按时触发,没有持续排队。设置短超时,在空闲时重试。
CREATE VIEW 故意放在同一事务内,避免重命名提交到视图创建之间出现 sensor_readings 不存在的间隙。
视图在读路径增加连接。原基数文章相对直接查询窄表测得,汇总查询约增加2%成本,亚毫秒查询差异低于测量噪声。视图仅用于读取,应显式将摄入转向窄表,而不是通过 INSTEAD OF 触发器。
切换提交后,摄入服务解析元数据代理键,写入 (recorded_at, tag_id, value)。没有视图,就得在切换当天改写全部仪表盘;有了视图,可以按自己的计划逐步将最热查询迁向窄表。
重新指向重命名后留在旧表上的依赖
依赖对象仍绑定原对象:连续聚合、保留与压缩策略、触发器和权限留在重命名后的旧表上。原作者演练中,真实连续聚合仍汇总 sensor_readings_old,没有报警,只是在摄入切换后不再看到新行。这类失败最可能造成真实数据损失。
切换前、表仍用原名时,清点全部依赖:
-- Continuous aggregates reading the wide table
SELECT view_name, materialization_hypertable_name
FROM timescaledb_information.continuous_aggregates
WHERE hypertable_name = 'sensor_readings';
-- Retention and columnstore policies bound to it. Refresh policies list
-- under the aggregate's materialization hypertable, found above.
SELECT job_id, application_name, proc_name, config
FROM timescaledb_information.jobs
WHERE hypertable_name = 'sensor_readings';
-- Triggers and grants
SELECT tgname FROM pg_trigger
WHERE tgrelid = 'sensor_readings'::regclass AND NOT tgisinternal;
SELECT grantee, privilege_type FROM information_schema.role_table_grants
WHERE table_name = 'sensor_readings';
切换后逐个重新设置。连续聚合不能直接换源,需基于窄表重建。如果按描述信息分组,则连接元数据表:TimescaleDB 2.10起支持这种连接,但只追踪超表变化;元数据更新需手动刷新才进入聚合。恢复刷新策略,新聚合追上后删除旧聚合。
在窄表上重建触发器。原读者如今访问视图,因此旧表的读取权限需授予视图。
保留策略最危险,应最后重建。窄表上的策略第一次执行时,会按当前时间检查刚迁入的全部块,早于 drop_after 的立即删除。原作者演练中,历史最新记录都超过一年,365天策略几秒内删除所有块。
重加策略前,比较保留窗口与窄表最老块,明确历史是否应保留。如果应保留,策略要等到历史迁出或归档。旧表自己的保留策略也有相反风险:它跟随 sensor_readings_old,继续删除你依赖的回退历史。切换后立即使用同样的 alter_job 暂停它。
核验迁移,并知道失败后如何处理
在重叠范围一致前保留旧表。摄入写窄表、视图从窄表提供读取、旧表保留。在以下检查通过前维持对账窗口。
便宜的检查是逐切片比较控制表记录的行数:
SELECT s.slice_start, s.row_count AS moved, (SELECT count(*) FROM sensor_readings_old o WHERE o.recorded_at >= s.slice_start AND o.recorded_at < s.slice_end) AS original FROM migration_slices s WHERE s.row_count <> (SELECT count(*) FROM sensor_readings_old o WHERE o.recorded_at >= s.slice_start AND o.recorded_at < s.slice_end) ORDER BY s.slice_start;
结果为空,表示每个切片迁入行数与旧表对应窗口一致。有记录,可能是内连接丢弃了标签,或切片运行时窗口仍有摄入。
更强检查是在两侧样本窗口按天计算校验信息:行数、首末时间戳精确比较,值总和使用相对容差。容差很重要:两次全表扫描以不同物理顺序累加一亿个双精度数,即使数据无误,精确比较也可能因浮点舍入失败。原作者七天校验为零差异。
WITH old_side AS ( SELECT recorded_at::date AS day, count(*) AS n, min(recorded_at) AS min_ts, max(recorded_at) AS max_ts, sum(value) AS sum_value FROM sensor_readings_old WHERE recorded_at >= TIMESTAMPTZ '2024-01-01' AND recorded_at < TIMESTAMPTZ '2024-01-08' GROUP BY 1 ), new_side AS ( SELECT recorded_at::date AS day, count(*) AS n, min(recorded_at) AS min_ts, max(recorded_at) AS max_ts, sum(value) AS sum_value FROM sensor_readings_narrow WHERE recorded_at >= TIMESTAMPTZ '2024-01-01' AND recorded_at < TIMESTAMPTZ '2024-01-08' GROUP BY 1 ) SELECT coalesce(o.day, n.day) AS day, o.n AS old_n, n.n AS new_n FROM old_side o FULL OUTER JOIN new_side n USING (day) WHERE o.n IS DISTINCT FROM n.n OR o.min_ts IS DISTINCT FROM n.min_ts OR o.max_ts IS DISTINCT FROM n.max_ts OR abs(o.sum_value - n.sum_value) > 1e-9 * greatest(abs(o.sum_value), abs(n.sum_value), 1);
只有两项检查都通过、仪表盘在视图上跑过正常周期后,才删除旧表。此时也通过 alter_job、scheduled => true 恢复先前暂停的列存任务。删除旧表之前,切换仍保留重命名回退基础。
| 现象 | 原因 | 处理 |
|---|---|---|
| 步骤1触发 tag_name UNIQUE | 标签有两套描述信息 | 停止,按步骤0生命周期情形重新界定;不要放宽约束 |
| 切片中途失败 | 事务内崩溃、终止或超时 | 重跑;完整回滚与主键保护使直接重试安全 |
| 切片行数不一致 | 回填后新标签被内连接丢弃,或窗口仍摄入 | 重跑步骤1,删除该切片事实及控制行,再从旧表重跑 |
| 部分窗口没完成 | 使用高水位驱动 | 查询控制表缺失窗口并执行 |
| 索引构建中断 | 根索引无效 | 查 pg_index 的 indisvalid IS FALSE,删除并重建 |
| 重命名超时 | 长查询持锁 | 空闲时重试,不能移除 lock_timeout |
| 切换后仪表盘失效 | 视图缺少查询所需列 | 修复视图,不改查询 |
| 聚合或策略仍读旧表 | 重命名保留对象绑定 | 按上一节重新指向 |
迁移后你负责什么
你得到窄超表、每标签一行的元数据表、无感切换的仪表盘,以及尚可回退的旧表。正式窗口前应演练:先运行步骤0,再将一周宽表数据复制到临时模式,保持相同冻结条件运行步骤1–4,并故意终止一个切片,观察自己硬件上的回滚。
若不希望自行维护重建的列存与保留策略,Tiger Cloud 可管理超表、压缩和策略;正式迁移前希望专家复核,也可使用 Tiger Data 支持计划。
附录:标签不全是浮点数时
仅浮点模式便于说明,许多遥测表确实只有测量值。若还有泵运行状态、阀门位置、报警码或文本模式,通常使用 Tiger Data 所称“中等表结构”:每种数据类型一个可空值列。新标签仍只增加一行元数据,只有新类型才增加列。
CREATE TABLE sensor_readings_narrow (
recorded_at TIMESTAMPTZ NOT NULL,
tag_id BIGINT NOT NULL,
value DOUBLE PRECISION, -- measurements
value_int BIGINT, -- booleans as 0/1, state and alarm codes
value_text TEXT, -- mode labels, free-text states
CHECK (num_nonnulls(value, value_int, value_text) = 1)
) WITH (
tsdb.hypertable,
tsdb.partition_column = 'recorded_at',
tsdb.create_default_indexes = false,
tsdb.segmentby = 'tag_id'
);
浮点列仍叫 value,步骤4视图可以继续为测量标签提供原列。CHECK 保证每行恰好一个非空值,不需查表,因此比外键在摄入路径上便宜。另有三处变化:
- 步骤2:INSERT 将各类型列写入对应目标,而不只是单个 value。
- 步骤4:兼容视图同时选择 value_int、value_text,保留状态和文本查询。
- 核验:按天校验仅累加 value,写错类型列的状态/文本可能漏检,应在两侧增加 count(value_int)、count(value_text)。
原文:Normalizing a Wide Well and Pipeline Telemetry Table Without Re-Ingesting History
作者:Damaso Sanoja,2026年9月30日。本文为中文译稿;计时、演练和结果均为原文报告,本次未运行 SQL。SQL 中注释恢复为独立行以避免网页文本提取将 -- 后续语句吞入注释;标识符与示例值保留原文。© Timescale, Inc., d/b/a Tiger Data,保留所有权利;转载依据用户明确授权。原文配图尚待核验。











暂无评论内容