之前带推送功能的项目,我前后做过两次。第一次图省事,让前端每5秒轮询一次接口,结果用户在线量稍微上来,业务数据库就被查得满头包,消息延迟也从“看起来实时”慢慢变成“卡到怀疑人生”。第二次彻底推倒重来,才有了这套基于长连接的实时消息推送系统。
先明确一点,这类系统通常不是给用户发短信、发系统通知那么简单。它的典型场景是你打开网页或App后,后端有任务进度变化、审批结果、交易状态更新,能在一两秒内主动把消息塞到用户屏幕前,用户不用刷新页面。整个链路会牵扯到连接管理、在线状态、离线消息补偿、ACK确认、多端登录,踩坑点非常多。这篇文章我从整体设计写到实际运维中遇到的问题,完整还原一套可落地的方案,适合正在做站内信、公告中心、工单提醒的后端同学,也适合想搞明白推送背后到底发生了啥的前端朋友。
1. 先花十分钟想清楚:到底要不要上长连接
1.1 从“用户觉得慢”拆出的真实指标
“实时”这个词很容易被滥用。有的场景其实只是点击某个按钮后希望尽快看到结果,前端用个Loading转圈就够。真正的实时消息推送,它的业务特征是事件由服务端异步产生,用户没有主动发起请求,却需要在秒级内感知到变化。
我们最初的核心需求很简单:某个工单被处理人更新状态后,创建人页面上的处理进度条要马上变化,同时右下方弹出可点击的提醒气泡。如果把这个需求拆成几个硬指标,大概是:
- 服务端产生事件到用户看到推送,目标延迟在2秒以内
- 用户可能长时间停留在页面,甚至挂机一晚上,连接不能断
- 消息不能丢,但也不能因为重推而出现一堆重复提醒
第一眼觉得不难,真上手就知道,难的不是“推”这个动作,而是前面那串“如果”。
当时的业务并发还没到几百万连接那种夸张程度,日活用户中同时在线大概几万人。这个量级不需要上特别重的自研框架,但对扩展性和稳定性还是有一定要求。因为你不确定明年业务量会不会翻几倍,设计上至少要做到网关节点能横向扩容,而不是把所有连接都死绑在一台机器上。
1.2 四个候选方案的真实取舍对比
选实时通道之前,我把市面常见的几种方式都列了一遍,做了一张对比表:
| 方案 | 实时性 | 服务端压力 | 谁主动 | 适合场景 |
|---|---|---|---|---|
| HTTP短轮询 | 取决于间隔,秒级~分钟级 | 高,每个周期都有大量空请求 | 客户端 | 低实时性、低频率的场景 |
| HTTP长轮询 | 秒级 | 中高,连接挂起占用较多 | 客户端挂起 | 老系统改造,已基本不推荐 |
| SSE(Server-Sent Events) | 秒级 | 低,单工长连接 | 服务端 | 只需要服务端单向推送文本 |
| WebSocket | 毫秒级 | 低,全双工长连接 | 双向 | 聊天、协作、实时消息推送主流方案 |
短轮询那个方案已经被我们干掉了,主要问题有两个。第一是请求密度太高:5000个在线用户,轮询间隔5秒,每秒平均产生1000个请求,大部分还没数据返回。第二是体验没法保证,数据库和服务端压力一大,响应变慢,用户看到的消息可能已经是几十秒前的了。
长轮询虽然能实现较低延迟,但每个挂起的请求都会占用服务端线程资源,维护成本也不低。SSE看起来挺诱人的,实现简单、还能自动重连,但它只能服务端往客户端推,并且浏览器对连接数有限制。我们的业务里用户会主动操作,客户端也要向服务端上报消息已读等状态,SSE反而要再维护一根普通请求通道,不如WebSocket一根连接搞定。
最终选择WebSocket,纯粹是因为业务里双向通信的需求确实存在,而且团队对这种协议比较熟。如果你只是做一轮公告推送,用户不需要回传状态,用SSE会轻很多。
2. 系统链路与在线路由设计
2.1 拆成三个逻辑角色,心里就不会乱
我习惯把整个推送系统拆成三个逻辑角色,代码和部署上可以合并也可以拆开:
- 接入网关(Gateway):负责和客户端保持WebSocket长连接,处理连接鉴权、心跳、消息收发
- 路由中心(Router):维护“用户ID → 当前连接的网关节点与连接标识”的映射关系
- 业务接入层(API):给业务系统提供推送接口,把推送请求换算成具体网关节点上具体连接ID的指令
这三个角色各管一摊。接入网关最复杂,因为它要和操作系统网络栈打交道;路由中心相对简单,实际落地上我们用的是Redis保存在线关系。业务接入层则不需要知道底层有多少台网关,只要调接口、传用户ID和消息体就行。
这套拆法还带来一个额外好处:当线上需要升级网关代码时,可以单独摘节点重启,不影响业务方使用推送接口。要是把所有逻辑都塞在一个服务里,后面维护会非常难受。
2.2 在线状态到底放哪:两种视角的反复平衡
在线状态是这类系统最容易想歪的地方。刚开始我脑中的方案是:把每个用户当前连着的网关IP和连接对象直接存Redis,业务推送时从Redis取出连接直接发。后来发现这里要区分两个视角:
网关视角:每台网关会维持几万个长连接,它本身必须知道“我这个进程里有哪些连接”。这个数据只能放网关本地内存,绝不能放Redis,否则每条消息都要跨网络查库再定位对象,延迟和复杂度都受不了。
路由中心视角:当用户断线重连到另一台网关上时,业务方投递消息要知道该发到哪台网关。这时才需要Redis记录“用户现在挂在哪台网关节点上”。
因此最终路由表的更新是这样完成的:客户端连上网关后,网关把连接元数据写入一个带过期时间的Redis Key。路由中心查询时直接读这个Key,如果网关节点宕机,Key会随着连接断开的清理操作被删掉,或者等待TTL自然过期兜底。
下面是路由表写入的大概伪代码:
# 连接建立完成、鉴权通过后调用 def register_session(user_id, conn_id, gateway_id): key = f"route:user:{user_id}" session = { "conn_id": conn_id, "gateway_id": gateway_id, "ts": int(time.time()), "device": device_info } redis.hset(key, conn_id, json.dumps(session)) redis.expire(key, 90) # 定期续期,避免异常残留这里有个注意点:Redis中不能只存单个连接,因为同一个用户可能同时用手机和电脑登录,甚至同一种设备开多个标签页。连接维度必须是一个Set或Hash结构,每次推送时遍历该用户的所有连接,再根据业务需求决定是全端推送还是只推某个端。
2.3 多端在线与“到底该推给谁”的策略
多端登录后,一个经典问题出现了:用户手机上已读了一条消息,电脑上那条推送附件还要不要弹?如果不做策略控制,用户就会觉得“这个系统有点傻”。
我们的做法是在消息体里带一个pushPolicy字段,取值为ALL或者LAST_CONTEXT。例如“任务状态变更”这类消息,用户在工作台上正在打开的网页收到即可,App可以同步站内信但不强弹提醒。当策略是单端时,网关只给当前活跃端下发,其他端只更新一个小红点数量,不触发弹窗声音。
实际开发中我建议做一个简单的“设备维度去重”:每次客户端上线时上报deviceId + 平台 + 应用版本,路由中心把它作为Hash的field保存。业务推消息时如果不指定设备,就默认推所有端,但各端收到后可以根据消息里的msgId自行过滤一遍,避免同一条消息被推送两次引发重复提醒。这个策略看着简单,却省掉了大量重复消息投诉。
3. 连接从握手到保活的那些细节
3.1 一条连接是怎么被“信任”的
WebSocket握手本质上还是HTTP。客户端连接时,我们不是直接允许建立通道,而是要求它带一个短时有效的accessToken。这个Token由登录接口下发,有效期一般控制在10分钟到半小时,接入网关在升级连接前校验一次。
校验逻辑不推荐网关每次去查用户中心数据库,否则连接风暴时用户中心会被打到怀疑人生。更稳妥的做法是网关进程启动时从配置中心拉取用户中心的RSA公钥,本地验签。Token里只包含用户ID、设备ID和过期时间,签名校验通过就认为可信。
鉴权通过后,服务端立即下发一个连接级配置,内容大致是:
{ "type": "conn.ready", "serverTime": 1712300000000, "heartbeatInterval": 30, "heartbeatTimeout": 10, "serverSeq": 10086 }其中serverSeq是服务端为这个用户当前分配的初始消息序号,后面拉离线消息时有用。整个握手里还有个容易被忽视的点:不要在没有鉴权的状态下让连接闲置太久,否则大量半开连接会占住文件描述符。我们设置了“握手等待期”,5秒内没完成鉴权连接直接被RST。
3.2 服务端和客户端心跳不是一回事
长连接最怕的不是断开,而是“看起来连着,实际上已经死了”。最常见的场景是用户电脑休眠、网络切换,TCP连接没等到正常的四次挥手,服务端却一直不知道该清理。
业界通用解法是WebSocket的Ping/Pong帧。服务端每隔一段时间发一个Ping,客户端收到后必须回Pong。如果客户端在超时时间内没回,服务端就关闭这条连接。这是一套“应用层心跳”,比TCP KeepAlive更能确切感知客户端存活,因为NAT设备和中间代理可能对空TCP包不敏感,而WebSocket的Ping/Pong是带实际数据帧的。
时间参数上,我们采用30秒心跳周期、90秒超时阈值。注意,这两个参数不是越大越好,也不是越小越好。对于90秒阈值,意味着一条连接异常断开后,服务端最多可能要90秒才感知到。而一旦连接被NAT网关回收,客户端继续发消息会等不到响应,客户端的感知反而更慢。
真正能救回体验的是客户端侧的自动重连机制。前端判断连接关闭后,不能立刻狂点重连,也不能傻傻等半小时。我实际用的策略是:
- 第一次断线:立即尝试重连
- 连续失败:按1秒、2秒、4秒、8秒、16秒指数退避
- 最大退避时间封顶为60秒
- 用户切回前台时立刻重试一次
这套组合拳下来,用户在弱网环境里既能感受到连接的自动恢复,又不会把网关打爆。
3.3 连接数上限与Linux内核参数调优
很多人第一次压测长连接时会发现:程序明明没报错,可连接数到一万多就再也上不去了。这不是框架问题,八成是文件描述符限制。
单个进程能打开的文件描述符数量默认是1024,必须调大。我们应用到生产环境前会检查并修改几个关键参数:
# 查看当前进程可打开的fd数 ulimit -n # 临时调整 ulimit -n 1048576 # 永久调整,写在 /etc/security/limits.conf # * soft nofile 1048576 # * hard nofile 1048576还有TCP层的一些参数,也直接影响大量长连接的稳定性:
# 增大TCP连接复用能力,避免大量TIME_WAIT堆积 net.ipv4.tcp_tw_reuse = 1 # 减少TCP KeepAlive探测频率,省下无谓的包 net.ipv4.tcp_keepalive_time = 600 net.ipv4.tcp_keepalive_intvl = 30 net.ipv4.tcp_keepalive_probes = 3除Linux参数外,连接内存也要粗算。我实测一个空闲WebSocket连接在网关进程内大约会占2KB到3KB左右的内存,如果有大量业务消息积压会更高。单台8C16G的机器,预留操作系统和其他服务内存后,建议单机连接数控制在5万到8万以内。再多就加机器,别硬扛。
4. 消息不丢又不重复,才是真正见功夫的地方
4.1 业务服务把消息交给谁最放心
推送链路里第一个可能的丢消息点是业务服务调用推送API时,网络抖动或者网关重启导致请求没到达。我们在接入层做了一层简单的“消息先落库再推送”机制,核心流程是:
- 业务服务生成
messageId(UUID或雪花ID),调用推送API - API层先把消息写入Redis的待投递队列,返回“已接收”
- 后台异步任务从队列消费,确认消息要投递的所有目标用户
- 如果投递失败,重新回到队列,等下次重试
这里的重点是messageId必须由业务服务生成,而不是推送平台生成。因为如果平台生成,业务方重试一次就会产生两条内容相同但ID不同的消息,接收端无法识别是同一件事。利用业务方生成的messageId做幂等键,Redis或者数据库收到重复请求时直接丢弃,就能保证业务方无论重试多少次,用户最终只收到一条消息。
我用伪代码表示这层逻辑:
def push_to_user(user_id, message): # messageId 由业务调用方传入 message_id = message["messageId"] # 幂等检查 if redis.setnx(f"msg:dup:{message_id}", 1, ex=3600): dispatch_message(user_id, message) return "accepted" else: return "duplicated"4.2 从网关到客户端的ACK与重发机制
网络是不可靠的,这台网关把消息转发到客户端连接时,客户端不一定能真正收到。比如用户正好进了电梯,Wi-Fi信号断了但连接还挂在手机上,服务端发出去的消息随着连接断开就丢了。
一些团队会把消息直接发送后就当成功,这对实时提醒还能容忍,但工单、交易等场景绝对不能这么做。我们给每条客户端消息设置了一个clientSeq字段,客户端收到消息后必须回一条ACK帧,网关收到ACK才认为消息投递成功。
投递失败或者超时未ACK时,网关把消息放入一个本地专门处理未确认消息的Pending队列。设置重试次数、超时时间:默认每10秒重试一次,最多重试3次。连续失败就认为连接有问题,直接断开连接,让重连逻辑介入。客户端重连后,通过传入自己最后处理过的lastAckSeq,网关把大于该序号的消息重新下发。
这套流程能保证“不丢”,代价是可能会产生重复消息。举个例子,客户端收到了第100号消息,也回了ACK,但ACK包在网络中丢了,网关会重发100号消息。因此客户端必须能做去重,判断依据就是clientSeq,只处理比当前处理序号更大的消息。
4.3 离线消息的存储与补拉
用户完全离线期间产生的消息怎么办?我们选择的消息队列只是为了保证内部传输不丢,但真正常态保存离线消息的是Redis里的一个List结构。每条消息按用户维度追加:
offline:{userId}收到离线消息时,判断用户是否在线。如果在线且能正常ACK,消息就不需要进离线队列。如果用户不在线,消息异步追加到List中,设置过期时间,主流做法是保留3~7天,具体看业务要求。
一个容易忽略的问题是:用户重新上线的一瞬间,系统会同时做两件事——推送离线消息、并把该用户的新实时消息也发下来。如果顺序控制不好,用户可能看到旧消息跳到新消息后面,搞混上下文。我们的做法是:用户连接建立并鉴权通过后,服务端会暂存这个用户的新消息,等离线消息批量补发完成,再按序释放实时消息。补拉量也需要限制,比如单次最多拉最近200条,如果超过则提示用户到消息中心查看全部历史。
5. 上线后踩到的坑和排查记录
5.1 常见奇怪问题速查表
把实际运维期间高频出现的几类问题整理成了一张速查表,遇到类似症状可以直接照方抓药:
| 现象 | 可能原因 | 排查方式与处理 |
|---|---|---|
| 用户反馈“消息时有时无” | 客户端连的不一定是最新网关 | 检查路由中心对应账号的gatewayId,确认是否路由过期 |
| 连接建立后几分钟被断开 | Nginx空闲超时或未启用WebSocket升级 | 修改代理超时配置,并确认Connection/Upgrade头正常透传 |
| 推送延迟突增 | 网关Pending队列积压或CPU被打满 | 查看pending队列长度,CPU热点是否在消息序列化上 |
| 同一条消息收到多次 | 客户端缺少基于clientSeq的去重 | 检查客户端收包逻辑,加入最后处理序号 |
| 大量TIME_WAIT连接 | 短连接与长连接混跑,或大量重新握手 | 开启tcp_tw_reuse,排查是否有频繁重建连接的逻辑 |
| 用户掉线后未感知 | 客户端在后台长时间没有发心跳 | 根据前后台切换事件触发立即重连,不要让JS定时器在后台被冻结 |
最坑的一类问题是代理层设置超时时间过短。长时间没有业务消息时,客户端只发心跳包,但某些代理服务器在WebSocket场景下可能只识别Ping/Pong帧,若设置为普通的60秒超时,就会误杀连接。上线前一定要对代理服务器也做长连接压测。
5.2 一次典型故障:网关重启后用户集体“失联”
有一回线上某个网关节点因为发版被摘除,其他网关自动接替服务。用户反馈在那个时间点之后,网页上收工单提醒要手动刷新好几次才弹出来。
查了半天发现根因是旧网关节点被摘除前,Redis里还残留着大量用户的gatewayId指向已经下线的节点。客户端虽然很快重连到了新网关,但新网关写入路由表前有一个时间窗,业务推送读到的路由还是旧网关,于是消息被转发到了一个空节点。
修复方式有两层。第一层,网关优雅停机时,要把自己负责的所有连接逐一切断,让客户端主动重连,同时把Redis里对应自己的会话Key全部删除。第二层,路由表Key要带较短的TTL和续期机制,客户端连接的网关每60秒重新续期一次。如果节点异常宕机,最多延迟60秒,路由表就能被清理干净,业务推送失败后自动降级到离线消息流程。
这里我学到的最重要一点是:推送系统的错误恢复不能只靠客户端重连,路由状态的收敛是核心。客户端重连再快,路由表不更新,新连接也收不到消息。
5.3 压测时最容易误导人的几个指标
我压这个系统时犯过一个错误:只看“同时在线连接数”,忽略了消息吞吐。后来发现,单纯挂连接时,网关CPU占用率极低;一旦开始大量广播或频繁推送给单用户,序列化和网络IO开销立刻成为瓶颈。
压测时建议重点看几个指标:
- 建立连接速度:每秒能成功完成多少次WebSocket握手
- 单连接空闲内存:评估单机可支撑连接数
- 消息推送P99延迟:从消息进入API层到客户端收到ACK的平均延迟
- 单网关每秒能处理的推送条数
在我这边的环境,8C16G单网关维持3万连接时,每秒推送单个用户消息约1万条,延迟P99能保持在100毫秒以内。但一旦单条消息推送目标超过5000个在线用户,扇出就会成为性能瓶颈,此时需要考虑将广播消息拆到多个连接批次并发发送,而不是用一个for循环顺序发。
提醒:不要把“单条连接推送延迟”当成系统整体能力。真正考验系统的是“同一时刻大量用户在线并面对全局广播”这种极端场景,提前压测一次能帮你发现很多隐藏问题。
6. 这版方案沉淀下来的经验,以及还可以扩展的方向
项目上线后稳定运行了大半年,回头看最有价值的不是某一套框架或者某个中间件,而是设计过程中把连接状态和消息状态理清了。刚开始总想把在线状态都集中存储,后来发现分散保持、集中路由反而更简单;追求不丢消息时差点把所有状态都落库,最后在客户端记一下lastAckSeq就解决了大量重复问题。
如果你后续业务会扩展到手机App,可以在网关后面增加一层针对厂商推送通道的适配,例如利用苹果和安卓各自系统级推送通道转发WebSocket离线期间的消息。注意,这层扩展不会改变主链路设计,只是把“设备离线”这个事件转发给通道适配器,用系统级推送唤醒App,再让App重新建立WebSocket连接。这么做的价值是大大降低进程被系统杀掉后消息完全触达不到的概率。
另外给所有准备自己动手做的人一个小技巧:先把一条消息从业务发起到用户看到的全链路日志打出来,从API接收、路由计算、网关投递、ACK确认,每一步都打点并带上同一个messageId。上线后你八成会感谢当时这个决定,因为推送系统的链路跨越多个服务,一旦消息丢了,靠眼睛看代码是查不出来的,只能靠全链路日志定位到底是在哪一跳出现了问题。