在前面的文章中,我们聊了HTTP协议的基础知识。HTTP作为一个无状态、请求-响应模式的协议,在Web发展的早期阶段确实解决了很多问题。
回顾一下HTTP的演进历程:早期的HTTP/1.0每次请求都要重新建立TCP连接,这在网络开销上是相当昂贵的。想象一下,每发送一个简单的GET请求,都要经历TCP的三次握手和四次挥手,这对服务器资源来说是个不小的负担。
HTTP/1.1的出现带来了连接复用(Connection: keep-alive),让我们可以在同一个TCP连接上发送多个请求,这确实是个不错的改进。但即便如此,HTTP协议本身的一些限制依然存在。
虽然HTTP协议经过多年发展已经相当成熟,但在某些场景下,它的局限性还是比较明显的:
在实际开发中,我们经常会遇到需要频繁请求数据的场景,比如实时监控、股票行情等。让我们来看一个简单的例子:
GET / HTTP/1.1\r\n
Host: www.example.com\r\n
\r\n这个最简单的GET请求总共42个字节,其中协议相关的内容就占了相当一部分。如果我们需要每秒发送多次这样的请求来获取实时数据,这些"额外"的字节累积起来就不容忽视了。
当然,这里说的"开销大"是相对的概念。对于传输大量数据的场景,这点开销可能微不足道;但对于只需要传输少量数据却需要高频通信的场景,这个比例就显得有些"浪费"了。
HTTP的请求-响应模式有个天然的限制:服务器无法主动向客户端推送数据。这在很多实时应用场景下就显得力不从心了。
虽然我们可以通过轮询的方式来模拟"实时"效果,但这又回到了第一个问题——频繁的HTTP请求会带来不必要的开销。而且轮询的实时性也不够理想。
正是这些痛点,催生了WebSocket协议的诞生。
WebSocket是一个基于TCP的全双工通信协议。简单来说,就是让客户端和服务器都能随时主动发送数据给对方,而不需要等待对方先"问"。
这种设计很好地解决了HTTP的两个痛点:
WebSocket的工作方式很巧妙:它先通过一个HTTP请求完成"握手",告诉服务器"我想升级到WebSocket协议",握手成功后就切换到WebSocket模式进行通信。这样既保证了兼容性,又获得了更高的效率。
WebSocket的连接建立是一个两步走的过程:首先是标准的TCP三次握手,然后是WebSocket特有的HTTP升级握手。
这个设计很有意思:WebSocket并没有重新发明轮子,而是巧妙地利用了现有的HTTP基础设施。这样做的好处是可以复用现有的代理、防火墙等网络设备,降低了部署的复杂度。
第一步:客户端发起升级请求
客户端发送一个特殊的HTTP请求,告诉服务器"我想升级到WebSocket":
GET /访问路径 HTTP/1.1\r\n
Host: www.example.com\r\n
Connection: Upgrade\r\n
Upgrade: websocket\r\n
Sec-WebSocket-Version: 13\r\n
Sec-WebSocket-Key: mgZ6+kXU1+mEgOXWDPPsBg==\r\n
\r\n这个请求看起来就是个普通的HTTP GET请求,但几个关键的头部字段表明了它的特殊意图:
Connection: Upgrade - 告诉服务器"我想升级连接"Upgrade: websocket - 具体要升级到WebSocket协议Sec-WebSocket-Version: 13 - 使用WebSocket协议版本13(目前的标准版本)Sec-WebSocket-Key - 一个随机生成的Base64字符串,用于后续的握手验证这里的Sec-WebSocket-Key很有意思,它不是用来做安全认证的,而是用来确保服务器真的理解WebSocket协议。如果服务器只是一个普通的HTTP服务器,它可能会忽略这些特殊头部,但无法正确处理这个Key,客户端就能知道"这个服务器不支持WebSocket"。
第二步:服务器确认升级
如果服务器支持WebSocket,它会返回一个101状态码的响应:
HTTP/1.1 101 Switching Protocols\r\n
Connection: Upgrade\r\n
Upgrade: websocket\r\n
Sec-WebSocket-Accept: qIs5tRK57T9vTjEtFfTLOSe3K3w=\r\n
\r\n这个响应的关键是状态码101,表示"协议切换"。从这一刻开始,这个TCP连接就不再使用HTTP协议了,而是切换到WebSocket模式。
服务器需要返回一个Sec-WebSocket-Accept字段,这是对客户端发送的Sec-WebSocket-Key的"回应"。具体的计算方法是:
258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接客户端收到响应后,会用同样的方法计算一遍,如果结果匹配,就确认握手成功。
握手完成
整个握手过程其实就是一次HTTP请求-响应,但它的意义重大:
到这里,握手阶段结束,真正的WebSocket通信才开始。接下来我们看看WebSocket是如何传输数据的。
握手完成后,WebSocket就进入了数据传输阶段。这里就能看出WebSocket相比HTTP的优势了。
WebSocket的数据传输以"帧"(Frame)为单位。每个帧都有一个精心设计的结构,既保证了功能的完整性,又尽可能地减少了开销。

