ch02 架构解剖:四层分层 + 时序/关系双库 + 多租户隔离

第 3 / 14 章
ch02 架构解剖:四层分层 + 时序/关系双库 + 多租户隔离

事故现场:一套"看起来很美"的 MES 上线后崩了三回

电芯厂 A 一期 MES 项目的初版架构是这么设计的:一套 Spring Boot 应用 + MySQL + Kafka + Vue3 前端,看起来技术栈主流、组件齐全。上线 30 天内崩了三回:

崩 1:把秒级点位写 MySQL,3 个月锁表。涂布+辊压+化成 96 柜设备,OPC UA 推 5000 个点位/秒,全往 MySQL 一张 device_point_log 表写。3 个月表行数 4.2 亿,索引膨胀到 18 GB,任何按设备+时间段查询都超过 30 秒。化成柜实时看板加载要 1 分钟一刷。

数据库膨胀

崩 2:采集进程和业务进程同进程,OPC UA 重连打挂 MES。采集模块和派工单/追溯/SPC 业务逻辑全在一个 Spring Boot 进程里跑。某次化成柜 OPC UA 服务端故障,采集模块启动指数退避重连,重连风暴下线程池被打满,整个 MES 进程 OOM,业务系统全停 1 小时。期间正好客户来现场参观,看到的是黑屏。

崩 3:单租户设计,二期接 3 个分厂全部推倒重来。一期只管长三角工厂,二期要接西北工厂、华南工厂。代码里大量"长三角工厂"的硬编码(SQL WHERE site_id = 1),二期要么改代码加 if 分支、要么改库加 site_id 列。算下来工作量比一期还大,老板脸色铁青。

这三起事故的根因是架构没分层——采集与业务耦合、时序与关系混存、租户没隔离。MES 不是 ERP,它有工业实时性、海量时序、多工厂扩展三大硬约束,必须在架构层就划清。

排查:MES 的三个架构级硬约束

我把 MES 与传统 Web/ERP 的架构差异列出来:

维度 传统 Web/ERP MES 工业硬约束
**数据写入频率** 用户输入,<10 TPS 设备推点,1000~50000 TPS
**单条价值密度** 高(订单/财务一条值钱) 低(一颗点位不值钱,海量才有价值)
**可用性要求** 8x5 + 99.5% 7x24 + 99.95%(停产=亏钱)
**多租户规模** 1 公司/客户 多工厂,多车间,多产线
**延迟敏感** 用户操作 1s 内可接受 设备控制 < 100ms,看板 < 1s
**数据生命周期** 长期保留 时序数据 1~3 个月在线,3~12 月归档

三个硬约束:

  1. 海量时序写入——5000 TPS × 86400s = 4.3 亿条/天,传统关系库吃不下;必须有专用时序库(IoTDB/InfluxDB/TDengine)。
  2. 采集与业务隔离——OPC UA 长连接断线重连风暴、Modbus 设备无响应、SECS 设备异常,这些"工业级故障"不能传到业务进程;必须进程隔离。
  3. 多租户隔离——按工厂租户、按车间 schema 隔离;不能靠 if 分支兜底。

底层原理:四层架构 + 双库 + 多租户

四层架构

四层架构 我把 MES 切成四层,每层独立进程、独立部署、独立扩展:

┌──────────────────────────────────────────────────────────┐
│ L4  看板层 (Vue3 + Nginx)                                 │
│   Andon 大屏 / OEE 看板 / 班次报表 / 排产看板              │
└──────────────────────────────────────────────────────────┘
                  ↑ HTTP/WS(BFF 聚合)
┌──────────────────────────────────────────────────────────┐
│ L3  业务层 (Spring Boot MES-Core)                         │
│   派工 / 追溯 / SPC / OEE / Andon / 排产                  │
│   - 关系库 PostgreSQL (工单/BOM/批次/控制图)              │
│   - Redis (Andon 状态机/限流/锁)                          │
└──────────────────────────────────────────────────────────┘
                  ↑ 业务事件订阅 (Kafka - cellmes.events)
