车间断网导致工单被覆盖?用 CRDT 算法构建“绝对离线可用”的 Local-First 移动 SCADA
2026-05-19 10:01:00
#LocalFirst #CRDT #WebSCADA #Yjs #弱网同步 #移动巡检 #分布式系
一、 场景痛点:Wi-Fi 死角里的“脏写”与转圈圈
上周,我们在一家大型重型机械装配厂排查了一个导致停工的严重软件 Bug:
现状:工厂推行了“无纸化装配”。质检员 A 和质检员 B 各拿着一台工业防爆平板,通过浏览器访问同一套 Web 质检系统(基于 Vue3 + RESTful API)。
事故现场:
质检员 A 走到一个巨大的钢铁罐体后面(Wi-Fi 盲区),点击了“工序 3 合格”。因为没网,屏幕一直弹“Network Error”,数据停在 A 的平板内存里。
此时,质检员 B 在网络良好的区域,发现工序 3 有划痕,将其修改为“不合格”,并成功提交到服务器。
5 分钟后,A 走出了盲区。A 的平板网络恢复,之前失败的 API 请求被自动重试,强行把服务器上的“不合格”覆盖成了“合格”。
最终:残次品顺利流向下一道工序,导致后续测试报废。
架构师指令:在工业移动场景中,严禁使用传统的 “Client-Server (C/S)” 在线强依赖架构。
工厂网络永远存在死角。我们必须采用 “Local-First (本地优先)” 架构,并引入 CRDT (Conflict-free Replicated Data Type, 无冲突复制数据类型) 算法。让 App 永远先读写本地,网络一通,所有设备像魔术一样自动且无冲突地完成状态合并。
二、 架构设计:数据在本地,同步靠 P2P
传统的 CRUD 架构是“悲观锁”:不连上数据库,不让你改数据。
Local-First 架构是“乐观锁”与“最终一致性”:
本地数据库即真理:平板上的 Web App 直接把数据写进浏览器的 IndexedDB 或内存里,响应延迟永远是 0ms,彻底干掉“Loading 转圈圈”。
CRDT 数据结构:使用特殊的 CRDT 字典/数组。不管设备断网多久,无论 A 和 B 修改了多少次同一份数据,CRDT 都能在底层通过逻辑时钟(Logical Clocks)保证合并后的结果在所有端都是绝对一致的。
P2P 局域网互联:甚至不需要云端服务器!质检员 A 和 B 在同一个断网的车间里,只要平板连着同一个局域网,利用 WebRTC 就能在两台平板之间点对点(P2P)同步数据。
拓扑图:
[平板 A UI] <-> [本地 Yjs CRDT] <===(WebRTC 局域网点对点同步)===> [本地 Yjs CRDT] <-> [平板 B UI]
| |
+==========(WebSocket 连网时自动后台回传)==========> [中央数据库]
三、 核心实施步骤 (Copy & Paste)
我们将使用业界最成熟的开源 CRDT 库 Yjs 和它的 WebRTC 插件,在 Vue 3 / React 环境中实现一个离线可用的工单状态机。
1. 初始化 CRDT 文档与 P2P 通信
不需要写后端的 API 接口,直接在前端建立 Yjs 文档。
JavaScriptimport * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'
import { IndexeddbPersistence } from 'y-indexeddb'
// 1. 初始化一个 CRDT 根文档
const ydoc = new Y.Doc()
// 2. 离线持久化:即使刷新网页、杀掉进程,数据依然在本地 IndexedDB 里
const indexeddbProvider = new IndexeddbPersistence('work-order-room', ydoc)
// 3. 开启局域网 P2P 同步 (基于 WebRTC)
// 只要两个平板在一个车间,断了公网也能互相同步状态!
const webrtcProvider = new WebrtcProvider('work-order-room', ydoc, {
signaling: ['ws://local-signaling-server:4444'] // 本地信令服务器
})
// 4. 定义一个共享的质检状态字典
const sharedOrderState = ydoc.getMap('orderState')4. 监听与本地极速写入
UI 组件只需绑定这个 sharedOrderState。写入操作直接在本地完成,无需 await fetch()。
JavaScript// --- 写入数据 (0ms 延迟,断网直接写) ---
function updateQualityCheck(stepId, status, inspectorName) {
// Yjs 会自动处理向量时钟,记录修改的因果关系
sharedOrderState.set(stepId, {
status: status,
inspector: inspectorName,
timestamp: Date.now()
})
console.log("本地写入成功,若有网会自动 P2P 同步")
}
// --- 监听远程合并 (当走出 Wi-Fi 死角时触发) ---
sharedOrderState.observe(event => {
console.log("数据已发生变更(被自己或远程合并):")
const currentState = sharedOrderState.toJSON()
// 触发 UI 响应式刷新 (如 Vue 的 ref.value = currentState)
renderUI(currentState)
})四、 踩坑复盘 (Red Flags)
1. “物理互锁”绝对不能用 CRDT!
致命坑:你把“启动大型切割机”的按钮状态做成了 CRDT。网管 A 在控制室点了“开”,网管 B 在现场维修时点了“关”。两人处于断网状态。网络恢复时,CRDT 算法根据时间戳合并,把状态合并成了“开”。机器突然启动,把网管 B 绞伤。
架构红线:CRDT 只适用于信息聚合(如工单打卡、表单填写、缺陷标注)。涉及到物理硬件状态控制(开/关/急停),必须老老实实走强一致性的中心化服务器(加分布式锁),或交由底层 PLC 硬互锁处理。人命关天的逻辑,决不能“最终一致”。
2. CRDT 的历史膨胀 (History Bloat)
现象:App 运行了三个月后,原本只有 1MB 的工单数据,在平板上占用了 500MB 内存,App 越来越卡。
原因:CRDT 为了保证无冲突合并,会保存每一次按键、每一次修改的完整操作历史(Tombstones)。
对策:必须定期执行垃圾回收 (Garbage Collection, GC) 和文档压缩 (State Vector Compaction)。在项目结单后,将 Yjs 文档销毁,只将最终结果 JSON 存入中央数据库。
3. WebRTC 的局域网穿透限制
现象:两个平板在同一个车间,但连的不是同一个 AP(无线路由器),WebRTC P2P 连不上。
解决:工厂的网络往往做了 AP 之间的二层隔离(Client Isolation)。如果要让 WebRTC 工作,必须在 IT 防火墙上放行 STUN 协议,或者在局域网的核心交换机旁部署一个本地的 WebSocket 转发节点(Y-WebSocket)。
五、 关联资源与选型
要跑这套纯离线的 Local-First 架构,你的移动终端必须有足够的内存和计算能力来处理 IndexedDB 和 CRDT 的矢量合并。
硬件终端推荐:
研华/松下 工业级防爆平板 (Windows/Android):内存必须 8GB 起步。不要拿那些 2GB 内存的老古董跑 CRDT,合并复杂文档时浏览器会崩。
基建网络推荐:
虽然 Local-First 不怕断网,但无缝的 P2P 依然需要一张强健的局域网。
一键克隆架构
不知道怎么在 Vue3 里融合 Yjs?
我们开源了一套 "Local-First 离线移动工单脚手架"。
包含:Yjs 与 Vue3 Reactivity 的双向绑定 Hook、基于 IndexedDB 的本地持久化策略、以及冲突合并状态指示器组件。