\n\n```\n\n---\n\n## 四、实战:写一个双人聊天室\n\n### 4.1 项目结构\n\n```\nD4-demo/\n├── package.json\n├── server.js\n└── public/\n └── index.html\n```\n\n### 4.2 完整代码\n\n**package.json**\n\n```json\n{\n \"name\": \"socketio-chat-demo\",\n \"version\": \"1.0.0\",\n \"description\": \"socket.io 双人聊天室\",\n \"type\": \"module\",\n \"scripts\": {\n \"start\": \"node server.js\"\n },\n \"dependencies\": {\n \"socket.io\": \"^4.8.0\"\n }\n}\n```\n\n**server.js**(覆盖 4 个考点:连接 / 自定义事件 / room / broadcast / ack)\n\n```javascript\nimport { createServer } from 'node:http'; // 注意拼写:createServer 不是 creatServer\nimport { readFileSync } from 'node:fs';\nimport { Server } from 'socket.io';\n\n// 1. 原生 http 托管聊天页面(不装 express,少个依赖)\nconst server = createServer((req, res) => {\n res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });\n res.end(readFileSync(new URL('./public/index.html', import.meta.url)));\n // ↑ res 负责\"结束响应\",readFileSync 负责\"读文件\"——一个读一个还\n});\n\nconst io = new Server(server);\n\nio.on('connection', (socket) => {\n console.log('[上线]', socket.id);\n\n // 2. 自定义事件 join:进房间(事件名末尾不能有空格,按字符串精确匹配!)\n socket.on('join', ({ room, name }) => {\n socket.join(room); // 登记进房间(服务器内存里的一张表)\n socket.data = { room, name }; // 记住自己是谁、在哪个房\n io.to(room).emit('system', `\"${name}\" 加入了房间 ${room}`);\n });\n\n // 3. 自定义事件 chat:说话(第二个参数 ack 是回执回调)\n socket.on('chat', (text, ack) => {\n ack?.('✓ 服务器已收到');\n // 发对象不发裸字符串——对手要靠 name 才知道是谁说的\n socket.broadcast.to(socket.data.room) // broadcast = 同房间、除自己以外\n .emit('chat', { name: socket.data.name, text });\n });\n\n // 4. 断开:关页面 / 断网心跳超时都会走到这(socket.io 自动探活)\n socket.on('disconnect', () => {\n console.log('[下线]', socket.id);\n });\n});\n\n// 5. 开机——没有这行,写再好也访问不了\nserver.listen(3000, () => console.log('聊天室已启动 → http://localhost:3000'));\n```\n\n**public/index.html**\n\n```html\n\n\n\n\nsocket.io 双人聊天室\n\n\n\n

socket.io 双人聊天室

\n\n \n
\n 房间号 \n 昵称 \n \n
\n\n \n
\n
\n \n \n
\n\n \n \n \n\n\n```\n\n### 4.3 运行 + 四个实验\n\n```bash\nnpm install\nnpm start\n# 打开 http://localhost:3000,开两个窗口(第二个建议无痕窗口)\n```\n\n| # | 操作 | 现象 | 验证知识点 |\n|---|------|------|-----------|\n| 1 | 两窗口相同房间号、不同昵称,互发消息 | 对方窗口**不刷新**就蹦出消息 | 服务器主动推——HTTP 做不到 |\n| 2 | 观察发送方 | 每条消息下出现「✓ 服务器已收到」 | ack 回执 |\n| 3 | 第三个窗口换房间号再进 | 收不到上一个房间的消息 | room 隔离 |\n| 4 | 关掉一个窗口 | 终端打出 `[下线] + socket.id` | disconnect(心跳探活) |\n\n**留意一个细节**:自己发的消息不是服务器推给自己的——服务器用的是 `broadcast`(排除自己),屏幕上\"我:xxx\"是本地直接渲染的。\n\n### 4.4 我手写时踩过的 8 个坑(手写面试代码必看)\n\n第一次不看参考手写这个 demo,错了 8 处,每一处都是\"手写代码\"场景的典型翻车点:\n\n1. **`creatServer` 拼写错误**(少个 e),和下面用的 `createServer` 对不上 → ReferenceError\n2. **`res.readFileSync(...)`**——把两个 API 揉一起。正确:`res.end(readFileSync(...))`,res 负责结束响应,readFileSync 负责读文件\n3. **忘写 `server.listen(3000)`**——服务器写好了但没\"开机\"\n4. **把 `console.log` 手滑写成 `socket.join`**——效果变成\"把人拉进名叫 `[上线]` 的房间\"\n5. **事件名 `'join '` 末尾多一个空格**——socket.io 按字符串精确匹配,多一个空格 = 永远监听不到 = 点按钮毫无反应,**而且控制台一行错都不报**。手写代码最阴的坑\n6. **`emit('chat', text)` 只发字符串**——客户端要读 `d.name`,显示 `undefined:你好`。跨端要约定数据结构,发对象\n7. **IDE 自动补进来的垃圾 import**(打参数名 `text` 时被自动导入了 `node:stream/consumers`)——凡是自己没主动写的 import 一律警惕\n8. **漏了 `disconnect` 处理**——第四个考点直接丢了\n\n---\n\n## 五、综合设计:实时匹配对战系统(四层架构)\n\n**总纲:socket.io 管通信,Redis 队列管匹配,服务端权威管对战,Redis pub/sub 管多实例扩展。**\n\n### 5.1 第一层:通信层(socket.io)\n\n- 玩家连上后拿到一个 socket(带唯一 id)\n- 配对成功的两人 `join` 进同一个 room(如 `battle:9527`)\n- 所有对战事件通过这个 room 定向广播:推题、对手进度、比分、结算\n- 心跳保活 + 断线自动重连(socket.io 自带)\n\n### 5.2 第二层:匹配层(Redis 匹配队列)\n\n```\n用户点\"开始匹配\"\n ↓\n把 userId + 积分塞进 Redis zset(积分当分数)\n ↓\n服务端撮合循环:ZRANGEBYSCORE 找积分相近的两人(ELO 机制,防大佬虐新手)\n ↓\n配对成功 → 生成 roomId → 两人 join 同一个 room → 广播 matched 事件\n```\n\n**为什么用 Redis 不用数据库**:匹配是秒级高频操作,撮合完就删,天然适合内存队列;写库太慢还脏数据。进队列前用 SETNX 保证同一人不能重复排队。\n\n### 5.3 第三层:对战层(核心原则:服务端权威)\n\n```\n服务端选 5 道题 → io.to(room).emit('question', 题目) // 只推题干,不推答案!\n玩家 A 提交答案 → socket.on('answer') 服务端判定对错、记分\nA 答完 → socket.broadcast.to(room).emit('opponentDone') // 通知对手(不含自己)\n超时没答 → 服务端 setTimeout 自动判错、跳下一题 // 计时以服务端为准\n5 题结束 → 判胜负 → 更新积分表 → emit('result') → 解散 room\n```\n\n**防作弊三件套**(面试官最爱追问):\n\n1. **答案永不下发**——客户端只拿到题干,判定在服务端做(答案发到前端,F12 就能看到)\n2. **计时以服务端为准**——不信客户端传的时间戳(改本地时间就能作弊)\n3. **得分由服务端计算**——客户端只提交选项,不提交\"我得了 10 分\"\n\n### 5.4 第四层:扩展层(Redis pub/sub 跨实例)\n\n**问题**:room 存在单机内存里。用户 A 连服务器 1、用户 B 连服务器 2 → 服务器 1 的 `io.to(room)` 够不到 B。\n\n```\nA 在服务器 1 emit 消息\n ↓\n消息发到 Redis 频道 battle:9527\n ↓\n每台服务器都订阅着频道,各自转发给\"自己机器上\"在这个 room 里的 socket\n ↓\nB 在服务器 2 收到\n```\n\nsocket.io 官方提供 `@socket.io/redis-adapter`,两行接入。\n\n### 5.5 表设计\n\n用户表、积分表(含 ELO 分)、房间表(状态机:**匹配中 → 对战中 → 已结束**)、题目表、对战记录表。\n\n加分句:*\"房间状态用状态机管理,每次变更只允许合法流转,防止'已结束的房间还能答题'这类脏状态。\"*\n\n---\n\n## 六、面试问答速查\n\n**Q1:WebSocket 和 HTTP 的区别?**\n\n> HTTP 是请求-响应模型,一问一答,服务器不能主动推,每次请求还带完整头部开销。WebSocket 是全双工长连接:借 HTTP 握手(Upgrade 头 + 101 状态码)完成升级,之后用自己的帧协议通信,双方随时发消息,头部开销极小,自带 ping/pong 心跳保活。适用:聊天、实时对战、行情推送、协同编辑。\n\n**Q2:WebSocket 怎么建立连接?**\n\n> TCP 三次握手 → HTTP 请求带 `Upgrade: websocket` → 服务器返回 `101 Switching Protocols` → 协议切换为 WebSocket 帧,连接建立。\n\n**Q3:SSE 和 WebSocket 怎么选?**\n\n> SSE 基于 HTTP,只能服务器单向推,自带断线重连,简单够用(AI 流式输出常用)。需要客户端也随时发消息(对战、聊天)才上 WebSocket。\n\n**Q4:socket.io 和 WebSocket 什么关系?**\n\n> WebSocket 是全双工长连接协议,socket.io 是基于它的库,额外封装了自动重连、心跳保活、room 房间、ack 回执、降级轮询。注意客户端和服务端必须配套(它有自己的握手协议)。\n\n**Q5:为什么选 WebSocket 而不是前端每秒拉一次接口?长轮询不行吗?**\n\n> 对战场景里,\"匹配成功\"\"对手已答题\"\"比分更新\"这些事件必须由服务器主动推,HTTP 一问一答做不到。轮询每秒拉一次,90% 空查询还带 1 秒延迟;1000 人在线每秒轮询,光重复 HTTP 头就是每秒 1MB 垃圾流量、数据库白挨 1000 QPS。长轮询虽能等来消息,但每条消息都是一次完整 HTTP 开销,服务器还得挂大量等待连接。WebSocket 借 HTTP 握手升级后变全双工长连接,一条连接复用,帧头开销极小,具体实现用 socket.io,还能白拿 room、自动重连。\n\n**Q6:两个人对战,怎么保证 A 的操作只发给 B?**\n\n> 配对成功后把两个 socket join 进同一个 room,`io.to('roomId').emit` 定向广播;通知对手但不通知自己,用 `socket.broadcast.to('roomId').emit`。\n\n**Q7:用户断网 10 秒再连回来,怎么让他回到对局?**\n\n> 客户端 socket.io 自动重连;服务端在 connection 时按 userId 从 Redis 查到他的 roomId,重新 join 进去,并推当前题目和比分补状态。\n\n---\n\n## 七、写在最后\n\n三个类比串起全文:\n\n- **HTTP vs WebSocket**:发微信等回复 vs 打电话——接通后双方随时开口\n- **WebSocket vs socket.io**:裸手机 vs 微信——多送群聊(room)、回执(ack)、断线重连、心跳降级\n- **对战四层**:socket.io 是对讲机,Redis 队列是媒人,服务端权威是裁判,Redis pub/sub 是广播站\n\n技术选型的判断标准一句话:**只要\"服务器有情况要主动告诉你\",就是 WebSocket 的地盘;低频场景(如一周一更的博客)别为了炫技上长连接,SSE 或轮询的优雅版(SWR)更合适。**","keywords":["Postgresql","redis"],"author":{"@type":"Person","name":"KeXiongpeng"},"mainEntityOfPage":{"@type":"WebPage","@id":"https://kxpwty.cn/blog/url-friendly-webscoket"}}
Portfolio
← 返回博客列表
webscoket---实时通信方案

2026-09-15 · 阅读 17

webscoket---实时通信方案

Postgresqlredis

一、为什么需要 WebSocket:HTTP 的天生局限

1.1 HTTP 是"一问一答"

HTTP 是请求-响应模型:客户端问一次,服务器答一次,答完连接就没了。服务器无法主动推送消息——这是它天生的设计,不是 bug。

类比:HTTP 像发微信等回复——你不问,对方永远不会主动说话。

1.2 服务器想"主动通知你",以前只能这么干

旧方案怎么做两大致命伤
轮询 Polling客户端每 2 秒问一次"有新消息吗?"90% 的请求是白问的;消息最多延迟 2 秒才到
长轮询 Long Polling服务器扣住请求不放,有消息才返回,客户端收到立刻再发下一个每条消息仍是一次完整 HTTP 开销;服务器要挂大量等待连接

1.3 算一笔账:轮询到底浪费了什么

服务器资源不抽象,云服务器的账单是按量计费的,跟水电表一样:

抽象资源现实中对应怎么扣费
带宽快递费,按包裹重量收费按 GB 计费
CPU后厨厨师的劳动力跑满 → 买更贵的实例
内存餐厅座位不够 → 崩溃/升配
数据库 QPS银行柜员每笔业务的手续费撞顶 → 排队变慢 → 升档

假设 1000 人在线,每秒轮询一次:

  • 每次请求头 + 响应头 ≈ 1KB(Cookie、JWT 每次都原样重发)
  • 1000 人 × 1KB/秒 = 1MB/秒 ≈ 每月 2.6TB 纯垃圾流量(内容全是"没有新消息")
  • 服务器每秒解析 1000 个 HTTP 请求,其中 90% 白干
  • 数据库白挨 1000 QPS,而且登录、下单这些正经业务要跟垃圾查询抢同一个数据库

恶性链条:垃圾请求占满 CPU/数据库 → 正经请求排队 → 接口从 50ms 变 3s → 请求积压 → 崩溃 → 花四倍钱升配置。

换成 WebSocket:握手 1 次,之后每条消息帧头只有 2~14 字节(约 HTTP 头的 1/50),只有真的有消息才发。


二、WebSocket 是什么

类比:WebSocket 就是打电话——接通之后,双方随时都能开口,不用等对方先问。

2.1 建立连接:借 HTTP 的门进场,然后掀桌子换规则

① 客户端发起一个普通 HTTP 请求(复用 80/443 端口,防火墙不拦)
     GET /chat HTTP/1.1
     Upgrade: websocket          ← "我想升级协议"
     Connection: Upgrade
     Sec-WebSocket-Key: xxx      ← 随机串,防老缓存代理瞎转发

② 服务器同意,返回 101
     HTTP/1.1 101 Switching Protocols

③ 从这一刻起,这条 TCP 连接不再是 HTTP——
   双方改用 WebSocket 帧格式收发,任意一方随时可发

核心设计点:

  • 握手用 HTTP,通信不用:天然过防火墙、过 Nginx
  • 全双工(full-duplex):一条 TCP 连接上双向同时收发,服务器可以主动推了
  • 帧协议:数据切成一帧帧发,帧有类型标记(文本/二进制/ping-pong 心跳/close)——TCP 只是根水管,WebSocket 规定了水流的格式
  • 事件驱动:编程模型是 on('message') 监听回调,和 Node 的 EventEmitter 一回事

2.2 连接的一生

TCP 三次握手 → HTTP Upgrade 握手 → 双方随时收发帧(互发 ping/pong 心跳)→ close 帧关闭

心跳的两个作用:① 防止中间的 Nginx/路由把"太久不说话"的连接掐掉;② 探测对方死没死——pong 不回 = 断开了,触发重连。

2.3 原生 WebSocket 代码(Node ws 库)

服务端:

// npm i ws
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 3000 });