┌──────────────────────────────────────────────────────────┐
│ L2  采集层 (Spring Boot MES-Collector)                    │
│   OPC UA 长连接 / Modbus 轮询 / SECS/GEM 会话             │
│   - 时序库 IoTDB (秒级点位)                               │
│   - 边缘规则引擎 (Threshold / 状态机)                     │
└──────────────────────────────────────────────────────────┘
                  ↑ 工业协议
┌──────────────────────────────────────────────────────────┐
│ L1  设备层 (PLC / 涂布机 / 化成柜 / 卷绕机)                │
└──────────────────────────────────────────────────────────┘

L4 看板层:Vue3 + Vite + Element Plus + ECharts,Nginx 静态托管,BFF 聚合接口(按看板一次性聚合 L3 业务数据 + L2 时序聚合数据)。不直连 L3 业务库——所有数据走 BFF REST,避免看板 SQL 把业务库拖死。

L3 业务层:Spring Boot 3 + MyBatis-Plus,承载 ch01 建模的三件套(Routing/BOM/Dispatch)+ 派工/追溯/SPC/OEE/Andon 全部业务逻辑。用 PostgreSQL 存工单/BOM/批次/控制图主键库(关系数据,<10 TPS)。Redis 存 Andon 状态机 + 派工单草稿 + 限流 token bucket。

L2 采集层:独立 Spring Boot 进程,专门做协议适配。OPC UA 长连接(Milo SDK)+ Modbus 轮询(j2mod)+ SECS/GEM(HSMS 半双工)。点位写入 IoTDB 时序库,不写 PostgreSQL。状态变更(设备状态机变化、点位越阈值)通过 Kafka 推 L3。

L1 设备层:物理资产,不归 MES 管,但 MES 必须懂它们的协议——这是 ch04 的主题。

双库:时序库 IoTDB + 关系库 PostgreSQL

为什么必须双库?看一组数字:

数据 频率 单条价值 查询模式 库
设备秒级点位 5000/s 低 时间窗口扫描 IoTDB
设备分钟级 KPI 96/min 中 设备+时间段 IoTDB
工单/派工单 100/day 高 主键查/列表 PostgreSQL
批次/追溯 10万/day 高 SN 反查(递归) PostgreSQL
SPC 控制图原始值 10万/day 中 设备+料号+批次 PostgreSQL(控制图只存聚合值+样本,不存全量)
Andon 事件 50/day 高 状态机 PostgreSQL + Redis
用户/权限 <1000 高 主键查 PostgreSQL

关键约束:时序数据进时序库,关系数据绝不进时序库——把工单、批次写 IoTDB 等于在时序库建一张大宽表,工单关联查询会全表扫描,时序库的"对齐时间线"优势被抵消。

反向也成立:把秒级点位写 PostgreSQL(电芯厂 A 一期崩 1 的根因),3 个月表锁死。

双库间的"事件桥"

L2 采集到设备点位/事件后,怎么和 L3 业务打通?三道桥:

  1. 状态变更事件(设备状态机变化 RUN→STOP):L2 → Kafka topic cellmes.events.device-status → L3 订阅,更新 dispatch_order.actual_status、oee_downtime 表。
  2. 阈值越界事件(如化成电流 > 控制线):L2 边缘规则引擎判定 → Kafka topic cellmes.events.spc-violation → L3 SPC 服务订阅,触发控制图异常。
  3. 批产出事件(每完成一颗电芯):L2 收 PLC 信号 → Kafka topic cellmes.events.cell-produced → L3 追溯服务订阅,更新 material_consumption、cell_sn 表。

关键设计:L2 → L3 单向,L3 不直接读 L2——业务进程永远不直连时序库做实时查询(避免时序库被业务拖累),所有需要"实时设备状态"的业务场景都靠 Kafka 推送 + Redis 缓存。这是工业实时系统设计的重要原则,叫"读少写多走推不拉"。

多租户隔离

电芯厂 A 的多租户模型:

  • 租户层级:Enterprise(集团)→ 多个 Site(工厂)租户
  • 隔离策略:行级(Row-Level Multi-Tenancy)+ 敏感数据 schema 隔离
  • tenant_id 字段:每张业务表必带 tenant_id BIGINT NOT NULL,MyBatis-Plus 拦截器自动注入 WHERE tenant_id = ?
