在线状态与路由表:Redis 存储与快速查找

用户 A 给用户 B 发消息,逻辑层只拿到 uid_B,却必须立刻回答:B 此刻挂在哪台接入机(gateway)的哪条连接上?这个「用户 → 连接」的映射就是路由表。它的读写特征非常极端:写少读多(登录、登出、心跳才写,每投递一条消息都要读)、允许秒级不一致,但绝不允许读不到——读不到就只能走离线兜底,用户体感是「消息发出去了,对方没反应」。

三个查询维度

一次投递链路里,路由数据要回答三个不同的问题,所以必须用三种键分别组织,只存一份会顾此失彼:

查询输入输出调用方
谁在哪uid + deviceIDgatewayID + 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 丢数据的情况。

笔记加载中…