工单系统 WebSocket 实时消息推送方案
工单系统里最烦人的体验之一就是「提交了工单,但看不到实时状态」。早期我们用的是定时轮询,每 5 秒拉一次接口,不仅浪费带宽,延迟也很高。后来迁移到 WebSocket,体验提升了一个量级。
为什么要从轮询迁移到 WebSocket
轮询的问题很明显:
- 延迟高 — 最坏情况下要等一个完整的轮询周期
- 浪费资源 — 90% 的请求返回空数据
- 并发压力 — 100 个用户在线,每秒 20 次请求
WebSocket 是全双工通信,服务端有消息就推,客户端零延迟接收。
架构设计
1 2 3 4 5 6 7 8 9 10 11 12
| ┌─────────────┐ WebSocket ┌──────────────┐ │ 前端 H5 │ ◄──────────────► │ WS Gateway │ └─────────────┘ └──────┬───────┘ │ ┌──────▼───────┐ │ 消息队列 │ │ (Redis PubSub)│ └──────┬───────┘ │ ┌──────────▼──────────┐ │ 工单服务 / 通知服务 │ └─────────────────────┘
|
关键点是用 Redis PubSub 做消息中转。工单状态变更时,工单服务发布事件到 Redis,WebSocket Gateway 订阅并推送给对应用户。
核心代码
连接管理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64
| import { ref, onMounted, onUnmounted } from 'vue'
export function useWebSocket(url: string) { const ws = ref<WebSocket | null>(null) const isConnected = ref(false) const reconnectCount = ref(0) const maxReconnect = 5 const heartbeatInterval = 30000 let heartbeatTimer: ReturnType<typeof setInterval> | null = null let reconnectTimer: ReturnType<typeof setTimeout> | null = null
function connect() { ws.value = new WebSocket(url)
ws.value.onopen = () => { isConnected.value = true reconnectCount.value = 0 startHeartbeat() }
ws.value.onclose = () => { isConnected.value = false stopHeartbeat() attemptReconnect() }
ws.value.onmessage = (event) => { const data = JSON.parse(event.data) if (data.type === 'pong') return handleMessage(data) } }
function startHeartbeat() { heartbeatTimer = setInterval(() => { if (ws.value?.readyState === WebSocket.OPEN) { ws.value.send(JSON.stringify({ type: 'ping' })) } }, heartbeatInterval) }
function stopHeartbeat() { if (heartbeatTimer) clearInterval(heartbeatTimer) }
function attemptReconnect() { if (reconnectCount.value >= maxReconnect) return const delay = Math.min(1000 * 2 ** reconnectCount.value, 30000) reconnectTimer = setTimeout(() => { reconnectCount.value++ connect() }, delay) }
onMounted(connect) onUnmounted(() => { stopHeartbeat() if (reconnectTimer) clearTimeout(reconnectTimer) ws.value?.close() })
return { ws, isConnected, reconnectCount } }
|
消息处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import { ref } from 'vue'
export function useWorkOrderEvents() { const events = ref<any[]>([])
function handleMessage(data: any) { switch (data.type) { case 'workorder:status_change': updateWorkOrderStatus(data.payload) break case 'workorder:comment': addComment(data.payload) break case 'workorder:assigned': showNotification(data.payload) break } }
return { events, handleMessage } }
|
断线重连策略
我们用了指数退避重连,初始 1 秒,最大 30 秒,最多重试 5 次。重连期间显示「连接中…」的状态提示。
还有一个重要细节:重连后要重新订阅之前的频道。第一次连接时订阅了工单 A 和工单 B 的状态更新,断线重连后要重新发订阅消息。
离线消息队列
移动端网络不稳定,用户可能在电梯里、地铁上。我们做了两层保障:
- 消息缓存 — 未送达的消息存到 IndexedDB
- 重连同步 — 重连后拉取离线期间的增量消息
1 2 3 4 5
| async function syncOfflineMessages(lastMessageId: string) { const response = await fetch(`/api/messages/sync?since=${lastMessageId}`) const messages = await response.json() messages.forEach(handleMessage) }
|
消息确认机制
重要的工单变更(如状态流转、指派)需要客户端确认。服务端发消息后会等一个 ACK,超时未确认就重新推送。
1 2 3 4 5 6 7 8 9 10 11
| async function sendWithAck(userId: string, message: Message) { const ack = await redis.set(`ack:${message.id}`, 'pending', 'EX', 10) gateway.push(userId, message) const result = await waitForAck(message.id, 10000) if (!result) { await messageQueue.enqueue({ userId, message, retry: true }) } }
|
踩过的坑
WebSocket 连接数限制 — 浏览器对同一域名最多 6 个 WebSocket 连接。SPA 切换页面时旧连接没关,会耗尽配额。必须在组件卸载时 close。
心跳间隔太长 — 一开始设了 60 秒心跳,中间代理层(Nginx)的超时是 60 秒,连接被断了都不知道。心跳间隔要小于代理超时的一半。
消息顺序 — 理论上 WebSocket 保证顺序,但重连后拉取的离线消息可能和实时消息交叉。需要给消息加递增 ID,客户端按 ID 排序去重。
效果
迁移到 WebSocket 后:
- 消息延迟从平均 2.5 秒降到 < 200ms
- 服务器带宽消耗降低 60%
- 用户不再需要手动刷新页面查看工单状态
实时推送不是银弹,但在工单系统这种协作场景下,体验提升是实打实的。