门店桌台状态在收银台、平板、小程序间对不上:WebSocket实时同步、断线重连与状态对账实践

简介: 门店桌台状态要同时显示在收银大屏、服务员平板和顾客小程序上,早期用轮询既有延迟空窗,还常出现多端状态不一致、重复开台。本文讲改用WebSocket实时同步并把长连接做稳的完整方案:应用层心跳保活加指数退避断线重连消除假在线,给状态加单调版本号挡住消息乱序与重复,服务端作为唯一权威、重连先拉快照对齐再加定时对账兜底,附关键代码与五个踩坑。

导读

线下门店的桌台状态,是要同时出现在好几块屏幕上的:收银台的大屏、服务员手里的平板、顾客那边的小程序,看到的都得是同一份。我们最早图省事用轮询,每隔几秒各自去拉一次状态,结果现场经常对不上——服务员平板上刚开了台,收银大屏还显示空闲,另一头又给同一桌下了一单,等发现时已经乱了。轮询不仅有延迟空窗,还白白发了大量请求。这篇讲我们后来怎么改成 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 连上",而是连接之后那一堆脏活:心跳和重连解决连接不可靠,版本号解决消息乱序重复,服务端权威加快照对账解决断线遗漏和并发冲突。把这三层补齐,桌台状态在收银台、平板、小程序之间才能真正做到同屏同态。这套思路并不只属于桌台,库存、叫号、工单这类需要多端实时一致的场景,面对的是同一组问题,照着这三层搭基本不会错。

相关文章
|
23天前
|
缓存 NoSQL 测试技术
商品详情页高并发读:多级缓存与击穿、雪崩、穿透三件套防护实践
商品详情页是商城读流量最大的入口,大促时缓存一旦击穿、雪崩、穿透,会直接打垮数据库。本文讲清多级缓存架构(本地缓存+Redis+数据库)与三类故障的成因和防护:互斥重建、逻辑过期、布隆过滤器,并给出Java与Redis命令和五个生产踩坑。
|
10天前
|
存储 缓存
学员App里学的进度,网页登录后不见了:学习进度多端同步实战
结论先说:学习进度"手机看得到、电脑看不到",九成不是数据丢了,而是多端各自为政——App端、网页端各存一份进度,谁也没告诉对方。本文复盘一次在线教育平台的学习进度多端同步改造。
|
15天前
|
消息中间件 运维 前端开发
万人直播课弹幕和答题消息又丢又重:高并发互动消息的削峰、扇出与可靠投递实践
直播课里弹幕丢失、统一点答题时成绩重复或缺失,根因通常不是带宽不够,而是把弹幕、答题这类突发高峰消息直接写库,又用不可靠方式扇出到长连接。本文复盘一次万人在线大班课的互动消息改造,讲清接入层削峰、批量聚合、长连接分层扇出,以及消息的不丢不重与答题幂等。
|
22天前
|
缓存 前端开发 JavaScript
轻应用首屏白屏近4秒:资源分包、接口预请求与本地缓存的前端提速实践
活动报名轻应用在中低端机上首屏白屏近4秒,后端接口都在百毫秒内,问题集中在前端:主包近2MB、首屏请求串行、静态资源没配缓存。本文记录完整提速过程:先用Performance和真机指标定位时间去向,再用资源分包与按需加载瘦主包、接口预请求与并行消灭串行等待、带哈希的长效缓存让回访走本地,三刀把首屏压到1.2秒左右,附关键代码与五个踩坑。
|
13天前
|
缓存 小程序 NoSQL
周末饭点排队叫号总“跳号”“过号”:取号并发、叫号状态机与多端同步实战
结论先说:排队叫号“跳号”“过号”乱象,根因是叫号状态没有单一事实来源。本文复盘一次连锁餐饮排队叫号改造,讲清取号并发、叫号状态机与多端同步。
|
13天前
|
小程序 新制造 数据库
同一秒几百人预约同一时段:预约库存的原子扣减、超卖防护与超时释放实战
结论先说:预约一放出来就被"抢光"还超卖,根因是"先查后扣"的库存扣减没有原子性。本文复盘一次轻应用预约系统改造,讲清原子扣减、超卖防护与超时释放。
|
15天前
|
缓存 JavaScript 前端开发
官网首页大图加载慢、LCP常年超标:首屏性能的图片格式、懒加载与预连接排查实践
手机上打开官网先白屏三四秒、大图才慢慢刷出来,问题十有八九出在LCP元素本身:一张没压缩、没缩放、也没排好加载优先级的首屏主图。本文复盘一次官网改版后移动端LCP从4.8秒压到1.9秒的排查过程,讲清怎么定位LCP元素、图片格式与响应式尺寸怎么选、预加载和懒加载怎么分工,以及渲染阻塞资源和CDN缓存怎么配,给一套可复现的首屏优化路径。
|
21天前
|
并行计算 API 开发者
Windows 环境下 llama.cpp 编译运行指南
本文详解Windows下用llama.cpp本地部署大模型:从VS2026编译(支持CPU/GPU)、ModelScope下载GGUF格式Qwen2.5-1.5B模型,到启动llama-server API服务及Python调用验证,全程轻量、低延迟、高隐私,助力开发者快速落地。
438 2
|
23天前
|
人工智能 IDE 数据挖掘
办公还是开发?千问办公QwenWork、Qoder CN、QoderTeams能力拆解与落地实战教程
随着AI智能体工具持续迭代,面向办公、研发的多款产品功能存在部分重叠,很多企业和个人使用者容易混淆千问办公(QwenWork)、Qoder CN、QoderTeams三款产品。不少用户误以为三款产品能力可以互相替代,甚至以为账号、积分资源可以互通,采购之后才发现产品能力与自身岗位业务不匹配,造成预算浪费。千问办公主打通用职场办公自动化;Qoder CN(原通义灵码)是面向开发者的AI编码智能体全家桶;QoderTeams并不是独立软件,而是Qoder CN体系下面向研发小组的企业团队订阅套餐。三者底层基座同源,但产品定位、目标人群、集成生态、计费体系、数据合规能力完全不同。本文完整拆解三款产品
241 2