纲要
- 引言:从基础连接到稳定长连接
- 为什么需要心跳检测
- 连接空闲断开
- 异常断连检测
- 心跳的双重功能:保活与失活判定
gorilla/websocket中的心跳实现- 三个核心定时器分析
- 空闲检测与主动探测的配合
- IM 服务中的心跳检测实现
- 自定义
Conn结构体 - 读写操作与空闲时间更新
- 心跳检测协程:空闲超时检测
- 服务端改造:区分心跳消息与业务消息
- 连接池与鉴权适配
- 配置化最大空闲时间
- 防止重复登录
- 自定义
- 测试验证
- 空闲超时自动断开
- 消息发送重置计时
- 总结
引言
在之前的系列文章中,我们基于 go-zero 框架搭建了 IM 服务的 WebSocket 通信模块,并成功集成了 JWT 鉴权。至此,客户端已经能够安全地建立长连接并发送业务消息。然而,在生产环境中,长连接面临着网络波动、中间设备超时、客户端异常退出等问题。服务端必须及时感知连接的“存活”状态,以便回收资源、避免消息投递到僵尸连接。这就需要引入心跳检测机制。
本文将深入分析心跳检测的基本原理,解读 gorilla/websocket 库中的经典实现方式,然后在此基础上,为我们的 IM 服务封装自定义连接对象,实现一套轻量级、可配置的心跳检测方案。
为什么需要心跳检测
连接空闲断开
负载均衡器(如 Nginx、HAProxy)和操作系统网络栈通常会对长时间无数据交互的 TCP 连接进行超时回收。以 Nginx 为例,默认的 proxy_read_timeout 为 60 秒。一旦服务端与客户端之间长时间无数据传输,底层 TCP 连接就可能被中间设备静默关闭,而应用层仍错误地认为连接有效。
异常断连检测
客户端崩溃、网络中断或设备进入省电模式时,TCP 连接可能不会及时发送 FIN 包。服务端若没有探测机制,会长期持有这些“僵尸连接”,造成内存和连接池资源的浪费,甚至出现消息投递失败但无任何感知的情况。
心跳的双重功能:保活与失活判定
心跳机制的核心任务有两项:
- 保活 (Keep‑Alive):通过定期发送少量数据(心跳包),重置中间设备的空闲计时器,防止连接被误杀。
- 失活判定 (Dead Peer Detection):如果在约定的时间内没有收到对方的任何数据(包括心跳响应),则判定对端已不可达,主动关闭连接并清理资源。
下面这张图简要展示了心跳检测的工作流程:
gorilla/websocket 中的心跳实现
gorilla/websocket 库内置了强大的连接生命周期管理能力。在其内部,服务端对每个连接会启动一个名为 keepAlive 的方法,该方法使用三个定时器协同工作:
-
空闲超时定时器
检查连接的空闲时长是否超过了允许的最大空闲时间(MaxConnectionIdle)。如果超过,则优雅关闭连接;如果未超过,则计算距离超时还差多久,并在那个时间点再次检查。 -
连接最大生存期定时器
限制一个连接在系统中的最大存活时长(例如 10 小时),超过该时间后无论是否活跃都会被关闭。 -
主动探测定时器
由服务端主动向客户端发送Ping帧,并等待Pong响应。该定时器的周期通常小于空闲超时时间,这样可以在空闲超时之前就检测到对端是否存活,同时也能告知客户端服务端仍在正常运行。如果发送Ping后未收到Pong,则判定连接失效。
这三个定时器相辅相成:空闲定时器负责被动检测,主动探测定时器则提供了更及时的失活判定,且二者共同维持了连接的双向活性。
IM 服务中的心跳检测实现
在理解了上述机制后,我们开始对现有的 WebSocket 服务进行改造,主要工作包括:自定义连接对象 Conn,在其中嵌入心跳逻辑;重写读写方法以更新最后活跃时间;在服务端消息循环中区分心跳消息与业务消息;以及为配置化提供支持。
自定义 Conn 结构体
我们需要一个包含心跳元数据的连接对象,它组合了 *websocket.Conn,并添加以下字段:
server:所属的Server实例,用于访问配置和连接池。lastRead:最后一次读取消息的时间,所有的心跳和业务消息都会更新它。maxIdle:最大允许的空闲时间,超过该时长未读取到任何消息就认为连接失效。closeCh:关闭信号通道,用于优雅退出心跳协程。mu:保护lastRead的互斥锁,避免并发更新。once:确保closeCh只关闭一次,防止 panic。
// internal/logic/connection.go
package logic
import (
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/zeromicro/go-zero/core/logx"
)
// Conn 自定义连接对象,组合 websocket.Conn 并支持心跳检测
type Conn struct {
*websocket.Conn
server *Server
lastRead time.Time // 最后一次读取消息的时间
maxIdle time.Duration // 最大空闲时间
closeCh chan struct{} // 关闭信号
mu sync.Mutex // 保护 lastRead
once sync.Once // 确保 closeCh 只关闭一次
}
// NewConn 创建一个新的 Conn 实例,同时启动心跳检测协程
func NewConn(s *Server, w http.ResponseWriter, r *http.Request) (*Conn, error) {
wsConn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
return nil, err
}
maxIdle := s.opts.MaxIdleTime
if maxIdle <= 0 {
maxIdle = 10 * time.Minute // 默认 10 分钟空闲断开
}
conn := &Conn{
Conn: wsConn,
server: s,
lastRead: time.Now(),
maxIdle: maxIdle,
closeCh: make(chan struct{}),
}
// 启动心跳检测协程
go conn.keepAlive()
return conn, nil
}
读写操作与空闲时间更新
无论是普通消息还是心跳消息,只要连接上发生了读写,都表示对端活跃。我们重载 ReadMessage 和 WriteMessage 方法,在操作成功后更新 lastRead。同时,为了保证并发安全,更新操作需要加锁。
// ReadMessage 读消息并更新最后活跃时间
func (c *Conn) ReadMessage() (int, []byte, error) {
msgType, data, err := c.Conn.ReadMessage()
if err == nil {
c.mu.Lock()
c.lastRead = time.Now()
c.mu.Unlock()
}
return msgType, data, err
}
// WriteMessage 写消息并更新最后活跃时间
func (c *Conn) WriteMessage(msgType int, data []byte) error {
err := c.Conn.WriteMessage(msgType, data)
if err == nil {
c.mu.Lock()
c.lastRead = time.Now()
c.mu.Unlock()
}
return err
}
心跳检测协程:空闲超时检测
核心思路:启动一个独立的 goroutine,定期检查当前连接的空闲时长是否超过了 maxIdle。若超时,则触发关闭。为了简化实现,我们仅保留空闲超时检测(第一个定时器的功能),这已经能够满足大多数场景的需求。检查频率设置为 maxIdle / 2,既不会太频繁,也能在超时后及时响应。
// keepAlive 心跳检测协程,监测连接空闲超时
func (c *Conn) keepAlive() {
ticker := time.NewTicker(c.maxIdle / 2)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.mu.Lock()
idle := time.Since(c.lastRead)
c.mu.Unlock()
if idle >= c.maxIdle {
c.server.logx.Infof("connection idle timeout, closing")
c.Close()
return
}
case <-c.closeCh:
return
}
}
}
// Close 关闭连接,释放资源
func (c *Conn) Close() {
c.once.Do(func() {
close(c.closeCh)
// 从连接池中移除
if uid, ok := c.server.connPool.GetUserIDByConn(c); ok {
c.server.connPool.Remove(uid, c)
}
c.Conn.Close()
})
}
服务端改造:区分心跳消息与业务消息
在 ServeWS 的消息读取循环中,我们需要识别 Ping 帧(或自定义的心跳消息),并予以回复。gorilla/websocket 已经自动处理了 Ping 帧并回复 Pong,但为了展示自定义心跳消息的处理,我们约定业务层消息 method 为 "ping" 时,直接回复 "pong",不进入路由分发。
// ServeWS 中的主循环片段(更新后)
for {
msgType, payload, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
s.logx.Errorf("read error: %v", err)
}
break
}
// 文本消息,判断是否为心跳
if msgType == websocket.TextMessage {
var msg Message
if err := json.Unmarshal(payload, &msg); err != nil {
s.logx.Errorf("unmarshal error: %v", err)
continue
}
if msg.Method == "ping" {
pong := NewMessage("pong", "server", nil)
data, _ := pong.Marshal()
conn.WriteMessage(websocket.TextMessage, data)
continue
}
// 正常业务路由
handler, ok := s.routes[msg.Method]
if !ok {
resp := NewMessage("error", "server", "method not found")
data, _ := resp.Marshal()
conn.WriteMessage(websocket.TextMessage, data)
continue
}
handler(s, conn, &msg)
}
}
当然,更标准的做法是利用 gorilla/websocket 提供的 SetPingHandler 和 SetPongHandler,这样协议层的心跳完全透明,无需在应用层判断。我们可以在 NewConn 中进行设置:
conn.SetPingHandler(func(appData string) error {
return conn.WriteMessage(websocket.PongMessage, []byte(appData))
})
conn.SetPongHandler(func(appData string) error {
conn.mu.Lock()
conn.lastRead = time.Now()
conn.mu.Unlock()
return nil
})
这样,当收到 Ping 帧时自动回复 Pong,收到 Pong 时更新空闲时间,完全不用改动业务消息循环。
连接池与鉴权适配
由于连接对象类型变为 *Conn,连接池的 map 类型也需要同步修改:
// internal/logic/connection_pool.go
type ConnectionPool struct {
mu sync.RWMutex
userConn map[string]*Conn
connUser map[*Conn]string
}
func (p *ConnectionPool) Add(userID string, conn *Conn) {
p.mu.Lock()
defer p.mu.Unlock()
p.userConn[userID] = conn
p.connUser[conn] = userID
}
func (p *ConnectionPool) Remove(userID string, conn *Conn) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.userConn, userID)
delete(p.connUser, conn)
}
func (p *ConnectionPool) GetConnByUserID(userID string) (*Conn, bool) {
p.mu.RLock()
defer p.mu.RUnlock()
conn, ok := p.userConn[userID]
return conn, ok
}
func (p *ConnectionPool) GetUserIDByConn(conn *Conn) (string, bool) {
p.mu.RLock()
defer p.mu.RUnlock()
id, ok := p.connUser[conn]
return id, ok
}
鉴权部分无需修改,因为 NewConn 在握手成功后才调用,此时用户 ID 已确定。
配置化最大空闲时间
在 ServerOptions 中增加 MaxIdleTime 字段,并在 Server 的初始化中使用它:
// internal/logic/server_options.go
type ServerOptions struct {
Auth Auth
MaxIdleTime time.Duration
}
func WithMaxIdleTime(d time.Duration) ServerOption {
return func(opts *ServerOptions) {
opts.MaxIdleTime = d
}
}
在配置文件中添加相应配置:
# etc/im-ws.yaml
Name: im-ws
Host: 0.0.0.0
Port: 8888
MaxIdleTime: 30s
在 main.go 中读取并传递:
var c config.Config
conf.MustLoad(*configFile, &c)
...
server := logic.NewServer(
c.Host+":"+strconv.Itoa(c.Port),
logic.WithMaxIdleTime(c.MaxIdleTime),
)
防止重复登录
当同一用户再次建立连接时,我们需要“踢掉”之前的旧连接,避免资源浪费。在 ServeWS 中,鉴权成功后,可以检查连接池是否已存在该用户的连接,若存在则关闭旧连接,再添加新连接。
// 鉴权成功后
userID := s.auth.UserID(r)
if oldConn, ok := s.connPool.GetConnByUserID(userID); ok {
// 踢掉旧连接
oldConn.Close()
}
s.connPool.Add(userID, conn)
defer s.connPool.Remove(userID, conn)
测试验证
启动服务后,使用 ApiPost 或 websocat 进行测试:
-
空闲超时断开
连接ws://localhost:8888/ws?user_id=alice,不发送任何消息。等待配置的空闲时间(如 30 秒)后,连接自动断开,服务端日志输出connection idle timeout, closing。 -
消息发送重置计时
在空闲时间到达前发送任意消息,计时器重置,连接保持。发送{"method":"ping"},服务端立即回复{"method":"pong"},同时空闲计数器归零。 -
重复登录测试
用相同的user_id再次连接,旧连接会被关闭,新连接正常通信。
# 连接并测试
$ websocat ws://localhost:8888/ws?user_id=alice
> {"method":"ping"}
< {"method":"pong"}
总结
本文从原理出发,阐述了心跳检测在长连接场景下的必要性,并借鉴 gorilla/websocket 的设计思路,在已有的 IM 服务上进行了工程化落地。通过自定义 Conn 结构体,我们封装了空闲超时检测、读写时间更新、连接关闭等逻辑;通过配置化,使得心跳行为可灵活调整;通过区分心跳与业务消息,保证了业务逻辑的纯净。最终,我们的 WebSocket 服务具备了自动检测失活连接并回收资源的能力,为后续的私聊、群聊、消息持久化等高级功能奠定了坚实的基础。

1114

被折叠的 条评论
为什么被折叠?



