
摘要
写物联网平台的人,大多在"写入"这件事上踩过同一个坑:传感器数据用一张大宽表存,振动原始波形、设备状态、告警工单、设备档案全往里塞。上线时一切正常,跑两三个月后开始出怪事------高频测点查询越来越慢、设备状态更新总有时延、告警工单偶尔出现重复确认、历史报表扫描把内存吃光。
这些问题表面看是"数据量大",根因往往是用一种存储结构去承载多种访问模式。工业 IoT 平台里至少有四类数据,访问模式完全不同:高频时序是"只追加、按设备+时间点查",设备档案是"按主键高频更新、查最新值",告警工单是"带事务的状态流转",历史归档是"全表扫描做大跨度聚合"。把它们塞进同一个引擎,等于让一个引擎去做它不擅长的事。
DolphinDB 提供了 TSDB、OLAP、PKEY、IMOLTP、VECTORDB 多个存储引擎。本文不谈概念表,只谈工程取舍 :每一个引擎内部是什么结构,哪类 IoT 数据该用它,建表时sortColumns/keepDuplicates/primaryKey怎么设计,最后给出一套覆盖测点、档案、工单、归档的多引擎落地方案。
一、问题起点:为什么"一张表存所有"会出问题
1.1 四类数据,四种访问模式
一个稍具规模的工业 IoT 平台,数据大致可以分成四类。它们看起来都"是时序数据",但写入和查询的形态完全不同。
|-------------|----------------|-------------|-------------|
| 数据类型 | 典型例子 | 写入特征 | 主要查询 |
| 高频测点时序 | 振动波形、温度、压力原始值 | 只追加,极高吞吐 | 按设备 + 时间段点查 |
| 设备档案 / 最新状态 | 设备型号、安装位置、当前工况 | 高频小批量更新 | 按主键取最新值 |
| 告警 / 工单流水 | 告警确认、工单流转 | 带状态变更的事务 | 按主键 + 状态过滤 |
| 历史归档 / 报表 | 年度报表、长周期统计 | 批量灌入,写后基本不动 | 全表 / 大跨度聚合 |

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

