连接模型:会话、设备与在线态
长连接是 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 → 关闭
状态机只有三个状态(待鉴权、在线、关闭),把状态数压到最少是接入层稳定的前提:每个额外状态都意味着排障时要多看一批日志。
心跳与超时参数
心跳是"两边都用最低成本确认对方还活着"的机制。参数取舍:
| 心跳间隔 | 断线感知 | 服务端写入压力 | 移动端耗电 | 说明 |
|---|---|---|---|---|
| 15s | 45s | 高 | 较高 | 金融级实时场景 |
| 30s | 90s | 中(推荐) | 中 | 大多数 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 万文件描述符预留,并用有界发送队列、退避重连、优雅下线三件事守住稳定性。