前两年我给自己做过一个实时消息推送的模拟项目,用的就是JS实现WebSocket服务器端。从零手写握手、解析数据帧、维护心跳,虽然代码量不大,但把整个协议跑通之后,再去看各种封装库的源码,会顺畅很多。很多前端同学天天用new WebSocket(url),却不清楚服务端为什么返回101、数据帧长什么样、连接又是怎么保持的。这篇文章就把WebSocket服务器端的核心细节拆开讲一遍,既适合想做聊天室、通知中心、协同编辑的人,也适合对实时通信原理感兴趣的开发者。目标不是让你放弃成熟库,而是让你在能直接调库的前提下,知道底层发生了什么。
1. 为什么选中JS来写WebSocket服务端
1.1 一条长连接到底难在哪
WebSocket和普通HTTP最大的区别,是连接建立后不再走“请求-响应”模式。HTTP下服务器不能主动给客户端发消息,想做实时推送就得靠轮询,客户端频繁发请求问“有数据吗”,效率低,延迟也高。WebSocket一旦握手成功,双方可以随时向对方扔数据,真正解决了服务端主动推送的问题。
但“长连接”听起来简单,做起来并不轻松。第一个难点是状态管理。一个HTTP请求处理完就结束了,资源释放很干净;WebSocket连接会一直挂在服务器上,每一路连接都要记住自己的握手状态、鉴权信息、最后活跃时间、是否已经发送过关闭帧。连接多了以后,这些状态就是一笔不小的内存开销。第二个难点是并发。用传统多线程模型写长连接服务,每来一个连接就起一个线程,两万路连接就是两万个线程,线程切换会把CPU拖垮。第三个难点是TCP流本身的特性,数据在网络上是一截一截的流,应用层需要自己处理“半包”和“粘包”,也就是这次收到的Buffer里可能只有半帧消息,也可能塞了两帧完整消息,拆错了就全乱套。这也是为什么很多刚从HTTP转过来的人会觉得WebSocket服务端难写,不是协议难,而是对数据流的处理方式变了。
1.2 JS事件循环天然适合处理长连接
Node.js采用的是单线程事件循环模型,配合底层异步I/O,可以让一个进程同时维护大量连接。它的优势在于,连接没有到来时不会占着线程干等,而是由系统统一监听事件;连接有数据到了,事件循环再去处理。这个模型特别适合WebSocket这种“大部分时间都不说话,偶尔突然来一下”的场景,跟现实里客服坐在工位上等电话很像,电话没响的时候他可以干别的,响了一声才去接。
所以用JS写WebSocket服务端,最大的收益是开发成本低。前端本来就要处理WebSocket对象、JSON消息格式,现在后端也用同一门语言,连数据序列化都不用跨语言反复对字段,直接一套代码搞定。另外,Node生态里已经有非常成熟的WebSocket实现,轮子很全,不需要自己折腾底层的加密扩展、分片重组、优雅关闭这些边角问题。当然也要记住单线程的短板:如果某个连接的某个回调里做了耗CPU的重计算,比如解压大文件、处理复杂图片,事件循环就会被卡住,其他人的心跳、消息全都得排队。真碰到这种场景,得把重活丢给worker_threads或子进程,别阻塞主线程。
1.3 选型:原生Node模块还是依赖第三方库
做WebSocket服务端,摆在面前的路线大致有三条:自己用http模块写一套、直接用ws库、用更上层的Socket.IO这类框架。三者的差别很直观:
| 方案 | 协议标准 | 学习价值 | 生产可用度 | 适合场景 |
|---|---|---|---|---|
原生http模块手写 | 自己控制,容易踩坑 | 极高,能彻底搞懂WebSocket | 低,边角情况多 | 教学、协议研究 |
ws库 | 标准RFC6455,成熟稳定 | 中,用起来方便 | 高,很多项目都在用 | 生产环境实时通信 |
Socket.IO | 自带协议,兼容WebSocket | 低,封装太重 | 高,但协议不标准 | 快速搭建、需要自动重连的业务 |
我自己当初是为了搞懂原理,先用原生方式写了一遍;后来做实际业务就换成了ws。如果你刚开始接触,我也建议两条路都走一遍:先手写握手和帧解析,再用ws库实现同一个功能。你会发现,用库的时候连“掩码”“分片”“心跳”这些概念都不用管,那是因为库已经把细节消化掉了,反而容易让人忽略它们的存在。
2. WebSocket握手与协议细节拆解
2.1 HTTP升级请求:从普通请求到101 Switching Protocols
WebSocket连接的起点不是一个特殊协议,而是一次普通HTTP请求。浏览器发送的握手请求大概长这样:
GET /ws HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13服务端收到这个请求后,如果同意切换协议,就要返回状态码101 Switching Protocols,同时带上Upgrade、Connection和Sec-WebSocket-Accept头。这里的关键是101不是200。101表示HTTP协议要切换到WebSocket协议,后续双方不再按HTTP规则通信,而是按WebSocket帧格式直接对话。如果用200响应,客户端会认为这是一个普通HTTP页面,不会建立长连接。
在Node的http模块里,当客户端发出升级请求时,服务端并不会走普通的request事件,而是触发upgrade事件。此时回调会拿到原始的req和底层socket,写响应头、开始帧通信都得在这层做。
2.2 Sec-WebSocket-Accept的计算原理
握手过程中最容易让人困惑的是Sec-WebSocket-Accept是怎么来的。它不是一个随机字符串,而是用固定算法生成的:取出客户端发来的Sec-WebSocket-Key,拼上一个固定的GUID字符串,做SHA1哈希,再用Base64编码。固定GUID是协议里写死的:
258EAFA5-E914-47DA-95CA-C5AB0DC85B11为什么需要这个固定字符串?说白了是为了防止普通HTTP客户端或者中间层误把WebSocket请求当成普通GET请求去处理。加上这个固定值之后,只有真正实现了WebSocket协议的服务端才能算出正确的Accept值,客户端收到后可以自己验证,确认对面不是一个恰好返回101的普通HTTP服务。Node里算起来很简单:
const crypto = require('crypto'); const key = req.headers['sec-websocket-key']; const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; const accept = crypto.createHash('sha1') .update(key + GUID) .digest('base64');不要把Sec-WebSocket-Key当成密钥或者Token,它的作用更像是一次随机挑战值。真正做鉴权的时候,通常需要在URL参数、Cookie或者子协议里带认证信息,不能靠这个Key保证安全。
2.3 帧结构:mask、opcode、payload length
WebSocket协议中的数据不是流式字节,而是被组织成一帧一帧的。每一帧头部有固定的结构,我建议你在脑子里记住这一段:
| 位段 | 长度 | 含义 |
|---|---|---|
| FIN | 1bit | 是否为消息的最后一帧 |
| RSV1-3 | 3bit | 扩展保留位,一般置0 |
| opcode | 4bit | 帧类型,文本、二进制、关闭、Ping等 |
| MASK | 1bit | 客户端发来的帧是否使用掩码 |
| Payload length | 7bit / 16bit / 64bit | 负载长度,分三种情况表示 |
| Masking key | 4字节 | 如果有掩码,用于解码负载 |
| Payload data | 可变 | 实际数据 |
opcode是最常用的几个值:0x1表示文本帧,0x2表示二进制帧,0x8表示关闭连接,0x9是Ping,0xA是Pong。协议规定客户端发给服务端的帧必须设置MASK,服务端发给客户端的帧不能设置MASK。为什么要这么设计?主要为了防止早期缓存投毒类攻击,强制客户端加掩码,服务端收到后再把掩码解开,相当于多一层校验。明白这一点,写解析代码时就不会忘了处理掩码。
Payload length最容易被忽略。如果长度小于126,直接用7位就能表示;如果长度在126到65535之间,头两字节后面要再读2个字节作为16位长度;如果长度更大,要读8个字节作为64位长度。很多初学者只处理了第一种情况,一传大文本就解析失败,就是这个原因。
3. 核心实现:从零写一个可用的WebSocket服务器
3.1 用Node原生模块完成第一个握手
先跑通最基础的一步:收到升级请求,计算Accept,返回101响应。我用http.createServer创建普通服务,但不在request事件里处理业务,而是监听upgrade事件。代码如下:
const http = require('http'); const crypto = require('crypto'); const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; const server = http.createServer((req, res) => { res.writeHead(400); res.end(); }); server.on('upgrade', (req, socket) => { const key = req.headers['sec-websocket-key']; if (!key) { socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return; } const accept = crypto.createHash('sha1') .update(key + GUID) .digest('base64'); socket.write( 'HTTP/1.1 101 Switching Protocols\r\n' + 'Upgrade: websocket\r\n' + 'Connection: Upgrade\r\n' + `Sec-WebSocket-Accept: ${accept}\r\n` + '\r\n' ); }); server.listen(8080, () => { console.log('WebSocket server listening on 8080'); });写响应头时一定要用\r\n换行,最后还要留一个空行,也就是再补一个\r\n。这两个字符少了或者顺序不对,客户端会一直等待响应,连接卡在握手阶段。writeHead、res.end这些HTTP层面的API在这里不适用,因为升级之后就要直接在socket上写原始字节。
3.2 解析与组装WebSocket数据帧
握手完成之后,客户端随时可能发来数据帧。这一步最核心的是处理TCP粘包和半包。Socket每次data事件带来的Buffer,不一定正好是一帧。我建议用一块累积缓冲区,不断往里追加,然后循环尝试解析,直到数据不足一帧为止。下面是我整理的一个解析函数:
function decodeFrames(buffer) { const frames = []; let offset = 0; while (true) { if (buffer.length - offset < 2) break; const b0 = buffer[offset]; const b1 = buffer[offset + 1]; const fin = (b0 & 0x80) !== 0; const opcode = b0 & 0x0f; const masked = (b1 & 0x80) !== 0; let payloadLength = b1 & 0x7f; let headerLength = 2; if (payloadLength === 126) { if (buffer.length - offset - 2 < 2) break; payloadLength = buffer.readUInt16BE(offset + 2); headerLength += 2; } else if (payloadLength === 127) { if (buffer.length - offset - 2 < 8) break; const bigLength = buffer.readBigUInt64BE(offset + 2); if (bigLength > BigInt(1024 * 1024)) { throw new Error('payload too large'); } payloadLength = Number(bigLength); headerLength += 8; } let maskKey = null; if (masked) { if (buffer.length - offset - headerLength < 4) break; maskKey = buffer.slice(offset + headerLength, offset + headerLength + 4); headerLength += 4; } if (buffer.length - offset - headerLength < payloadLength) break; let payload = Buffer.from( buffer.slice(offset + headerLength, offset + headerLength + payloadLength) ); if (masked && maskKey) { for (let i = 0; i < payload.length; i++) { payload[i] ^= maskKey[i % 4]; } } frames.push({ fin, opcode, payload }); offset += headerLength + payloadLength; } return { frames, consumed: offset }; }这段代码的核心是“读不到完整一帧就退出循环”。比如缓冲区里只有一个半帧,第二次判断条件不足,就返回已解析的帧和实际消耗的字节数。外部再把剩余Buffer留到下一次。我用Buffer.from包了一层,避免slice共享内存带来的隐式修改风险,否则解掩码的时候会影响原始缓冲区。
3.3 实现文本消息收发与广播
帧解析出来之后,要能原样回复给客户端,就需要一个编码函数。服务端发给客户端的帧不需要掩码,所以比解析简单:
function encodeFrame(opcode, payloadBuffer) { const length = payloadBuffer.length; let header; if (length < 126) { header = Buffer.alloc(2); header[1] = length; } else if (length < 65536) { header = Buffer.alloc(4); header[1] = 126; header.writeUInt16BE(length, 2); } else { header = Buffer.alloc(10); header[1] = 127; header.writeBigUInt64BE(BigInt(length), 2); } header[0] = 0x80 | (opcode & 0x0f); return Buffer.concat([header, payloadBuffer]); }如果发文本消息,opcode传1,最终第一字节就是0x81。0x80代表FIN置1,表示这是单帧完整消息;0x81中的1表示文本opcode。我在项目里用一个Set维护所有在线连接,收到新消息后遍历所有连接做广播:
const clients = new Set(); function handleTextMessage(socket, payload) { const text = payload.toString('utf8'); const out = encodeFrame(1, Buffer.from(text)); [...clients].forEach(client => { if (client.readyState === 'open') { client.write(out); } }); }广播的时候用[...clients]先拍个快照,是为了避免在发送过程中有连接关闭,导致一边遍历一边增删集合而报错。实际项目里如果消息量大,这种简单全量广播会有性能问题,需要设计房间、订阅和定向推送,但原理是一样的。
3.4 心跳检测与掉线清理
WebSocket连接看起来是长连接,但实际网络链路中很多设备会对空闲连接做清理。没有人说话的空连接,可能过几分钟就被某个网关或中间设备断掉。所以服务端要主动发Ping帧,客户端收到后一般会自动回Pong帧,服务端就知道这个连接还活着。
我这里用一个lastActive时间戳标记每个连接的最后活跃时间,定时器每30秒检查一次。超过90秒没有Pong就把连接销毁,同时清掉定时器,避免内存泄漏。
socket.lastActive = Date.now(); socket.isAlive = true; const heartbeatTimer = setInterval(() => { if (Date.now() - socket.lastActive > 90000) { socket.destroy(); clearInterval(heartbeatTimer); return; } socket.write(encodeFrame(0x9, Buffer.alloc(0))); }, 30000);如果是用ws库,心跳会更简单,因为库内置了ping()和pong事件,只需要在pong回调里标记isAlive = true,再定时遍历清理。但原理不变:主动探测,超时关闭。
4. 实操过程中踩过的坑与问题排查
4.1 粘包问题与Buffer拼接
我第一次用原生方式实现时,直接在data事件里把当前chunk当完整帧解析,结果消息一多就乱码。后来才意识到TCP是字节流,不是消息流。它不会按照WebSocket帧的边界给你送数据,底层可能把两帧放在同一个chunk里,也可能把一个很长的帧拆成多个chunk。如果把TCP比作水管,WebSocket帧就是水管里的一个个气泡,气泡之间没有物理分隔,需要应用层自己去切分。
解决办法就是累积Buffer加循环解析,也就是上面那段decodeFrames的思路。还有一个容易被忽视的点:解析完成后要正确更新剩余缓冲区。很多人解析完一帧,忘了用buffer = buffer.slice(consumed)把已读部分丢掉,结果缓冲区越攒越大,最终内存飙升。排查的时候,我会在data事件里打印一下chunk长度和十六进制前几个字节,一般立刻能看出是不是粘包问题。
4.2 客户端鉴权与Origin校验
WebSocket的握手请求本质是HTTP,所以起源校验是可行的。服务端可以读取req.headers.origin,判断请求是不是来自允许的页面。但我要提醒一句:千万别只靠Origin做安全边界。因为WebSocket客户端不只是浏览器,命令行脚本、桌面程序、移动端都能发请求,这些场景下Origin可以随意伪造。
我实际做过的一个项目里,是在握手URL上带一个短时效的签名参数,服务端再解析校验。先创建一个不带业务逻辑的HTTP Server,在upgrade事件里先做鉴权,校验通过再继续握手,不通过就直接销毁socket。这种方案比等连接建立后再过滤消息可靠得多,因为一个WebSocket连接建立后能持续很久,如果攻击者已经连上了,再想踢掉往往要费更多功夫。
4.3 反向代理层引发的空闲断开问题
本地调试一切正常,部署到服务器之后发现连接每隔一分钟左右就断一次,这是我遇到过最典型的问题。根源不在Node进程,而在WebSocket连接经过的反向代理层。不少反向代理默认会对空闲连接设置超时时间,常见的是60秒左右,只要超过这个时间没有数据流,就会主动掐断连接。
如果心跳间隔设置得比这条超时时间更长,就必然会被切断。比如每90秒发一次Ping,代理层在60秒先断了,Ping根本发不过去。后来我把心跳间隔改成30秒,同时确认代理层能支持WebSocket升级和长连接,问题就消失了。这个经验告诉我,在部署WebSocket服务时,一定要把整个链路都梳理一遍:客户端到负载均衡、负载均衡到Node进程,每一层的空闲超时必须大于心跳间隔,否则任何一层偷偷断连都会让你误以为是代码问题。
4.4 性能观察与容量预估
用JS实现的WebSocket服务器能撑到多少连接,跟内存、文件描述符和处理速度都有关。每个连接除了Socket对象本身,还有收发缓冲区、业务状态对象。简单估算时,一路连接分配1KB缓冲区,一万路就是10MB,看起来很轻松,但加上TCP内核缓冲区、定时器、集合结构,实际占用会比理论值高不少。我自己的一个模拟项目里,万级连接在单进程下是能跑的,主要瓶颈反而不是CPU,而是文件描述符上限和内存。
如果连接数要继续往上走,单进程方案就要考虑横向扩展。比较常见的做法是用多个Node进程,每个进程维护一部分连接,进程间用消息总线同步事件。一个进程收到广播,把消息发到消息总线,其他进程订阅后再转发给各自的本地客户端。这样连接分散到多进程,单进程的压力就降下来了。
5. 生产级建议与经验总结
5.1 是否需要自己造轮子
自己手写一遍WebSocket服务端,最大的价值是搞懂协议,而不是在生产环境替代成熟库。像分片重装、扩展协商、压缩、关闭握手这些细节,协议里都写得清清楚楚,但自己实现很容易漏。哪怕是错误码处理,标准库也会比手写代码严谨得多。我在模拟项目里手写过一次之后,再也不想在真实业务里维护那一套帧解析逻辑了,到了生产环境老老实实换成了ws。
不过这个手写过程仍然值得做。它帮你建立了一种直觉:以后遇到WebSocket超时、乱码、握手失败,你能知道问题大概出在哪个环节。是Accept算错了?是掩码没解?是长度没读够?还是代理层切断了连接?没有底层经验,排查这些问题就像在黑箱里猜。
5.2 使用ws库时的高频配置说明
ws库的用法非常简洁,但有几个配置值得注意。一个是通过noServer模式把WebSocket服务和已有的HTTP服务共用端口,还能在upgrade事件里先做鉴权。代码形态大致是这样的:
const http = require('http'); const WebSocket = require('ws'); const server = http.createServer((req, res) => { res.writeHead(200); res.end(); }); const wss = new WebSocket.Server({ noServer: true, maxPayload: 1 * 1024 * 1024 }); server.on('upgrade', (req, socket, head) => { // 这里可以做自定义鉴权 wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req); }); }); wss.on('connection', (ws, req) => { ws.on('message', (data) => { ws.send(data.toString()); }); }); server.listen(8080);maxPayload一定要按业务实际设置。如果默认允许上百兆的数据帧,攻击者只要发一个超大Payload就能把你的内存吃光。noServer:true还有一个好处是,你不用让WebSocket服务占用单独端口,HTTP和WebSocket可以共存。perMessageDeflate压缩如果业务场景里文本很大,可以启用;如果都是小消息,压缩带来的CPU开销可能不划算,可以关掉。
5.3 多进程与部署注意点
部署WebSocket服务时,文件描述符限制经常被忽略。Node在Linux上默认能打开的Socket数量可能只有1024,也就是连接数稍微多一点就直接报EMFILE。我一般建议在服务管理配置里把文件描述符上限调高,或者至少在启动前用ulimit -n确认当前限制,不然代码写得再好也只能连到一千路就卡住。
多进程方案的另一个关键点是连接保持。如果负载均衡层做随机轮询,同一个WebSocket连接握手和后续消息可能会被分到不同进程,连接就断掉了。通常需要开启会话保持或者按客户端IP哈希转发,保证同一连接始终落在同一进程上。但这也会带来热点不均的问题,一旦某个IP的连接特别多,某个进程的压力就会很大。小型项目单进程加ws库一般够用,真到了多进程阶段,再引入外部消息总线做跨进程广播也不迟。
我自己动手写过一遍握手和收帧之后,最大收获不是用不上原生实现,而是再看ws库源码时不觉得陌生了。以前遇到WebSocket连接总是掉线,我只会怀疑服务器不稳定;现在我会先查心跳间隔、再看代理层超时、然后翻业务代码有没有阻塞事件循环,排查路径清晰很多。最后分享一个实用小技巧:调试WebSocket协议时,打开浏览器开发者工具里的Network面板,找到WebSocket连接后切到Frames标签,能看到每一帧的opcode和payload,配合这里的帧结构知识,很多时候一眼就能发现问题出在客户端还是服务端。