2.1 长连接网关:连接管理与会话表

场景

连接建起来之后,第一个问题是:服务器怎么知道某个用户在哪台机器上?IM 不是 HTTP,请求来了可以随便路由到任意一台。网关 A 上的用户给网关 B 上的用户发消息,必须有某种机制让逻辑层找得到接收方的连接在哪。这就是会话表要解决的事,也是长连接网关连接管理的核心。天龙八部里有句话叫珍珑棋局,层层叠叠——连接管理也是这个味道,表面上是一个 map 读写,底下压着多端、伸缩、漂移、退避四股暗流。

方案

最朴素的思路是所有机器共享一张表:用户连上哪台,就在 Redis 里写一条 uid 到 gatewayIP 的映射。这个方案简单但不完美——映射变更非常频繁,上下线一次写一次,而且读写分离的延迟会让短暂的踢人策略失效。我们最终的设计是两级结构:网关本地维护完整连接表,Redis 只存用户在哪台机器上一条索引,后者变更频率低一个数量级,只在上下线时写。

方案 查找复杂度 写入压力 一致性 我们的选择
全局共享连接表 高(每次查 Redis) 高(心跳即写) 强 否
本地表加 Redis 索引 低(本地内存) 低(仅上下线) 准实时 是
一致性哈希路由 最低 无 强 否(不便扩缩容)
广播找连接 最低 无 弱 否(太费带宽)

补充一句:所谓查找复杂度高,量化下来是每条消息多一次 Redis 往返。高峰期每秒几万条消息,这个往返费用直接乘到消息延迟账单上。本地表方案把这次查询变成内存 map 查找,读路径只剩一次本地分发,这也是我们压测延迟 P99 能压到 30ms 之下的原因之一。

一致性哈希纯路由方案我们犹豫过——按 uid 哈希分布连接,逻辑层直接算出来去哪台推。否掉的原因是扩缩容时会大面积重连:哈希环一变,几十万连接全得挪窝。反观索引方案,机器增删只影响新连接的落点,老连接纹丝不动。另外还有一派观点是干脆让逻辑层自己维护连接(即网关与逻辑层合体),小规模可行,规模一大网关层就成了广播与鉴权的瓶颈,早晚要拆开,不如一开始就拆。

索引的可一致性也有讲究。用户在 A 机器上线,索引写 Redis 时如果 A 恰好也挂了呢?我们用了 SET 加 TTL 的写法,每次心跳续期,机器挂掉索引自然过期,新连接落点由最新在线的那台写回。这样即使出现短暂的索引漂移,最多一次心跳周期内自愈。

配置与代码

本地连接表用 map 加读写锁,worker 分片减少锁争用。连接对象持有用户 ID、设备类型、连接句柄和最近活跃时间:

type Conn struct {
    UID       int64
    Device    string // phone / web / pc
    ws        *websocket.Conn
    lastSeen  int64
    sendBuf   chan *Frame
}

type ConnTable struct {
    shards [64]*connShard // 按 uid 分片降低锁粒度
}

查找一条连接就是定位分片再读 map,写操作同样只在分片锁内:

func (t *ConnTable) Get(uid int64) []*Conn {
    s := t.shards[uid%64]
    s.RLock()
    defer s.RUnlock()
    return s.conns[uid] // 一个用户可能多端在线,返回切片
}

上下线时更新 Redis 索引,TTL 设得比心跳长一点,保证僵尸索引自愈:

// 用户上线:登记所在网关
func (g *Gateway) register(uid int64) error {
    key := fmt.Sprintf("online:%d", uid)
    return redis.SetEX(ctx, key, g.ip, 90*time.Second).Err()
}

跨网关推送的路径也顺带给一下,整个会话表最后就是为这一个函数服务的:

// 推送给某用户(可能多端在线)
func (g *Gateway) Push(uid int64, f *Frame) int {
    ip, err := redis.Get(ctx, fmt.Sprintf("online:%d", uid)).Result()
    if err != nil { return 0 } // 离线走拉取,不推
    return g.cluster.Send(ip, uid, f) // 转发给目标网关
}

注意 Push 失败并不代表消息丢失——必达由先落库来保证(见 写入链路),推只是加速通道。掌握了这个分工前提,网关的代码可以写得很简单:推就尽力而为,失败由客户端的 sync 补齐。这层心智模型确立之后,整个推送链路的重试逻辑都变得清晰,不再为实现承诺而过度设计。

