联机对战消息收发实战:WebSocket可靠通信与断线重连设计
发布时间:2026/10/19 14:30:37 锦皓数字建站

1. 联机对战里消息收发为什么是“命门”做过实时对战类项目的人都有一个共识画面卡顿可以忍操作延迟高也能凑合但消息一旦丢了或者顺序乱了这局基本就废了。我前后写过好几个联机小项目从最早的轮询到后来的长连接踩过的坑几乎都集中在“消息”这两个字上。这次要聊的是我在做一个卡牌对战类联机Demo时客户端消息收发这一层的完整实现思路。标题里提到的“游戏王”对战本质上是一个回合制、强状态同步的卡牌对战场景它和即时射击类游戏不一样不需要每秒几十帧的位置同步但对“指令的可靠送达”和“状态的最终一致”要求极高。你出一张牌对面必须收到你结束回合对面必须知道。任何一条消息的丢失都会导致两边状态对不上玩家体验直接崩掉。这篇文章面向的是已经有一点前端基础、想自己动手做一个联机对战Demo的开发者。不管你用的是原生JS还是框架消息收发的核心逻辑是通用的。我会把客户端这一侧的消息收发拆开揉碎从协议设计、连接管理、消息队列、心跳保活、断线重连到实际代码落地一步步讲清楚。看完之后你应该能直接把这套方案搬到自己的项目里少走我当年走过的弯路。先说清楚这个Demo的整体架构不然后面聊消息收发会没有上下文。服务端我用的是Node.js加ws库维护房间、玩家、对局状态客户端是纯HTMLJS通过WebSocket和服务端保持一条长连接。客户端负责渲染界面、采集玩家操作、把操作封装成消息发出去同时接收服务端推过来的状态变更和对手操作更新本地界面。整个通信模型是典型的“客户端发指令服务端做裁决再把结果广播给房间内所有玩家”。这个模型很关键它决定了客户端消息收发的设计方向客户端不能自己改状态只能发“意图”等服务器确认。为什么强调这一点因为很多新手做联机对战第一反应是“我本地出一张牌本地先扣血然后告诉对面也扣血”。这种乐观更新在弱网下会出大问题——两边各算各的一旦有一方消息没到状态就分叉了。卡牌对战不像动作游戏可以容忍短暂的不一致它的状态是离散的、可枚举的一旦分叉就很难自动收敛。所以我在这个Demo里坚持服务端权威客户端只做两件事把玩家意图可靠地发上去把服务端裁决可靠地收下来。消息收发这一层就是保证这两件事不出错的基石。2. 消息协议怎么设计才不容易翻车2.1 为什么不用纯文本而选JSON结构WebSocket本身支持发送字符串和二进制数据。我见过有人图省事直接发“playCard:123”这种冒号分隔的字符串解析的时候split一下。小Demo这么干确实快但一旦消息类型多起来字段变复杂这种格式就是灾难。你加一个字段要改解析逻辑字段里带个冒号整个就乱了调试的时候肉眼也难看清嵌套结构。我选的是JSON。原因很直接可读性好调试时console.log出来一目了然扩展性强加字段不影响老逻辑前后端都能原生解析不需要额外依赖。代价是比二进制多占一点带宽但对于卡牌对战这种消息频率每秒撑死几条来说这点开销完全可以忽略。真正需要省带宽的是高频同步的场景那不是这个Demo要解决的问题。消息的基本结构我定成这样{ type: PLAY_CARD, // 消息类型决定服务端怎么处理 seq: 1024, // 客户端自增序号用于去重和排序 roomId: room_001, // 房间标识 playerId: p_9527, // 发送者标识 payload: { // 具体数据随type变化 cardId: card_003, targetId: p_8888 }, timestamp: 1710000000000 // 客户端发送时间用于延迟统计 }这里有几个字段是我踩坑之后才加上的。seq是客户端维护的自增序号服务端收到后可以判断有没有重复或乱序。WebSocket基于TCP理论上不会乱序但重连之后新旧消息混在一起的情况我遇到过有个seq就能过滤掉过期指令。timestamp主要是用来做延迟统计和排查问题线上出问题时看一眼时间戳能快速判断是客户端发晚了还是服务端回慢了。2.2 消息类型划分与命名规范消息类型我分成三大类客户端到服务端的指令类、服务端到客户端的通知类、以及双向的控制类。命名上我统一用大写下划线动词在前名词在后比如PLAY_CARD、END_TURN、SURRENDER。这样一眼就能看出这条消息是干什么的不用去翻文档。指令类消息是客户端主动发起的代表玩家的操作意图。这个Demo里主要有出牌PLAY_CARD、发动效果ACTIVATE_EFFECT、结束回合END_TURN、认输SURRENDER、准备READY。每一条都对应一个明确的玩家动作服务端收到后先做合法性校验再更新状态。通知类消息是服务端推给客户端的代表状态变更。主要有对局开始GAME_START、状态同步STATE_SYNC、对手操作OPPONENT_ACTION、回合变更TURN_CHANGE、对局结束GAME_OVER。这类消息客户端只负责接收和渲染不做任何状态裁决。控制类消息是双向的用于维持连接和处理异常。主要有心跳PING/PONG、错误ERROR、重连请求RECONNECT。这类消息不涉及游戏逻辑但缺了它们整个通信层就不稳。提示消息类型一定要集中定义成常量前后端共用一份。我早期是前端写一遍字符串、后端写一遍字符串结果有一次前端发的是PLAY_CARD后端监听的是PLAYCARDS排查了半小时才发现是拼写不一致。后来我把类型定义抽成一个单独的模块两边都引用它这类问题再没出现过。2.3 消息序号与去重机制的设计考量前面提到seq字段这里展开说一下为什么需要它。WebSocket在正常连接下是可靠有序的但断线重连之后情况就复杂了。假设客户端发了seq为100的出牌指令服务端还没处理完连接就断了客户端重连后不确定这条指令有没有生效如果无脑重发可能出两次牌如果不重发可能这张牌就丢了。我的处理方式是客户端维护一个lastAckedSeq记录服务端已确认处理的最大序号。每条指令发出去后进入待确认队列服务端处理完会回一个带相同seq的ACK。重连时客户端把待确认队列里seq大于lastAckedSeq的消息重新发一遍服务端根据seq去重已经处理过的直接回ACK不再执行。这样就保证了“至少一次送达”配合服务端的去重表实现“恰好一次执行”的效果。去重表在服务端维护记录每个玩家最近处理过的seq范围。这个表不用太大保留最近几百条就够因为重连重发的窗口不会很长。这个机制听起来有点重但对于对战类应用来说一条指令重复执行比如多扣一次血比多花点带宽严重得多。3. 客户端连接管理从建立到保活3.1 建立连接与状态机设计WebSocket连接的建立本身很简单new WebSocket(url)一行就够了。但连接是有生命周期的从CONNECTING到OPEN到CLOSING再到CLOSED每个阶段能做什么不能做什么都不一样。如果不管理好这个状态就会出现“连接还没建立就发消息”或者“连接已关闭还在发消息”的情况控制台一堆报错。我在客户端封装了一个Connection类内部维护一个状态字段取值就是connecting、open、closing、closed四种。所有发送操作前先检查状态不是open就进队列等着而不是直接丢出去。连接建立成功后触发onopen把状态切到open然后flush队列里的消息。连接关闭时切到closed根据关闭原因决定是否触发重连。class Connection { constructor(url) { this.url url; this.state closed; this.ws null; this.sendQueue []; this.reconnectAttempts 0; this.maxReconnectAttempts 5; } connect() { if (this.state connecting || this.state open) return; this.state connecting; this.ws new WebSocket(this.url); this.ws.onopen () { this.state open; this.reconnectAttempts 0; this.flushQueue(); this.startHeartbeat(); }; this.ws.onmessage (event) { this.handleMessage(event.data); }; this.ws.onclose (event) { this.state closed; this.stopHeartbeat(); if (!event.wasClean) { this.scheduleReconnect(); } }; this.ws.onerror (err) { console.error(WebSocket error:, err); }; } }这段代码里有个细节值得说onclose里判断event.wasClean。如果是正常关闭比如玩家主动退出不需要重连如果是异常断开网络抖动、服务端重启才触发重连。这个区分很重要不然玩家退出后客户端还在傻傻地重连浪费资源还刷日志。3.2 心跳保活为什么需要以及怎么做WebSocket连接在理想网络下可以一直保持但现实网络里中间的路由器、负载均衡、运营商网关都可能在不活跃一段时间后悄悄掐断连接。而且这种掐断往往不触发onclose客户端还以为连接活着发消息出去石沉大海。这就是所谓的“假死连接”。解决办法就是心跳。客户端每隔一段时间发一个PING服务端收到回一个PONG。如果连续几个周期没收到PONG就判定连接已死主动关闭并重连。心跳间隔我设的是15秒连续3次没响应也就是45秒判定断开。这个值怎么定的太短了浪费流量和电量太长了发现断线太慢。15秒是移动端和桌面端都比较平衡的值卡牌对战对实时性要求没那么极端这个间隔足够。startHeartbeat() { this.heartbeatTimer setInterval(() { if (this.state ! open) return; this.pendingPing Date.now(); this.send({ type: PING, timestamp: this.pendingPing }); this.missedPongs (this.missedPongs || 0) 1; if (this.missedPongs 3) { console.warn(心跳超时判定连接已断开); this.ws.close(); } }, 15000); } handlePong() { this.missedPongs 0; const rtt Date.now() - this.pendingPing; this.updateLatency(rtt); }这里顺手把RTT往返延迟也算出来了显示在界面上让玩家看到自己的网络状况。这个功能不是必须的但体验上加分不少玩家看到延迟高会自己检查网络而不是骂服务器卡。注意心跳消息不要走业务消息队列要单独发。因为业务队列可能因为等待ACK而阻塞心跳被堵住就失去意义了。我在send方法里对PING做了特殊处理直接走ws.send不排队。3.3 断线重连与状态恢复断线重连不是简单地重新connect就完事。玩家断线期间对局可能已经发生了变化——对手出了牌、回合已经切换、甚至对局已经结束。重连之后客户端必须把本地状态和服务端对齐否则玩家看到的是一个过期的战场。我的做法是重连成功后客户端发一个RECONNECT消息带上playerId和roomId。服务端收到后把当前房间的完整状态打包成一个STATE_SYNC消息推回来。客户端收到STATE_SYNC后用服务端的状态完全覆盖本地状态重新渲染界面。这个过程对玩家来说就是“卡了一下然后画面刷新到最新”。重连的退避策略也要讲究。不能断开后立刻疯狂重连那样服务端压力大而且如果是服务端重启你重连再快也没用。我用的是指数退避第一次等1秒第二次2秒第三次4秒最多等30秒重试5次后放弃并提示玩家手动刷新。这个策略在实测中比较稳既不会让玩家等太久也不会给服务端造成冲击。scheduleReconnect() { if (this.reconnectAttempts this.maxReconnectAttempts) { this.onReconnectFailed this.onReconnectFailed(); return; } const delay Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); this.reconnectAttempts; setTimeout(() { this.connect(); }, delay); }4. 消息发送队列、确认与顺序保证4.1 发送队列的必要性前面反复提到发送队列这里说清楚它解决什么问题。假设玩家手速很快连续点了三张牌三条PLAY_CARD消息几乎同时产生。如果直接ws.send它们会按调用顺序进入TCP缓冲区正常情况下没问题。但如果此时连接刚好在重连ws对象是null或者状态不对直接send就会报错消息就丢了。发送队列的作用是解耦“产生消息”和“发送消息”。消息产生后先入队由队列管理器在连接可用时逐条发送。连接不可用时消息在队列里等着连接恢复后自动flush。这样业务代码只管产生消息不用关心连接状态。send(message) { if (this.state open) { this.ws.send(JSON.stringify(message)); if (message.type ! PING message.type ! PONG) { this.pendingQueue.push(message); } } else { this.sendQueue.push(message); } } flushQueue() { while (this.sendQueue.length 0) { const msg this.sendQueue.shift(); this.ws.send(JSON.stringify(msg)); if (msg.type ! PING msg.type ! PONG) { this.pendingQueue.push(msg); } } }pendingQueue是等待服务端ACK的队列和sendQueue不是一回事。sendQueue是还没发出去的pendingQueue是发出去了但还没确认的。重连时重发的是pendingQueue里的消息sendQueue里的消息在连接恢复后正常flush即可。4.2 消息确认与超时重发服务端每处理完一条指令回一个ACK消息带上被确认的seq。客户端收到ACK后从pendingQueue里移除对应消息。如果一条消息发出后超过一定时间我设的是5秒还没收到ACK就认为可能丢了重新发送。重发次数上限3次超过就报错给玩家提示操作失败。checkPendingTimeouts() { const now Date.now(); const timeout 5000; this.pendingQueue this.pendingQueue.filter(msg { if (now - msg.timestamp timeout) { if (msg.retries 3) { this.onMessageFailed this.onMessageFailed(msg); return false; } msg.retries (msg.retries || 0) 1; msg.timestamp now; this.ws.send(JSON.stringify(msg)); return true; } return true; }); }这个超时检查用一个定时器每秒跑一次。5秒这个值是根据实测RTT定的正常网络下RTT在50到200毫秒之间5秒足够覆盖绝大多数情况。如果玩家网络特别差RTT超过5秒那其实已经没法正常游戏了报错让玩家检查网络比无限重试更合理。4.3 顺序保证与并发控制卡牌对战里有些操作是有顺序依赖的。比如你先出牌A触发了一个效果再出牌B这两条指令服务端必须按顺序处理。WebSocket本身保证TCP层面的有序但重发机制可能打乱顺序——如果消息1超时重发消息2已经发出去了服务端可能先收到消息2再收到消息1。我的处理方式是同一玩家的指令严格串行。也就是说消息1没有收到ACK之前消息2不发送在队列里等着。这样虽然牺牲了一点并发度但保证了顺序。卡牌对战不是高频操作玩家点一张牌到点下一张牌之间至少隔几百毫秒串行完全不影响体验。sendSequential(message) { if (this.inFlight) { this.waitingQueue.push(message); return; } this.inFlight message; this.send(message); } onAck(seq) { this.pendingQueue this.pendingQueue.filter(m m.seq ! seq); this.inFlight null; if (this.waitingQueue.length 0) { const next this.waitingQueue.shift(); this.sendSequential(next); } }这个inFlight标志就是串行的关键。同一时间只有一条指令在途收到ACK才发下一条。实现简单逻辑清晰实测下来没有出现过顺序错乱的问题。5. 消息接收解析、分发与状态更新5.1 消息解析与异常兜底收到消息的第一步是解析。event.data是字符串JSON.parse一下拿到对象。但这里有个坑如果服务端发来的不是合法JSON比如服务端出bug发了半截消息JSON.parse会抛异常如果不捕获整个onmessage回调就挂了后续消息可能都收不到。handleMessage(raw) { let msg; try { msg JSON.parse(raw); } catch (e) { console.error(消息解析失败:, raw, e); return; } if (!msg || !msg.type) { console.warn(消息格式不合法:, msg); return; } this.dispatch(msg); }解析失败直接丢弃并记日志不影响后续消息处理。这个兜底看起来简单但线上环境里服务端偶尔发个异常消息是常有的事没有这层保护客户端可能直接白屏。5.2 消息分发器的设计解析出来的消息要根据type分发到不同的处理函数。最直接的做法是一堆if-else或者switch-case但消息类型多了之后这个方法会变得很长不好维护。我用的是注册表模式维护一个type到处理函数的映射收到消息查表调用。const handlers { GAME_START: handleGameStart, STATE_SYNC: handleStateSync, OPPONENT_ACTION: handleOpponentAction, TURN_CHANGE: handleTurnChange, GAME_OVER: handleGameOver, ACK: handleAck, ERROR: handleError, PONG: handlePong }; dispatch(msg) { const handler handlers[msg.type]; if (handler) { handler.call(this, msg); } else { console.warn(未知消息类型:, msg.type); } }这种写法的好处是扩展方便加一个新消息类型只需要在handlers里加一行不用改dispatch逻辑。而且每个处理函数独立方便单独测试。5.3 状态同步消息的处理策略STATE_SYNC是最重要的通知类消息它携带了服务端裁决后的完整对局状态。客户端收到后要做的是“全量覆盖”而不是“增量合并”。为什么因为增量合并需要客户端自己推断哪些字段变了一旦推断逻辑和服务端不一致状态就会分叉。全量覆盖虽然传输的数据多一点但逻辑简单可靠不会出错。function handleStateSync(msg) { const state msg.payload; // 全量覆盖本地状态 gameState.players state.players; gameState.currentTurn state.currentTurn; gameState.phase state.phase; gameState.field state.field; gameState.hand state.hand; gameState.graveyard state.graveyard; // 重新渲染整个界面 renderAll(); }renderAll是全量重绘听起来性能不好但卡牌对战的界面元素不多全量重绘一次也就几毫秒玩家完全感知不到。比起增量更新可能带来的状态不一致这点性能代价完全值得。5.4 对手操作消息的动画衔接OPPONENT_ACTION消息用来通知客户端“对手做了什么”主要用于播放动画。比如对手出了一张牌客户端收到消息后先在对手区域播放出牌动画动画结束后再更新状态。这里要注意的是动画是纯表现层的不能影响状态。状态以STATE_SYNC为准OPPONENT_ACTION只是让过程看起来更自然。function handleOpponentAction(msg) { const action msg.payload; if (action.type PLAY_CARD) { playCardAnimation(action.cardId, () { // 动画结束后不做状态修改等STATE_SYNC }); } }有时候OPPONENT_ACTION和STATE_SYNC会几乎同时到达这时候动画可能还没播完状态就更新了。我的处理是给动画加一个最短播放时间状态更新后如果动画还在播等动画播完再渲染新状态。这样视觉上不会突兀。6. 实战中踩过的坑与排查技巧6.1 消息丢失的三种典型场景第一种是连接假死。前面说的心跳就是解决这个的。没有心跳的时候我遇到过玩家反馈“我出了牌对面没反应”一看日志消息发出去了但服务端根本没收到因为连接早就被中间设备掐了客户端还以为活着。第二种是重连期间的消息丢失。玩家断线重连的几秒钟里如果点了操作消息进了sendQueue但重连后如果直接清空队列就会丢。我的做法是重连成功后先flush队列再发RECONNECT请求状态同步。顺序很重要先同步状态再发积压指令否则指令可能基于过期状态执行。第三种是服务端处理超时但客户端没收到ACK。这种情况客户端会重发服务端需要去重。如果服务端没做去重就会重复执行。我早期版本就出过这个问题玩家反馈“我明明只出了一张牌怎么扣了两次血”。后来加了seq去重表才解决。6.2 常见问题速查表现象可能原因排查方法解决方案消息发出无响应连接假死看心跳PONG是否正常加心跳检测超时重连操作重复执行重发未去重查服务端日志有无重复seq服务端加seq去重表状态两边不一致客户端自行改状态对比两端state快照坚持服务端权威全量同步重连后状态错乱未做状态同步看重连后有无STATE_SYNC重连后强制全量同步消息顺序错乱并发发送查消息seq顺序同玩家指令串行发送界面卡顿全量重绘太频繁看渲染耗时合并渲染用requestAnimationFrame6.3 调试工具与日志策略调试WebSocket消息浏览器开发者工具的Network面板里选WS标签能看到所有收发的帧。这个是最基础的。但线上环境没法开DevTools所以我在代码里加了一个消息日志开关打开后把所有收发的消息打到console格式化成表格方便看。function logMessage(direction, msg) { if (!DEBUG_MODE) return; const style direction send ? color: blue : color: green; console.log(%c[${direction}] ${msg.type}, style, msg); }日志里带上方向、类型和完整内容排查问题时一目了然。但要注意生产环境关掉不然console会爆。实操心得我习惯在每条消息里带一个traceId客户端生成服务端原样带回。这样在日志里搜一个traceId就能看到一条指令从发出到确认的完整链路排查跨端问题特别有用。7. 消息收发的性能与体验优化7.1 消息合并与批量发送虽然卡牌对战消息不多但有些场景下还是可以合并。比如玩家连续调整手牌顺序每次调整都发一条消息太浪费可以攒200毫秒合并成一条。这个用防抖实现。const batchSend debounce((messages) { connection.send({ type: BATCH, payload: messages }); }, 200);但要注意不是所有消息都能合并。有顺序依赖的指令不能合并否则服务端处理顺序可能和玩家操作顺序不一致。我一般只对“无状态影响”的操作做合并比如调整手牌顺序、切换视图这类。7.2 延迟显示与网络状况反馈前面提到算RTT这个数据除了显示给玩家还可以用来做动态调整。比如RTT超过500毫秒时给操作加一个“等待中”的loading状态让玩家知道指令发出去了但还没确认而不是以为没点上又点一次。function updateLatency(rtt) { const indicator document.getElementById(latency); if (rtt 200) { indicator.textContent 网络良好; indicator.className good; } else if (rtt 500) { indicator.textContent 网络一般; indicator.className fair; } else { indicator.textContent 网络较差; indicator.className poor; } }这个反馈看起来是小功能但能显著减少玩家因为不知道指令有没有生效而重复操作的情况。7.3 消息压缩的取舍JSON消息在消息量大时可以考虑压缩比如用短字段名或者二进制协议。但这个Demo里我没做因为消息频率太低压缩带来的收益微乎其微反而增加了调试难度。我的原则是先保证正确性和可维护性性能优化等到真的成为瓶颈再说。卡牌对战的消息量离瓶颈还差得远。8. 写在最后的一些个人体会这套消息收发机制我在几个Demo里反复打磨过从最早的裸奔ws.send到现在的队列加确认加重连每一步都是被实际问题逼出来的。最开始我觉得卡牌对战消息少随便发发就行结果线上被玩家各种反馈教做人。后来才明白联机对战里“可靠”比“快”重要得多一条消息丢了可能整局就废了。如果你也在做类似的项目我的建议是消息层一定要封装成独立的模块业务代码不要直接碰ws对象。这样以后换协议、加加密、做压缩都只改一个地方。另外日志和调试开关一定要早做不要等到出问题才临时加那时候你连问题复现都难。还有一个点测试的时候一定要模拟弱网。Chrome DevTools的Network面板可以调网络状况调到Slow 3G试试你的重连和超时逻辑。我很多bug都是在这种极端网络下才暴露出来的正常网络下根本看不出来。消息收发这层做扎实了上层的游戏逻辑才能安心写不然你永远在给通信问题擦屁股。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。