连接模型:会话、设备与在线态

长连接是 IM 的"插座":消息能不能推下去,取决于插座是否还插着。连接模型要回答四个问题——用什么协议承载、一条连接如何被唯一标识、一个账号的多台设备如何组织、以及"在线"到底是什么意思。这四件事定义错了,后面的路由表、投递、同步全都要返工。

传输方式选型

先定承载方式,再谈模型。对比维度是穿透性、延迟、服务端内存开销与运维复杂度:

方式延迟移动端友好度服务端成本适用场景
HTTP 轮询 / 长轮询秒级,取决于间隔耗电、耗流量低到中(长轮询占连接)弱实时、兼容性兜底
WebSocket双向、百毫秒级好(浏览器原生)高(每连接常驻)主推方案、Web 端首选
自建 TCP/TLS双向、可定制需自研保活高移动端主链路、需私有协议
QUIC双向、弱网迁移友好好高(连接迁移、无队头阻塞)弱网优化、网络切换频繁

工程上的常见组合是移动端自建 TCP+TLS(或 QUIC),Web 端 WebSocket,服务端共用同一套帧协议(第 04 章),这样逻辑层只面对一种消息格式。

连接标识与会话

每条连接在服务端必须有全局唯一标识,它同时是路由表的值与日志的关联键:

connID = gatewayID(16bit) | 本地自增序号(48bit)
gatewayID 由配置中心下发、进程重启不变;本地序号进程内 atomic 自增
→ 全局唯一,且按高位即可定位接入机,省掉一次路由查询

会话(session)是与连接绑定的鉴权上下文,连接断开即销毁,不需要服务端对多端做"会话共享":

// Conn 是一条长连接在服务端的全部状态,字段越少越好(每连接都要占内存)
type Conn struct {
    ConnID    int64         // gatewayID<<48 | seq
    UID       int64         // 鉴权通过后填充,未鉴权为 0
    DeviceID  string        // 设备指纹,同一账号区分多端
    DeviceType int32        // 1=iOS 2=Android 3=Web 4=Desktop
    GatewayID string
    AppVersion string       // 用于协议兼容与灰度
    Authed    atomic.Bool   // 是否已通过 auth 帧
    LastActive atomic.Int64 // 秒级时间戳,心跳与任意上行帧都刷新
    sendCh    chan []byte   // 有界队列,容量 256,满即踢
    CloseOnce sync.Once
}

// 每个连接一个 reader goroutine:只做解码、心跳与转发,业务逻辑交给 logic
func (c *Conn) readLoop(ctx context.Context) {
    defer c.closeAndCleanup() // 统一交给 connManager 清理,不在循环里做重逻辑
    for {
        frame, err := c.codec.ReadFrame() // 读超时设为心跳间隔的 3 倍
        if err != nil {
            return
        }
        c.LastActive.Store(time.Now().Unix())
        if frame.Cmd == CmdHeartbeat {
            c.trySend(EncodePong(frame.Seq)) // 心跳在接入层闭环,不进逻辑层
            continue
        }
        if !c.Authed.Load() && frame.Cmd != CmdAuth {
            return // 未鉴权连接只能发 auth 与心跳
        }
        c.dispatch(ctx, frame) // 业务帧转发给 logic,错误由 dispatch 内部收口
    }
}

连接生命周期

  TCP/TLS 建连
      │  接入层分配 connID,登记到 connManager(此时 UID=0)
      ▼
  等待鉴权 ──10s 超时──▶ 关闭
      │  收到 auth 帧 → 调 logic.Auth → 写路由表 Bind(ttl=90s)
      ▼
  已在线 ⇄ 心跳(30s 一次,90s 未收到即判离线)
      │
      ├─ 客户端 Close/网络断开 → OnDisconnect → 删路由 → 触发离线逻辑
      ├─ 服务端主动踢(多端互踢/风控)→ 发送 kick 帧 → 优雅关闭
      └─ 池化下线(发布)→ 发送 reconnect 帧(带 retry_after)→ drain 30s → 关闭

状态机只有三个状态(待鉴权、在线、关闭),把状态数压到最少是接入层稳定的前提:每个额外状态都意味着排障时要多看一批日志。