跨网关的连接管理还有一处隐含约束:同一用户的多端可能分布在多台网关上,Push 实现里必须按在线索引广播给所有落点,不能推了一端就算完。这里我们也踩过一次:只推了第一个查到的落点,结果用户在电脑上收不到手机会话的消息,排查半天才意识到落点查询漏拿了列表。多端同步的完整模型在 需求拆解 里有铺垫。

踩坑

第一个坑是多端连接挤在一个 map 键下。用户同时登了手机和电脑,早期代码按 uid 存单个连接,后登的设备把先登的踢掉,用户莫名其妙掉线。改成 uid 加 device 的复合键后,还顺手做了同设备才互踢的策略,Web 端开两个标签页才不会互相打架。这里还有个小细节:多个连接共享 uid 时,推送要按设备能力筛——有些消息(比如来电)只推手机,有些(比如文件)只推 PC,会话表存了 device 字段后这种筛选才是可能的。

第二个坑是僵尸连接。客户端断网但没发 FIN,服务端不知道,连接对象一直占内存。靠 心跳与重连 里的 lastSeen 检查踢掉。我们设定的淘汰规则是超过三次心跳周期未活跃即关闭,最初只设一次周期的阈值,误杀了大量地铁里的用户,投诉在那周特别集中。除了超时判断,我们也尝试过 TCP keepalive 探活,但它对应用层的超时判断不敏感,最后还是回到应用心跳这一条路上,keepalive 只作为兜底开着。

第三个坑是网关下线时的连接迁移。某次发版直接 kill 进程,十万用户瞬间断线重连,回源峰值把鉴权服务压垮。后来实现了优雅退出:先从负载均衡摘掉,等连接自然衰减到零再退,最多等五分钟,等不及就强制。这套优雅退出与 心跳与重连 的客户端退避是一套组合技——服务端负责慢点走,客户端负责别一起涌,两边配合才把发版掉线做成用户无感。压测时我们对优雅退出也做了专项验证:模拟摘掉一台机器,观察回源 QPS 曲线是否平滑,而不是只看功能是否跑通。

第四个坑是索引的 TTL 与踢人冲突。运营要踢某个用户,网关读索引发现 IP 是另一台,发指令过去,结果那台机器刚好下线、索引还没过期,踢人指令石沉大海。加了一层确认重试之后才算可靠。

第五个坑在压测时暴露:64 个分片在百万级连接下仍有锁热点。扫全表做心跳检查时,持锁遍历会拖慢正常读写。我们把扫描改成每轮只扫一个分片子集,分摊到多个周期完成,白天高峰期的延迟毛刺降了一个数量级。

小结

连接管理的设计要点:本地表管连接、Redis 管索引、扩缩容不影响老连接、连接对象永远要带设备类型。这一层稳定了,后面的 心跳与重连 和 登录鉴权 才有地基可踩。网关的容量数据来自 单机压测,两篇对着看更清楚。我们后来又给会话表加了冷热分级策略,活跃连接常驻内存,长时间无心跳的先降级到慢速通道再淘汰,内存常驻降了两成,这件事说明压测给出的容量数字永远不是定值,工程上总还能再挤出一点。

回看整个连接层的设计,最值得骄傲的不是某个巧妙的函数,而是从一开始就把连接表、索引、心跳三者当成一个生命周期来设计:上线写索引、心跳续索引、超时清连接、下线删索引。环环相扣但各自独立,任何一环替换实现都不会影响其他环节。

连接表本身的监控值得单设一个面板。我们盯四个数:在线总数、按网关分摊的分布、分钟级新增与断开数、注册索引写入成功率。分布那张图专门看分流是否健康——某台网关的连接数长期高于中位数两倍,往往意味着负载均衡器的健康检查或权重配错,而不是它性能更好。分钟级新增断开数是连接风暴的预警,正常情况两条曲线贴合,背离超一倍就要去看是不是有客户端在疯狂重连。这套面板在灰度发布期间也直接复用,分桶前后的连接数对比一眼可见。

会话表还有一处的一致性要提前想清楚:它是进程内内存态,网关重启或主备切换的瞬间是空的。新主刚起来时大量客户端重连过来,内存表还没建立,如果此时做会话校验会把合法连接拒之门外。我们的做法是新主启动后的前两分钟进入预热模式,这期间会话校验降级为只查存储层,等内存表跟随重连潮重建得差不多了再恢复纯内存校验。配套要点是预热窗口要有监控,如果重连成功率在预热期掉得过低就说明降级策略本身有问题,而不是用户网络问题。

延伸阅读