深入理解Websocket协议

Websocket是实现了客户端与服务端完全全双工通讯的一种协议,支持服务端和客户端之间的互相交互,它是在Http协议的基础之上扩展而来,属于有状态的长连接通讯方式。通过websocket,可以实现高效实时数据交互,一般用户服务端实时主动推送消息给客户端。

Part1:使用场景

最近使用到的关于WEBSOCKET的场景主要是要求客户端几乎实时地获取动态变化数据。在没有使用WS之前,可能想到的就是由客户端以轮询的方式来获取最新数据,但非常明显,这种方式存在以下弊端:

  • 数据延时,即使缩小客户端轮询区间,也无法避免数据的延时,导致非实时数据出现,特别是对一些数据实时性要求很高的场景,比如交易所交易数据。
  • 性能损耗,客户端需要不断地创建和关闭HTTP连接,这种性能上耗费的代价是非常庞大的,如果再加上并发量,轮询的方式一定不可取。

所以,是否有一种方式来实现客户端的数据同步问题呢?其实这种场景很早之前就已经出现过并已经有了相关的解决方案。我们试想,既然数据在服务端更新,那为何不用服务端在通知客户端数据有更新呢?没错,websocket实现的正是一种服务端可以主动像客户端推送数据的协议。

我们一般会说,Websocket是一种TCP长连接,是由无状态的HTTP协议经过握手升级后的新协议。

Part2:Websocket与HTTP的比较

HTTP有几个常见的版本,如HTTP1.0,1.1和2.0。在HTTP1.0的时候每次请求都会创建一次全新的TCP Socket连接实例,这在当一个网页有很多请求的时候就需要线性的按照每个请求创建一个全新的实例,可想而知,这是非常耗费资源的。那能不能复用呢?为了解决这个问题,HTTP1.1出现了,也就是支持某种意义上的长连接,即keep-alive。本质上是一个TCP连接上支持发送多次请求,减少了每次都重新创建连接的时间。而且也是默认开启keep-alive的。

然而keep-alive也不是百利而无一害的。长时间的tcp连接容易导致系统资源无效占用。配置不当的keep-alive,有时比重复利用连接带来的损失还更大。所以,正确地设置keep-alive timeout时间非常重要。而具体的设置则需要根据相应的业务场景来确定。

注意,tcp_keepalive_time以及是TCP连接建立后双方无往来时时间,服务器内核就会尝试向客户端发送侦测包,来判断TCP连接状况,如果没有收到对方的回答(ack包),则会在 tcp_keepalive_intvl后再次尝试发送侦测包,直到收到对对方的ack,如果一直没有收到对方的ack,一共会尝试 tcp_keepalive_probes次,每次的间隔时间在这里分别是15s, 30s, 45s, 60s, 75s。如果尝试tcp_keepalive_probes,依然没有收到对方的ack包,则会丢弃该TCP连接。TCP连接默认闲置时间是2小时,一般设置为30分钟足够了。

所以对于比如Nginx,则需要关注这几个参数:

tcp_keepalive_time     #系统内核参数
tcp_keepalive_probes   #系统内核参数
tcp_keepalive_intvl    #系统内核参数
keepalive_timeout      #web server参数

由此可知,当web服务器设置的keepalive_timeout小于内核的tcp_keepalive_time时,会导致长连接在服务端还没有发送侦测包就已经自动断开。

Part3: Websocket初探

Websocket沿用了原来HTTP协议的一些头部信息,所以可以看作是在HTTP协议的基础上进化而来,而这个进化的过程就是Upgrade.一个标准的WS请求的头部信息如下:

GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Host: 127.0.0.1:8080
Origin: http://127.0.0.1:8001
Sec-WebSocket-Key: hj0eNqbhE/A0GkBXDRrYYw==
Sec-WebSocket-Version: 13

首先必须基于HTTP/1.1,因为要进行TCP复用。Upgrade: websocket则告诉服务器这个连接是一个WS连接,要将这个HTTP连接升级到websocket。Sec-WebSocket-Key是客户端生成的一组16位的随机base64编码的字符串,而Sec-WebSocket-Version表示协议的版本。

服务端在接收到请求后,如何进行验证合法性呢?其实本质上就是服务端根据客户端发来的Sec-WebSocket-Key,进行校验后返回一个新的Sec-Websocket-Accept,那么这个值如何产生呢?我在网上找了一个生成的算法:

 def compute_accept_value(key):
        """Computes the value for the Sec-WebSocket-Accept header,
        given the value for Sec-WebSocket-Key.
        """
        sha1 = hashlib.sha1()
        sha1.update(utf8(key))
        sha1.update(b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11")  # Magic value
        return native_str(base64.b64encode(sha1.digest()))

客户端接受到应答后用自己的Sec-WebSocket-Key做同样的加密,如果和Sec-Websocket-Accept一致则握手完成。

Part4: Websocket实践

Websocket是HTML5的产物,所以H5也内置了Websocket的相关包。比如使用JS创建的WS客户端就很简单:

//H5
ws = new WebSocket("ws://xxx.com/xxx");
ws.onopen = function () {};
ws.onmessage = function (evt) {};
ws.onclose = function () {};
ws.onerror = function(){};
ws.send('xxxx');
ws.close();

服务端则每个后端语言都有,比如Golang,Java等等,当然客户端也可以是这些语言。

在使用Nginx配置反向代理的时候,注意需要设置以下参数:

# Nginx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 600s;

Part5: Websocket心跳机制

Websocket提供了心跳维持机制,但是默认是不超时的,通过设置来实现心跳的超时。值得关注的是,如果是用了代理,比如Nginx,可能要重点关注一下下面这两个配置:

proxy_read_timeout 30s;
proxy_send_timeout 30s;

这两个值如果比程序设置的超时时间短时,可能会导致在心跳更新超时之前就已经被Nginx断掉了.

所以最好是设置成比程序设置的读写超时时间更大一些,这样能够避免一些不必要的麻烦.

在程序中,我们通过设置readdeadlinewritedeadline来更新这个超时时间.个人建议一般在接收到客户端的PING时更新readdeadline时间,在应答发送PONG时更新writedeadline,如:

// use "github.com/gorilla/websocket"
conn.SetWriteDeadline(time.Now().Add(ReadTimeout))
conn.Client.SetReadDeadline(time.Now().Add(ReadTimeout))

Part6: 另一个长连接协议:MQTT

MQTT也是一种长连接协议,是一种基于C/S的发布/订阅方式的轻量级协议,有点类似于消息队列,能够利用有限的带宽实现数据交互,一般用于物联网(loT)。

赞赏我吗