

一个稍具规模的工业 IoT ,数据大致可以分成四类。它们看起来都"是时序数据",但写入和查询的形态完全不同。
数据类型 | 典型例子 | 写入特征 | 主要查询 |
|---|---|---|---|
高频测点时序 | 振动波形、温度、压力原始值 | 只追加,极高吞吐 | 按设备 + 时间段点查 |
设备档案 / 最新状态 | 设备型号、安装位置、当前工况 | 高频小批量更新 | 按主键取最新值 |
告警 / 工单流水 | 告警确认、工单流转 | 带状态变更的事务 | 按主键 + 状态过滤 |
历史归档 / 报表 | 年度报表、长周期统计 | 批量灌入,写后基本不动 | 全表 / 大跨度聚合 |

把这四类数据塞进同一个引擎,相当于让一个引擎同时承担"高吞吐追加 + 高频更新 + 事务 + 大扫描"四种压力。任何引擎都有自己的甜点区(sweet spot),超出甜点区的代价就是写放大、查询抖动、去重失效。
代价一:高频点查退化成全表扫描
振动监测里最典型的查询是"看某台设备最近一分钟的波形"。如果底层是纯列存(没有排序键索引),引擎只能定位到时间分区,然后在该分区里逐块扫描。设备少时还能扛,设备上千台、单分区几亿行后,点查延迟会从毫秒涨到秒级。
代价二:状态更新变成"删了再插"
设备当前工况会变(待机 / 运行 / 故障)。如果用只追加的时序表存"当前状态",更新一次状态就要删除旧行、插入新行,或者依赖应用层做去重。一旦并发上来,就会出现"最新状态到底是哪条"的混乱。
代价三:事务性操作丢一致性
告警工单的"确认 → 处理中 → 关闭"是状态机,需要事务保证(不能两个工单同时确认同一条告警,不能关闭一个未确认的告警)。纯时序引擎没有事务,这类逻辑只能搬到应用层,用 Redis 或关系库再扛一层,链路又复杂了。
这三类代价,本质上都是存储结构和访问模式不匹配。DolphinDB 的多模引擎,就是用"匹配更合适的存储结构"的思路来解决这件事。
在谈选型之前,先把每个引擎"是什么"讲清楚。DolphinDB 不是把五个独立的数据库拼在一起,而是同一套分布式框架下的五种存储实现,共享分区、事务、SQL 接口,区别只在数据怎么落盘、怎么建索引。
TSDB 是 DolphinDB 处理时序数据的默认引擎,也是 IoT 场景用得最多的一个。它的核心特征是 PAX 行列混存 + 排序键(sortKey)索引。
sortColumns 指定,引擎按这个顺序在块内对数据排序,并建立 sortKey 索引。点查时引擎先用分区裁剪定位分区,再用 sortKey 索引定位块,最后块内二分查找,整体复杂度接近 O(log n)。ALL 保留所有行(高保真原始数据)、LAST 每个 sortKey 只保留最后一条(状态更新语义)、FIRST 保留第一条。这是 TSDB 区别于纯追加时序库的关键能力——它可以在写入时做轻量去重。TSDB 的甜点区:高频追加 + 按设备+时间点查,正是 IoT 测点数据的访问形态。

