1. 先把边界划清楚:授权端到底指什么
做即时通讯类客户端的人,几乎都绕不开一个词——登录态。一个连接建立起来容易,让它在几小时、几天、几周之后依然“像刚登录那样”可用,才是真正吃经验的地方。这篇内容围绕一个常见命题展开:某即时通讯平台的授权端(也就是负责鉴权、会话维持、长连接接入的那一层服务),在设计上应该怎么拆、怎么算、怎么兜底。长连接保活与登录态管理是两条主线,我会把它们拆成可以直接抄的结构,也会把踩过的坑列清楚。
先说清楚范围,这一点很重要。本文讨论的是自建授权服务端的工程实现——你自己有一套 App 或内部系统,需要一套会话签发、校验、续期、长连接接入的机制。这类系统里的“授权端”通常包含三件事:凭证签发(谁登录了)、会话维持(他还活着吗)、连接管理(怎么把消息送过去)。至于逆向官方客户端的通信协议、伪造官方客户端身份、绕过平台方授权体系这类做法,不在本文范围内,我也不会写具体字段和算法。原因很实际:这类做法直接违反平台服务条款,账号被封是轻的,牵扯到数据合规就是另一个量级的问题;而且平台方一改协议,你所有工作归零,投入产出比极差。我在实际项目里见过团队把半年工期砸在这上面,最后因为一次版本更新整体作废。
所以本文的适用读者是这样几类:正在做 IM、推送、协同办公、IoT 设备长连接接入的后端或客户端同学;正在设计统一登录中心、需要处理多端登录互斥的架构同学;以及被“用户反馈老是掉线”“Token 莫名其妙失效”这类问题磨了很久的运维和 SRE。文章里会给出具体的参数取值范围、计算公式、状态机设计和可运行的代码骨架,你可以按自己的业务量级裁剪。
注意:涉及第三方平台的账号体系时,唯一合规路径是走平台方公开的开放能力与授权流程。任何“模拟登录”的实现都应当理解为你自己系统内部的会话模拟(比如自动化测试里模拟用户已登录状态),而不是对他人平台的身份伪造。
1.1 三个角色:客户端、授权端、业务服务
把系统拆开看,永远是三个角色在对话。客户端负责发起登录、保存凭证、维持连接、失败重试;授权端负责验证身份、签发凭证、管理会话生命周期、在需要时主动踢人;业务服务只关心“这个请求属于哪个用户”,不应该自己去做密码校验或 Token 解析逻辑。很多团队出问题,就是因为这三者的职责糊在一起——业务服务里塞了一段 Token 解析,授权端又偷偷查业务库,最后改一处崩三处。
我比较推荐的职责切分是这样的:授权端只做身份相关的事,输出一个自包含的凭证(带签名的 Token 或者一个不透明 session id + 服务端存储),业务服务拿到凭证后只做一次轻量校验(本地验签或一次缓存查询),然后立刻进入业务逻辑。长连接网关是第四个角色,它挂在授权端旁边,负责握手时的鉴权、连接上的心跳、以及把下行消息按用户路由过去。网关不做业务,也不做持久化,它是个状态很轻的转发层。
这个切分带来的直接好处是扩容路径清晰。用户量涨了,先扩网关(无状态、横向加机器就行),再扩业务,授权端的压力相对固定——因为登录是低频操作。如果你的授权端 QPS 跟着用户量线性涨,那基本可以确定是有人在业务请求里同步调授权接口,这是要立刻改掉的。
1.2 自建授权端的最小职责模型
最小可用模型只有四个接口,多一个都算过度设计:登录(换取凭证)、刷新(用旧凭证换新凭证)、校验(内部服务用,验证凭证有效性)、登出(主动失效)。围绕这四个接口,再加三个后台能力:会话存储、踢人通道、审计日志。
会话存储我一般用 Redis 做主存、MySQL 做冷备。Redis 里存的是session:{uid}:{device_id}→ 会话元数据,包括签发时间、最后活跃时间、凭证指纹、客户端版本、粗略平台类型。MySQL 只存“这个用户当前有哪些活跃会话”这种摘要,用于后台管理和安全审计。为什么不全放 Redis?因为 Redis 一旦被清空(比如误操作 flushdb、或者内存打满触发淘汰策略),你需要有能力回答“刚才到底有哪些设备在线”,否则用户投诉你连排查依据都没有。
踢人通道走的是网关的下行能力:授权端判定某个会话失效后,往网关发一条控制消息,网关找到对应的连接直接关掉,并附带一个关闭码说明原因(凭证过期 / 异地登录 / 主动登出 / 风控)。客户端收到特定关闭码后不再自动重连,而是回到登录页。这个设计很关键——如果客户端不管什么原因断了都无脑重连,你会看到一台已被踢下线的设备在那疯狂握手,把网关的握手 QPS 打高好几倍。
审计日志别省。至少要记录:登录成功/失败(含失败原因分类)、刷新成功/失败、踢人事件、异常断连。日志里不要写明文凭证,存凭证的哈希前缀就够了。我踩过一次坑,日志里打了完整 Token,结果排查时顺手 grep 了一下,发现日志平台上有几千条有效凭证躺着,只能紧急全量吊销重发。
2. 整体架构设计:登录态到底该存在哪
登录态放哪儿,是这套系统里第一个必须拍板的决策,而且它几乎没有“改主意”的空间——一旦上线,迁移成本极高。常见的三种位置各有利弊,我用下来没有绝对答案,只有匹配度。
2.1 三种存储位置的取舍
全客户端(无状态 JWT):凭证自带全部信息,服务端验签即可,不需要查存储。优点是延迟极低、扩容无脑;缺点是无法主动失效——你没法让一个已签发的 JWT 提前作废,除非维护一张黑名单,那又回到有状态了。适合内部系统、短生命周期凭证。
全服务端(不透明 session id):客户端只拿一个随机串,所有信息在服务端。优点是控制力最强,随时能踢、能改权限、能统计在线;缺点是每个请求都要查一次存储,得靠本地缓存兜。
混合(短 JWT + 服务端 refresh):我目前最推荐的形态。access token 用 JWT,有效期短(10~30 分钟),业务服务本地验签,零额外开销;refresh token 是不透明串,存服务端,有效期长(7~30 天),只能用来换 access token。这样既保住了性能,又保住了控制力——想踢人,把 refresh 记录删掉,最迟一个 access 周期后该用户就彻底下线,同时网关可以直接掐连接做到即时生效。
| 方案 | 验签开销 | 主动失效 | 存储压力 | 适合场景 |
|---|---|---|---|---|
| 无状态 JWT | 极低(本地) | 不支持(需黑名单) | 无 | 内部系统、短会话 |
| 不透明 session | 每请求一次查询 | 即时 | 中 | 强管控、金融类 |
| JWT + refresh | 低(本地)+ 低频查询 | 一个周期内 / 网关即时 | 低 | 通用 IM、协同办公 |
2.2 双 Token 的有效期怎么算
参数不是拍脑袋定的,得从三个约束反推:安全窗口(凭证泄露后最长暴露时间)、刷新频率(客户端请求量和用户无感程度)、存储成本(refresh 记录条数)。
假设你希望凭证泄露后的最大风险窗口不超过 30 分钟,那 access token 的 TTL 就定 15 分钟,留一半余量给时钟漂移和网络延迟。refresh token 的 TTL 取决于产品的“免登录天数”要求,通常 7 天(金融类)、30 天(工具类)、90 天(内容消费类,且有活跃度滑动)。
滑动续期的规则:每次成功刷新时,refresh token 也轮换(rotation)——旧的立即作废,发新的。刷新记录条数 ≈ 日活设备数 × 免登录天数 ÷ 平均访问间隔天数,量级可控。轮换带来的一个副作用必须处理:并发刷新。客户端多线程、或者弱网重试,很容易两个请求同时拿旧 refresh 去换,第一个成功、第二个因为“旧 token 已作废”失败,用户被踢下线。解决方案有两个:一是刷新时保留一个短暂的宽限期(比如旧 token 作废后 30 秒内仍接受,但返回同一个新 token),二是客户端侧做单飞(single-flight),同一时刻只允许一个刷新请求在飞。我两个都做,因为线上永远有你想不到的客户端实现。
提示:宽限期不要设太长。设成 5 分钟就等于凭证有效时间凭空多出 5 分钟,安全窗口的计算就失效了。
2.3 设备标识与多端登录策略
多端策略是产品决策,但工程上必须提前定死,否则后期改会造成大量用户投诉。常见三种:互斥(新登录踢掉旧登录,工具类常见)、按端类型共存(手机 + 平板 + 桌面各留一个,IM 常见)、全放行(有上限,比如 5 台)。
设备标识的生成有个坑:不要用 IMEI、MAC 这类硬件标识,采集本身就有合规风险,而且在现代系统上要么拿不到,要么会被随机化。正确做法是客户端首次启动时生成一个 UUID,持久化到本地安全存储,登录时带上。这个 ID 的唯一作用是在服务端做会话索引和互斥判断,不承担任何安全职责——它可能被伪造,所以永远只作为“辅助键”,鉴权依据必须是凭证本身。
按端类型共存的话,会话键设计成session:{uid}:{platform}:{device_uuid},其中 platform 只取粗粒度枚举(ios / android / desktop / web)。互斥逻辑就是登录时扫一遍该 uid 下的会话,把同 platform 的旧会话标记失效并触发踢人。这里要注意并发:两个设备同一秒登录同一个 platform,可能互相踢,最后两个都掉线。加一个用户维度的分布式锁(key 用lock:login:{uid},TTL 5 秒)就能规避,锁的粒度必须到用户,不能到全局。
3. 长连接工程实践:从握手到保活
长连接这块的坑密度最高,因为它的失败模式是静默的——连接对象还在,readyState还是 OPEN,但底下早就断了。移动网络切换、NAT 表项老化、中间设备静默丢包,都会造成这种“假在线”。判断连接是否真活着,只能靠应用层心跳。
3.1 传输层选型
WebSocket是默认答案,握手走 HTTP、能穿大部分中间设备、浏览器原生支持、调试工具齐全。长轮询只在极端受限环境(老旧代理、某些企业内网)作为降级方案,它的延迟和连接开销都更差,但兼容性最好。私有 TCP 协议在延迟和包体开销上最优,适合高频小消息场景,但你要自己做加密、做多路复用、处理各种中间设备的不友好,工程量不小。
我的一般策略是主用 WebSocket,客户端内置降级链:WebSocket 失败 → 长轮询 → 短轮询(间隔拉长到 30 秒以上,只保证最终可达)。降级链要有明确的触发条件,比如 WebSocket 连续失败 3 次且错误码不是鉴权类,才降级;鉴权类错误直接回登录页,别浪费时间重试。
握手阶段的鉴权要放在第一个应用层消息里,不要塞在 URL 参数上。URL 会被完整记录到各级访问日志、代理日志、监控系统里,Token 放 URL 等于把凭证到处撒。正确做法是:握手成功后客户端立即发一条auth消息,网关在 5 秒内没收到就断开。这 5 秒是个经验值,够慢网完成一次往返,又不至于让未鉴权连接堆积太久。
3.2 心跳间隔的计算过程
心跳间隔不是“大家都用 30 秒所以我也用 30 秒”,得算。约束来自 NAT/防火墙的空闲连接超时:
- 家用路由器 NAT 表项空闲超时:常见 120~300 秒
- 运营商级 NAT:可能短至 180 秒
- 企业防火墙/负载均衡:常见 60~300 秒,有些云厂商 LB 默认 900 秒但可配
- 移动网络(蜂窝)侧网关:变化最大,实测有 60 秒就回收的
取最保守的 60 秒作为下限约束,心跳间隔 = 最小值 ÷ 3 ≈ 20 秒。为什么除以 3 而不是 2?因为要留出一次心跳丢失的容错——网络抖动导致一个心跳包丢了,下一次还能在超时前到达。除以 2 的话,丢一个包就已经逼近超时边缘了。
实际配置我一般这样定:心跳间隔 20 秒(移动端)/ 30 秒(桌面端,网络更稳定),心跳超时(多久没收到 pong 判定死链)设为间隔的 2.5 倍,即 50 秒 / 75 秒。同时服务端侧维护一个独立的清理任务,每 10 秒扫一遍连接表,把now - last_pong_at > 超时阈值的连接主动关闭并释放资源。客户端侧的判定是:发出 ping 后 2 个心跳周期内没收到 pong,就主动close()并进入重连流程。
| 参数 | 移动端 | 桌面端 | 说明 |
|---|---|---|---|
| 心跳间隔 | 20s | 30s | 按最保守 NAT 超时 ÷ 3 |
| 客户端判定死链 | 50s | 75s | 间隔 × 2.5 |
| 服务端清理周期 | 10s | 10s | 扫描 last_pong_at |
| 握手鉴权超时 | 5s | 5s | 未认证连接上限 |
| 单连接上行限速 | 20 msg/s | 50 msg/s | 防单连接打爆网关 |
心跳包体要小。我见过把完整用户信息塞进心跳的,一个心跳 2KB,十万连接每 20 秒就是 1GB 流量,纯浪费。心跳只带一个递增序号和时间戳就够了,pong 原样返回,顺便能算 RTT。
3.3 断线重连的退避策略
无脑立即重连是最常见也最致命的错误。网关抖动一次,十万客户端同时重连,握手 QPS 瞬间翻十倍,直接把网关打死,然后所有人都连不上,形成雪崩。必须用指数退避 + 随机抖动。
公式:delay = min(cap, base × 2^n) × (1 + random(-jitter, +jitter)),其中 base = 1 秒,cap = 60 秒,jitter = 0.2(即 ±20%),n 是连续失败次数。第 1 次失败等 0.8~1.2 秒,第 2 次 1.6~2.4 秒,第 3 次 3.2~4.8 秒……到第 6 次就顶到 48~72 秒区间。抖动的作用是打散同时重连的客户端,避免“同步化”——没有抖动的话,所有客户端会在同一时刻重连,形成周期性的尖峰。
还有两个细节。第一,失败计数什么时候清零:连接成功并且完成鉴权、稳定维持超过 30 秒后才清零,不是一连上就清。第二,重连期间要不要重新登录:如果重连失败的原因是鉴权错误(关闭码明确是凭证过期),不要重试,直接走刷新流程,刷新成功后用新凭证重连;如果是网络类错误,只重连不刷新,避免刷新接口被重连风暴打爆。
3.4 消息可靠性与去重
长连接的“可靠”是自己实现的,别指望 TCP 帮你。你需要三样东西:序号、确认、幂等。
上行消息带客户端生成的client_seq(单调递增)和msg_id(UUID)。网关收到后返回 ack,客户端收到 ack 才从本地待发队列删除,否则按超时重发。重发时msg_id不变——服务端侧靠msg_id做幂等去重,用一个最近窗口(比如最近 1000 条或最近 5 分钟)的布隆过滤器或 Redis Set 判断。这样即使客户端重发三次,业务只处理一次。
下行消息同理,服务端分配单调递增的server_seq,客户端按序号检测空洞。检测到空洞就发起一次拉取补齐(走 HTTP 接口拉 [last_seq+1, current_seq] 区间)。为什么不全靠推送补?因为推送通道在断线期间的消息必然丢失,必须有拉取兜底。我一般要求客户端在每次连接建立成功后先做一次增量拉取,再做正常收发,这能把断线窗口内的消息一次性补齐。
注意:序号必须是每会话单调递增,不能全局递增。全局递增会导致某个用户的消息序号跳跃巨大,客户端无法判断空洞大小。用
server_seq在会话维度维护(Redis INCR 一个 per-session key)即可。
4. 登录态模拟的正确姿势:状态机与续期
“登录态模拟”这个词在测试场景里非常有用——自动化测试需要跳过登录界面直接进入已登录状态。但工程上更值得讲的是登录态的生命周期管理,把它当成一个状态机来设计,很多疑难问题会消失。
4.1 会话状态机
一个会话只应该有五个状态:CREATED(已签发未使用)、ACTIVE(正常使用中)、REFRESHING(续期中)、EXPIRED(已过期)、REVOKED(已吊销)。状态转移必须收敛:
CREATED → ACTIVE:客户端首次用凭证成功发起请求ACTIVE → REFRESHING:收到刷新请求,加锁REFRESHING → ACTIVE:刷新成功,写入新凭证指纹,旧 refresh 标记作废(保留宽限期)ACTIVE → EXPIRED:超过 refresh TTL 且无心跳ACTIVE/REFRESHING → REVOKED:登出、踢人、风控命中
合法转移只有这几条,其余全部拒绝并记异常日志。比如REVOKED → ACTIVE是禁止的,如果监控里出现这种转移,说明有逻辑漏洞(可能是缓存没清,或者并发写入覆盖了状态)。我习惯在状态写入时带上版本号做 CAS,避免并发覆盖。
4.2 静默续期与并发互斥
静默续期(silent refresh)的触发点一般选在 access token 剩余有效期低于 5 分钟时,客户端后台自动发起,用户无感。这个 5 分钟阈值得配合“提前量”一起看:如果用户正好在这个窗口里发起一个业务请求,请求带着即将过期的 token 到达服务端,可能刚好过期。所以业务服务端遇到“签名有效但已过期不超过 60 秒”的 token,可以考虑放行(这是个有争议的做法,取决于安全等级),或者干脆让客户端在发起业务请求前先检查并刷新。
并发互斥用 Redis 的SET key value NX PX 5000做单飞锁,key 是lock:refresh:{session_id}。拿到锁的请求去执行刷新,没拿到的请求轮询等待最多 2 秒,然后直接读结果。这样十个并发刷新只会真正执行一次,其余都拿到同一个新 token。这个锁的 TTL 一定要短(我设 5 秒),因为刷新操作本身是毫秒级的,锁过期是兜底,防止死锁。
4.3 多端同步与踢下线的即时性
踢人这件事,用户体验和即时性是有冲突的。网关在线的时候,直接下发控制帧关闭连接,即时性最好(实测 100ms 内)。网关不在线(用户离线),就只能靠凭证失效来兜底——他下次上线时 refresh 已被删除,刷新失败回登录页。中间态是“连接还在但会话已吊销”,这时候业务请求会因校验失败被拒,客户端收到特定错误码后应该主动登出走人,而不是傻等。
多端同步还有一个场景:A 端改了密码,所有端都要下线。这种批量吊销我用的是“会话代际号”——用户表上存一个session_gen,每次密码变更就 +1。所有凭证里带上签发时的gen,校验时比对,不一致直接失效。好处是不需要遍历删除所有会话记录,一个字段搞定;代价是校验时要多读一次用户表,用本地缓存顶住就行,缓存 TTL 30 秒,意味着最坏情况下 30 秒内旧凭证仍可用,可接受。
5. 实操:搭一个能跑的最小授权端
理论说够了,落地看代码。下面这套骨架我用过几次,量级在十万级在线、百万级注册,单机 Redis + 两三个应用实例足够跑起来。语言选 Python 只是示例习惯,逻辑照搬到 Node 或 Go 完全一致。
5.1 依赖与存储键设计
依赖只有三样:一个 Web 框架(FastAPI / Express 都行)、Redis(会话 + 锁 + 序号)、一个签名库(标准库 hmac + hashlib 就够了,别引入重依赖)。存储键设计如下,键数量要控制住,避免 Redis 里出现几百万个小键。
| 键 | 类型 | TTL | 说明 |
|---|---|---|---|
sess:{sid} | Hash | refresh TTL | 会话元数据:uid、platform、gen、状态、last_active |
uid_sess:{uid} | Set | 无 | 该用户所有 sid,用于踢人和互斥 |
rt:{refresh_hash} | String | refresh TTL + 宽限 | refresh 指纹 → sid,防重放 |
lock:refresh:{sid} | String | 5s | 单飞锁 |
seq:{sid} | String | 会话 TTL | 下行序号计数器 |
dedup:{sid} | Set | 5min | 上行 msg_id 去重窗口 |
5.2 凭证签发:签名与结构
access token 用header.payload.signature的三段式,payload 里只放必要字段:sid、uid、gen、iat、exp、plat。不放昵称、头像、权限列表这些会变的东西——JWT 一旦签发就改不了,塞进去就是给自己找麻烦。
import hmac, hashlib, base64, json, time def _b64(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).rstrip(b'=').decode() def issue_access(sid: str, uid: str, gen: int, plat: str, key: bytes, ttl: int = 900) -> str: now = int(time.time()) payload = {"sid": sid, "uid": uid, "gen": gen, "plat": plat, "iat": now, "exp": now + ttl} body = _b64(json.dumps(payload, separators=(',', ':'), sort_keys=True).encode()) sig = _b64(hmac.new(key, body.encode(), hashlib.sha256).digest()) return f"{body}.{sig}" def verify_access(token: str, key: bytes, now: int | None = None) -> dict | None: try: body, sig = token.split('.') except ValueError: return None expect = _b64(hmac.new(key, body.encode(), hashlib.sha256).digest()) if not hmac.compare_digest(sig, expect): # 必须用 compare_digest return None payload = json.loads(base64.urlsafe_b64decode(body + '=' * (-len(body) % 4))) if payload["exp"] < (now or int(time.time())): return None return payload这段代码有两个细节值得单说。第一,compare_digest不能换成==,签名比较是典型的时间侧信道场景,用普通比较等于把签名逐字节泄露给攻击者。第二,payload 序列化必须sort_keys=True——否则同一个字典在不同 Python 版本、不同实例上序列化出来的字符串可能顺序不同,验签直接失败。我踩过这个坑,本地跑得好好的,上了 Docker 换个基础镜像就全挂了,排查了两小时。
5.3 登录与刷新:核心事务
登录流程是:校验业务凭证(密码/验证码,这部分走另一套逻辑,本文略)→ 生成 sid → 处理多端互斥 → 写会话 → 签发双 Token。刷新流程是:取锁 → 校验 refresh 指纹 → 校验状态和代际号 → 轮换 → 释放锁。
import uuid, secrets, time def do_login(r, uid: str, plat: str, gen: int, key: bytes) -> dict: # 1) 同平台互斥:踢掉旧会话 lock = f"lock:login:{uid}" if not r.set(lock, "1", nx=True, px=5000): raise RuntimeError("login busy") try: for old in r.smembers(f"uid_sess:{uid}") or []: meta = r.hgetall(f"sess:{old}") if meta and meta.get("plat") == plat and meta.get("state") != "REVOKED": r.hset(f"sess:{old}", mapping={"state": "REVOKED"}) r.delete(f"rt:{meta.get('rth', '')}") # 同步事件:通知网关关闭该连接(走内部消息队列) publish_kick(uid, old, reason="SAME_PLATFORM_LOGIN") sid = uuid.uuid4().hex rt_raw = secrets.token_urlsafe(32) # 给客户端 rt_hash = hashlib.sha256(rt_raw.encode()).hexdigest() # 存服务端 r.hset(f"sess:{sid}", mapping={ "uid": uid, "plat": plat, "gen": gen, "state": "ACTIVE", "rth": rt_hash, "created": int(time.time()), "last": int(time.time()) }) r.expire(f"sess:{sid}", 30 * 86400) r.sadd(f"uid_sess:{uid}", sid) r.set(f"rt:{rt_hash}", sid, ex=30 * 86400 + 30) return {"sid": sid, "access": issue_access(sid, uid, gen, plat, key), "refresh": rt_raw, "expires_in": 900} finally: r.delete(lock)refresh token 存哈希不存明文,这一点很多人省掉了。省掉的后果是:Redis 一旦泄露,攻击者拿到的是可以直接用的 refresh token。存哈希后,泄露的东西只能看不能用,而且 refresh 本来就是“一次一换”的,重放价值有限。代价是没法反查明文——但你本来也不需要反查,比对哈希即可。
5.4 长连接网关核心骨架
网关用 WebSocket,核心是三件事:握手后 5 秒内鉴权、心跳超时清理、下行路由。下面是最小实现,省略了消息队列接入部分。
import asyncio, json, time CONN: dict[str, "Conn"] = {} # sid -> Conn HEARTBEAT_TIMEOUT = 50 # 秒,移动端 AUTH_TIMEOUT = 5 class Conn: def __init__(self, ws): self.ws = ws self.sid = None self.uid = None self.last_pong = time.time() self.auth_deadline = time.time() + AUTH_TIMEOUT async def run(self): try: async for raw in self.ws: msg = json.loads(raw) if self.sid is None: if msg.get("type") != "auth": await self.ws.close(code=4001, reason="auth required"); return data = verify_access(msg["token"], KEY) if not data: await self.ws.close(code=4002, reason="bad token"); return # 校验会话状态与代际号(这里查一次缓存) meta = await get_session(data["sid"]) if not meta or meta["state"] == "REVOKED" or int(meta["gen"]) != data["gen"]: await self.ws.close(code=4003, reason="session revoked"); return self.sid, self.uid = data["sid"], data["uid"] CONN[self.sid] = self await self.ws.send(json.dumps({"type": "auth_ok", "seq": 0})) elif msg.get("type") == "ping": self.last_pong = time.time() await self.ws.send(json.dumps({"type": "pong", "ts": msg.get("ts")})) elif msg.get("type") == "msg": # msg_id 幂等 + 投递业务 await handle_upstream(self, msg) finally: if self.sid: CONN.pop(self.sid, None) async def sweeper(): while True: now = time.time() for sid, c in list(CONN.items()): if now - c.last_pong > HEARTBEAT_TIMEOUT: await c.ws.close(code=4004, reason="heartbeat timeout") CONN.pop(sid, None) await asyncio.sleep(10)关闭码的设计要有语义,客户端才能做出正确反应。我的约定:4001未鉴权、4002凭证无效、4003会话被吊销、4004心跳超时、4005服务端主动重启(客户端应快速重连)、4006限流。客户端逻辑就是:4001/4002 走刷新流程,4003 直接登出,4004 立即重连(不退避),4005 用短退避(1~3 秒),4006 长退避(30 秒起)。这套码表贴在客户端代码里,别只写在文档里。
5.5 压测与必须盯住的指标
上线前至少做一次断线风暴测试:起 5 万连接,然后一次性杀掉网关进程,观察客户端重连曲线。理想情况是一条平滑上升的曲线,30~60 秒内恢复 80% 以上;如果看到尖峰直接顶到连接上限,说明退避和抖动的参数没生效。
必盯的指标我列六个:连接成功率(握手成功/握手尝试)、鉴权耗时 P95(应该 < 50ms)、心跳 RTT P95(> 500ms 说明网络质量差或网关负载高)、异常断连率(非主动关闭 / 总连接,健康值 < 1%/小时)、重连次数分布(人均重连次数,突然升高必有因)、刷新失败率(> 0.5% 就要查,通常是并发互斥没做好)。
这些指标按platform + 客户端版本打标签,出问题时能立刻定位是某个版本引入的,还是全局的。我遇到过一次异常断连率从 0.3% 涨到 4%,最后定位是某个安卓版本在息屏后系统回收了网络资源但没触发 close 事件,客户端得靠心跳超时才发现,导致用户感知是“消息延迟 50 秒”。后来改成监听系统网络变化广播,息屏/切网时主动重建连接,问题消失。
6. 常见问题与排查实录
下面这些是我和同行们反复遇到的真问题,整理成速查表,遇到时按顺序过一遍,能省掉大量瞎猜的时间。
6.1 掉线与假在线排查表
| 现象 | 高概率原因 | 排查动作 |
|---|---|---|
| 用户反馈“消息收不到”,但后台显示在线 | NAT 表项老化,连接实际已死 | 查该连接last_pong_at,看心跳是否在正常上报 |
| 大量连接在整点集中断开 | 中间设备定时回收 / LB 空闲超时 | 对比断开时间分布,把心跳间隔降到 LB 超时的 1/3 |
| 移动端息屏后 3~5 分钟必掉 | 系统休眠网络 | 客户端监听网络状态,唤醒时主动重建而非等待超时 |
| 重连风暴,握手 QPS 暴涨 | 无退避或退避参数失效 | 检查重连代码路径,确认 jitter 生效、失败计数没被提前清零 |
| 单个用户连接数持续增长 | 客户端旧连接未清理 | 查 CONN 表按 uid 聚合,找出泄漏的 sid |
| 切换网络后 30 秒内消息全丢 | 新连接未做增量拉取 | 检查连接建立后的补齐逻辑是否执行 |
6.2 登录态失效类问题
“刚登录就被踢”:八成是同平台互斥逻辑太激进,或者设备 UUID 在每次启动时重新生成(没持久化)。检查方式很简单,让用户连续登录两次,看服务端记录的两个 device_uuid 是否一致。
“用着用着突然要重新登录”:通常是 refresh 失败。三个可能:refresh 过期(检查 TTL 是否被误设)、并发刷新导致旧 token 作废(加宽限期 + 客户端单飞)、服务端 Redis 里rt:{hash}被清(检查淘汰策略,会话数据不要设allkeys-lru)。
“改了密码其他端还在线”:代际号没生效。检查校验逻辑是否真的比对了gen,以及缓存 TTL 是不是太长。我设 30 秒,你可以更短,但别超过 60 秒。
“测试环境一切正常,线上偶发失效”:多实例部署,密钥不一致。签名密钥必须所有实例相同,且支持轮换(新旧两把同时有效,新签发的用新密钥,一个 access TTL 周期后旧的作废)。
6.3 几条用血换来的经验
第一条,别在业务请求里同步调授权服务。这是最常见的架构性错误,一旦授权服务抖动,全站 5xx。做法是业务侧本地缓存校验结果,或者直接用公钥/共享密钥本地验签。
第二条,心跳超时阈值宁短勿长。设长了用户体验差(消息延迟大),设短了只是多几次重连,成本远低于延迟。我见过设 300 秒的,用户手机明明有网,消息能延迟五分钟。
第三条,所有超时都要有上限和下限。没有上限的重试是灾难,没有下限的重试是自杀。我给每个重试点都写了max_attempts和max_elapsed,超了就上报失败让用户手动干预,比无限转圈强。
第四条,日志里永远不打凭证。包括完整 Token、refresh 明文、密码、验证码。需要关联排查就打印前 8 位哈希。
第五条,灰度发版必须先上客户端,再上服务端。新增关闭码、新增字段这类改动,客户端不认识就会出错。顺序反了,老客户端收到新错误码可能直接崩溃或者死循环重连。这个顺序我写在发布清单第一行。
第六条,也是我个人体会最深的一条:长连接系统的稳定性不是靠“不出问题”实现的,是靠“出了问题能在 30 秒内自愈”实现的。与其花两周优化握手性能,不如花两天把重连退避、增量拉取、会话状态机这三件事做扎实。这三件事做好了,网关随便崩,用户最多感知到一次几百毫秒的卡顿;做不好,一次 3 秒的网关抖动就能让整个系统雪崩,恢复时间以小时计。