WebSocket帧数据结构
从图中可以看到,WebSocket帧的头部信息非常紧凑。最小只需要2个字节,最大也不过14个字节(包含掩码的情况下)。相比HTTP每次请求都要几十个字节的头部,这个开销确实小了很多。
让我们来详细看看每个字段的作用:
FIN(1位)
这是帧的第一个位,用来标识这个帧是否是消息的最后一个片段:
这个设计支持了消息分片传输,对于大消息很有用。
RSV1-RSV3(各1位)
这三个位是为扩展预留的,目前必须为0。如果你在实现WebSocket客户端时收到了非0的RSV位,而你又不知道如何处理,那就应该关闭连接。
操作码(4位)
这4位定义了帧的类型,总共可以表示16种不同的帧类型:
数据帧(0x0-0x7):
控制帧(0x8-0xF):
帧分为两大类:数据帧用于传输实际的业务数据,控制帧用于连接管理和心跳检测。
控制帧的特点
控制帧有几个重要特征:
关闭帧(0x8)
用于优雅地关闭连接。当一方想要关闭连接时,会发送关闭帧,对方收到后也应该回复一个关闭帧,然后双方关闭TCP连接。关闭帧可以携带关闭原因(错误码+描述文本)。
心跳机制(Ping/Pong)
这个机制很实用,特别是在移动网络环境下,可以及时发现连接断开的情况。
数据帧类型
数据帧是我们日常使用最多的,主要有三种:
文本帧(0x1) 传输UTF-8编码的文本数据,比如JSON、XML等。这是Web应用中最常用的类型。
二进制帧(0x2) 传输任意二进制数据,比如图片、文件、或者自定义的二进制协议数据。
继续帧(0x0) 用于消息分片。当一个消息太大时,可以分成多个帧发送:
这种设计让大消息的传输更加灵活,也支持了流式处理。
WebSocket有一个有趣的设计:客户端发送的数据必须进行掩码处理,而服务端发送的数据不需要。
掩码的作用 这不是为了加密,而是为了防止某些代理服务器的缓存污染攻击。通过掩码,确保WebSocket数据看起来是"随机"的,避免被误认为是HTTP缓存内容。
掩码算法 很简单的异或操作:
masked_data[i] = original_data[i] XOR mask_key[i % 4]用4字节的掩码键,循环对数据进行异或。解码时再异或一次就能还原数据。
WebSocket使用了一种巧妙的变长编码来表示数据长度:
7位基础长度
扩展长度
这种设计很实用:小消息只用1字节表示长度,大消息才需要更多字节。所有多字节数值都使用网络字节序(大端序)。
掩码键(4字节) 当MASK位为1时存在,用于数据的异或运算。
载荷数据 包含两部分:
大多数情况下,载荷数据就是我们的业务数据,比如JSON字符串或二进制文件。
经过这么多年的发展,WebSocket已经在很多场景下得到了广泛应用:
适合的场景:
不太适合的场景:
作为一个全栈开发者,我觉得选择技术方案时需要考虑几个维度:
这些年在不同的项目中都有接触到WebSocket,从最初的iOS开发到后来的全栈开发,对这个协议的理解也在不断深化。希望通过这篇文章,能帮助大家更好地理解WebSocket的工作原理,在技术选型时做出更合适的决策。
WebSocket作为一个相对成熟的协议,在合适的场景下确实能解决HTTP的一些痛点。但技术没有银弹,关键是要根据具体的业务需求来选择合适的方案。
希望这篇文章对你有所帮助。如果有任何问题或者不同的看法,欢迎交流讨论。