心跳与超时参数

心跳是"两边都用最低成本确认对方还活着"的机制。参数取舍:

心跳间隔断线感知服务端写入压力移动端耗电说明
15s45s高较高金融级实时场景
30s90s中(推荐)中大多数 IM 的默认值
  • 判定离线用"3 倍心跳间隔":单次丢失属正常抖动,直接判离线会造成大量假离线;
  • 心跳在接入层闭环:不经过逻辑层,也不写路由表的 TTL(写 TTL 用独立定时任务,频率与心跳解耦,避免 5 万/s 的 Redis 写入);
  • 半开连接(half-open)必须靠应用层心跳兜底:TCP keepalive 默认 2 小时才探测,服务端只靠它会在用户换网后长时间挂着僵尸连接,白白占内存;
  • 读超时与写超时都要设:写超时未触发时,队头阻塞会让连接变成"能读不能写"的单向僵尸。

设备模型与在线态语义

多设备是 IM 与普通长连接服务最大的区别:一个账号 N 条连接,路由表的键必须带设备维度。

用户 u1001 的三条连接:
  iPhone13 (dt=1, did=a1b2) → gateway-07 / connID 0x0007_0031
  MacBook  (dt=4, did=c3d4) → gateway-02 / connID 0x0002_1a9f
  Web      (dt=3, did=e5f6) → gateway-09 / connID 0x0009_0008
在线态(presence)= 任一设备在线;最后活跃时间 = max(各设备 LastActive)
设备粒度状态 = 单条连接是否在线,用于"仅手机在线"这类展示
展示口径计算方式用途代价
任一在线即在线路由表 Lookup(uid) 非空会话列表、头像状态点最省,一次查询
按设备类型区分分别查各设备键"手机在线/电脑在线"查询次数 × 设备类型数
在线且活跃在线 + LastActive < 5min附近的人、实时协作需要额外活跃度存储

多端策略(同时最多几端、互踢规则)属于鉴权范畴,见第 05 章;连接模型只保证一件事:每条连接独立、每一端都能被单独定位与单独踢下线。

单机容量与内存估算

用户态(每连接):读缓冲 4KB + 写缓冲 4KB + Conn 结构体约 1KB
                 + 2 个 goroutine 栈(2KB 起,稳态约 4KB)≈ 13KB
内核态(每连接):socket 收发缓冲(tcp_rmem/tcp_wmem 调到 4KB~16KB)≈ 8KB
合计约 21KB/连接,单机 8 万连接 ≈ 1.7GB,8GB 内存机器余量充足
文件描述符:8 万连接 + 监听/日志/依赖 ≈ 8.2 万 → `ulimit -n` 至少 20 万,
           somaxconn 与 tcp_max_syn_backlog 同步调大(否则表现为建连超时)

工程注意点

  • 慢客户端要踢不要等:sendCh 有界(建议 256 帧),队满时按优先级丢弃(先丢在线态更新,再丢非关键通知),仍满就断连。一个不读 socket 的客户端会拖住整个 writer,最终牵连同机其他用户。
  • 连接风暴要服务端配合:客户端退避 1s→60s 指数增长加抖动,服务端下线时下发 retry_after;重连鉴权走轻量路径(不做全量会话拉取,避免重连瞬间打爆数据库);同一 uid 每秒最多建 3 条连接,超限直接拒绝并记录。
  • DNS 与接入点要可切换:客户端配置多个接入域名并做健康检查,接入层按可用区返回就近节点,避免单点域名成为全局故障。

小结:连接模型先定承载协议(移动端自建 TCP/QUIC、Web 端 WebSocket,共用一套帧协议),再用 gatewayID<<48 | seq 生成全局唯一 connID;连接状态机只保留待鉴权、在线、关闭三态,心跳 30s、3 倍间隔判离线且在接入层闭环;多设备用带设备维度的路由键组织,在线态定义为"任一设备在线";容量上按每连接约 21KB 与 20 万文件描述符预留,并用有界发送队列、退避重连、优雅下线三件事守住稳定性。

笔记加载中…