2.2 OLAP:大跨度聚合的列存引擎
OLAP 是更"传统"的纯列式引擎,没有 sortKey 索引,数据按列连续存放,适合全表扫描和长跨度聚合。
它的优势是压缩率极高、扫描吞吐大,劣势是没有高效的点查路径------查"某台设备某一秒"也得扫整个分区的列。所以 OLAP 适合写后基本不动、查询都是大范围聚合的场景,比如年度报表、长周期统计、历史冷数据。
2.3 PKEY:主键唯一 + CDC
PKEY 引擎围绕主键唯一性设计。它的内部维护一个按主键索引的结构,写入时如果主键已存在就覆盖旧值(upsert 语义),保证每个主键始终对应"最新的一条"。
这天然契合设备档案 / 最新状态 这类数据:每台设备一条记录,属性更新时直接 upsert,查询永远拿到当前值。PKEY 还支持 CDC(Change Data Capture,变更数据捕获),可以把每次主键值的变更作为流推出来,用于驱动下游的"设备状态变化"实时逻辑。
2.4 IMOLTP:内存事务引擎
IMOLTP 是内存数据库 + 事务 引擎,数据驻留内存,通过 redo log + checkpoint 做持久化,支持完整的 ACID 事务。它的甜点区是高并发更新 + 强一致性------典型代表就是告警工单、订单流转这类需要事务保证的状态机。
时序数据本身很少需要 IMOLTP,但一个完整的 IoT 平台里,"工单 / 告警状态机"这一层往往需要它。
2.5 VECTORDB:向量检索(本文略)
VECTORDB 面向大规模向量的近似最近邻检索,主要用于 AI 场景下的相似性搜索(比如异常波形检索、设备画像聚类)。它和前四个引擎的访问模式差异较大,且偏向 AI 应用,本文不展开,只在最后一节简单提一句它的位置。
三、主力引擎深挖:TSDB 的 sortColumns 与 keepDuplicates
TSDB 是 IoT 场景的核心引擎,这一节把它拆开讲透。
3.1 建一张"对"的测点表
下面是一个高频振动测点表的建表脚本。重点不在分区,而在 sortColumns 和 keepDuplicates。
sql
// 复合分区:日期 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 索引就建在这个顺序上。
3.2 sortColumns 的设计原则
排序键的设计直接决定点查性能,有几条原则:
-
把高基数、常用于过滤的列放前面 。设备 ID(
deviceId)的基数高、且几乎所有查询都会带它,放第一列;时间戳ts放第二列。这样查询where deviceId = "PUMP-007", ts between ...时,sortKey 索引能直接定位到该设备在该时间段的数据块。 -
不要把低基数列(比如工厂编号只有 3 个值)放第一个。低基数列在前会让 sortKey 区分度不够,索引退化。
-
排序键列数不宜过多,一般 2--3 列。列数多了,写入时排序开销变大,索引也膨胀。
排序键选错了,TSDB 的点查优势就没了,退化成接近全扫描。
3.3 keepDuplicates:用写时去重替代应用层去重
keepDuplicates 是 TSDB 经常被忽略、但特别契合 IoT 的能力。它有三种取值:
sql
// 场景 A:原始波形,要求高保真,一条都不能丢
keepDuplicates = ALL
// 场景 B:设备最新工况,同一个设备+时间点只保留最后一次写入
// 适合 MQTT 重传、边缘端补传导致的重复数据
keepDuplicates = LAST
// 场景 C:保留首次写入(较少用)
keepDuplicates = FIRST
举一个真实场景:边缘端因为网络抖动,把同一秒的状态数据重传了三次。如果用 ALL,表里就有三条重复记录,下游统计 count(*) 就会虚高;如果用 LAST,引擎在写入时按 sortKey 去重,同一个 deviceId + ts 只保留最后一条,下游拿到的就是干净数据。
这个能力把"去重"从应用层下沉到了存储引擎,省掉了写前去重表、写后清洗的一整套逻辑。
3.4 一张图理解 TSDB 的查询路径
把一次点查的路径串起来:
sql
查询: 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 平台的多引擎落地方案
讲清楚单个引擎后,来看完整的落地方案。一个工业 IoT 平台,按四类数据分别建库、各用各的引擎,再通过跨库关联把数据串起来。
4.1 高频测点时序 → TSDB
振动、温度、压力这类只追加、按设备+时间点查的数据,放 TSDB,设计同第三节。这是数据量最大、写入最频繁的一层。
sql
// 测点库:TSDB,复合分区,sortColumns=[设备, 时间],keepDuplicates=ALL
dbPoint = database("dfs://iot_point", COMPO, [
database("", VALUE, 2026.01.01..2026.12.31),
database("", HASH, [SYMBOL, 20])
], engine="TSDB")
4.2 设备档案与最新状态 → PKEY
设备型号、安装位置、固件版本、当前工况这类数据,需要按主键更新、查最新值。放 PKEY,主键就是设备 ID。
sql
// 设备档案库: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 始终是最新的一条。再也不用担心"重复插入产生多条档案"或"更新时漏删旧记录"。
sql
// 更新设备状态: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 可以把每次变更捕获成流,订阅消费即可。
4.3 告警工单与事务流水 → IMOLTP
告警确认、工单流转是状态机,需要事务保证:不能两个操作员同时确认同一条告警,不能关闭一个尚未确认的工单。这一层放 IMOLTP。
sql
// 工单库: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")
工单流转用事务包裹,保证状态机的一致性:
sql
// 事务:确认告警 + 创建工单,要么全成功要么全回滚
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 原子完成。如果中间任意一步失败(比如告警已经被别人确认),整个事务回滚,不会出现"告警确认了但工单没流转"的中间态。
4.4 长周期历史归档 → OLAP
年度报表、跨季度统计这类写后不动、查询都是大跨度聚合 的数据,放 OLAP。它的列存压缩率最高,扫描吞吐最大,做全表 group by 比 TSDB 更划算。
sql
// 归档库: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)
4.5 跨引擎关联:把档案和测点串起来
分库不等于数据孤岛。最常见的需求是"查某台设备某个时间段的振动数据,同时带上它的型号和当前状态"。这就需要把 PKEY 的档案和 TSDB 的测点关联起来。

sql
// 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 | 按时间粗粒度分区 |
几条容易踩的误区:
-
别用 TSDB 存设备档案 。档案需要按主键更新,TSDB 的更新是"标记删除 + 追加",高频更新会产生大量历史版本,
keepDuplicates=LAST只能在 sortKey 粒度去重,不是主键级 upsert。该用 PKEY。 -
别用 OLAP 做实时点查。OLAP 没有 sortKey 索引,点查会退化成列扫描。实时看板点查必须走 TSDB。
-
别把告警工单塞进 TSDB。TSDB 没有事务,状态机的一致性保证不了。该用 IMOLTP,或者至少用 PKEY + 应用层加锁。
-
别对归档数据用细粒度分区。OLAP 归档数据查询都是大跨度,分区太细(比如按天)会让一次年报查询跨几百个分区,元数据开销反而拖慢。按月或按季更合适。

六、写在最后
工业 IoT 平台的数据,从来不是单一的"时序数据",而是多种访问模式混合的数据集合 。用一种存储结构通吃所有,看似简单,实则是把每种访问模式都拖离了甜点区。DolphinDB 的多模引擎,本质上是承认了这种多样性------匹配更合适的存储结构,再用统一的分布式框架和 SQL 接口把它们组织起来。
落到工程上,记住一句话就够了:
先把数据按访问模式分类,再为每一类选引擎;分库不是为了隔离,是为了让每种数据都在它最擅长的结构里跑。
测点走 TSDB、档案走 PKEY、工单走 IMOLTP、归档走 OLAP,需要关联时库内 join 把它们串起来。这套分层不是过度设计,而是让每层数据都能在数据量增长后依然保持稳定的性能------因为结构本身就在帮你裁剪扫描量、保证去重、维持一致性。
存储引擎选对了,后面所有的计算、治理、分析,才有了靠谱的地基。