wss.on('connection', (socket) => {
  console.log('有人上线了,当前在线:', wss.clients.size);

  socket.on('message', (data) => {
    // 广播给所有人 —— 服务器主动推,这就是 HTTP 做不到的
    for (const client of wss.clients) {
      client.send(data.toString());
    }
  });
});

浏览器客户端:

const ws = new WebSocket('ws://localhost:3000');

ws.onopen = () => ws.send('大家好');        // 客户端 → 服务器
ws.onmessage = (e) => console.log(e.data);  // 服务器 → 客户端(主动推)

一句话总结:轮询是客户端定时拉,WebSocket 是服务器有货直接推。


三、socket.io:把裸手机变成微信

裸 WebSocket 是一部裸手机,socket.io 是微信——微信也靠手机通信,但额外白送四样东西。

3.1 协议 vs 库

WebSocket 是协议(规范),socket.io 是基于它封装的库(工具箱)。类比:TCP 是规范,微信是产品。

⚠️ 注意:socket.io 有自己的握手协议,客户端和服务端必须配套——浏览器原生 new WebSocket() 连不上 socket.io 服务器。

3.2 socket.io 比 WebSocket 多给你的东西

裸 WebSocket 的坑socket.io 白送的
断线了怎么办?自己写重连逻辑自动重连(指数退避:失败后隔 1s、2s、4s…再试)
Nginx 把空闲连接掐了?自己写 ping/pong内置心跳(默认每 25 秒 ping 一次,不回就判死)
想只给某些人发?自己维护 socketId 列表room 房间:socket.join() + io.to(room).emit() 一行搞定
发出去对方收到没?不知道ack 回执:emit('event', data, callback)
防火墙禁了 WebSocket?完了自动降级:退回 HTTP 长轮询,保证能用

