导读
线下门店的桌台状态,是要同时出现在好几块屏幕上的:收银台的大屏、服务员手里的平板、顾客那边的小程序,看到的都得是同一份。我们最早图省事用轮询,每隔几秒各自去拉一次状态,结果现场经常对不上——服务员平板上刚开了台,收银大屏还显示空闲,另一头又给同一桌下了一单,等发现时已经乱了。轮询不仅有延迟空窗,还白白发了大量请求。这篇讲我们后来怎么改成 WebSocket 实时同步,并把长连接真正用稳:心跳保活与断线重连、消息版本号防乱序防重复、服务端权威加多端对账,附上关键代码和五个踩坑。
一、先看清:多端实时同步到底难在哪
轮询的问题很直观:拉取间隔就是状态不一致的空窗,间隔越短越浪费、越长越滞后。换成 WebSocket 长连接,服务端状态一变就主动推,方向是对的,但长连接把一个问题换成了四个:连接会断(门店 WiFi 抖动、平板锁屏、切后台),消息会乱、会重、会丢(网络重传导致重复、多路径导致乱序、断线期间漏消息),多个端还会同时改同一张桌台。只把轮询换成 socket 而不处理这四件事,状态只会错得更隐蔽。下面三层就是分别对应它们。
二、连接层:心跳保活加断线重连,别留"假在线"
TCP 连接在移动端不会立刻感知到断开,网线拔了、路由切换了,套接字可能半天才报错,这期间应用层以为还连着,消息发出去石沉大海,这就是假在线。必须在应用层加心跳定时探活,超时没回应就主动判定断线并重连,重连用指数退避,避免一堆客户端同时重连把服务端打穿:
class SocketClient {
constructor(url) {
this.url = url;
this.retry = 0;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.retry = 0; this.startHeartbeat(); this.resync(); };
this.ws.onmessage = (e) => this.dispatch(JSON.parse(e.data));
this.ws.onclose = () => this.reconnect();
}
startHeartbeat() {
clearInterval(this.timer);
this.timer = setInterval(() => {
this.ws.send(JSON.stringify({
type: 'ping' }));
}, 25000); // 固定节奏发心跳
}
reconnect() {
clearInterval(this.timer);
const delay = Math.min(1000 * 2 ** this.retry++, 30000); // 指数退避,封顶30s
setTimeout(() => this.connect(), delay);
}
}
注意 onopen 里除了启动心跳,还调了一个 resync(),这是下一节的关键——重连成功后绝不能直接沿用本地旧状态。
三、消息层:给状态加版本号,挡住乱序和重复
实时推送下,同一桌台的状态变更可能连着来好几条。如果"消息到了就直接覆盖",一条因为重试迟到的旧消息,就可能把最新状态盖回去。做法是给每张桌台的状态带一个单调递增的版本号,客户端只接受比本地更新的版本,旧版本和重复版本直接丢弃:
dispatch(msg) {
if (msg.type !== 'table-state') return;
const local = this.tables[msg.tableId];
// 版本不大于本地,说明是迟到的旧消息或重复消息,丢弃
if (local && msg.version <= local.version) return;
this.tables[msg.tableId] = {
status: msg.status, version: msg.version };
this.render(msg.tableId);
}
服务端推进状态时同样用条件更新兜底,保证并发下版本号不会倒退、同一次开台不会重复生效:
UPDATE table_state
SET status = ?, version = version + 1
WHERE table_id = ? AND version = ?;
-- 影响行数为 0,说明期间已被别的端改过,本次提交要重新拉取再决策
这样无论消息重复投递还是乱序到达,最终落到每块屏幕上的状态都是收敛且一致的。
四、状态层:服务端权威,重连先对账,再用定时对账兜底
客户端的状态永远只是服务端的一份缓存,服务端才是唯一事实源。客户端操作可以先乐观渲染让操作跟手,但必须随时准备被服务端的权威状态纠正,绝不能反过来以客户端为准——否则两个端同时开同一桌,必然冲突。
最重要的是重连后的补拉。断线那一会儿服务端可能已经变更好几次,单靠补订阅收不到错过的消息,因此重连后要先拉一次当前全量快照对齐,再进入增量推送:
async resync() {
// 重连后先拉权威快照,覆盖本地,再继续收增量
const snapshot = await fetch('/api/tables/snapshot').then(r => r.json());
snapshot.forEach(t => {
this.tables[t.id] = {
status: t.status, version: t.version }; });
this.renderAll();
}
实时通道再稳也可能漏消息,所以还要有一道不依赖推送的兜底:每隔一段时间、以及每次进前台时做一次轻量对账,比对本地版本和服务端版本,发现落后就拉齐。
这里交代下选型背景。这套实时层是搭在门店一站式 SaaS 底座上做的,这里用的是乔拓云:底座把商品、会员、订单这些通用模块和多端界面托管掉,也提供了基础的消息下发通道;但"桌台状态在多块屏幕之间严格一致"这种贴着具体门店流程的实时逻辑,底座给的通道只是一根管道,状态机、版本号、重连补拉和对账都得我们在通道之上自己实现。哪些直接用、哪些必须自研,开工前划清楚,后面才不会互相甩锅。
五、踩坑清单
- 坑1:建了 WebSocket 却不做应用层心跳。移动网络下会出现大量假在线连接,以为消息发出去了其实早断了,必须固定心跳加超时判定,断线立刻走重连。
- 坑2:消息到了就覆盖,不带版本号。重试迟到的旧消息会把新状态盖回去,必须给状态配单调版本,客户端只认更高版本,乱序和重复一律丢弃。
- 坑3:重连后直接沿用本地状态。断线期间的变更全丢了,重连成功后要先拉一次权威快照对齐,再进入增量订阅,顺序不能反。
- 坑4:以客户端状态为准、多端各自改。并发开台必然冲突,服务端才是唯一事实源,客户端只做乐观呈现并随时接受纠正,提交走条件更新。
- 坑5:只靠实时推送、不做对账兜底。漏推一条就永久不一致,必须有定时或回前台时的全量/版本对账作为最终纠偏手段,实时和对账两条腿走路。
结语
多端状态同步的难点从来不是"把 WebSocket 连上",而是连接之后那一堆脏活:心跳和重连解决连接不可靠,版本号解决消息乱序重复,服务端权威加快照对账解决断线遗漏和并发冲突。把这三层补齐,桌台状态在收银台、平板、小程序之间才能真正做到同屏同态。这套思路并不只属于桌台,库存、叫号、工单这类需要多端实时一致的场景,面对的是同一组问题,照着这三层搭基本不会错。