引言:模型错一点,业务歪一片
设备运维系统的数据库设计,最怕的不是复杂,而是**"当时看着没问题"**。
我们第一个版本的设备表是个 60 多列的大宽表,塞满了所有想得到的字段。三个月后客户说"不同型号的空压机参数不一样",我们开始加列;又过了两个月客户说"我想给设备挂自定义参数",宽表彻底崩溃。重建数据模型花了我们六周,迁移历史数据时每一个夜晚都在祈祷。
这篇把重建后的模型完整讲一遍:设备台账、设备树/BOM、保养计划三块核心建模,附 DDL。设计决策全部附上"为什么",你可以不同意结论,但决策路径可以复用。
一、设备台账:静态属性与动态属性分离
设备信息天然分两类:
- 静态属性:名称、型号、厂商、出厂编号、安装位置、投运日期------低频变更,强结构;
- 动态属性:运行时长、累计产量、当前状态、健康分------高频变化,来源多样。
而静态属性内部还有一个隐藏坑:不同设备型号的关键参数完全不同(空压机有排气量,电梯有载重,水泵有扬程)。三种方案对比:
| 方案 | 优点 | 致命缺点 |
|---|---|---|
| 大宽表 | 查询简单 | 每接一个新品类就加列,表结构爆炸 |
| EAV(行转属性) | 灵活 | 查询要多次自连接,索引难做 |
| 固定列 + JSON 动态列 | 常用字段可索引,长尾字段灵活 | JSON 字段无强约束 |
我们选了第三种:高频筛选的属性固化为列(设备类型、品牌、位置、状态),型号特有参数进 JSON 列,MySQL 8.0 的 JSON 函数 + 虚拟列索引兜住了查询需求:
sql
CREATE TABLE device (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
device_code VARCHAR(64) NOT NULL COMMENT '设备编码,租户内唯一',
device_name VARCHAR(128) NOT NULL,
device_type VARCHAR(32) NOT NULL COMMENT '型号ID,关联device_type表',
dept_id BIGINT NOT NULL COMMENT '所属组织(数据权限用)',
location_path VARCHAR(128) NOT NULL COMMENT '设备树路径 /1/23/456/',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1在用2停用3报废',
commission_at DATE COMMENT '投运日期',
attrs JSON COMMENT '型号特有参数 {"exhaust_m3_min":12.5}',
version INT NOT NULL DEFAULT 0,
UNIQUE KEY uk_tenant_code (tenant_id, device_code),
KEY idx_location (location_path),
KEY idx_type_status (device_type, status),
generated_col: exhaust_m3_min DECIMAL(10,2)
GENERATED ALWAYS AS (attrs->>'$.exhaust_m3_min') VIRTUAL -- 示意
) COMMENT '设备台账';
设备类型表(device_type)里定义 JSON Schema 约束 + 默认点表/保养模板,新设备品类接入 = 建一个类型记录,不改表。这是当初重建模型最值的一个决定。
二、设备树:邻接表 + 路径字段的双写
设备组织结构是标准的树:厂区 → 车间 → 产线 → 设备,深 4~6 级,节点几千个。邻接表(parent_id)写起来最自然,但"查某车间下所有设备(含子产线)"需要递归------第一版的递归 SQL 在树深 6 级时性能惨不忍睹(第二篇踩坑里提过)。
重建后的方案:邻接表保关系,location_path 保查询:
sql
CREATE TABLE device_node (
id BIGINT PRIMARY KEY,
parent_id BIGINT,
node_type TINYINT NOT NULL COMMENT '1厂区2车间3产线4设备',
name VARCHAR(128) NOT NULL,
path VARCHAR(128) NOT NULL COMMENT '物化路径,如 /1/23/456/',
depth INT NOT NULL,
KEY idx_path (path)
);
核心查询对比:
sql
-- 查 456 节点整棵子树(含自身):一次前缀匹配,毫秒级
SELECT * FROM device_node WHERE path LIKE '/1/23/456%';
-- 查任意节点的祖先链:解析 path 字符串,按 / 拆 ID 后 IN 查询
SELECT * FROM device_node WHERE id IN (1, 23, 456);
双写的一致性靠子树整体重写 :移动一个节点时,把它及全部子孙的 path 前缀批量替换(UPDATE ... SET path = REPLACE(path, '/1/23/', '/1/99/') WHERE path LIKE '/1/23/%')。设备移动是低频操作,重写代价完全可接受。另外注意 LIKE '/1/23/456%' 的前缀陷阱:456 会匹配到 4567,所以路径段必须带尾分隔符(/1/23/456/),匹配 '/1/23/456/%'。
BOM(部件构成)是另一棵树,但我们没有让它复用设备树 :设备树是管理视角的组织结构(会按客户组织调整),BOM 是物理视角的构成关系(跟着设备型号走),两者生命周期不同。device_part 表带 device_id + parent_part_id + part_type_id,备件库存按部件级挂载,实现"换了这个轴承,这条工单的成本记到这个部件上"。
三、保养计划:三张表拆出规则的全部维度
保养计划的建模目标是把"什么设备、做什么事、按什么周期、做到什么程度"四个问题拆干净:
sql
-- 1. 计划头:绑定设备 + 状态
CREATE TABLE maintenance_plan (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
device_id BIGINT NOT NULL,
plan_name VARCHAR(128) NOT NULL,
plan_status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用2停用',
crew_id BIGINT COMMENT '默认责任班组',
biz_key_rule VARCHAR(64) COMMENT '幂等键规则',
UNIQUE KEY uk_tenant_device_name (tenant_id, device_id, plan_name)
);
-- 2. 保养项:计划做什么(可打包成组)
CREATE TABLE maintenance_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
plan_id BIGINT NOT NULL,
item_name VARCHAR(128) NOT NULL COMMENT '如:更换空滤',
std_hours DECIMAL(5,2) COMMENT '标准工时',
require_photo TINYINT DEFAULT 0 COMMENT '是否强制拍照',
item_group VARCHAR(64) COMMENT '同组保养项打包成一张工单',
sort_no INT
);
-- 3. 周期规则:按什么节奏(与保养项多对一或挂在计划头)
CREATE TABLE maintenance_cycle (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
plan_id BIGINT NOT NULL,
cycle_type TINYINT NOT NULL COMMENT '1固定日历2累计量',
interval_days INT COMMENT '类型1:间隔天数',
day_of_month INT COMMENT '类型1:每月几号(可空,用间隔则不填)',
metric_code VARCHAR(32) COMMENT '类型2:累计量测点编码',
metric_step DECIMAL(12,2) COMMENT '类型2:如每500小时',
shift_days INT DEFAULT 0 COMMENT '最多顺延天数',
CHECK (NOT (cycle_type = 1 AND interval_days IS NULL AND day_of_month IS NULL)),
CHECK (NOT (cycle_type = 2 AND (metric_code IS NULL OR metric_step IS NULL)))
);
两个建模决策值得展开:
- 周期规则和保养项分开,是因为同一个计划里"点检"是每天、"换油"是每 90 天------粒度不齐是常态,分开建模才不会把计划拆得支离破碎。排程时按"周期规则 × 该规则覆盖的保养项"生成工单。
- CHECK 约束写进库:周期类型与参数的对应关系光靠应用层校验,总有绕过 ORM 的脚本把它写脏。数据库层的 CHECK 是最后一道闸,上线以来抓过两次导入脚本的脏数据。
四、工单关联:快照思想,别只存外键
工单表关联设备和计划时,只存 device_id / plan_id 外键是不够的。设备后来改名、保养项后来调整内容,历史工单显示的信息就会"穿越"。
我们的做法是生成工单时冗余快照字段 :device_name_snapshot、plan_name_snapshot、item_list_snapshot(JSON)。展示用快照,统计关联用外键。这与第二篇讲的"缓存一致性"呼应:设备改名时快照不更新,界面标注"下单时设备名称",反而成为追溯利器。
五、范式取舍:哪里允许冗余
最后回答一个高频问题:这套模型哪里违反了范式、为什么允许?
- 工单冗余设备/计划快照(上面说的):用空间换历史正确性。
location_path冗余树结构:用写时维护换查询性能。- 备件出入库单冗余当时单价:成本核算不能用物料主数据的现价,必须用出入库时刻的价格。
这三处冗余的共同点是:冗余的是"事实的历史快照",而不是"当前状态"。当前状态冗余(比如工单里存设备当前状态)才是真正危险的,那会让数据自相矛盾。
六、踩坑记录
**坑一:JSON 列被当垃圾桶。**没有 Schema 约束的阶段,attrs 里混进了十几种写法(exhaust/exhaust_m3/排气量)。治理:JSON Schema 校验进代码审查清单 + 定期跑结构扫描脚本,发现未知字段告警。
**坑二:租户表漏了 tenant_id。**新加的 maintenance_item 表建表时漏了租户列,多租户查询靠 ORM 拦截器兜底才发现,差点越权。之后建表模板里 tenant_id 是默认列,Code Review 见到无租户列的业务表直接打回。
**坑三:软删字段的不一致。**有的表用 is_deleted,有的用 status=3,连表查询时漏条件就出鬼数据。统一规约:业务实体表一律 status(含"已删除"语义),日志类表物理删除。
写在最后
数据模型是设备管理系统里改造成本最高的部分------代码可以重写,数据的迁移和兼容是钝刀子割肉。值得花掉项目前期 20% 的时间把模型做对:属性分离、树结构带路径、规则拆细、快照冗余,这四个决策在我们后续所有项目里都被原样复用。