另外一个重要升级:通信单位从"字符串消息"变成了事件——socket.emit('事件名', 数据),事件名随便自定义。

3.3 三个关键机制

① 房间 room——本质是服务器内存里的一张表

room "对战-9527"  → [socketA, socketB]
room "直播间-3306" → [socketC, socketD, socketE, ...]
  • socket.join('对战-9527'):把自己登记进房间
  • io.to('对战-9527').emit(...):给这个房间所有人发
  • socket.broadcast.to('对战-9527').emit(...):给房间里除自己以外的人发

⚠️ room 存在单台服务器内存里 → 多实例部署时,连在 A 机的用户和连在 B 机的用户互相看不见,必须上 Redis adapter(见第五节扩展层)。

② 心跳——保活 + 探活:服务器定期 ping,对端必须回 pong;没回 → 触发 disconnect 清理房间。

③ 重连——断线自动复活:客户端自动重连,但重连后是新 socket(新 id),服务端要把它重新 join 回原来的房间。

3.4 socket.io 基本代码

服务端:

// npm i socket.io
const { Server } = require('socket.io');
const io = new Server(3000);

io.on('connection', (socket) => {
  console.log('用户上线:', socket.id);

  // 1. 收自定义事件(不是裸消息,是"事件名 + 数据")
  socket.on('chat', (msg, ack) => {
    ack && ack('服务器已收到');            // 2. ack 回执:回调证明送达
    socket.broadcast.emit('chat', msg);    // 3. 广播给除自己以外的所有人
  });

  socket.on('joinRoom', (roomId) => {
    socket.join(roomId);                          // 进房间
    io.to(roomId).emit('system', `${socket.id} 进来了`); // 只发给这个房间
  });

  socket.on('disconnect', () => {
    console.log('用户掉线:', socket.id);   // 心跳超时/主动关闭都会走到这
  });
});

