websocket连接管理

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 会直接 丢弃映射,释放资源。这种情况只有对端发送数据时候,才能发现异常,否则服务器就会一直保存着这个连接。

  1. 我们可以使用心跳包(Keep-Alive)机制来维持长连接,这样服务端就能知道客户端是否还存活,websocket本身协议头有定义了标准,使用ping-pong;同时时间间隔要低于NAT的超时时间,可以定时60秒(这个没具体时间,不同NAT设备设置的时间不同,想准确的话,可以增加一些检测机制去不断发包判断当前所在的NAT的超时)。如果使用的代码库不支持ping-pong,那么也可以在应用数据包里去约定协议即可;
json 复制代码
# 建立连接后,后端下发的心跳配置,间隔60秒Ping

{
    "type": "core/cfg/keep-alive",
    "cfg": {
        "interval": 60
    }
}
  1. 这样我们就能知道长连接是否还有效了,不过这还不够,我们还要避免占着茅坑不拉屎的情况,比如连接虽然建立了,但一直挂着啥事也不干,这也属于无效连接。我们可以记录连接最近的传输数据时间,如果长时间没通信,那么我们就可以主动断开连接释放资源。当然在关闭前我们应该先通知前端,这是主动关闭的,这样前端就不会判定为异常,不会发起重连。当然这还要看场景,比如聊天IM,就是会发言后然后等对方回复,需要连接一直建立,所以需要配置这个检查时间是不会过期的;
json 复制代码
# 通知前端连接关闭

{
    "type": "core/conn/close"
}
  1. 另外一种情况是孤儿连接,就是业务层自己退出了,没有调用Close,导致连接还保存着,而对端数据传输还存在,数据没有消费掉,就导致了数据堆积。因此还会记录一个连接的数据处理时间,当发现数据Read后,数据处理时间一直是落后于Read的时间,那么就将连接回收掉,通知前端重新建立连接。
go 复制代码
type ConnCtl struct {
	...
	LiveTime    int64 // ping的在线时间
	ReceiveTime int64 // 数据传输时间
	...
}

连接回收

因为连接Fd会存储在我们自己程序里了,所以除了要确保保存的连接是活跃的,我们还要对发生异常的连接进行清除。比如连接发生关闭(前端关闭/刷新页面等)、连接传输异常数据(协议有问题、数据包过大等)。 websocket状态码协议说明

  1. 当我们的程序阻塞在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
	}
	...
}
...
  1. 如果是其它异常情况,可能出现短暂的超时、数据包异常,不能直接关闭,而是要检测其失败率,如果失败率比较高,再关闭
go 复制代码
// 错误统计器;统计一段时间窗口内的失败率,高于配置的maxErrRate则关闭
type ErrorStats struct {
	startTime   time.Time
	totalCount  int
	errorCount  int
	window      time.Duration
	maxErrRate  float64
	minRequests int // 最小请求数阈值
}

连接管理

基于前面的内容,我们已经知道程序建立连接后,我们需要将Fd保存在程序内存结构里,同时需要对连接检查是否超时该释放了。那么我们面临的就有两个主要问题:

  1. 建立大量连接我们应该如何存储
  2. 如何快速找到过期的连接

建立大量连接我们应该如何存储:

先从程序本身的角度来看,我们需要能快速存储以及找到连接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。

相关推荐
晴殇i几秒前
一行代码解决深拷贝问题,JavaScript新特性解析
前端
方块海绵1 分钟前
RabbitMQ总结
后端
星辰大海的精灵2 分钟前
Python 中利用算法优化性能的方法
后端·python
雷渊3 分钟前
深度分析Scroll API(滚动搜索)方案
后端
AronTing3 分钟前
11-Spring Cloud OpenFeign 深度解析:从基础概念到对比实战
后端·spring cloud·架构
yifuweigan4 分钟前
J2Cache 实现多级缓存
后端
洛神灬殇7 分钟前
【Redis技术进阶之路】「原理分析系列开篇」探索事件驱动枚型与数据特久化原理实现(时间事件驱动执行控制)
redis·后端
Java中文社群9 分钟前
SpringAI版本更新:向量数据库不可用的解决方案!
java·人工智能·后端
日月星辰Ace10 分钟前
蓝绿部署
运维·后端
天天扭码11 分钟前
零基础入门 | 超详细讲解 | 小白也能看懂的爬虫程序——爬取微博热搜榜
前端·爬虫·cursor