驱动数字化 质变

从权威的技术洞察,到精准的软硬配置,为企业的每一次转型提供决策支持。

架构师笔记
车间断网导致工单被覆盖?用 CRDT 算法构建“绝对离线可用”的 Local-First 移动 SCADA

2026-05-19 10:01:00

#LocalFirst #CRDT #WebSCADA #Yjs #弱网同步 #移动巡检 #分布式系


一、 场景痛点:Wi-Fi 死角里的“脏写”与转圈圈

上周,我们在一家大型重型机械装配厂排查了一个导致停工的严重软件 Bug:

  • 现状:工厂推行了“无纸化装配”。质检员 A 和质检员 B 各拿着一台工业防爆平板,通过浏览器访问同一套 Web 质检系统(基于 Vue3 + RESTful API)。

  • 事故现场

  1. 质检员 A 走到一个巨大的钢铁罐体后面(Wi-Fi 盲区),点击了“工序 3 合格”。因为没网,屏幕一直弹“Network Error”,数据停在 A 的平板内存里。

  2. 此时,质检员 B 在网络良好的区域,发现工序 3 有划痕,将其修改为“不合格”,并成功提交到服务器。

  3. 5 分钟后,A 走出了盲区。A 的平板网络恢复,之前失败的 API 请求被自动重试,强行把服务器上的“不合格”覆盖成了“合格”

  4. 最终:残次品顺利流向下一道工序,导致后续测试报废。


架构师指令:在工业移动场景中,严禁使用传统的 “Client-Server (C/S)” 在线强依赖架构


工厂网络永远存在死角。我们必须采用 “Local-First (本地优先)” 架构,并引入 CRDT (Conflict-free Replicated Data Type, 无冲突复制数据类型) 算法。让 App 永远先读写本地,网络一通,所有设备像魔术一样自动且无冲突地完成状态合并。


二、 架构设计:数据在本地,同步靠 P2P

传统的 CRUD 架构是“悲观锁”:不连上数据库,不让你改数据。


Local-First 架构是“乐观锁”与“最终一致性”:

  1. 本地数据库即真理:平板上的 Web App 直接把数据写进浏览器的 IndexedDB 或内存里,响应延迟永远是 0ms,彻底干掉“Loading 转圈圈”

  2. CRDT 数据结构:使用特殊的 CRDT 字典/数组。不管设备断网多久,无论 A 和 B 修改了多少次同一份数据,CRDT 都能在底层通过逻辑时钟(Logical Clocks)保证合并后的结果在所有端都是绝对一致的

  3. 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 文档。

JavaScript
import * 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 的本地持久化策略、以及冲突合并状态指示器组件。