@Component
public class TenantInterceptor implements InnerInterceptor {
    @Override
    public void beforeQuery(Executor executor, MappedStatement ms,
                            Object parameter, RowBounds rb, ResultHandler rh,
                            BoundSql sql) {
        String sqls = sql.getSql();
        Long tid = TenantContext.get();
        if (tid == null) throw new IllegalStateException("tenant_id required");
        // 注入 WHERE tenant_id = ?
        String injected = sqls.replaceAll("(?i)\\bfrom\\b(\\s+\\w+)",
            "$0 WHERE tenant_id = " + tid);
        // 实际用 JsqlParser 重构更稳,这里给意思
        reflectSet(sql, injected);
    }
}

敏感数据 schema 隔离:批次追溯的 cell_sn / material_consumption 表数据量巨大(千万级/天),放独立 schema cellmes_trace_<tenant>,物理上与主业务表分库(同一 PostgreSQL 集群不同 schema,跨 schema 查询仍可 JOIN,但物理表空间隔离)。

为什么不用独立的物理库而用 schema 隔离:单租户(一个工厂)的批次量不到必须分库的程度(10 亿行内 PostgreSQL 单库扛得住),schema 隔离足够;如果未来某工厂规模突破(如宁德溧阳 100GWh),再做物理库迁移。

部署拓扑

物理机 1:mes-collector (Spring Boot) + IoTDB + Kafka
物理机 2:mes-core (Spring Boot) + PostgreSQL + Redis
物理机 3:mes-web (Nginx + Vue3 dist)
物理机 4(备份):DB backup / 日志归档

为什么采集和业务物理隔离:采集进程吃 IO + 网络长连接(OPC UA),业务进程吃 CPU + 数据库连接池,物理资源错位,混部署会互相影响(采集重连风暴会拖垮业务的数据库连接池)。这是电芯厂 A 一期崩 2 的根因防线。

正确姿势:架构关键决策

决策 1:时序库选 IoTDB 而不是 InfluxDB

维度 IoTDB InfluxDB TDengine
写入吞吐 100万 TPS/单机 70万 100万
工业协议 native OPC UA/Modbus 适配 无 有
SQL 方言 类 SQL(IoTDB-SQL) Flux/InfluxQL SQL
商业授权 Apache 2.0 商业版收费 开源
国内社区 国产(清华系) 国外 国产

选 IoTDB 的关键理由:① 与 gansu-ems 同栈,团队复用;② Apache 2.0 全开源无商业风险;③ 国产工业时序库,对 OPC UA / Modbus 协议有 native 适配层(IoTDB 的 IotSession 支持 OPC UA 数据源直连,省一层适配代码)。

决策 2:消息中间件选 Kafka 而不是 RabbitMQ

工业事件量大(设备状态变更 5万/天、点位越界 1万/天、批产出 5万/天),且需要回放(事故复盘要重放某天事件流),RabbitMQ 是工作队列模型,不利于"事件流回放"。Kafka 的"分区+持久化+回放"特性正好适配。

决策 3:采集层用 Java 而不是 Python

Python 有 opcua 库,但团队是 Java 栈,且 OPC UA Milo SDK(Java)的认证、长连接管理比 Python opcua 库成熟很多。Modbus j2mod、SECS/GEM 开源库(如 OpenSECS)也成熟。Python 适合边缘脚本(如视觉检测),不适合长连接采集层。

决策 4:看板 BFF 而不是直连数据库

电芯厂 A 一期 V0 版本是看板直连 PostgreSQL + IoTDB,看板 SQL 复杂,IoTDB 被复杂查询打挂。改造为 BFF:看板只调 BFF REST 接口,BFF 内部用 Promise.all 并行查 PG + IoTDB,做结果聚合。代价是 BFF 多一层进程,收益是 IoTDB 不再被看板拖累。

数据说话:四层架构上线后 30 天的实测数据

电芯厂 A 二期(重构后)上线 30 天的关键指标对比:

