跳到主内容

百万设备长连接网关设计实录:心跳、鉴权与优雅扩缩容的完整方案 ​

问题背景:有状态的连接,才是真正的难题 ​

IM、消息推送、IoT 设备管理这类业务,HTTP 轮询的方案很快就到天花板:百万设备每 5 秒轮询一次就是每秒 20 万请求,其中 95% 是空转,带宽、LB、后端都在白白买单。换成长连接后 QPS 瞬间降两个数量级,但新问题随之而来——连接是有状态的,会话记录在这台机器的内存里,扩容时新机器接不到旧连接的下行消息,缩容时强杀进程等于批量掉线;未鉴权的恶意连接能把文件描述符和内存耗光;心跳参数配错,设备在 NAT 后被运营商静默断连,服务端还握着一堆僵尸连接。长连接网关的设计,本质上是在回答一个问题:如何让「有状态」的东西在无状态的架构里优雅地扩缩容。本文的方案按百万级接入规模拆解,万级规模下各环节可按比例简化,但状态管理骨架是一样的。

方案对比:协议、鉴权与路由的三层选择 ​

协议层:WebSocket 适合浏览器与 App 混合接入,生态成熟、自带 text/binary 帧与 close 语义;MQTT 适合物联网设备,QoS 等级、遗嘱消息、保留消息都是协议内建;私有 TCP 协议性能最优但要自造轮子。没有特殊理由时,选 WebSocket,把 MQTT 留给真正的设备侧场景。鉴权层:连接建立后立即发送 auth 帧校验 token,优于把 token 拼在握手 URL 上——URL 会进访问日志,泄漏面更大,而且网关可以对「建连后 N 秒未认证」的连接强制断开,防止未认证连接占用资源。路由层:两种主流方案——会话表集中在 Redis(按设备 ID 查所在网关节点),实现简单但每次下行都多一跳网络;网关本地分片表 + 注册中心广播路由变更,下行直达,代价是要处理路由不一致。中小规模用 Redis,设备量过百万、下行 QPS 高时选后者。

实操步骤:从连接管理到优雅发布 ​

第一步:分片会话表,避免单锁竞争。 百万连接放一个 sync.Map 里,写入热点虽然可接受,但遍历清理、统计都会变慢,按连接 ID 分片是标准做法:

go
const shardCount = 256

type Gateway struct {
    shards [shardCount]sync.Map // connID -> *Conn,分片降低锁竞争
}

func (g *Gateway) Add(c *Conn) {
    idx := fnv32a(c.ID) % shardCount
    g.shards[idx].Store(c.ID, c)
}

func (g *Gateway) Get(connID string) (*Conn, bool) {
    idx := fnv32a(connID) % shardCount
    v, ok := g.shards[idx].Load(connID)
    if !ok {
        return nil, false
    }
    return v.(*Conn), true
}

第二步:心跳用服务端读超时驱动,而不是靠客户端自觉。 服务端为每个连接设置 readDeadline,收到任何帧(包括 ping)就续期;读超时触发即视为连接死亡,主动清理。这样无论客户端是异常断电还是网络切换,服务端都能在一个心跳周期内感知:

go
const authTimeout = 10 * time.Second
const heartbeat = 60 * time.Second

func (s *Server) serveConn(conn net.Conn) {
    defer s.remove(conn)
    // 建连后 10 秒内必须完成鉴权,防止未认证连接占坑
    conn.SetReadDeadline(time.Now().Add(authTimeout))
    auth, err := readAuthFrame(conn)
    if err != nil || !s.verifyToken(auth.Token) {
        writeFrame(conn, frameCloseAuthFailed)
        return
    }
    s.register(auth.DeviceID, conn)

    for {
        // 心跳周期兜底:60 秒无任何数据即清理僵尸连接
        conn.SetReadDeadline(time.Now().Add(2 * heartbeat))
        frame, err := readFrame(conn)
        if err != nil {
            return // 读超时或断开,触发 remove 清理会话与路由
        }
        s.dispatch(frame)
    }
}

第三步:下行投递先查路由再发送。 消息服务收到下发请求后,先从路由表查设备所在的网关节点,再通过节点间直连通道(gRPC)投递到对应网关,由网关从本地会话表找到连接写出;路由不存在则缓存消息等设备重连后补发,配合客户端 ack 做到至少一次送达。补发机制要再细一层:写出后不立刻视为送达,设备回 ack 前消息停留在「待确认」状态;ack 超时或路由缺失时写入离线队列,设备重连完成鉴权后按序补发,补发窗口设上限(如 72 小时),过期消息标记丢弃,避免设备长期离线导致队列无限膨胀。推送类业务还要处理「重连风暴」——设备重连瞬间可能堆积上千条待补发消息,按优先级分桶(告警类优先、营销类限速)逐批下发,否则补发本身就会把刚恢复的连接再次压垮。

第四步:优雅扩缩容,摘流量必须先行。 发布时流程固定为四拍:网关从注册中心摘除自己、停止接受新连接 → 向存量连接广播 RECONNECT 帧,客户端退避重连到新节点 → 等待 drain 窗口(比如 90 秒)让连接自然迁移 → 到期后对未迁移连接执行 close 再退出进程。强杀进程会让所有在线设备同时掉线重连,形成接入风暴,这在百万设备规模下足以打垮刚起的新节点。

避坑总结 ​

  1. 内存账要提前算清。 每连接一个 goroutine 栈约 8KB,加上读写缓冲区与业务对象,单连接常驻 30~50KB,百万连接就是 30~50GB;缓冲区复用(sync.Pool)与调小默认 buffer 是必做项,容量规划按单机 10~20 万连接封顶留冗余。
  2. 心跳必须双向兜底。 只依赖客户端 ping,客户端实现缺陷会造成服务端僵尸连接堆积;只依赖服务端超时,NAT 表项过期会掐断连接。服务端读超时 + 客户端 45~60 秒心跳是经过验证的组合,且心跳间隔要小于运营商 NAT 超时(通常 60~300 秒不等)。
  3. 鉴权失败的连接也要限速。 恶意客户端可以高频建连耗尽握手资源,网关入口要有 IP 维度的建连限流与全局连接数上限,认证失败计入黑名单。
  4. 路由不一致要有自愈手段。 设备重连后以「最新连接为准」覆盖旧路由,旧网关收到的下行找不到连接时回查路由表而不是静默丢弃。
  5. 压测要模拟掉线重连,而不仅是稳态连接。 真实设备的网络抖动率远超直觉,风暴期的新建连 QPS 才是网关的真正容量上限。

收尾 ​

长连接网关的全部设计难点,都围绕「状态」二字:会话状态用分片本地表 + 路由广播管理,连接状态用心跳与读超时管理,生命周期状态用摘流量再 drain 的发布流程管理。协议、框架都是可替换的实现细节,这三类状态的处理思路才是可以跨项目复用的骨架。把优雅发布演练成例行操作,而不是事故时才第一次验证,是长连接团队成熟的标志。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。