OLAP 是更"传统"的纯列式引擎,没有 sortKey 索引,数据按列连续存放,适合全表扫描和长跨度聚合。
它的优势是压缩率极高、扫描吞吐大,劣势是没有高效的点查路径——查"某台设备某一秒"也得扫整个分区的列。所以 OLAP 适合写后基本不动、查询都是大范围聚合的场景,比如年度报表、长周期统计、历史冷数据。
PKEY 引擎围绕主键唯一性设计。它的内部维护一个按主键索引的结构,写入时如果主键已存在就覆盖旧值(upsert 语义),保证每个主键始终对应"最新的一条"。
这天然契合设备档案 / 最新状态这类数据:每台设备一条记录,属性更新时直接 upsert,查询永远拿到当前值。PKEY 还支持 CDC(Change Data Capture,变更数据捕获),可以把每次主键值的变更作为流推出来,用于驱动下游的"设备状态变化"实时逻辑。
IMOLTP 是内存数据库 + 事务引擎,数据驻留内存,通过 redo log + checkpoint 做持久化,支持完整的 ACID 事务。它的甜点区是高并发更新 + 强一致性——典型代表就是告警工单、订单流转这类需要事务保证的状态机。
VECTORDB 面向大规模向量的近似最近邻检索,主要用于 AI 场景下的相似性搜索(比如异常波形检索、设备画像聚类)。它和前四个引擎的访问模式差异较大,且偏向 AI 应用,本文不展开,只在最后一节简单提一句它的位置。
TSDB 是 IoT 场景的核心引擎,这一节把它拆开讲透。
下面是一个高频振动测点表的建表脚本。重点不在分区,而在 sortColumns 和 keepDuplicates。
// 复合分区:日期 VALUE + 设备 HASH,TSDB 引擎
db1 = database("", VALUE, 2026.01.01..2026.12.31)
db2 = database("", HASH, [SYMBOL, 20])
db = database("dfs://iot_ts", COMPO, [db1, db2], engine="TSDB")
// 振动原始波形表:10kHz 采样,高频追加
schema = table(1:0, `ts`deviceId`xAxis`yAxis`zAxis`temperature,
[DATETIME, SYMBOL, DOUBLE, DOUBLE, DOUBLE, DOUBLE])
db.createPartitionedTable(
schema, "vibration", `ts`deviceId,
sortColumns = `deviceId`ts,
keepDuplicates = ALL
)sortColumns =deviceId`ts`` 是这张表性能的关键。它决定了引擎在块内按"先设备、再时间"排序,sortKey 索引就建在这个顺序上。
排序键的设计直接决定点查性能,有几条原则:
deviceId)的基数高、且几乎所有查询都会带它,放第一列;时间戳 ts 放第二列。这样查询 where deviceId = "PUMP-007", ts between ... 时,sortKey 索引能直接定位到该设备在该时间段的数据块。排序键选错了,TSDB 的点查优势就没了,退化成接近全扫描。
keepDuplicates 是 TSDB 经常被忽略、但特别契合 IoT 的能力。它有三种取值:
// 场景 A:原始波形,要求高保真,一条都不能丢
keepDuplicates = ALL
// 场景 B:设备最新工况,同一个设备+时间点只保留最后一次写入
// 适合 MQTT 重传、边缘端补传导致的重复数据
keepDuplicates = LAST
// 场景 C:保留首次写入(较少用)
keepDuplicates = FIRST举一个真实场景:边缘端因为网络抖动,把同一秒的状态数据重传了三次。如果用 ALL,表里就有三条重复记录,下游统计 count(*) 就会虚高;如果用 LAST,引擎在写入时按 sortKey 去重,同一个 deviceId + ts 只保留最后一条,下游拿到的就是干净数据。
这个能力把"去重"从应用层下沉到了存储引擎,省掉了写前去重表、写后清洗的一整套逻辑。
把一次点查的路径串起来:
查询: select * from vibration
where deviceId = "PUMP-007"
and ts between 2026.03.15 10:00:00 and 2026.03.15 10:01:00
1. 分区裁剪 → 定位到 [2026.03.15 分区, PUMP-007 所在的 hash 桶]
2. sortKey 索引 → 在该分区内定位到 deviceId=PUMP-007 的数据块
3. 块内二分查找 → 按时间定位到 10:00:00~10:01:00 的行
4. PAX 列存读取 → 只把 xAxis/yAxis/zAxis/temperature 这几列读出来
四步下来,扫描的数据量从"整个分区几亿行"缩减到"单设备一分钟几千行"。这就是 TSDB 在 IoT 点查场景下能稳定保持毫秒级的根本原因——不是靠 brute force 的算力,是靠存储结构把"要扫描的数据"提前裁到很小。
讲清楚单个引擎后,来看完整的落地方案。一个工业 IoT 按四类数据分别建库、各用各的引擎,再通过跨库关联把数据串起来。
振动、温度、压力这类只追加、按设备+时间点查的数据,放 TSDB,设计同第三节。这是数据量最大、写入最频繁的一层。
// 测点库:TSDB,复合分区,sortColumns=[设备, 时间],keepDuplicates=ALL
dbPoint = database("dfs://iot_point", COMPO, [
database("", VALUE, 2026.01.01..2026.12.31),
database("", HASH, [SYMBOL, 20])
], engine="TSDB")设备型号、安装位置、固件版本、当前工况这类数据,需要按主键更新、查最新值。放 PKEY,主键就是设备 ID。
// 设备档案库:PKEY 引擎,按设备 ID 分区,主键唯一
dbMeta = database("dfs://iot_meta", RANGE, 1..101, engine="PKEY")
profile = table(1:0,
`deviceId`model`site`installDate`firmware`latestStatus`lastHeartbeat,
[INT, SYMBOL, SYMBOL, DATE, SYMBOL, SYMBOL, DATETIME])
// primaryKey 声明主键,写入时自动 upsert
dbMeta.createPartitionedTable(profile, "deviceProfile", , primaryKey=`deviceId)设备上线时 insert 一条档案;状态变化时,应用层只管 upsert 新值,引擎保证每个 deviceId 始终是最新的一条。再也不用担心"重复插入产生多条档案"或"更新时漏删旧记录"。
// 更新设备状态:PKEY 的 upsert 语义,主键相同即覆盖
t = table(101 as deviceId, `RUNNING as latestStatus, now() as lastHeartbeat)
loadTable("dfs://iot_meta", "deviceProfile").upsert!(t)
// 查询永远拿到当前值,无需关心历史快照
select * from loadTable("dfs://iot_meta", "deviceProfile") where deviceId = 101如果需要把"状态变化"作为事件驱动下游(比如设备从 RUNNING 变成 FAULT 时触发告警),PKEY 的 CDC 可以把每次变更捕获成流,订阅消费即可。
告警确认、工单流转是状态机,需要事务保证:不能两个操作员同时确认同一条告警,不能关闭一个尚未确认的工单。这一层放 IMOLTP。
// 工单库:IMOLTP 引擎,内存事务
dbTxn = database("dfs://iot_txn", VALUE, 2026.01.01..2026.12.31, engine="IMOLTP")
ticket = table(1:0,
`ticketId`alertId`deviceId`status`assignee`createdAt`updatedAt,
[LONG, LONG, INT, SYMBOL, SYMBOL, DATETIME, DATETIME])
dbTxn.createTable(ticket, "alertTicket")工单流转用事务包裹,保证状态机的一致性:
// 事务:确认告警 + 创建工单,要么全成功要么全回滚
def confirmAndAssign(alertId, assignee) {
txn {
// 1. 告警状态改为已确认
update loadTable("dfs://iot_txn", "alertTicket")
set status = `CONFIRMED, updatedAt = now()
where alertId = :alertId and status = `OPEN
// 2. 分配处理人,工单进入处理中
update loadTable("dfs://iot_txn", "alertTicket")
set status = `PROCESSING, assignee = :assignee, updatedAt = now()
where alertId = :alertId and status = `CONFIRMED
}
}事务保证这两个 update 原子完成。如果中间任意一步失败(比如告警已经被别人确认),整个事务回滚,不会出现"告警确认了但工单没流转"的中间态。
年度报表、跨季度统计这类写后不动、查询都是大跨度聚合的数据,放 OLAP。它的列存压缩率最高,扫描吞吐最大,做全表 group by 比 TSDB 更划算。
// 归档库:OLAP 引擎,按年分区
dbArchive = database("dfs://iot_archive", VALUE, 2026.01M..2026.12M, engine="OLAP")
// 设备日聚合表:每天每台设备一条,用于月报/年报
daily = table(1:0, `day`deviceId`runHours`avgTemp`maxVib`alertCount,
[DATE, INT, DOUBLE, DOUBLE, DOUBLE, INT])
dbArchive.createPartitionedTable(daily, "deviceDaily", `day)分库不等于数据孤岛。最常见的需求是"查某台设备某个时间段的振动数据,同时带上它的型号和当前状态"。这就需要把 PKEY 的档案和 TSDB 的测点关联起来。