指标 一期(崩 3 次) 二期(四层架构) 改善
IoTDB 写入 TPS - 4872 / 5000 配置 接近线速
IoTDB 查询 99 线 (MySQL 30s+) 180 ms 100x
MES 业务可用率 99.5% (崩 3 次) 99.96% -
采集进程 OOM 次数 4 次 0 -
多工厂接入耗时 推倒重来 3 月 加 tenant_id 1 周 12x
Kafka 端到端延迟 - 25 ms P99 -
看板加载时间 60s 1.2s 50x
单工厂数据库规模 4.2 亿行/3 月 4.1 亿/3 月时序 + 800 万关系 时序归 IoTDB,关系库可控

关键数字:MySQL 30s 查询 → IoTDB 180ms = 100 倍。这是时序库与关系库的本质差异。崩溃事故归零。

面试怎么答:MES 架构的三个关键问题

问:MES 为什么必须用双库(时序+关系),不能合并?

答:① 写入模式不同——时序是海量小追加(5000 TPS),关系是事务性小批量(<10 TPS),合并后任何一种模式都会拖累另一种;② 查询模式不同——时序按"设备+时间段"扫描,关系按"主键/外键"JOIN,时序库的列存对 JOIN 弱,关系库的行存对时序扫描弱;③ 数据生命周期不同——时序高频归档(30 天/3 月),关系长期保留,合并后归档策略无法差异化;④ 故障隔离——时序库被看板拖垮不影响关系业务,反之亦然。

问:采集层和业务层为什么要进程隔离?

答:工业协议(OPC UA/Modbus/SECS)有"长连接 + 重连风暴"特性,断网后所有设备同时重连,线程池打满,如果采集和业务同进程,业务进程直接 OOM。进程隔离后,采集进程崩了业务还能继续(设备实时状态有 Redis 缓存兜底 60 秒,业务层用最近缓存做判定)。这是工业实时系统"故障域隔离"的基本要求。

问:多租户为什么用行级隔离而不是 schema 隔离?

答:行级隔离(tenant_id 字段)的优势:① 表结构统一,跨租户统计 SQL 简单;② 单租户规模可控时性能 OK(<1000 万行/月);③ 运维简单(一份 schema)。schema 隔离的优势:① 物理隔离强;② 单租户慢不影响其他租户;③ 适合超大规模租户(每租户 > 1 亿行)。MES 单租户(一个工厂)通常在 1000 万行/月以下,行级够用,schema 隔离是过度设计。但敏感数据可独立 schema——批次追溯表数据量大,做物理表空间隔离。

落地清单:架构落地动作

  1. 物理部署拓扑:采集进程(mes-collector)+ 业务进程(mes-core)+ 看板进程(mes-web)分物理机部署,不混部。
  2. 时序库 IoTDB:单实例 4 核 8G 起,按 5000 TPS 配置 chunk_size=64KB、enable_wal=true、复制 1 副。
  3. 关系库 PostgreSQL:单实例 8 核 16G,主要业务表 + 批次追溯 schema 分离;定期 VACUUM ANALYZE(每周日)。
  4. 消息中间件 Kafka:3 副本起,topic 按事件类型分:cellmes.events.device-status / cellmes.events.spc-violation / cellmes.events.cell-produced,分区数=租户数×4。
  5. 缓存 Redis:主从 + Sentinel,存 Andon 状态机、派工草稿、限流 token bucket。
  6. 多租户拦截器:MyBatis-Plus InnerInterceptor 自动注入 WHERE tenant_id=?,TenantContext 走 ThreadLocal(HTTP 入口过滤器设置,异步任务显式传)。
  7. BFF 聚合:看板接口在 BFF 内 Promise.all 并行查 PG + IoTDB,超时 200ms 各自降级返回缓存。
  8. 黄金回归:① 时序写入基准(5000 TPS 持续 1h);② Kafka 端到端延迟 P99 < 50ms;③ 多租户拦截器全 SQL 覆盖(用 Mybatis-Plus Plugin 测试工具)。

四层架构是 MES 的"骨架",下一章我们拆开 mes-core 这个进程,看 Spring Boot 3 + MyBatis-Plus + Flyway 的工程化骨架怎么搭。