浏览器客户端:

<script src="/socket.io/socket.io.js"></script>
<script>
  const socket = io();   // 断线自动重连是它自带的
  socket.emit('chat', '你好', res => console.log(res));  // 第三个参数传函数 = 要回执
  socket.on('system', msg => console.log(msg));
</script>

四、实战:写一个双人聊天室

4.1 项目结构

D4-demo/
├── package.json
├── server.js
└── public/
    └── index.html

4.2 完整代码

package.json

{
  "name": "socketio-chat-demo",
  "version": "1.0.0",
  "description": "socket.io 双人聊天室",
  "type": "module",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "socket.io": "^4.8.0"
  }
}

server.js(覆盖 4 个考点:连接 / 自定义事件 / room / broadcast / ack)

import { createServer } from 'node:http';   // 注意拼写:createServer 不是 creatServer
import { readFileSync } from 'node:fs';
import { Server } from 'socket.io';

// 1. 原生 http 托管聊天页面(不装 express,少个依赖)
const server = createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  res.end(readFileSync(new URL('./public/index.html', import.meta.url)));
  //    ↑ res 负责"结束响应",readFileSync 负责"读文件"——一个读一个还
});

const io = new Server(server);

io.on('connection', (socket) => {
  console.log('[上线]', socket.id);

  // 2. 自定义事件 join:进房间(事件名末尾不能有空格,按字符串精确匹配!)
  socket.on('join', ({ room, name }) => {
    socket.join(room);                       // 登记进房间(服务器内存里的一张表)
    socket.data = { room, name };            // 记住自己是谁、在哪个房
    io.to(room).emit('system', `"${name}" 加入了房间 ${room}`);
  });

  // 3. 自定义事件 chat:说话(第二个参数 ack 是回执回调)
  socket.on('chat', (text, ack) => {
    ack?.('✓ 服务器已收到');
    // 发对象不发裸字符串——对手要靠 name 才知道是谁说的
    socket.broadcast.to(socket.data.room)    // broadcast = 同房间、除自己以外
      .emit('chat', { name: socket.data.name, text });
  });

  // 4. 断开:关页面 / 断网心跳超时都会走到这(socket.io 自动探活)
  socket.on('disconnect', () => {
    console.log('[下线]', socket.id);
  });
});

