SOCKET.IO最佳实践-代理篇
前言:
在传统的轮询中,客户端定期向服务器发送请求,询问是否有新的数据可用。这会导致很多不必要的空请求,尤其是在没有新数据可用的时候。而且如果使用的是HTTP/1.0版本,每个请求/响应都需要打开一个新连接,考虑到连接的建立、关闭、TCP慢启动机制等因素,这是一个很大的开销。因此HTTP/1.1引入了2个头部:Connection头部和Upgrade头部,用于协议升级。
其中Connection: keep-alive可以将HTTP短链接升级为长连接,这意味着在一个 TCP 连接上可以传输多个 HTTP 请求和响应,这样就减少大量请求建立、关闭等因素的开销,并且依靠这个机制,可以实现一种长轮询的模式,进一步减少空请求的损耗。需要注意的是,HTTP/1.0也可以使用Connection: keep-alive,但是服务端并不一定支持,因此尽可能使用HTTP/1.1版本,同时本文后续出现的HTTP,默认指的都是HTTP/1.1。
同样,可以使用Connection: Upgrade与Upgrade: websocket将HTTP连接升级为websocket,具体可以参考本人另外一篇文章《浅析nginx实现websocket原理》。
1、HTTP 长轮询:
HTTP长轮询通过使用Connection: keep-alive实现服务端消息的“推送”。具体过程如下:
1.客户端发送一个HTTP请求到服务器,但服务器不立即响应。客户端发送的请求类似于下图所示:
1 |
|
2.服务器保持请求打开,等待有新的数据或事件发生。
3.一旦有新的数据或事件发生,服务器立即响应请求,将数据传输给客户端。服务端的响应类似于下图所示:
1 |
|
Keep-Alive: timeout=5 表示服务器愿意在响应后保持连接打开,等待可能的进一步请求,而这个连接将在5秒钟后自动关闭,除非另外有新的请求。
4.客户端收到响应后,立即再次发起新的HTTP请求,重复上述过程。
相比于传统轮询,HTTP长轮询减少了不必要的空请求,因为服务器只在有新数据时才会响应。并且这种方式可以降低通信的延迟,因为服务器在有数据时立即将其传输给客户端,而不需要等到下一次定期轮询。
但是服务器必须维护大量的打开连接,这可能导致服务器资源的浪费。而且在某些情况下,中间代理(如代理服务器或防火墙)可能会中断长轮询连接,导致不稳定的通信。
所以对于服务端推送消息的场景,websocket是一种更好的方式,HTTP长轮询只是提供了一种在不支持WebSocket的环境中实现实时通信的方法。
2、Socket.io
Socket.IO 是一个库,可以在客户端和服务器之间实现低延迟, 双向和基于事件的通信。Socket.IO在普通的websocket上提供一些功能,如自动重新连接、广播、HTTP长轮询回退(无法与服务端建立websocket连接,将回退为HTTP长轮询)等功能。因此一个socket.io客户端和服务端的交互过程中,可能会同时存在HTTP长轮询与websocket协议的请求。在存在代理的链路中,不当的配置会导致通信失败,本文将着重分析在多级代理中,如何正确配置使得socket.io客户端与服务端正常通信。
2.1、会话id
在 Socket.IO 中,每个客户端连接都会被分配一个唯一的标识符,通常被称为会话id,会话id在服务端会关联客户端的连接。所有后续HTTP请求的参数中必需携带这个值,这个标识符可以用于在服务器端跟踪和识别特定的客户端连接。
通过这种标识符,服务器可以维护一个连接池,用于管理和处理来自不同客户端的实时通信。这对于实现诸如广播消息、单播消息、断线重连等功能都非常有用。
在 Socket.IO 中,连接建立时会触发一个事件(通常是 connection 事件),服务器会分配一个唯一的 sid给客户端连接,一个HTTP长轮询请求如下图所示。
2.2、socket.io集群
Socket.IO客户端和服务端是靠会话id一一对应的,所以客户端请求到了错误的Socket.IO服务端时,就会报错,因为服务端识别不了。
因此,当socket.io为集群时,nginx做代理,如果负载策略是轮询,那么客户端和服务端会有概率不匹配。下图的场景,client-A的请求必须路由到第二个socket.io节点,因为nginx是轮询,因此每3笔会有一笔失败,在浏览器的现象就是,刷新2次界面后,系统恢复正常。
因此,需要在nginx开启会话保持,即ip_hash,这样一个客户端的ip会固定路由到一个Socket.IO服务端,这样就不会出现不匹配的问题。
3、websocket
websocket很明显是优于HTTP长轮询的,只要维持一条长连接,就可以实现全双工通信,避免了频繁的建立连接。通常Socket.IO首先会发起HTTP长轮询请求,服务端会在响应中返回upgrades 数组,表示服务器支持更好的传输协议,如下所示。然后,socket.io客户端就会将协议升级为upgrades 数组中的一种。
1 |
|
sid 是会话的ID,它必须包含在sid所有后续HTTP请求的查询参数中
upgrades 数组包含服务器支持的所有“更好”传输的列表
pingInterval 和 pingTimeout 值用于心跳
3.1、socket.io升级websocket的过程
1、 最开始客户端的请求
2、服务端响应
3、客户端发送请求建立websocket连接
其中请求头为
响应头为
4、心跳
心跳间隔为25s,与”pingInterval”: 25000是一致的
3.2、此websocket非常规的websocket
Socket.IO 可以使用 WebSocket 协议,但它为每个数据包添加了额外的元数据。所以 WebSocket 客户端将无法成功连接到 Socket.IO 服务器,而 Socket.IO 客户端也将无法连接到普通 WebSocket 服务器。
我们团队提供的wss组件,虽然支持了socket.io与普通的websocket,但是同一时刻也只能支持其中的一种,即使用socket.io的客户端和websocket客户端连接wss,总有一个会失败。
4、代理
由于会话id的存在,每个携带唯一会话id的HTTP请求都必须路由到对应的socket.io服务端,尤其是socket.io服务端为多节点时。本节着重讲解如何正确配置代理节点,使得socket.io客户端与服务端可以使用HTTP长轮询与websocket进行正常通信。
1、socket.io为单节点
- HTTP长轮询
socket.io为单节点时,客户端和服务端肯定是对应的,所以不管中间代理怎么路由,都没有问题。
- websocket
中间节点都需要升级HTTP协议为websocket,如果是四层负载,那就不需要做任何改动,websocket只针对七层负载。
2、socket.io多节点
1、一级代理
1、代理为单节点
- HTTP长轮询
由于client使用session id和socket.io端一一对应,因此需要保证同一client的请求一直路由到同一socket.io服务端,否则会报400的错误(其他服务端识别不了未知的sid)。所以nginx需要配置会话保持,即ip_hash,其他负载均衡器类似。
- websocket
如果nginx配置了协议升级,client到nginx、nginx到socket.io的连接都是websocket协议的连接,即长连接,nginx保证了client与服务端一一对应
2、多级代理
1、单-单
l HTTP长轮询
如果代理是单节点-单节点,如下图。为了保证客户端与服务端一一对应,那么需要在第二个nginx配置会话保持。
- Websocket
毫无疑问,如果每个nginx都配置了websocket协议升级,将不会出现任何问题
2、单-多
- HTTP长轮询
如果代理节点是单节点-多节点,为了保证客户端与服务端一一对应,那么需要多级nginx都需要配置会话保持。这时我们应该发现了一个规律,只要某个节点后面的节点是集群,那么当前节点就需要配置会话保持,这其实就是HTTP长轮询在多级代理场景下的核心
深度思考二个问题:
1、上图第一个nginx真的需要配置ip_hash吗?
2、下图,哪些nginx需要配置ip_hash?
答案将会放在第5节,如果你能回答正确,那么你就真正理解了如何代理长轮询
- websocket
毫无疑问,如果每个nginx都配置了websocket协议升级,将不会出现任何问题
3、多-多
这种场景和单-多的场景没有任何区别,因为集群前面肯定有一个单节点的负载均衡器做负载,本质也是单-多。有些人可能好奇,双活难道不是每个节点都是集群吗,整条链路如果存在单节点,这个单节点挂掉之后,整条链路随之挂掉,事实上就是这样的,所以在负载的最前面,都是用DNS做分发。
4、多-单
同上,本质和单-多没有区别
3、HTTP长轮询与websocket同时存在
Socket.io的机制会同时存在HTTP长轮询与websocket协议的请求,所以代理节点需要同时配置会话保持与协议升级的配置。
5、问题解答
1、首先回答第4节的2个问题
1、第一个nginx真的需要配置ip_hash吗?
ip_hash是将某一ip的客户端,固定路由到后台的某一台服务器。所以假设client-A、client-B与某个socket.io建立了会话,,那么后续请求也需要一一对应,我们可以推测nginx-M是否设置ip_hash的路由场景
- nginx-M设置了ip_hash
因为client-A与client-B的ip不一样,nginx-M又设置了ip_hash,所以client-A的请求都会走到nginx-A(这是假设,事实上不走A,就会走B,这里假设走A),client-B的请求都会走到nginx-B。重点来了,nginx-M的ip是固定的,所以对于nginx-A和nginx-B而言,一样的ip,他们都会路由到同一个服务端(ip_hash的算法决定),假设都路由到了socket.io-A,因此socket.io-B其实一直是空闲的!
- nginx-M没有设置ip_hash
如果nginx-M没有设置ip_hash,client的请求,nginx-M会轮询分发到ngina-A和nginx-B,但是由于nginx-M的ip是固定的,所以对于nginx-A和nginx-B而言,一样的ip,他们都会路由到同一个服务端,假设都路由到了socket.io-A,因此socket.io-B其实一直是空闲的!,我们发现和上面一模一样,所以结论是nginx-M是不需要设置ip_hash的。
2、哪些nginx需要配置ip_hash?
2个问题的本质是一样的,事实上,只要nginx-M、nginx-C、nginx-D设置了ip_hash就可以保证客户端和服务端一一对应
2、结论
其实为了简单化问题,我们可以对每个nginx都设置ip_hash,但是需要注意的是,总会有一层的集群变成了”单点”,有节点总是处于空闲状态!,导致集群变成了单点
6、socket.io集群同步
试想,如果socket.io集群间能同步数据,那么是不是客户端可以随意对应哪个socket.io了?
答案是的
由于客户端可能连接到集群中不同的节点,为了在集群中不同的节点之间传递消息,socket.io官方以redis的发布订阅功能为基础做了消息路由分发:socket.io-redis。socket.io-redis在节点向客户端群发消息时会将该消息发布到redis的订阅队列中,让其他节点能够订阅到该消息,从而实现节点间消息推送。不过这有额外的开发工作量,目前来看,公司内部的socket.io并没有做集群的数据共享
7、sticky&&哈希 && 一致性哈希
1、不能使用ip_hash,要使用hash
nginx的ngx_http_upstream_ip_hash_module.c包含了具体hash负载策略的实现,在另外一篇文章会分析nginx的几大负载策略,这里简要概括一下,nginx会使用r->connection的地址,即取上一个节点的ip进行负载,也就是第5节说的问题,多级代理下,会导致集群退化成单节点。
同时nginx提供了hash,因此需要使用hash,而不是ip_hash,且要这么配置
1 |
|
1 |
|
如果使用$http_x_forwarded_for,会将第一个地址进行哈希,还是所有地址呢?
这里先来理解一下X-Forwarded-For,X-Forwarded-For包含了经过的所有代理服务器的IP地址。同样在多级代理下,每级代理(包括最初始的客户端)都需要传递X-Forwarded-For头部,并添加自身的ip .X-Forwarded-For头部的值由多个IP地址组成,以逗号分隔。例如,如果请求经过了三个代理服务器,则X-Forwarded-For头部的值为:
1 |
|
其中,client_ip代表客户端的真实IP地址,proxy1_ip和proxy2_ip分别代表两个中间代理服务器的IP地址。
从逻辑来讲来看,应该取第一个地址,因为如果在多级代理下,比如client -> F5 -> (A/B) ->server,同一个client可能会走A或者B,这就会导致hash值发生变化。简单搭个环境,打开debug测试一下,hash模块用的确实是真实客户端ip,也就是第一个ip,这样就是没有问题的。
有意思的是,如果客户端后的第一个节点没有获取到client_ip,第二个节点获取到了第一个节点的ip,就会导致X-Forwarded-For的client_ip始终是客户端后第一个节点的ip,ip固定了,hash直接就无效了,因为不管客户端再怎么变化,client_ip始终是第一个代理节点。所以要保证整个链路支持X-Forwarded-For。
事实证明,生产根本不是这样的,尤其是你的客户用了各种各样的负载均衡器,7层还好一般都支持,4层就够呛了(第一层代理节点要从tcp连接获取客户端的ip),如果客户买了F5,就一定会配置,或者愿意配置吗?
对比一下几种算法
功能/支持 | 使用真实客户端ip进行ip_hash | 一致性哈希 |
---|---|---|
ip_hash | 否 | 否 |
hash | 是 | 是 |
sticky | 否,使用cookie的router值路由 | 否 |
1、使用不同的算法会有什么问题?
1、使用ip_hash代理,退化成单节点
如果停掉在用的,当前会话直接挂掉,nginx会重新选取一个节点。根据socket.io的特性,会出现报错,停掉空闲节点,不会有影响。
另外补充一个点,如果 client – nginx – 后端服务,这种模式下,nginx开启ip_hash后,如果停掉一个节点,nginx会重新计算权值,这个值影响最终请求被路由到哪台机器,也就是粘性会话失效了。
也就是ip_hash的2个缺点,集群退化单节点与不支持一致性哈希
2、使用hash
首先肯定是使用真实客户端ip进行hash路由,这样可以避免使后端集群退化成单节点
而且现在因为后端服务是多节点,停止掉一个节点后,数据项的hash值会发生变化,客户端的请求会路由到其他的节点,且这个服务的数据丢失。
但是使用一致性哈希后,数据会同步到顺时针的下一个节点,整个集群不会因为增删节点,影响对外提供功能
以ingress-nginx举例:
1 |
|
8、实际问题
1、实际问题 1
1、业务背景
某系统使用socket.io做数据推送,架构如下图,打开F12,这个前端同时存在polling和websocket的请求,有了上面的经验,这个很容易,我们在Nginx,针对socket.io的请求,配置会话保持和websocket升级的配置就可以,但是有问题,这主要是业务的使用方式。
2、 业务的使用
1、client首先会发送一个post请求,告知后台微服务,客户端想获取什么数据。
2、服务端组装好数据后,通过Socket.io将就绪的通知,通过websocket的请求推送到客户端。
3、客户端接收到数据就绪的通知后,再次发送一个get请求,下载数据 。
业务只是对socket.io的请求配置了ip_hash,那么试想,
1、如果客户端的websocket请求是和第一个Socket.io服务端建立的
2、post请求没有配置ip_hash,所以是轮询负载的,每2笔会有一笔发给了第二个Socket.io。
3、第二个Socket.io服务端收到了前端的请求,但是它没法通知客户端数据已经准备好了,因为没有websocket的连接,所以客户端一直不会发起下载的请求。
4、请求卡住了,导致整个系统卡住了
所以将这2个请求也配置为ip_hash,事实证明,问题解决。
3、 提问环节
本文一直没有提七层负载和四层负载的区别,刚好这里有个四层负载,那么有2个问题需要留给读者
1、上图的四层负载没有做任何改动,如果换成七层负载,需要加什么配置?
提示:对于polling的请求,上图的负载均衡器是四层和七层其实没区别(只限于这种架构,其他架构可能会有区别,具体问题具体分析),那么我们需要考虑的就是websocket协议了,可以参考另外一篇文章,《浅析nginx实现websocket原理》
2、哪个集群退化成了单点?
2、实际问题 2
为了建立一个WebSocket连接,客户端需要建立一个tcp连接并且发送一个握手协议。连接最初状态为CONNECTING,但是客户端最多有一条连接可以处于CONNECTING状态。如果多个连接尝试同时与一个相同的IP地址建立连接,客户端必须把他们进行排序。如果是web端,Chrome浏览器最多允许对同一个域名Host建立6个TCP连接,不同的浏览器有所区别。
因此某客户遇到了一个问题,使用的socket.io服务总是连接不成功,且浏览器会直接卡死,严重影响业务的正常运转。排查问题是要讲究步骤的,因此
1、首先,查看了现场的现象,发现浏览器卡死,且发送了很多socket.io的请求,刚好是6条,但是请求一直处于pending状态。
2、然后,确认了现场的架构拓扑,如下:
1 |
|
3、确认了F5的负载模式是7层负载,且没有配置websocket协议升级,Nginx倒是配置了会话保持(现场的实施根据部署文档进行配置),但是配置还存在问题。
4、如果确认了上面的信息,那么我们可以清晰的发现,问题解决很简单。先来解答一下异常现象
- 为什么请求处于pengding状态?
因为F5使用了7层负载,但是没有配置websocket协议升级,而客户端是同时存在polling和websocket请求的,所以客户端和F5之间的websocket请求一直是建立失败的,只是建立了HTTP1.1的连接,虽然也是长连接,但是属于HTTP,数据的传输格式不一样,报错是肯定的。但是先不要着急,现在还没到这个报错的时候,HTTP的请求如果一直没有响应,那么就是pengding的状态,所以这就是为什么请求都处于pengding的状态。
- 浏览器为什么会卡死?
如果websocket的连接建立不成功,它会一直重复发,直到浏览器的上限,6条tcp连接。
5、这个架构其实我们已经很熟了,上面也有例子,F5配置websocket协议升级,HSIAR配置会话保持+websocket协议升级即可,但是F5支不支持websocket呢?有版本限制,且配置比较复杂,客户不想研究,那么直接将7层负载变为4层负载,这样问题就解决了。