JWT 与 WebSocket
token 签名吊销与联机状态同步
几乎所有项目都在签发 JWT,但把一枚真实 token 粘进解码器逐字段看过的开发者不多;把单机小游戏改成联机版也一样,真正的难点从来不是「建立连接」,而是连接建立之后。前一件事的全部机制就两句话:用 base64url 编码一段声明的 JSON,再对它做一个签名;后一件事则拆成三问:谁的版本是权威的、同步什么内容、掉线了怎么办。本文先解剖 token 的三段结构,把算法选型与吊销难题的账算清,再走进长连接的战场,看两大同步流派如何分账、房间如何在断线中活下来。
令牌解剖学:三段式、选型与吊销
理解这一切最短的路径,是把一枚真实 token 粘进解码器逐字段看一遍。编码是明文的,签名是防篡改的,除此之外没有任何魔法——理解了这一点,它的安全模型、选型依据和所有翻车点都会自动展开。
三段式与 base64url
一个 JWT 长这样(jwt.io 上那个经典的演示 token):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c三个点分隔的三段,分别是 header(声明签名算法)、payload(声明本体)、signature(签名)。
前两段是 base64url 编码。注意不是标准 base64:URL 安全变体把 + 换成 -、/ 换成 _,并且去掉填充等号。
手工解码时容易在这栽跟头。拿 atob 直接解 base64url 会报错,正确姿势是先把字符替换回来、再补齐 length % 4 缺的等号。
把上面两段解开:
// header{ "alg": "HS256", "typ": "JWT" }
// payload{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }payload 里的字段叫 claims(声明),分注册声明和自定义声明两类。
注册声明是一组约定俗成的标准字段:iss(签发者)、sub(主体,通常是用户 ID)、aud(受众)、exp(过期时间,Unix 秒)、iat(签发时间)、nbf(生效时间)、jti(令牌唯一 ID)。
自定义声明随意添加。这正是第一课:payload 就是普通 JSON,任何拿到 token 的人都能解开它。
base64 是编码不是加密,姓名、邮箱、权限列表放进去等于明文广播,敏感信息(密码、卡号、内部标识)放进去就是事故。
顺手理清三个常被混用的术语:JWT、JWS、JWE。
JWT 是「声明容器」这个抽象概念。把它签名后封装就是 JWS(三段式,今天 99% 的「JWT」实际是 JWS)。把它加密后封装则是 JWE(五段式,payload 对第三方不可见)。
日常语境说「JWT 安全吗」,问的几乎都是 JWS。它证明内容没被改,但不保证内容没人看,保密性永远要另找 TLS 或 JWE 补位。
想在不泄露内容的前提下验证结构,把 token 粘进 JWT 解析器:它会自动按 base64url 解开三段、格式化输出 header 和 payload,并检查 exp。未过期时显示剩余时间,已过期则标红。
编码转换这层如果想在别的场景手工做,编码转换工具提供 base64 与 URL 编解码等一组转换页签,同一条 base64url 规则在不同工具之间是通用的。
会话问题的演化
理解 JWT 为什么长成这样,得先看它解决了什么。
最早的登录态朴素直接:用户登录后服务器发一个 Session ID,并在自己的内存或数据库里存一行「这个 ID 对应这个用户」。
问题随规模而来。Session 状态长在服务器身上,集群里每一台机器都得认识同一个 Session。
于是 sticky session(把同一用户粘到同一台机器,牺牲负载均衡)、集中式会话存储(Redis,多一次网络往返还成了单点)轮番登场。
移动端与开放 API 又补了一刀:Cookie 体系在非浏览器客户端里水土不服,「凭证该怎么带、跨域怎么带」成了通用问题。
JWT 的答案是釜底抽薪:把会话状态从服务器挪进令牌本身。
用户 ID、权限、过期时间直接写进 payload,签名保证它没被篡改,服务器校验时只需一把密钥、零次存储查询。任何一台机器、任何一个服务,拿到密钥就能独立判定「这个请求是谁」。
水平扩展瞬间变简单:没有会话同步这回事了。
两种方案摆在一起看,取舍一清二楚:
| 维度 | Session | JWT |
|---|---|---|
| 状态位置 | 服务器(内存/Redis) | 令牌本身(客户端持有) |
| 立即吊销 | 删一行记录即可 | 做不到,需额外机制 |
| 水平扩展 | 需共享存储或粘性会话 | 天然无状态 |
| 请求开销 | 一次存储查询 | 一次签名运算 |
| 凭证大小 | 几十字节的 ID | 数百字节起步,随声明膨胀 |
| Cookie 依赖 | 天然绑定 | 可脱离 Cookie 走 Header |
注意最后两行:JWT 的凭证体积随声明数量线性增长,每个请求都要驮着它跑一遍。
payload 里塞几十个权限项的令牌,在移动弱网下是可感知的负担。
这笔交易的全部细节,得到什么、失去什么,贯穿这一章剩下的每一节。
HS256 与 RS256 的选型
第三段签名是 JWT 与「随便拼个 JSON」的本质区别。
签名的意思是:持有密钥的一方确认了这两段内容未被篡改。
它不保密任何东西。保密是 TLS 的工作,签名只做完整性校验与来源认证。
最常用的 HS256 是 HMAC-SHA256。
服务器持有一个共享密钥,对 base64url(header) + "." + base64url(payload) 做带密钥的哈希,结果作为第三段。
校验方必须是密钥持有者。重新算一遍签名、对比是否一致。
在浏览器里用 Web Crypto 实现只要十几行:
async function signHS256(header, payload, secret) { const enc = (obj) => btoa(JSON.stringify(obj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); const data = `${enc(header)}.${enc(payload)}`; const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"] ); const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data)); return data + "." + btoa(String.fromCharCode(...new Uint8Array(sig))) .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");}RS256 则是非对称路线:私钥签名,公钥校验。
这对密钥的「形状」决定了两者的选型分界:
| 维度 | HS256(对称) | RS256(非对称) |
|---|---|---|
| 密钥结构 | 单一共享密钥 | 私钥签发 + 公钥校验 |
| 校验方权限 | 能校验就能签发 | 公钥只能校验、不能伪造 |
| 密钥分发 | 每个校验方都要拿到密钥 | 公钥可以公开(JWKS 端点) |
| 签发/校验开销 | 极快(哈希级) | 校验比 HMAC 慢,签发更慢 |
| 典型场景 | 单体应用、内部微服务 | 资源服务器不信任签发方、第三方集成 |
选型的判据一句话:校验方是否需要与签发方互信。
单体应用里签发与校验是同一个进程,HS256 简单又快。
一旦资源服务器和认证中心分属不同团队(甚至不同公司),把 HS256 的共享密钥发给所有校验方,等于把「验票权」和「印票权」捆在一起发给所有人。任何一个校验服务被攻破,攻击者就能自己签发任意身份。
RS256 的公钥可以随便发,JWKS 端点轮换密钥也顺理成章。
微服务架构下默认选 RS256(或 Ed25519 这类 EdDSA 算法,签名更快、密钥更短),把 HS256 留给互信边界内部,是安全的默认姿势。
顺带把椭圆曲线一族放回视野。
ES256(ECDSA over P-256)与 Ed25519 相比 RSA 有一个工程上很实惠的性质:签名与密钥的尺寸小一个量级(ES256 签名约 64 字节,RSA-2048 签名 256 字节),在 HTTP 头这种寸土寸金的地方是实打实的减负。
代价是 ECDSA 对随机数极其敏感(k 复用一次私钥即告泄漏),实现必须依赖库而不是手搓。
选型顺序因此可以这样记:优先 EdDSA,其次 ES256,需要最大兼容性时用 RS256,互信边界内部才轮到 HS256。
JWT 解析器的第二个页签就是签发实验室:预置了上面那个演示 payload,可编辑 header、payload 与密钥,选择签名后直接生成完整 token。
也可以切换到校验模式验证签名是否匹配。亲手签一次再篡改 payload 一个字符去校验,比读十篇文档更能体会「签名绑定了字节级内容」这句话。
手撕一次签发
把签发流程逐字节走一遍,剩下的「魔法感」就清零了。
以 HS256 为例,全程只有四步:
- header 与 payload 各自紧凑序列化(键序、空格都算数),UTF-8 编码成字节;
- 两段字节分别 base64url 编码,用点拼接成 signing input。注意这一步没有任何密钥参与,谁都能算,它只是「待签名的内容」;
- 对整个 signing input 算 HMAC-SHA256,得到 32 字节摘要;
- 摘要再做一次 base64url,固定 43 个字符( 字节, 向上取整),接在第二段后面。
header {"alg":"HS256","typ":"JWT"} -> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9payload {"sub":"42","exp":1735689600} -> eyJzdWIiOiI0MiIsImV4cCI6MTczNTY4OTYwMH0signing input = header + "." + payload (无密钥参与,人人可构造)signature = base64url(HMAC-SHA256(secret, signing input)) -> 43 字符两个细节值得停下来看。
其一,改一个字符,签名全变。
把 payload 里的 "sub":"42" 改成 "sub":"43" 再 base64url,整段长度不变、肉眼几乎无法分辨。但校验方重算的 HMAC 是对篡改后的字节算的,与第三段必然对不上。
这就是「签名绑定字节级内容」的准确含义:它连一个比特的改动都不放过,也因此对「语义相同、字节不同」(重排键序、多一个空格)毫无宽容。同样一份声明,换种写法就是不同的 token。
其二,第三段的长度泄露了算法。
HS384 与 HS512 的摘要分别是 48 与 64 字节,base64url 后是 64 与 86 个字符。拿到一个三段式 token,量一眼第三段的长度就能反推签名算法族。
这也是排查「文档说 HS256、实际签的是别的」这类不一致时最快的土办法。
吊销难题
传统 session 认证把状态放在服务器:每个登录用户一行记录,登出就删掉,踢人就是删记录。
JWT 反其道而行:服务器不存任何东西,token 自带 exp,校验只靠签名和过期时间。
换来的是水平扩展的极大自由:任何一台机器拿到公钥(或共享密钥)就能独立校验,无需共享会话存储。
代价在「提前失效」这一刻现形。
token 在 exp 之前是自证有效的,服务器想主动吊销一个还没过期的令牌,就必须引入状态。而这正是无状态设计要消除的东西。
泄漏了一个 7 天有效期的 access token,你没有「立刻作废」的按钮。
这个问题没有免费解,只有一组权衡:
- 短有效期 + refresh token:access token 压到 5 到 15 分钟,真正的长效凭证是 refresh token,后者存服务端、可以吊销。泄漏的 access token 最多为害一刻钟。这是目前的主流方案,本质是把「无状态的便利」留给高频小令牌,把「有状态的控制」收回到低频大令牌。这一层还能再加固:refresh token 轮换。每次用 refresh token 换新 access token 时,旧 refresh token 立即作废、同时下发新的。一旦某个已作废的 refresh token 再次被出示,即可判定它被盗用(正常客户端拿不到作废凭证),立即吊销该用户的整个令牌家族。重放检测把「凭证被盗」从静默损失变成可观测的安全事件,代价是 refresh token 的校验必须完全有状态;
- 黑名单:吊销的
jti(令牌唯一 ID)写进 Redis,校验时多查一次。有效,但每次校验多一次网络往返,而且黑名单要覆盖到所有exp。无状态的优势被打了对折,等于用 JWT 实现 session; - 版本号:把「密码修改时间 / 全局撤销版本」之类的字段放进用户表,token 里也带一份,校验时比对。一次吊销全部旧 token,粒度粗但实现最简。
三案共享同一个认识:吊销天然是有状态的操作。
工程上的成熟答案不是回避状态,而是把状态放到对的位置:低频的凭证层,而不是高频的请求层。
时钟偏移与 JWKS
选了非对称算法,校验就从「一个进程里的事」变成了跨网络的协作,多出一整套基础设施问题,集中在时间与公钥分发两件事上。
时间。
exp 与 nbf 都是绝对时间戳,而校验发生在「别人的钟」上。
生产集群里机器时钟漂移几十秒并不罕见,用户设备更是重灾区。手机时间被手动调整、时区错乱、电池断电后时钟回跳,都会让一枚「刚签发」的 token 在校验方眼里「尚未生效」或「已经过期」。
惯例是给时间断言加 leeway(宽限窗口,通常几十秒量级):判定条件从严格的 now < exp 放宽为 now < exp + leeway、now > nbf - leeway,把时钟误差从「随机拒绝合法请求」降级为「边界上稍微宽限」。
leeway 是拿安全边际换可用性的显式权衡。写进配置并让人看得见,而不是埋成没人知道其存在的魔法数。
公钥分发。
RS256 的校验方从哪里拿公钥?答案是 JWKS(JSON Web Key Set)端点。
认证中心公布一个形如 /.well-known/jwks.json 的地址,里面是一组公钥,每个公钥带一个 kid(key ID)。签发时把用到的 kid 写进 header,校验方按号取钥。
这套机制存在的理由是密钥轮换。私钥泄漏或例行更换时,认证中心把新公钥加进 JWKS、改用新私钥签发,旧公钥保留到最后一枚旧 token 过期为止。全程无需通知任何校验方,轮换就是一次数据更新。
代价是校验方要正确实现缓存策略:JWKS 取回后本地缓存,遇到未知 kid 再回源刷新,且在因「未知的 kid」拒绝之前至少刷新一次。跳过回源直接拒绝,轮换的瞬间就是全量 401 的事故现场。
顺带把一条攻击写进翻车清单:kid 注入。
kid 在一些实现里会被拿去查数据库、拼文件路径,攻击者构造 {"kid": "x' UNION SELECT '我控制的密钥"} 这样的 header,就可能让校验方用攻击者提供的密钥验签。
签名机制整个失守,而这一刀根本没碰密码学,切的是「把查询索引当受信输入」的实现习惯。
任何把 kid 拼进 SQL、路径或命令的代码,都是注入漏洞的全新入口。
翻车点清单
JWT 的安全模型很干净,翻车几乎都翻在实现层。
以下几条按出现频率排序,每条都值得对着自己的代码检查一遍。
none 算法攻击
header 里的 alg 字段声明「我用什么算法签的名」,但它本身就在不加密的 header 里,攻击者可以随意改写。
经典攻击(2015 年前后横扫一批库):把 alg 改成 none、删掉签名段,声明这是一个「未签名的令牌」。实现不严格的库会直接信任 payload,等于把「验票」整个跳过。
防护原则只有一条:校验方永远不信 token 自述的 alg,代码里写死自己允许的算法白名单,收到白名单之外的任何值直接拒绝。
算法混淆
更狡猾的变体:服务端用 RS256 签发,但校验代码「按 token 里的 alg 动态选算法」。
攻击者把 alg 改成 HS256,用本可公开的 RSA 公钥当 HMAC 密钥签名。校验方取到公钥、按 HMAC 一算,居然对得上。
防护与上一条同源:算法白名单写死,RS256 场景下绝不允许 HMAC 路径出现。
弱密钥
HS256 的安全性完全压在密钥熵上。
secret、your-256-bit-secret、公司名加年份这类密钥,攻击者拿字典几秒钟跑穿。拿到密钥就能离线伪造任意身份的 token。
防护:HS256 密钥至少 256 位随机(openssl rand -base64 32),并定期轮换。密钥永远不进前端代码和公共仓库。
过期信任与存储位置
两类常见错误:校验代码「懒得查」 exp、nbf、aud、iss(签名对了就放行,于是过期十年的 token 依然有效)。
以及反过来:前端拿到 payload 里的角色/权限字段直接渲染控制 UI。UI 可以骗,payload 可以任意伪造,所有授权判断必须在服务端用验过的 token 重做。
存储位置是另一个分叉:localStorage 便于使用但任何 XSS 都能偷走 token;HttpOnly + Secure + SameSite 的 Cookie 让 JS 摸不到,但要处理 CSRF。
没有完美选项,只有与威胁模型匹配的选择。
库的默认值
最后一条最隐蔽:签名库的默认行为不等于安全行为。
同类事故反复发生。校验函数把「算法」设计成可选参数,不传就从 token 的 header 里读(none 攻击与算法混淆的温床)。decode 与 verify 是两个函数,前者只做 base64 解码、不做任何验证,拿 decode 的结果做鉴权判断等于没验。过期校验默认关闭或需要显式开关的库也真实存在。
这些默认值的共同点是对集成者过于宽容:代码能跑、测试能过、token 合法时一切正常,唯独在被构造的恶意 token 面前敞开。
防护方式不是背库的文档,而是立一条代码审查规矩:凡是鉴权路径上的 token 校验,必须显式传入算法白名单、显式开启全部时间与受众断言。安全参数永远写死,不依赖任何默认值。
这些翻车点的完整清单,连同注入、会话管理、访问控制等其余类别,不需要背。
安全速查手册收录了 OWASP Top 10 (2021) 在内的一组速查专题,做代码审查时按图索骥即可。
JWT 相关的检查项归结起来其实就是一条主线:校验方对 token 的每一段都不做任何假设。算法写死、密钥够熵、声明逐项核对、授权判断不信 payload。
登录态的本质
把所有线索收拢:JWT 的全貌是一份「自证的凭证」,不是 session 的替代品,而是一笔明码标价的交易——拿走吊销的即时性,换回扩展的无状态。
落到架构里还有一道常见的选择题:校验放在哪一层?
网关统一校验再透传用户身份给内部服务,是集中管控的极简路线,代价是内网里「信任头部」的边界必须靠网络隔离兜底。
各服务各自校验则是零信任的彻底版本,代价是每个服务都要接住 JWKS、leeway、算法白名单这整套细节。
多数团队的真实答案是折中:网关做一遍完整校验挡住外部流量,内部服务做一遍轻量校验以防内网直连。
想清楚这笔交易的收付,再决定令牌放在哪一层、配多短的过期时间和哪种密钥体系。
token 回答的是「这条连接是谁」;可一旦把连接升级成联机战场,问题立刻变成「如何让所有客户端看到同一个世界」。顺带一提,上一章的 JWT 正是 WebSocket 鉴权方案里的常客——尽管它绝不该被直接塞进 URL。下一章以回合制与你画我猜类游戏为背景,把同步与重连的工程答案摊开。
长连接的战场:同步、协议与重连
三个问题里,前两个的答案构成网络游戏的两大流派:状态同步与帧同步;最后一个的答案决定你的房间会不会变成玩家黑名单上的死房。
动手版本:WebSocket 调试器、API Mock 生成器、你画我猜画板。
为什么是 WebSocket
联机的第一步是让服务器能「主动说话」。
HTTP 是请求-响应模型:客户端不问,服务器不答。
要让浏览器端游戏实时收到对手的操作,只有三条老路。定时轮询(延迟等于轮询间隔,空请求海啸)、长轮询(挂住请求直到有数据,每次响应后重新发起,服务器连接管理噩梦)、SSE(服务器单向推送,省事但上行还是得走 HTTP)。
WebSocket 把这条路修直了:浏览器先发一个带 Upgrade: websocket 头的 HTTP 请求完成握手,此后这条 TCP 连接升级为全双工通道,双方随时互发帧,没有请求头开销,没有重连浪费。
代价也要认清:WebSocket 需要服务器常驻内存维护每条连接(HTTP 的无状态优势没有了)、需要处理代理与负载均衡器对长连接的支持(sticky session)、线上必须用 wss:// 加密。
四条实时通道摆在一起看,差异一目了然:
| 通道 | 方向 | 延迟 | 开销特征 |
|---|---|---|---|
| 短轮询 | 客户端拉 | 轮询间隔起 | 空请求海啸,头开销远大于载荷 |
| 长轮询 | 服务器推(伪) | 较低 | 每次响应后重新握手,连接管理噩梦 |
| SSE | 服务器单向推 | 低 | 轻量,但上行仍要走 HTTP |
| WebSocket | 全双工 | 最低 | 握手一次,此后帧级开销(2~14 字节) |
判断要不要用 WebSocket 有一个朴素的分界:消息由谁发起。
纯客户端拉数据,HTTP 足矣。双向、低延迟、高频,WebSocket 入场。
游戏恰好是后者的极端情形。这和写 REST 接口是两种世界观,接口讲「资源」,连接讲「事件流」。
连接长什么样,可以亲手摸一下。
WebSocket 调试器是一个把协议当 REPL 玩的小工具:输入任意 ws:// 或 wss:// 地址建立连接,发送消息,日志区实时记录 open、收到的每条消息、error 与 close 事件。
先对着公共回声服务把连接、发送、断开的手感建立起来,再去看自己服务的日志会顺畅得多。
握手之后
握手完成不等于高枕无忧,长连接的生死往往决定在三个夹层里。
帧层。
WebSocket 的一切消息都以「帧」为单位传输:文本帧、二进制帧、ping/pong 控制帧、close 帧。
浏览器 API 把这些全部抹平。send() 发文本就是文本帧,onmessage 收到的就是解好的消息。
但有两件事会漏到应用层:一是掩码,协议强制客户端发往服务器的每一帧都带随机掩码(防中间代理缓存污染的历史遗留),服务器下行帧则不掩码,所以「客户端到服务器」和「服务器到客户端」的流量成本天然不对称。二是消息边界,TCP 是字节流,WebSocket 帮你保住了消息边界,但一条大消息可能被拆成多帧到达,走二进制协议时要在应用层自带长度前缀或分隔符的纪律。
心跳层。
RFC 6455 自带 ping/pong 控制帧,但浏览器端的 JavaScript API 不暴露它们。send() 只能发数据帧。
于是应用层心跳成为事实标准:客户端每 20 到 30 秒发一条最小的应用消息(比如 {"t":"ping"}),服务器在约定窗口内没听到任何消息就判定连接死亡、主动回收。
这不是可选项:NAT 网关、防火墙、反向代理都会静默掐断「看起来空闲」的连接,超时从 30 秒到 5 分钟不等。而掐断时双方都收不到 close 事件。
没有心跳的连接,死亡时是悄无声息的。
代理层。
生产环境的 WebSocket 前面几乎必然站着反向代理,而多数代理默认按 HTTP 的请求-响应节奏配置超时。
Nginx 的 proxy_read_timeout 默认 60 秒,意味着一条 60 秒内不发消息的连接会被代理掐掉。心跳间隔必须小于这条线。
另一件必须在上线前确认的事是 Upgrade 头的透传:代理默认把 HTTP 升级请求当普通请求处理,proxy_set_header Upgrade $http_upgrade 与 Connection "upgrade" 这两行配置缺一条,握手就会以一个莫名的非 101 状态码失败。
症状永远表现为「本地好的,线上一连就断」。
两种流派
所有联网游戏都要回答同一个问题:每个客户端各自模拟游戏世界,如何保证它们看到同一个世界?
两大流派给出两个方向相反的答案。
状态同步:服务器权威地运行游戏逻辑,把游戏状态(或状态的增量)广播给所有人,客户端只负责渲染。
棋类、卡牌、桌游、多数回合制游戏选它。每回合一次状态广播,数据量小到可以忽略,作弊面也被压到最小(客户端根本没有游戏逻辑,算不了「必胜外挂」)。
帧同步(lockstep):服务器只广播玩家的输入指令,每个客户端收到全部输入后各自确定性模拟出同样的世界。
RTS 与 FPS 的传统选项。一局《星际争霸》几万单位的状态如果逐帧广播,带宽扛不住,而指令流每帧只有几字节。
它对「确定性」的要求苛刻到变态:浮点运算、随机数种子、遍历顺序、甚至不同 CPU 的浮点行为都必须逐比特一致,否则两个客户端的世界悄悄分叉,几分钟后各自看到不同的游戏。
| 维度 | 状态同步 | 帧同步 |
|---|---|---|
| 网络上传 | 状态/增量,较大 | 仅输入指令,极小 |
| 客户端职责 | 渲染 | 完整模拟 + 渲染 |
| 确定性要求 | 无 | 逐比特一致 |
| 防作弊 | 天然强(逻辑在服务端) | 弱(逻辑全在客户端) |
| 断线重连 | 拉一份当前快照即可 | 必须重放全部历史指令 |
| 适合 | 回合制、卡牌、你画我猜 | RTS、格斗、MOBA |
取舍的核心是带宽与控制的交换:帧同步用「只发指令」换带宽,付出确定性与重连的代价。状态同步用「服务器说了算」换安全与简单,付出带宽与服务器算力。
对回合制和聚会类小游戏,带宽根本不是瓶颈,没有理由放弃状态同步的省心。
你画我猜的账本
有意思的是,你画我猜类游戏不属于任何纯粹的流派,它是两者的混合体。
这也是它作为网络编程练习的绝佳之处。
站内的你画我猜画板是它的单机形态:本地画板配词库,随机抽一个词开始作画,可以随时揭示答案换下一个。限时模式则是 60 秒倒计时,画完按剩余时间计分。
它的全部数据恰好可以拆成两类,正好对应联机版的两种流:
- 笔迹是指令流(帧同步式)。画一笔的本质是
begin(x, y)、move(x, y)× N、end()这样的指令序列。联机版无需把画布快照传来传去。广播指令、各端在本地重放,就能画出同一条线。带宽优化也是指令流式的:把 60Hz 的鼠标采样按帧合并批量、传输时用增量坐标,一笔的流量可以压到几百字节; - 回合是状态流(状态同步式)。当前词、倒计时、当前画者、每人得分、猜中的玩家列表。这些由服务器权威管理、按事件广播。猜词消息走服务器裁决(不能让猜的人自己宣布「我猜对了」),倒计时由服务器发出(不能让各端自己数秒,否则早晚漂移)。
这个混合结构给所有「过程性 + 裁决性」并存的玩法提供了模板:过程(笔迹、聊天、表情)走指令流追求实时,裁决(得分、回合、胜负)走状态流追求一致。
哪条流承担什么,在设计期就要写进协议,而不是实现到一半再补。
顺着这条账本再推一步,会撞上广播语义的分工问题。
房间里的消息有三种投递方式,各自有明确的使用边界:广播(发给房间所有人)用于笔迹、聊天、公共状态变更。单播(服务器只发给某一个连接)用于必须保密的内容。最典型的就是「当前要画的词」,它只能发给画者本人,一旦走广播,猜题的人打开开发者工具看一眼 WebSocket 帧就直接作弊了。回执(服务器对发送者的确认)用于 ACK 与错误提示。
单播的保密语义有一个常被忽略的推论:保密性由服务器保证,与前端无关。
词在画者客户端的内存里就是明文,协议能保证的只是「非画者的连接上根本不出现这个词的字节」。
协议设计的三个习惯
连上只是开始,消息怎么组织决定了这个联机系统能活多久。
习惯一:每个消息带信封。
裸发游戏数据是新手最快的坑,第二天想加「丢包统计」就得全端改代码。
信封字段从第一天就固定:
{ "t": "stroke", "seq": 1042, "ts": 1699000000000, "room": "r7x2", "d": { "x": 33, "y": 87 } }t 是消息类型,seq 是发送方单调递增序号,ts 是时间戳,d 是载荷。
序号让重连后能对账(「我断线期间漏了哪些」),时间戳让插值与超时判断成为可能。
习惯二:区分可靠有序与「最新即真相」。
WebSocket 跑在 TCP 上,所有消息天然可靠有序。
这对猜词、聊天是福气,对笔迹却可能过头:网络抖动半秒后,一串迟到的旧笔迹点成批到达,画面突然「抽搐着补画历史」。
位置类、笔迹类消息的正确姿势是带序号、只接受比已应用序号新的消息,迟到的旧增量直接丢弃。
这就是「最新状态覆盖」语义,用 TCP 的可靠承载非可靠的业务语义。
习惯三:心跳与合帧。
代理和 NAT 会静默掐断「看起来空闲」的长连接,客户端每 20 到 30 秒发一个 ping 帧(或最小的应用层心跳消息)保活,同时充当断线探测器。
发送侧则要做合帧:把一帧内积累的多个 move 指令合并成一条消息再发,既降流量又给服务器喘息。
单客户端每秒 60 条消息就能拖垮一个房间,合帧后是每秒几条。
协议还没定型、服务端还没就绪时,客户端开发不必等:API Mock 生成器可以按声明的 schema({"id": "integer", "name": "string", "active": "boolean", "tags": ["string"]} 这样的键值对)批量生成随机 JSON 数据。
把消息信封的载荷先 mock 出来,前端的状态机与渲染逻辑就能先行自测。
连接的身份与慢消费者的背压
两个工程上躲不开、教程里却最少被讲的话题,放在一起说。
鉴权。
HTTP 请求可以带 Authorization 头,浏览器端的 WebSocket 握手却不允许自定义请求头。
凭证怎么带,需要专门设计。
常见的四条路:一是 Cookie,握手请求自动携带(本质是借用 HTTP 通道),同站部署时最省事,但跨域与移动端场景受限。二是一次性票据,先用 HTTP 登录接口换一张 30 秒有效的 ticket,再拼在握手 URL 的查询参数里,服务器验票后把连接与用户绑定。参数会进访问日志,所以票据必须短时效、一次性,绝不能把长效 JWT 直接放 URL。三是首消息鉴权,握手通过后第一条消息必须先发凭证,超时未发即断开。四是 WebSocket 子协议字段(Sec-WebSocket-Protocol)夹带 token,能用但语义偏离,不推荐。
四条路的共同底线:鉴权发生在握手环节或第一条消息,绝不允许「先连再说、消息里自称是谁」。那等于把身份声明权交给了客户端。
断线重连也由此简化:重连就是重新走一遍握手鉴权,票据机制天然幂等。
背压。
广播型房间的隐形杀手是慢消费者:一个玩家网络卡顿,服务器按正常速率向它的 TCP 发送缓冲区灌笔迹数据,缓冲区涨满后数据在服务器的内存队列里持续堆积。
连接没有断(TCP 不主动失败)、玩家没有退房、服务器却在为一个失能连接白白广播每一笔。
检测手段浏览器与 Node 都给了:ws.bufferedAmount(尚未发出去的字节数)持续走高就是慢消费者的指纹。
处置策略按游戏节奏定:回合制可以粗暴等待。实时类要「降级投递」。跳过该客户端的旧增量、只发最新快照,再不行就断开重连(反正有快照对账)。
与背压配套的还有广播的扇出成本:向一个 50 人房间广播一条消息就是 50 次 send()。
笔迹合帧(把一帧的多个点合并成一条消息)同时压低了服务器与慢消费者的双重压力。
这也是「习惯三」里合帧的第二个理由。
这两个话题都不出现在最小 demo 里,但线上联机系统的事故复盘里,一半和它们有关。
断线重连与房间生命周期
移动网络下断线不是异常而是常态,重连设计的质量直接写进玩家口碑。
连接层:readyState 四态(CONNECTING / OPEN / CLOSING / CLOSED)之外,应用层要维护自己的重连状态机。
重连用指数退避加抖动(1s、2s、4s……封顶 30s,每次叠加随机量)。不退避的重连会在服务端重启的瞬间汇成重连风暴,把刚恢复的服务再次打垮。
业务层:重连成功不等于恢复游戏,还差两步。
先对账:带上断线前的最后已处理 seq,请服务器补发缺口。
再取快照:状态同步流派里直接拉一份当前房间状态的完整快照,覆盖本地。这是状态同步对帧同步最大的工程善意,帧同步此时必须从开局重放全部指令,成本高出一个量级。
对玩家可见的体验目标也由此确定:状态同步的游戏,断线 30 秒内重进应能无缝归位。
房间层:房间本身是一台需要精心设计的状态机。
等待中 → 游戏中 → 结算 → 回到等待,外加几个容易漏判的边界:空房回收(最后一人离开后倒计时关闭,防泄漏)、宿主迁移(房主掉线时按规则指定新宿主)、座位保留(掉线玩家的座位保留一个宽限期而不是立刻踢出,宽限期结束才移除)、重入幂等(重连请求携带旧的身份凭证,服务器认出「这是旧人回归」而不是「新人加入」,游戏状态不重置)。
每一个没写进状态机的边界,都会在生产环境变成一局「永远 60 秒」的死局或一个「永远差一人」的幽灵房。
重连设计还差最后一层才算完整:把它翻译成玩家看得见的东西。
连接状态与应用状态是两套独立的状态机,UI 要同时订阅两者。
「重连中」的横幅必须出现得比玩家的疑惑早(第一次退避触发就显示),座位宽限期要在房间里可视化(「某某掉线,30 秒后自动移除」),恢复失败要给出明确出口(返回大厅而不是卡在黑屏)。
断线体验的工业标准可以一句话概括:玩家应当永远知道现在发生了什么、自己还能不能回去。
网络层的所有状态机,最终都是为这一句服务的。
从 echo 开始的服务端骨架
把以上所有要素的最小骨架落到 Node 上,不过几十行。一个广播回显房间的雏形:
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });const rooms = new Map(); // roomId -> Set<ws>
wss.on("connection", (ws) => { ws.on("message", (raw) => { const msg = JSON.parse(raw); if (msg.t === "join") { ws.room = msg.room; if (!rooms.has(ws.room)) rooms.set(ws.room, new Set()); rooms.get(ws.room).add(ws); return; } // 服务器裁决示例:只有当前画者的笔迹才广播 if (msg.t === "stroke" && ws.isDrawer) { for (const peer of rooms.get(ws.room)) { if (peer !== ws && peer.readyState === 1) peer.send(raw); } } }); ws.on("close", () => { const room = rooms.get(ws.room); if (room) { room.delete(ws); if (room.size === 0) rooms.delete(ws.room); } });});心跳、退避重连、快照对账、房间状态机,都在这个骨架上逐层添加。
单进程这套结构支撑一个中型房间规模绰绰有余。需要多实例时,加一层 sticky session 保证同一连接落在同一进程,跨房间的广播再用 Redis 发布订阅串起来,扩展路径是清晰而渐进的。