// 5. 开机——没有这行,写再好也访问不了
server.listen(3000, () => console.log('聊天室已启动 → http://localhost:3000'));

public/index.html

<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<title>socket.io 双人聊天室</title>
<style>
  body { font-family: sans-serif; max-width: 460px; margin: 24px auto; }
  #msgs { border: 1px solid #ccc; height: 280px; overflow-y: auto; padding: 8px; margin-bottom: 8px; }
  p { margin: 4px 0; }
  .sys { color: #999; }
</style>
</head>
<body>
  <h3>socket.io 双人聊天室</h3>

  <!-- 登录区:房间号 + 昵称 -->
  <div id="login">
    房间号 <input id="room" value="battle-9527">
    昵称 <input id="name" placeholder="你的昵称">
    <button onclick="join()">加入</button>
  </div>

  <!-- 聊天区:加入后显示 -->
  <div id="chat" style="display:none">
    <div id="msgs"></div>
    <input id="input" placeholder="说点什么..." style="width:320px">
    <button onclick="send()">发送</button>
  </div>

  <!-- socket.io 客户端由服务器自动提供,不用下载 -->
  <script src="/socket.io/socket.io.js"></script>
  <script>
    const socket = io();   // 连接 + 断线自动重连,都自带
    const $ = id => document.getElementById(id);
    const log = html => { $('msgs').insertAdjacentHTML('beforeend', html); $('msgs').scrollTop = 1e9; };

    function join() {
      socket.emit('join', { room: $('room').value, name: $('name').value || '路人' });
      $('login').style.display = 'none';
      $('chat').style.display = 'block';
    }

    function send() {
      const text = $('input').value.trim();
      if (!text) return;
      // emit 第 3 个参数传函数 = 要 ack 回执
      socket.emit('chat', text, res => log(`<p class="sys">${res}</p>`));
      log(`<p><b>我</b>:${text}</p>`);   // 自己的消息本地直接显示
      $('input').value = '';
    }

    // 对手的消息:服务器主动推过来的(这就是 HTTP 做不到的事)
    socket.on('chat', d => log(`<p><b>${d.name}</b>:${d.text}</p>`));
    socket.on('system', m => log(`<p class="sys">—— ${m} ——</p>`));
  </script>
</body>
</html>

4.3 运行 + 四个实验

npm install
npm start
# 打开 http://localhost:3000,开两个窗口(第二个建议无痕窗口)
#操作现象验证知识点
1两窗口相同房间号、不同昵称,互发消息对方窗口不刷新就蹦出消息服务器主动推——HTTP 做不到
2观察发送方每条消息下出现「✓ 服务器已收到」ack 回执
3第三个窗口换房间号再进收不到上一个房间的消息room 隔离
4关掉一个窗口终端打出 [下线] + socket.iddisconnect(心跳探活)

留意一个细节:自己发的消息不是服务器推给自己的——服务器用的是 broadcast(排除自己),屏幕上"我:xxx"是本地直接渲染的。

4.4 我手写时踩过的 8 个坑(手写面试代码必看)

第一次不看参考手写这个 demo,错了 8 处,每一处都是"手写代码"场景的典型翻车点:

  1. creatServer 拼写错误(少个 e),和下面用的 createServer 对不上 → ReferenceError
  2. res.readFileSync(...)——把两个 API 揉一起。正确:res.end(readFileSync(...)),res 负责结束响应,readFileSync 负责读文件
  3. 忘写 server.listen(3000)——服务器写好了但没"开机"
  4. 把 console.log 手滑写成 socket.join——效果变成"把人拉进名叫 [上线] 的房间"
  5. 事件名 'join ' 末尾多一个空格——socket.io 按字符串精确匹配,多一个空格 = 永远监听不到 = 点按钮毫无反应,而且控制台一行错都不报。手写代码最阴的坑
  6. emit('chat', text) 只发字符串——客户端要读 d.name,显示 undefined:你好。跨端要约定数据结构,发对象
  7. IDE 自动补进来的垃圾 import(打参数名 text 时被自动导入了 node:stream/consumers)——凡是自己没主动写的 import 一律警惕
  8. 漏了 disconnect 处理——第四个考点直接丢了

五、综合设计:实时匹配对战系统(四层架构)

总纲:socket.io 管通信,Redis 队列管匹配,服务端权威管对战,Redis pub/sub 管多实例扩展。

5.1 第一层:通信层(socket.io)

  • 玩家连上后拿到一个 socket(带唯一 id)
  • 配对成功的两人 join 进同一个 room(如 battle:9527)
  • 所有对战事件通过这个 room 定向广播:推题、对手进度、比分、结算
  • 心跳保活 + 断线自动重连(socket.io 自带)

5.2 第二层:匹配层(Redis 匹配队列)

用户点"开始匹配"
   ↓
把 userId + 积分塞进 Redis zset(积分当分数)
   ↓
服务端撮合循环:ZRANGEBYSCORE 找积分相近的两人(ELO 机制,防大佬虐新手)
   ↓
配对成功 → 生成 roomId → 两人 join 同一个 room → 广播 matched 事件

为什么用 Redis 不用数据库:匹配是秒级高频操作,撮合完就删,天然适合内存队列;写库太慢还脏数据。进队列前用 SETNX 保证同一人不能重复排队。

5.3 第三层:对战层(核心原则:服务端权威)

服务端选 5 道题 → io.to(room).emit('question', 题目)   // 只推题干,不推答案!
玩家 A 提交答案 → socket.on('answer') 服务端判定对错、记分
A 答完 → socket.broadcast.to(room).emit('opponentDone') // 通知对手(不含自己)
超时没答 → 服务端 setTimeout 自动判错、跳下一题          // 计时以服务端为准
5 题结束 → 判胜负 → 更新积分表 → emit('result') → 解散 room

防作弊三件套(面试官最爱追问):

  1. 答案永不下发——客户端只拿到题干,判定在服务端做(答案发到前端,F12 就能看到)
  2. 计时以服务端为准——不信客户端传的时间戳(改本地时间就能作弊)
  3. 得分由服务端计算——客户端只提交选项,不提交"我得了 10 分"

5.4 第四层:扩展层(Redis pub/sub 跨实例)

问题:room 存在单机内存里。用户 A 连服务器 1、用户 B 连服务器 2 → 服务器 1 的 io.to(room) 够不到 B。

A 在服务器 1 emit 消息
   ↓
消息发到 Redis 频道 battle:9527
   ↓
每台服务器都订阅着频道,各自转发给"自己机器上"在这个 room 里的 socket
   ↓
B 在服务器 2 收到

socket.io 官方提供 @socket.io/redis-adapter,两行接入。

5.5 表设计

用户表、积分表(含 ELO 分)、房间表(状态机:匹配中 → 对战中 → 已结束)、题目表、对战记录表。

加分句:"房间状态用状态机管理,每次变更只允许合法流转,防止'已结束的房间还能答题'这类脏状态。"


六、面试问答速查

Q1:WebSocket 和 HTTP 的区别?

HTTP 是请求-响应模型,一问一答,服务器不能主动推,每次请求还带完整头部开销。WebSocket 是全双工长连接:借 HTTP 握手(Upgrade 头 + 101 状态码)完成升级,之后用自己的帧协议通信,双方随时发消息,头部开销极小,自带 ping/pong 心跳保活。适用:聊天、实时对战、行情推送、协同编辑。

Q2:WebSocket 怎么建立连接?

TCP 三次握手 → HTTP 请求带 Upgrade: websocket → 服务器返回 101 Switching Protocols → 协议切换为 WebSocket 帧,连接建立。

Q3:SSE 和 WebSocket 怎么选?

SSE 基于 HTTP,只能服务器单向推,自带断线重连,简单够用(AI 流式输出常用)。需要客户端也随时发消息(对战、聊天)才上 WebSocket。

Q4:socket.io 和 WebSocket 什么关系?

WebSocket 是全双工长连接协议,socket.io 是基于它的库,额外封装了自动重连、心跳保活、room 房间、ack 回执、降级轮询。注意客户端和服务端必须配套(它有自己的握手协议)。

Q5:为什么选 WebSocket 而不是前端每秒拉一次接口?长轮询不行吗?

对战场景里,"匹配成功""对手已答题""比分更新"这些事件必须由服务器主动推,HTTP 一问一答做不到。轮询每秒拉一次,90% 空查询还带 1 秒延迟;1000 人在线每秒轮询,光重复 HTTP 头就是每秒 1MB 垃圾流量、数据库白挨 1000 QPS。长轮询虽能等来消息,但每条消息都是一次完整 HTTP 开销,服务器还得挂大量等待连接。WebSocket 借 HTTP 握手升级后变全双工长连接,一条连接复用,帧头开销极小,具体实现用 socket.io,还能白拿 room、自动重连。

Q6:两个人对战,怎么保证 A 的操作只发给 B?

配对成功后把两个 socket join 进同一个 room,io.to('roomId').emit 定向广播;通知对手但不通知自己,用 socket.broadcast.to('roomId').emit。

Q7:用户断网 10 秒再连回来,怎么让他回到对局?

客户端 socket.io 自动重连;服务端在 connection 时按 userId 从 Redis 查到他的 roomId,重新 join 进去,并推当前题目和比分补状态。


七、写在最后

三个类比串起全文:

  • HTTP vs WebSocket:发微信等回复 vs 打电话——接通后双方随时开口
  • WebSocket vs socket.io:裸手机 vs 微信——多送群聊(room)、回执(ack)、断线重连、心跳降级
  • 对战四层:socket.io 是对讲机,Redis 队列是媒人,服务端权威是裁判,Redis pub/sub 是广播站

技术选型的判断标准一句话:只要"服务器有情况要主动告诉你",就是 WebSocket 的地盘;低频场景(如一周一更的博客)别为了炫技上长连接,SSE 或轮询的优雅版(SWR)更合适。

评论 (0)

0 / 1000

还没有评论,来抢沙发吧~