在线状态与路由表:Redis 存储与快速查找
用户 A 给用户 B 发消息,逻辑层只拿到 uid_B,却必须立刻回答:B 此刻挂在哪台接入机(gateway)的哪条连接上?这个「用户 → 连接」的映射就是路由表。它的读写特征非常极端:写少读多(登录、登出、心跳才写,每投递一条消息都要读)、允许秒级不一致,但绝不允许读不到——读不到就只能走离线兜底,用户体感是「消息发出去了,对方没反应」。
三个查询维度
一次投递链路里,路由数据要回答三个不同的问题,所以必须用三种键分别组织,只存一份会顾此失彼:
| 查询 | 输入 | 输出 | 调用方 |
|---|---|---|---|
| 谁在哪 | uid + deviceID | gatewayID + connID | 逐设备投递 |
| 谁在线 | uid | 是否存在任一设备条目 | 在线态判断、按在线过滤 |
| 这台机器上有谁 | gatewayID | 该网关上的全部 uid+device | 网关宕机后的批量清理 |
对应的 Redis 键设计:
route:uid:{uid} Hash field=deviceID value=JSON{gateway_id,conn_id,login_at}
route:conn:{connID} String value="uid:deviceID",TTL = 心跳间隔 × 3
route:gw:{gwID} Set member="uid:deviceID",网关重启后批量清理
presence:uid:{uid} String value=最后活跃毫秒时间戳,TTL=5min,仅用于在线态展示
用 Hash 而不是多个 String 存设备路由,是为了让「一个用户多端在线」的查询退化成一次 HGETALL:多端(手机 + 桌面 + Web)是常态,用 String 就得先知道设备列表才能拼出 MGET 的键,而 Hash 一次往返就能取回全部设备。
登录时的原子写入
写完 route:uid:{uid} 却漏写 route:gw:{gwID},后果是网关宕机时清理不掉幽灵路由,之后的投递会一直往一台死机器上发。用一段 Lua 把多个键绑成一次原子操作:
-- KEYS[1]=route:uid:{uid} KEYS[2]=route:gw:{gwID} KEYS[3]=route:conn:{connID}
-- ARGV[1]=deviceID ARGV[2]="uid:deviceID" ARGV[3]=routeJSON ARGV[4]=ttlSec
redis.call('HSET', KEYS[1], ARGV[1], ARGV[3])
redis.call('EXPIRE', KEYS[1], ARGV[4])
redis.call('SADD', KEYS[2], ARGV[2])
redis.call('SET', KEYS[3], ARGV[2], 'EX', ARGV[4])
return 1
Redis 集群下要注意哈希标签(hash tag):route:uid:{uid} 的槽位由 uid 决定,route:gw:{gwID} 由 gwID 决定,两者几乎不可能同槽,上面这段 Lua 会被集群拒绝(跨槽操作)。两种处理方式:
| 方案 | 做法 | 取舍 |
|---|---|---|
| 拆成两次调用 | 先写 uid,再写 gw 集合 | 简单,接受极短时间的不一致 |
| 非集群部署 | 单机或哨兵(sentinel)模式使用 Lua | 集群化后需要改造 |
查找侧:本地缓存 + 批量取回
路由读远多于写,可以在网关与逻辑层各放一层进程内缓存,TTL 1~3 秒:
type Route struct {
UID int64 `json:"uid"`
DeviceID string `json:"device_id"`
GatewayID string `json:"gateway_id"`
ConnID string `json:"conn_id"`
LoginAt int64 `json:"login_at"`
}
// Lookup 返回某设备的路由;redis.Nil 视为离线,不算错误。
func (r *Router) Lookup(ctx context.Context, uid int64, deviceID string) (*Route, error) {
key := fmt.Sprintf("route:uid:%d", uid)
raw, err := r.rdb.HGet(ctx, key, deviceID).Bytes()
if errors.Is(err, redis.Nil) {
return nil, nil // 未登录或 TTL 已过期,交给离线兜底
}
if err != nil {
return nil, fmt.Errorf("hget %s: %w", key, err)
}
var rt Route
if err := json.Unmarshal(raw, &rt); err != nil {
return nil, fmt.Errorf("bad route payload %s: %w", key, err)
}
return &rt, nil
}
// LookupAll 一次取回全部在线设备,用于多端投递。
func (r *Router) LookupAll(ctx context.Context, uid int64) ([]Route, error) {
m, err := r.rdb.HGetAll(ctx, fmt.Sprintf("route:uid:%d", uid)).Result()
if err != nil {
return nil, err
}
out := make([]Route, 0, len(m))
for _, raw := range m {
var rt Route
if err := json.Unmarshal([]byte(raw), &rt); err != nil {
continue // 单条脏数据不影响其他设备投递
}
out = append(out, rt)
}
return out, nil
}
缓存带来一个必须处理的问题:stale 路由。用户刚刚从网关 A 重连到网关 B,缓存里还是 A,投递到 A 会失败。因此投递失败(连接不存在)时要立刻做三件事:失效本地缓存、删除 route:uid:{uid} 对应字段、转离线兜底。这一步让不一致的窗口从「TTL 到期」缩短到「一次失败投递」,是路由表能容忍缓存的根本原因。
落地兜底表
Redis 是内存存储,主从切换或实例重启可能丢最近写入。用一张表做最终一致的重建来源,同时给运维一个可查询的现状视图:
CREATE TABLE route_info (
uid BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
device_id VARCHAR(64) NOT NULL COMMENT '设备标识',
gateway_id VARCHAR(64) NOT NULL COMMENT '接入机实例 ID',
conn_id VARCHAR(64) NOT NULL COMMENT '连接 ID',
login_at DATETIME NOT NULL COMMENT '登录时间',
heartbeat DATETIME NOT NULL COMMENT '最后心跳时间',
PRIMARY KEY (uid, device_id),
KEY idx_gateway (gateway_id)
) ENGINE=InnoDB;
写路径保持异步:登录成功后先写 Redis(同步,投递立刻可用),再用消息队列异步落这张表。Redis 整体不可用时,路由服务降级为「查表 + 探测网关」,延迟从亚毫秒升到毫秒级,但链路不断。
方案取舍
| 方案 | 查找延迟 | 一致性 | 运维成本 | 适用场景 |
|---|---|---|---|---|
| Redis Hash(本方案) | 亚毫秒 | 秒级最终一致 | 中 | 数量级百万在线、多端 |
| 网关间内存同步 | 纳秒 | 秒级,易漏 | 高 | 在线数小、几十台网关 |
| 一致性哈希(consistent hashing)定位 | 亚毫秒 | 强一致 | 中 | 用户与网关绑定,扩容需迁移 |
一致性哈希把「用户固定映射到某网关」的做法省掉了路由表,但代价是网关故障时那一批用户的连接全部要重新分配、扩缩容时会话大面积重连,IM 的会话本身很贵(TLS、鉴权、连接上还有未完成的重传),因此主流做法仍是网关无状态、路由表集中存,让任何一台网关都能接任何用户。
失败场景与兜底
| 场景 | 现象 | 处理 |
|---|---|---|
| 网关进程崩溃(kill -9) | 路由残留,投递失败 | 网关启动时用 route:gw:{gwID} 批量清理;投递失败时单点删字段 |
| 客户端断网但未发 FIN | 半开连接,路由看似有效 | 心跳超时(如 30s 未续期)主动摘除路由并断开连接 |
| Redis 主从切换丢写 | 部分用户查不到路由 | 回退查 route_info 表并回写 Redis |
| 同设备重复登录 | 两条连接都认为自己有效 | 后登录者覆盖 Hash 字段,并向旧连接下发踢下线指令 |
| 路由查询超时 | 投递延迟抬升 | 50ms 熔断,直接判离线,避免拖垮整条投递链路 |
小结:路由表要同时支持「谁在哪、谁在线、机器上有谁」三种查询,用 route:uid:{uid} Hash 存设备到网关的映射、route:gw:{gwID} Set 做批量清理、route:conn:{connID} 做连接反查,写入用 Lua 保证原子、集群下注意哈希标签;读取用进程内缓存加速,但必须靠「投递失败即失效并转离线」把不一致窗口压到最短,最后用 route_info 表 + 异步落库兜住 Redis 丢数据的情况。