// PKEY 档案 join TSDB 测点:用设备的元信息过滤时序数据
select v.ts, v.deviceId, p.model, p.site, p.latestStatus,
v.xAxis, v.yAxis, v.zAxis
from loadTable("dfs://iot_point", "vibration") as v
inner join loadTable("dfs://iot_meta", "deviceProfile") as p
on v.deviceId = p.deviceId
where v.deviceId = 101
and v.ts between 2026.03.15 10:00:00 : 2026.03.15 10:05:00
and p.model = `PUMP-A因为两个库都在 DolphinDB 同一个集群内,关联是库内计算,不涉及数据导出和网络搬运。这正是多模引擎的价值——分库是为了让每种数据用最优结构存储,关联时又能像在同一个库里一样无缝 join。
落到决策上,给一张工程上够用的选型表。
数据特征 | 推荐引擎 | 关键设计 |
|---|---|---|
只追加,按设备+时间点查(测点原始值) | TSDB | sortColumns=[设备, 时间], keepDuplicates=ALL |
只追加但需写时去重(状态补传) | TSDB | keepDuplicates=LAST |
高频更新,查最新值(设备档案/状态) | PKEY | primaryKey=设备ID |
需捕获变更驱动下游(状态变化事件) | PKEY + CDC | 订阅 CDC 流 |
事务性状态机(工单/告警流转) | IMOLTP | txn { ... } 包裹 |
写后不动,大跨度聚合(报表归档) | OLAP | 按时间粗粒度分区 |
几条容易踩的误区:
keepDuplicates=LAST 只能在 sortKey 粒度去重,不是主键级 upsert。该用 PKEY。
工业 IoT 的数据,从来不是单一的"时序数据",而是多种访问模式混合的数据集合。用一种存储结构通吃所有,看似简单,实则是把每种访问模式都拖离了甜点区。DolphinDB 的多模引擎,本质上是承认了这种多样性——匹配更合适的存储结构,再用统一的分布式框架和 SQL 接口把它们组织起来。
落到工程上,记住一句话就够了:
先把数据按访问模式分类,再为每一类选引擎;分库不是为了隔离,是为了让每种数据都在它最擅长的结构里跑。
测点走 TSDB、档案走 PKEY、工单走 IMOLTP、归档走 OLAP,需要关联时库内 join 把它们串起来。这套分层不是过度设计,而是让每层数据都能在数据量增长后依然保持稳定的性能——因为结构本身就在帮你裁剪扫描量、保证去重、维持一致性。
存储引擎选对了,后面所有的计算、治理、分析,才有了靠谱的地基。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。