工业 IoT 存储引擎选型:多模引擎在数据模型与存储设计上的取舍


摘要

写物联网平台的人,大多在"写入"这件事上踩过同一个坑:传感器数据用一张大宽表存,振动原始波形、设备状态、告警工单、设备档案全往里塞。上线时一切正常,跑两三个月后开始出怪事------高频测点查询越来越慢、设备状态更新总有时延、告警工单偶尔出现重复确认、历史报表扫描把内存吃光。
这些问题表面看是"数据量大",根因往往是用一种存储结构去承载多种访问模式。工业 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)。

  • 去重策略 keepDuplicatesALL 保留所有行(高保真原始数据)、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 建一张"对"的测点表

下面是一个高频振动测点表的建表脚本。重点不在分区,而在 sortColumnskeepDuplicates

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 把它们串起来。这套分层不是过度设计,而是让每层数据都能在数据量增长后依然保持稳定的性能------因为结构本身就在帮你裁剪扫描量、保证去重、维持一致性。

存储引擎选对了,后面所有的计算、治理、分析,才有了靠谱的地基。

相关推荐
凌虚14 小时前
面向 MySQL 用户的 PostgreSQL 快速上手指南
数据库·后端·架构
五阿哥永琪14 小时前
MySQL中操作json的函数!
数据库·mysql·json
来者皆善14 小时前
了解Mysql优化吗?如何优化索引?
数据库·mysql
赤壁小虾17 小时前
【渲染流水线】[逐片元阶段]-[透明度测试]以UnityURP为例
java·前端·数据库
数字新视界17 小时前
国产信创动环监控系统助力环境安全管控创新
物联网·安全·电力监控系统·用电安全·大榕树
MC皮蛋侠客17 小时前
SQLAlchemy 系列(七):高级建模与高效写入——批量 DML、方言与扩展
数据库·python
奈斯先生Vector18 小时前
把 Midjourney 二次编辑做成生产系统:customId 能力令牌、Action Graph 与 WebUI 精修工作台
数据库·人工智能·架构·aigc·音视频·midjourney
Lethehong18 小时前
双擎并驱·全链路并行:KFS让TB级异构增量同步秒级到达
数据库
MC皮蛋侠客18 小时前
SQLAlchemy 系列(八):AsyncIO、并发与 Web 生命周期——让每个并发任务持有自己的 Session
数据库·python