websocket连接管理
在现代网络应用中,WebSocket 技术因其支持全双工通信而备受青睐。本文将深入分析一个基于 WebSocket 的连接池管理项目,探讨其技术实现、优秀设计以及优化建议。
背景
随着互联网应用的迅速发展,实时通信需求与日俱增。传统的 HTTP 协议采用请求-响应模式,难以有效支持实时数据传输。而 WebSocket 协议的问世为实时通信提供了理想的解决方案------它能在客户端和服务器之间建立持久连接,实现全双工通信,显著提升了数据传输效率。
在这种背景下,我们的项目旨在通过 WebSocket 实现高效的连接管理,支持大规模并发连接,适用于实时通信场景。
本文不会深入探讨 WebSocket 协议的具体设计(已有优秀的开源项目实现 github.com/coder/webso... ) 以及IM架构设计,而是重点关注如何确保连接建立的安全性,以及如何有效管理已建立的连接。
项目实现的主要目标和挑战包括:确保连接的安全性和可靠性、实现高效的连接池管理、以及处理大规模并发连接的性能优化。在接下来的内容中,我们将详细探讨这些关键方面的具体实现方案。
安全性:
WebSocket本身是建立在TCP协议之上的应用层协议。通过HTTP upgrade机制后,客户端和服务端之间会建立一条TCP连接,后续的通信都在这条连接上进行。与HTTP协议类似,如果不进行数据加密,传输内容将以明文形式传递,存在被网络窃听的安全风险。相信你的用户肯定是接受不了,他的"悄悄话"在网络上暴露无遗。
那Http怎么解决的,Websocket也是用同样的解决方式,即基于TLS确保通信的安全性和数据的加密传输。
lua
客户端 服务器
| |
| -- TLS握手 (SSL/TLS启动) ---------->|
| |
| <------ TLS握手确认 ----------------|
| (建立安全连接) |
| |
| -- WebSocket连接请求 -------------->|
| |
| <----- WebSocket连接确认 ------------|
| (连接已建立) |
| |
| <--------- 数据传输 ---------------->|
| |
| ------ 关闭连接请求 ---------------->|
| |
| <------- 连接已关闭 ----------------|
| |
对于开发来说,使用TLS应该是要无感的,比如前端由浏览器代理处理好了协议交互,而后端也有网关进行TLS的卸载(不建议后端开发者去做证书的处理,而是交给代理层,专注于业务开发,一是减少性能损耗,二是避免证书的更新替换,导致程序发版而带来未知维护风险)。
心跳机制
那既然是长连接,肯定是有别于Http那种处理完就回收资源的请求-响应模式,我们需要将连接保存在程序内存里,以便于后续持续通信。
所以建立连接后,我们还要确保连接一直是活跃的,避免连接已经断了,而服务端还保存着连接,导致无效的资源占用。虽然叫做长连接,但并不是网络上真的存在一条线连着双方,而仅仅是双方在自己的系统里存储着彼此的映射状态(IP+端口 → Fd句柄)。所以当其中一端消失了,如果没有一些机制,其实另一端是不会知道的,会一直保存着Fd,而系统能创建的Fd是有限的。
好在系统底层/协议处理层已经帮我们做了这件事,当连接断开是有一定的处理机制,以TCP连接为例,业务要进行连接断开,就会调用close方法,那么系统就会产生4次挥手,对方就能感知到收到回调,这样就能将资源回收掉。然而并非所有都能这么顺利,在系统发送崩溃、网络中断(比如光纤被挖)、网络丢包严重(4次挥手失败)等情况,对端是无法感知到的。你可能会觉得这种出现的概率太小了,给程序带来的内存占用可以忽略不计。然而有一种情况就不能忽略不计了,我们上网一般是分配的一个局域网内一个内网IP,是局域网的出口NAT设备(网络地址转换)做了连接映射 ,与后端建立连接的实际是NAT出口IP。因此NAT维护这个映射数据,也会消耗资源,为了减少连接的维护,如果连接长时间没有传输数据, NAT 会直接 丢弃映射,释放资源。这种情况只有对端发送数据时候,才能发现异常,否则服务器就会一直保存着这个连接。
- 我们可以使用心跳包(Keep-Alive)机制来维持长连接,这样服务端就能知道客户端是否还存活,websocket本身协议头有定义了标准,使用ping-pong;同时时间间隔要低于NAT的超时时间,可以定时60秒(这个没具体时间,不同NAT设备设置的时间不同,想准确的话,可以增加一些检测机制去不断发包判断当前所在的NAT的超时)。如果使用的代码库不支持ping-pong,那么也可以在应用数据包里去约定协议即可;
json
# 建立连接后,后端下发的心跳配置,间隔60秒Ping
{
"type": "core/cfg/keep-alive",
"cfg": {
"interval": 60
}
}
- 这样我们就能知道长连接是否还有效了,不过这还不够,我们还要避免占着茅坑不拉屎的情况,比如连接虽然建立了,但一直挂着啥事也不干,这也属于无效连接。我们可以记录连接最近的传输数据时间,如果长时间没通信,那么我们就可以主动断开连接释放资源。当然在关闭前我们应该先通知前端,这是主动关闭的,这样前端就不会判定为异常,不会发起重连。当然这还要看场景,比如聊天IM,就是会发言后然后等对方回复,需要连接一直建立,所以需要配置这个检查时间是不会过期的;
json
# 通知前端连接关闭
{
"type": "core/conn/close"
}
- 另外一种情况是孤儿连接,就是业务层自己退出了,没有调用Close,导致连接还保存着,而对端数据传输还存在,数据没有消费掉,就导致了数据堆积。因此还会记录一个连接的数据处理时间,当发现数据Read后,数据处理时间一直是落后于Read的时间,那么就将连接回收掉,通知前端重新建立连接。
go
type ConnCtl struct {
...
LiveTime int64 // ping的在线时间
ReceiveTime int64 // 数据传输时间
...
}
连接回收
因为连接Fd会存储在我们自己程序里了,所以除了要确保保存的连接是活跃的,我们还要对发生异常的连接进行清除。比如连接发生关闭(前端关闭/刷新页面等)、连接传输异常数据(协议有问题、数据包过大等)。 websocket状态码协议说明
- 当我们的程序阻塞在Read接口上时,如果发生正常的连接关闭,我们就直接对连接进行关闭回收
go
_, data, err := c.conn.Read(c.ctx)
if err != nil {
// 需要判断连接是否关闭
if websocket.CloseStatus(err) == websocket.StatusNormalClosure ||
websocket.CloseStatus(err) == websocket.StatusGoingAway {
c.Close(true, "connection closed") // c为程序定义的连接对象
return
}
...
}
...
- 如果是其它异常情况,可能出现短暂的超时、数据包异常,不能直接关闭,而是要检测其失败率,如果失败率比较高,再关闭
go
// 错误统计器;统计一段时间窗口内的失败率,高于配置的maxErrRate则关闭
type ErrorStats struct {
startTime time.Time
totalCount int
errorCount int
window time.Duration
maxErrRate float64
minRequests int // 最小请求数阈值
}
连接管理
基于前面的内容,我们已经知道程序建立连接后,我们需要将Fd保存在程序内存结构里,同时需要对连接检查是否超时该释放了。那么我们面临的就有两个主要问题:
- 建立大量连接我们应该如何存储
- 如何快速找到过期的连接
建立大量连接我们应该如何存储:
先从程序本身的角度来看,我们需要能快速存储以及找到连接Fd,那最合适的结构就是哈希了。将连接对象指针作为Key即可,我们知道当数据量越大,哈希冲突就越严重,导致频繁数据迁移。那其中一个解决思路就是分而治之,提前创建多个哈希表,将数据按Key哈希分配到多个桶,这样就能将压力分散掉。
go
type Server struct {
connBuckets []*sync.Map
bucketSize uint32
...
}
当然一个程序的能建立的连接总会有上限的,你再怎么优化也不能突破硬件瓶颈。所以要从更高的层次来解决,比如K8s架构下,我们要给程序配置好弹性伸缩HPA,及时发现瓶颈,让程序能够快速扩容,支持更高的连接数。这顿操作下来,我们已经能够支持很高的连接数了,然而现实是残酷的,总会有人不怀好意,来攻击我们(建立大量无用连接,消耗服务资源),这时候我们还要有Waf来对抗,将恶意连接拦截掉,避免业务被打瘫。
如何快速找到过期的连接:
因为连接对象会记录着活跃时间,所以一开始我的想法是有个数据结构,可以拿到TopK数据(key是活跃时间,值是连接对象),比如最小堆。然而活跃时间不是死的,会一直变化,那么就得频繁更新结构,而且我们也不止活跃时间一个检测,还有数据传输时间和处理时间,真这么做那维护和性能消耗可不小。
从redis得到一个启发,没必要做到要快速找到过期的连接,而是定时捞出一批数据,循环检查是否过期即可,这样就简单很多了。
前面我们已经分桶存储了,所以我们可以定时循环拿一个桶,检查里面的连接是否过期即可。
连接监控
我们已经能高效处理建立的连接了,那么是不是建立的连接数是合理的,以及业务的处理连接情况是怎么样的,我们都是需要知道的,而不是提供个SDK给上层开发接入就完事了。
以连接数监控为例:当建立连接时候,SDK会要求传入一个场景值(比如scene=im-bot),这样当连接新增和销毁时候,就记录到监控状态值,然后定时将状态值上报到Prometheus,这样就能看到服务建立的连接总数和不同场景下的连接数。
go
type ConnNum struct {
Total *atomic.Int64
SceneNum *sync.Map
}
结语:
本文更多的是从服务端的视角,去分享websocket连接管理中会遇到的问题。对于前端也会有相应的事项处理,比如自动重连,异常上报等等,以及约定交互协议,同时也会更多从产品使用体验角度出发。未来有机会,会从业务的视角,分享我们业务对话Bot的设计实现,类似于现在的AI聊天Bot。