1. 技术选型:我为什么把轮询换成了 WebSocket
做实时消息推送系统之前,我一直觉得推送这件事很简单——客户端定时拉一下接口不就行了?直到业务方把需求拍到我桌上:消息要在 1 秒内触达用户,同时在线量要支持几十万级别,而且消息不能丢。轮询方案直接出局,反复权衡之后,我选了一条更主流也更稳妥的路子:以 WebSocket 作为长连接通道,配合服务端主动推送。
先说一个核心认知:实时推送的本质是"反向请求"。传统 HTTP 接口是客户端主动发起请求、服务端返回结果,而推送系统要求服务端在某个业务事件发生时,主动把数据"塞"给客户端。轮询能做到这件事,但代价巨大——每次请求都要携带完整 HTTP 头,服务端要处理大量无效请求,而且轮询间隔越短,实时性越好,服务端压力也越大。实测下来,一个 10 万在线的业务,如果轮询间隔是 3 秒,网关层每秒要扛 3 万多 HTTP 请求,其中绝大多数是无更新时的空转。对比之下,WebSocket 只建立一次 TCP 连接,后续所有消息都走这条全双工通道,没有重复握手,更没有无效轮询。
1.1 三种实时方案的真实对比
我在项目启动前做了一个对比表格,把轮询、SSE(Server-Sent Events)和 WebSocket 放在一起看,心里就非常有数了:
| 对比维度 | 短轮询 | SSE | WebSocket |
|---|---|---|---|
| 传输方向 | 客户端拉取 | 服务端单向推送 | 全双工双向传输 |
| 实时性 | 取决于轮询间隔 | 秒级,有断线重试机制 | 毫秒级 |
| 连接开销 | 每次请求都走完整 HTTP | 长连接,但头部开销高 | 长连接,头部开销低 |
| 断线重连 | 天然支持 | 内置自动重连 | 需要自己实现 |
| 二进制数据 | 任意 HTTP 响应 | 仅文本帧 | 文本帧和二进制帧都支持 |
| 服务端实现复杂度 | 最低 | 较低 | 中等,需要处理心跳和粘包 |
SSE 其实是个容易被低估的方案——如果业务只需要服务端单向推送,比如股票价格刷新、公告通知、日志流,SSE 的自动重连优势非常省心。但我们既然叫"实时消息推送系统",往往还伴随着客户端回执、在线状态上报这类上行消息,所以 WebSocket 的双向能力更合适。
我自己的经验是:别一上来就迷信 WebSocket。如果需求只是"服务端往客户端推",SSE 可以帮你省掉心跳和重连的底层工作。等需求里出现"客户端需要向服务端发信令"的时候,再切 WebSocket 不迟。
1.2 业务场景对选型的决定性影响
选型不能只看技术指标,还得看业务形态。我们的推送系统一开始有两类典型场景:
第一类是 IM 私信。消息由某个用户发起,目标用户要立刻收到,还需要送达回执。这类场景天然需要双向通道——发送端需要知道"消息已到达",接收端需要回 ACK。
第二类是系统通知。比如订单状态变更、优惠券到账,这类消息由服务端触发,用户只是被动接收,但量非常大,可能一次活动就要推送几十万条。
这两类场景混在一起,决定了我的系统要同时具备"长连接管理"和"离线消息容错"两种能力。前者靠 WebSocket 网关,后者靠消息存储和拉取接口配合。
还有一点容易被忽略:客户端运行的网络环境。移动端在 Wi-Fi 和蜂窝网络之间切换、App 被系统杀进程、弱网丢包严重……这些都会导致连接频繁断开。所以推送系统真正的难点不在于"建立连接推一条消息",而在于"连接断了之后怎么把消息补上"。这个我放在后面第 3 章重点讲。
1.3 最终架构与接入层设计
最终我的架构分为四层:
生产者(业务服务) → 推送中间件(消息队列 + 路由) → 接入网关(WebSocket 长连接集群) → 客户端(App / 浏览器)接入层我单独抽出了一个网关服务,没有让业务服务直接跟客户端通信。这样做的理由很实际:
- 业务服务不该关心客户端在线状态,这是网关的职责
- 多业务共用一套推送通道,避免每个服务都维护一个长连接体系
- 网关水平扩展时可以独立扩容,不影响业务服务
接入层的核心是"连接注册与路由"。每个客户端连接建立后,网关会把连接信息(连接 ID、用户 ID、网关节点地址)写入一个全局路由表。推送时先查路由表,找到用户落在哪个网关上,再把消息转发过去。这里我用的是 Redis 存储路由信息,Key 是用户 ID,Value 是网关节点和连接 ID,TTL 设为心跳周期的三倍,防止路由数据过期残留。
这一层最需要注意的坑是:不要在网关内存里维护路由。单个网关只知道自己的连接,如果用户连到了 A 网关,推送系统却把消息发到了 B 网关,B 网关查不到连接就只能丢弃。集中式路由表虽然多了一次 Redis 访问,但换来的是推送路径的正确性。
2. 连接生命周期管理:心跳、重连与僵尸连接清理
很多人以为 WebSocket 连上就能一直用,实际上网络环境远比想象中脆弱:Nginx 超时断开、手机锁屏后网络链路静默、代理服务器回收空闲连接……随便一个都能把连接"偷偷"断掉,而服务端和客户端都还蒙在鼓里,以为连接是好的。
2.1 心跳机制的具体参数设计
心跳是解决"假死连接"的唯一办法。所谓假死,就是 TCP 连接本身还挂着,但数据链路已经不可用。心跳的原理是客户端定时发一个 ping 帧,服务端收到后回 pong 帧,以此证明链路仍然活着。
我的参数设计是这样的:
- 客户端每 30 秒发送一个应用层心跳包(不是 WebSocket 协议层的 ping 帧,而是自定义的 JSON 心跳消息)
- 服务端连续 3 次(90 秒)未收到心跳,判定连接死掉,主动断开并清理路由
- 服务端自己也发心跳,间隔也是 30 秒,客户端如果 90 秒没收到服务端心跳,主动发起重连
为什么不用协议层的 ping/pong?因为 WebSocket 的 ping/pong 帧在部分代理层是透明的,中间可能被吞掉,而应用层心跳是业务数据,穿透性更好,还能顺便携带客户端时间戳做延迟统计。
注意:心跳间隔不是越短越好。我见过有人设 5 秒心跳,连接是保住了,但 10 万在线每秒就有 2 万个心跳包,网关压力白白增加。30 秒是平衡实时性和开销之后的经验值。
2.2 僵尸连接怎么拖垮了我们的网关
这个坑我印象特别深。系统刚上线那阵子,网关的连接数一路走高,从 5 万涨到 9 万,但在线用户数其实只有 4 万多。按说连接数应该跟在线数接近,多出来的近 5 万连接是哪里来的?
查了一圈发现,大量连接是客户端已经杀掉 App 却没能正常发送关闭帧留下的残骸。TCP 连接本身不会因为进程消失而立刻消失,如果客户端异常退出,服务端只能靠超时判断。而当时我们没有兜底清理机制,僵尸连接越积越多,最终把网关的文件描述符耗光了。
解决方式是双管齐下:
第一,在服务端加了一个"空转检测"后台任务。每隔 5 分钟扫描所有连接,凡是超过 90 秒没有任何数据收发的连接,直接强关。这个逻辑独立于心跳判断,专门对付那些心跳超时判不出来、但确实已经没数据的连接。
第二,引入优雅关闭。业务服务主动断开连接前,发送一个关闭帧,客户端收到后在 5 秒内完成资源清理并回复,服务端收到回复才真正释放连接。这样正常退出的连接不会变成僵尸。
2.3 重连退避策略与全局重连风暴防护
重连这件事,看起来简单——断了就重连。但如果所有客户端都在同一时刻重连,那就是事故。
一个典型的场景:网关发布重启,1 万个连接同时断开,1 万个客户端同时发起重连请求。网关刚启动还没完全就绪,又遇到 1 万并发握手,直接就挂了;挂了之后另一批客户端也超时重连,雪崩。
我的防护策略有两层:
第一层是客户端退避。重连不再是无脑立即重试,而是采用"退避 + 抖动"的算法:第一次 1 秒,第二次 2 秒,第三次 4 秒……封顶 60 秒,每次重连前再加一个 0 到 1000 毫秒的随机抖动。这样即使是同一时间断开的连接,也会在 1 到 60 秒的区间内均匀散开,避免集中冲击。
第二层是服务端过载保护。网关启动后的前几秒,如果连接请求量超过预设阈值,直接返回"服务暂不可用",客户端收到这个响应会自动加大退避时间。这相当于给重连风暴加了一个安全阀。
3. 消息可靠送达:ACK、超时重试与离线补偿
推送系统最核心的口碑指标就一个字:准。消息要么不推,推了就一定要让用户看到。但网络和客户端的不可控因素实在太多,"推出去"和"送到"完全是两回事。
3.1 为什么"发出去"不等于"送到"
有三种情况会让"发出去"变成一场空:
- 连接假死。服务端以为连接还在,实际上消息已经发不出去了,数据卡在 TCP 缓冲区里直到超时。
- 客户端崩溃。消息推到了客户端,但 App 在处理之前被系统杀掉了。
- 离线场景。用户根本没连上,消息根本无处可推。
所以可靠送达必须建立在"客户端确认"之上,而不是"服务端发送"之上。我设计了一套基于 ACK 的确认链路,核心数据模型长这样:
{ "mid": "msg_20240418_001", "from": "system", "to": "user_1024", "type": "order_update", "payload": {}, "timestamp": 1713427200000 }每条消息都有一个全局唯一的mid,客户端收到消息后,需要回一个确认帧:
{ "cmd": "ack", "mid": "msg_20240418_001" }服务端只有收到这个 ACK,才认为消息送达成功。
3.2 ACK 确认机制的设计
ACK 机制说起来简单,细节上有个特别重要的问题:等待 ACK 的窗口期设多长?
设太短,消息在网络里飞一下就超时了,服务端白白重推;设太长,真丢消息时用户感知延迟会很大。我的做法是设置 10 秒的超时窗口,超时后进入重试队列。重试采用指数退避:第一次 5 秒后、第二次 10 秒后、第三次 20 秒后……最多重试 3 次。三次都失败,就把消息标记为"待离线推送",触发第 3.3 节的补偿逻辑。
这里还有一个容易忽略的细节:ACK 不能只做"收到消息"的确认,还得分清"到达 ACK"和"已读 ACK"。到达 ACK 是客户端回执,证明客户端收到了;已读 ACK 是用户点开消息后上报的,证明用户看到了。两者混在一起会导致监控数据失真——比如客户端收到但用户没解锁手机,"到达"了却永远不"已读",如果业务方用已读率衡量推送效果,数据就会出现严重偏差。
3.3 离线消息拉取与版本号方案
用户重连之后,服务端如何知道要补哪些消息?我用了"版本号 + 拉取"的思路,简单可靠:
- 每个用户维护一个消息版本号
version,每新增一条消息,版本号自增 1 - 客户端本地保存最后收到的版本号
last_version - 重连成功后,客户端主动调用一个补偿拉取接口,把
last_version传给服务端 - 服务端查出 (
last_version,current_version] 区间内的所有消息,一次性补推
这个方案有个很明显的优势:不依赖服务端为每个用户维护庞大的离线消息队列,消息存到 Redis 或数据库之后,按版本号范围查即可。缺点是如果某个用户离线时间很长,版本号区间会累积大量消息,拉取一次数据量很大。我最后加了个限制:超过 100 条且 7 天前的消息,合并为一条"你有 N 条未读消息"的摘要推送,避免客户端拉起时被消息洪流淹没。
经验提示:版本号方案要特别注意并发问题。用户同时登录多台设备(手机 + 平板 + 网页),每台设备都有自己的
last_version,如果客户端重复推送版本号,会出现丢消息或重复消息。我的做法是为每个设备分配独立的device_id,版本号按(user_id, device_id)维度记录,互不干扰。
4. 消息幂等与去重:重复推送问题的根因与解法
做个推送系统,没人想被用户骂"怎么又推一遍"。但重复推送这个问题,几乎每一个做推送的团队都会踩进去。
4.1 网络重试导致的消费端重复
重复推送的根因,往往不在业务侧,而在传输链路本身。举一个真实场景:
服务端把消息推给网关,网关把消息写入发送队列,但网关还没来得及真正发出去,客户端网络闪断了一下,发送失败。服务端检测到超时,进入重试流程,重新推了一遍。此时如果客户端已经收到了第一条消息,只是 ACK 没来得及返回,那两条一模一样的消息就会同时出现在用户面前。
这个问题的本质是:在不可靠网络上,"重试"会引入重复,"不重试"会引入丢失。两者不可兼得,只能通过去重来兼顾。
4.2 基于消息 ID 的幂等方案
客户端侧做去重是我第一道防线。每条消息都有一个全局唯一的mid,客户端收到消息后先查本地去重表(或者 Redis),如果mid已经存在,直接丢弃,不再通知用户。
服务端也要做一道幂等校验:重试推送前,先查一下该消息的目标连接是否已经收到过 ACK。如果收到了,说明不需要重推,直接标记成功;如果没收到,先插入一条"推送中"的记录,避免多个重试任务并发重复推送同一条消息。这道校验在 Redis 里实现,SET key = mid value = pushing NX EX = 10,只有抢到锁的那一个重试任务才真正执行推送。
命中重复时,我记了一套日志,用来统计重复率和网络稳定性。如果某段时间重复率异常升高,大概率是客户端网络波动加剧,或者是网关超时参数配得太激进。
4.3 业务层的最终一致性兜底
即使传输层做了去重,业务层有时还是会出现重复。比如用户购买商品后同时收到"支付成功"和"订单更新"两条通知,内容看起来很像但mid不同,用户依然会觉得烦。这类问题的解法不在推送系统,而在业务系统的消息收敛策略。
我给出的建议是:在同一业务事件链路上,尽量合并通知。支付成功、订单状态变更、物流更新,如果三个事件在 5 分钟内都发生,推送系统只触发一次聚合通知,内容包含所有状态变化。这个策略我是在一次真实反馈后加的——连续 3 条相似推送让用户直接退订了通知,后来才意识到推送频控和内容聚合也是体验的关键一环。
5. 性能优化与压测:从单机千级到万级长连接
推送系统跟普通 HTTP 服务有个显著区别:长连接占用的资源模式完全不同。普通请求来了就处理、处理完就释放,长连接则是一旦建立就长期占用一个文件描述符和一小块内存。所以推送网关的性能瓶颈往往不在 CPU,而在文件描述符数量和连接管理的效率上。
5.1 网关层优化要点
首先调整系统级参数:
# /etc/sysctl.conf net.ipv4.ip_local_port_range = 1024 65535 net.ipv4.tcp_fin_timeout = 30 net.ipv4.tcp_tw_reuse = 1 fs.file-max = 1000000然后是 Nginx 作为 TLS 终结层时的配置:
worker_processes auto; worker_rlimit_nofile 2000000; events { worker_connections 65535; use epoll; multi_accept on; } http { upstream ws_gateway { server 10.0.0.1:8080; server 10.0.0.2:8080; keepalive 1024; } map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 443 ssl; http2 on; location /ws { proxy_pass http://ws_gateway; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } } }注意一个关键参数:
proxy_read_timeout。Nginx 默认 60 秒没有数据就会断开代理连接,这跟我的心跳策略直接冲突。我把超时调到 3600 秒,让心跳来负责真正的存活检测,Nginx 只做通道转发,不做业务判断。
网关服务本身我用的是 Netty,核心线程池和业务线程池分离。Netty 的 boss 线程只负责 accept 新连接,worker 线程负责 IO 读写,业务处理(比如消息路由、ACK 处理)丢给独立的业务线程池执行。这样即使业务线程池被某个慢操作阻塞,IO 线程也不会被拖住,连接不至于因为业务阻塞而全部超时。
5.2 推送链路的异步化改造
初期我的推送链路是同步的:业务服务直接调网关接口,网关同步拿到连接再同步推送,最后同步返回结果。上线后发现一个明显的问题:当消息量大时,同步链路里任何一环变慢,上游生产者都会被拖住,数据库连接、线程池很快被打满。
改造后我引入了消息队列作为中间缓冲。业务服务把推送任务丢进 RabbitMQ,推送消费者从队列里拉取任务,再执行路由和下发。异步化之后,业务服务和推送网关彻底解耦,推送洪峰被队列吸收,宁可推送延迟几秒钟,也不能让业务服务被推送任务拖死。
这里有个数据处理逻辑要注意:消费者拉取到任务后要批量处理。单条消息单次发送的效率非常低,我改成按用户 ID 聚合,同一个用户的连续任务合并成一条批量推送命令,减少网络往返。实测这个改动让网关的吞吐提升了将近一半。
5.3 压测数据与参数调优
压测我用的是开源的压测工具改造后用,模拟了 20 万客户端连接,重点看三个指标:连接成功率、消息推送延迟 P99、网关 CPU 和内存。
实测数据如下:
| 并发连接数 | 推送速率(条/秒) | P99 延迟 | 网关 CPU | 网关内存 |
|---|---|---|---|---|
| 2 万 | 5000 | 23ms | 35% | 1.2GB |
| 5 万 | 10000 | 41ms | 52% | 2.8GB |
| 10 万 | 10000 | 66ms | 70% | 5.5GB |
| 20 万 | 10000 | 132ms | 85% | 11GB |
20 万连接时 P99 明显升高,排查后发现是 GC 压力上来了——每一条消息都要在内存里创建多个临时对象,连接数一多,Young GC 频率急剧上升。优化策略是:复用发送缓冲区,把频繁创建销毁的字节数组对象改为 ThreadLocal 缓存;另外把连接对象里不常用的字段(比如握手时的一些临时信息)延迟初始化,减少内存占用。
压测还发现了一个很有趣的问题:连接数超过 10 万后,即使消息量不变,网关的 CPU 也会缓慢上涨。分析定位到是心跳扫描线程的算法效率问题。最初心跳检测用 ConcurrentHashMap 遍历所有连接,复杂度是 O(n),连接数越多扫描越慢。后来改成时间轮(HashedWheelTimer)管理心跳超时,扫描复杂度降为 O(1),CPU 曲线立刻平了。
6. 可观测性建设:推送系统到底该怎么监控
推送系统最怕什么?怕"用户说没收到,但所有指标都正常"。可观测性如果只做到"服务没有挂",是远远不够的。我上线后逐步积累了下面这一套监控体系,基本能做到问题早于用户投诉被发现。
6.1 关键指标清单
我分四个维度定义监控指标:
| 维度 | 指标 | 告警阈值 |
|---|---|---|
| 连接层 | 活跃连接数、连接成功率、重连率 | 连接数突降超过 20% |
| 传输层 | 推送 P99 延迟、ACK 成功率、超时重试次数 | P99 超过 200ms |
| 消费层 | 队列积压数、消费速率 | 积压超过 1 万条持续 5 分钟 |
| 业务层 | 到达率、已读率、退订率 | 到达率低于 95% |
其中 ACK 成功率是我最看重的指标。它衡量的是"推出去的消息中有多少被客户端确认收到"。如果发现 ACK 成功率下降,第一时间排查的应该是客户端网络状态分布,而不是服务端——大概率是弱网用户比例升高,或者某类手机系统把 App 后台网络给掐了。
6.2 一次线上抖动排查全程复盘
有一次线上告警:推送延迟 P99 从 40ms 飙到 800ms,用户侧反馈消息延迟明显。当时我按这个顺序排查:
- 先看网关自身指标——CPU、GC、线程池活跃度,都正常
- 再看消息队列积压——积压数量并不高,说明生产者侧没有堆积
- 看 Redis 慢查询——发现大量 GET 命令的响应时间超过 100ms
- 进一步查 Redis 信息——内存碎片率高得异常,原来是路由表的 Key 设置了 3 天 TTL,但用户量大,大量过期 Key 没有及时清理,内存碎片积累导致 Redis 性能劣化
修复方案:把路由表 TTL 改为心跳周期的 3 倍(即 270 秒),并开启 Redis 的activedefrag yes。调整后延迟降回正常水平。这个问题的教训是:凡是跨组件依赖,都要时刻注意对方的资源生命周期是否跟自己的业务模型匹配。一个为短期会话设计的 Key 给了 3 天 TTL,就注定会积累垃圾数据。
6.3 给后来者的实用建议
最后分享几条我在这个项目里用真金白银换来的经验:
第一,一定要做连接数基线管理。给每个网关节点的最大连接数设上限,超过上限拒绝新连接并返回"稍后重试",而不是无底线地硬扛。无底线扩连接,最后一定是内存耗竭、整节点宕机。
第二,消息格式设计要考虑扩展性。我一开始用 JSON 作为消息传输格式,后来发现高吞吐场景下 JSON 序列化开销太大。如果量级很大,可以考虑改用 Protocol Buffers 之类的二进制格式,业务字段加几个不用改协议。
第三,不要把推送系统的成功标准定义为"服务不出错",而应该定义为"用户真的收到了他想收的消息"。技术指标再漂亮,用户没感知到,都等于零。
我在这套系统上踩过的坑,十个手指头数不过来。从选型时的纠结,到上线前压测的各种调优,再到线上真实事故的排查复盘,每走一步都对"实时推送"这四个字理解得更深一些。如果你也在搭建自己的推送系统,希望这篇记录能帮你少走几段弯路,特别是那些连接生命周期管理、ACK 确认链路的细节,真的值得在刚开始设计架构时就认真考虑进去。