1. 从需求到架构:实时消息推送到底在解决什么问题
做后端这几年,我接手过不少系统,但“实时消息推送”这个需求几乎每个项目都绕不开:APP里来了新通知要立刻弹出来,Web后台有新的工单要马上提醒运营,或者一个协同编辑的文档被同事改动后需要同步到所有打开页面的人。表面上看都是“把消息发给客户端”,但往深了想,真正的痛点有几个:一是服务端怎么主动找到在线的客户端,二是网络环境千奇百怪(NAT、弱网、移动网络切Wi-Fi),长连接怎么保活,三是消息发到一半客户端掉线了,消息怎么补发,四是用户量上来之后一台机器扛不住,怎么水平扩展。
这篇文章不是给你贴一份某个开源框架的README,而是把我自己从零搭一套实时推送系统的完整思路、技术选型、关键代码和踩坑记录整理出来。适合正在设计IM、通知中心、工单提醒这类需要服务端主动推数据给浏览器的后端同学,也适合想搞清楚WebSocket和轮询到底怎么选的产品或前端同学。你会看到实际生产环境下,光有WebSocket是不够的,还得配合心跳机制、消息队列、离线消息存储和集群路由,才能让推送既快又稳。
先说我遇到的一个具体场景:公司内部有一个运营后台,运营人员需要实时看到用户提交的反馈工单。之前的方案是前端每5秒轮询一次接口拉最新数据,但用户量大了之后,数据库压力越来越大,而且运营反馈“明明看到有人提交了,页面就是不刷新,非要手动刷新才会出来”。说白了,轮询这种方式拿到永远不是“真实时”,只是“看起来像实时的某个延迟版本”。从那时起我开始认真研究长连接推送的架构,后来陆续在几个项目里实践,今天就拿一套相对完整的方案来拆解。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型对比:为什么最后选了WebSocket而不是轮询或SSE
选技术方案不能拍脑袋,得对着需求列条件。实时推送类需求最常见的候选方案有这么几类:短轮询、长轮询、SSE(Server-Sent Events,服务器推送事件)、WebSocket,还有物联网场景常见的MQTT。每个方案都有自己适用的范围,下面这张表是我自己整理的心得。
| 方案 | 通信方向 | 实时性 | 连接开销 | 适用场景 |
|---|---|---|---|---|
| 短轮询 | 客户端主动拉取 | 取决于轮询间隔 | 每次请求都有HTTP头,开销大 | 数据变化不频繁,实时性要求低的场景 |
| 长轮询 | 客户端请求挂起,服务端有数据才返回 | 接近实时 | 每次连接仍有HTTP开销,但请求次数减少 | 老项目改造,WebSocket暂时接不进来的场景 |
| SSE | 服务端单向推送 | 实时 | 单条长连接,轻量 | 只需要服务端推给客户端,比如通知、行情 |
| WebSocket | 全双工双向通信 | 实时 | 一次HTTP握手后升级为长连接,后续走帧 | 需要双向交互的场景,IM、协同编辑、实时推送 |
| MQTT | 基于发布订阅,多端同步 | 实时 | 协议更精简,适合弱网 | IoT设备、移动端弱网环境 |
我最终选WebSocket,核心原因有两个。一是我们的业务不仅需要服务端推给客户端,客户端偶尔也要主动上报一些状态,比如“我已读这条消息”“我在输入框准备回复”,双向通信用WebSocket天然合适。二是WebSocket在前端的兼容性已经非常成熟,浏览器、微信内置WebView、各类小程序容器基本都支持,不需要额外引入复杂的SDK。
但如果你只做单向的“服务端通知客户端”,比如股票价格刷新、后台任务进度条,SSE其实是个更轻的选择。它复用HTTP协议,断线重传机制由浏览器事件自动帮忙处理,实现成本非常低。我记得有个项目只需要把AI模型训练进度推给前端,我就用SSE写了个事件流接口,几十行代码搞定,根本不用上WebSocket。所以选型的第一步永远是“你到底需不需要双向通信”,双向就WebSocket,单向但要求实时就SSE,单向且延迟可接受就轮询。不要因为WebSocket听起来高级就把所有业务都往里面塞。
另外说一句MQTT,它是基于TCP/IP协议栈之上的轻量消息协议,非常节省流量和电量,但需要额外起Broker(比如EMQX、Mosquitto),对后端团队来说多了一套基础设施要维护。如果不是IoT类设备接入,只做Web端/APP端推送,直接用WebSocket就够了,别为了“统一”硬上MQTT。
3. 长连接链路拆解:一次完整的推送是如何从服务端到浏览器渲染的
选定WebSocket之后,下一个问题是整个链路里都有哪些角色。我先画一条线:浏览器或APP客户端先建立WebSocket连接,经过负载均衡器(Nginx/网关),打到后端的连接网关服务集群,连接网关把客户端连接信息和用户身份绑定注册到路由中心(这里一般用Redis),推送服务产生消息后,查询路由中心拿到该用户对应的连接网关地址,将消息投递给连接网关,连接网关再通过WebSocket连接把消息帧发给客户端。这条链路里每个环节都可能出问题,我一个个拆开讲。
3.1 连接网关的核心职责:不只是转发帧
连接网关是整个推送系统的门面,所有客户端的长连接都挂在它身上。它的职责比想象中多:接收连接请求、完成鉴权、维护连接状态、转发下行消息、解析上行消息、处理心跳、在连接断开时清理路由信息。我用Go写了一个轻量的连接网关来承载这个角色,核心代码结构大概是这样的:
go复制type WsServer struct {
// 连接ID -> *Connection
conns sync.Map
// 用户ID -> 连接ID列表,一个用户可能多个端登录
userConns map[string]map[string]struct{}
mu sync.RWMutex
}
type Connection struct {
UserID string
ConnID string
Device string
Conn *websocket.Conn
SendCh chan []byte
LastHeartbeat time.Time
}
每个连接内部维护一个SendCh发送通道,推送的消息不是直接写WebSocket,而是先丢进这个通道,由每个连接独立的goroutine负责从通道里取数据再写帧。这是Go很经典的并发模型,好处是避免了多个goroutine同时写同一个TCP连接导致的写冲突。消息写入通过通道串行化之后,连接的安全性就有了基本保障。
3.2 鉴权与握手:别让陌生人连上来
WebSocket握手说到底还是一个HTTP Upgrade请求,所以在握手之前完全有条件做鉴权。我的做法是客户端先通过HTTP接口换取一个短期有效的token,建立WebSocket连接时在请求头(或者Sec-WebSocket-Protocol字段)里带上这个token,网关收到握手请求后先校验token,再决定升级还是拒绝。
这里有一个容易被忽略的坑:很多浏览器端的WebSocket API不支持自定义Header,只能在URL上带参数或者用子协议字段带。我在一个管理后台项目里就是这么被卡了一下,前端同学说new WebSocket(url)根本没法加Header,最后改用URL query参数传token,网关在握手路由里把query里的token取出来校验。URL上带token会有日志泄露风险,所以token有效期必须短,最长10分钟,过期就让客户端重新走HTTP接口换新的。
3.3 心跳机制:为什么说TCP长连接并不可靠
长连接最大的敌人不是“连不上”,而是“连着一个已经死掉的连接”而不自知。比如手机App退到后台,系统把网络断开了,但因为TCP的优雅关闭流程没走完,服务端那条连接其实还挂着,如果服务端不主动检测,这条连接就变成了一个僵尸连接,占着内存不干活,等用户量大了之后,吃掉的资源相当可观。
解决僵尸连接的通用办法就是心跳包。客户端每隔一段时间(我一般设30秒)发送一个ping帧,服务端在收到ping之后回pong帧,同时更新LastHeartbeat时间戳。服务端另起一个定时任务,每10秒扫描一次所有连接,如果发现某个连接的LastHeartbeat已经超过90秒没有更新,就主动关闭这个连接,并在路由中心里把该用户的注册信息删掉。
有人可能会问,WebSocket协议本身有ping/pong控制帧,为什么不直接用?事实上WebSocket的ping/pong是协议层面的,很多浏览器实现不给应用层暴露钩子,实际项目中仍然需要应用层自己定义一套JSON格式的心跳消息,比如{"type":"ping"}和{"type":"pong"}。两种心跳可以同时存在,协议层的ping/pong保证TCP链路探测,应用层的ping/pong用来携带一些客户端状态。
3.4 断线重连:客户端要做的最后一层防守
服务端做得好,不代表网络不会抖。移动网络切Wi-Fi、进电梯过隧道、弱网丢包,都会导致WebSocket连接悄然断开,而且这个断开不是马上能被感知的。客户端的断线重连策略设计得是否健壮,直接决定了用户的体感。
我给前端定的重连策略是:检测到连接关闭后,延迟1秒重连,如果连续失败,延迟时间指数退避,1秒、2秒、4秒、8秒,最多到60秒封顶。同时加一个随机化抖动,避免大量客户端在同一时间点集中重连,把服务端打挂。重连成功后,客户端要上报最后一条消息的ID,服务端据此补发离线期间的消息,这一块在下一部分展开讲。
4. 消息可靠性与时序:在线推送、离线补偿与幂等消费
实时推送系统最容易被吐槽的就是“消息丢了”。如果用户在线,消息通过WebSocket直接推送,逻辑很简单。但如果用户当时不在线,或者在线但连接刚好断了,这条消息怎么补?这就需要一套完整的可靠性设计。我把消息生命周期分成三个阶段来管理。
4.1 阶段一:消息落地优先,推送其次
很多初做推送的人会先把消息推出去,再存数据库。这个顺序是错的。正确的顺序是:先把消息持久化,再执行推送。为什么?因为推送过程可能失败、客户端可能不在线,如果先推后存,推失败的时候消息就真的丢了。反过来,先把消息存进数据库(或消息表/堆表),推送只是“把存储里的消息同步给客户端”,天然可重试。就算推送失败,客户端下次上线还能把消息捞回去。
我用的存储表非常简单,核心字段大概是:
sql复制CREATE TABLE push_message (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_type VARCHAR(64) NOT NULL COMMENT '业务类型:notice/chat/task',
biz_id VARCHAR(64) NOT NULL COMMENT '业务侧消息ID,用于幂等',
user_id BIGINT NOT NULL,
payload JSON NOT NULL,
status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待推送 1-已推送 2-已读',
created_at DATETIME NOT NULL
);
biz_id这个字段非常关键,它是业务侧生成的唯一消息ID,比如工单系统里的工单变更记录ID、IM系统里消息自身的全局ID。推送服务消费业务消息的时候,用biz_id做唯一性校验,如果库里已经存在相同biz_id,直接跳过,这就保证了“业务方一次重试不会产生两条重复推送”。
4.2 阶段二:在线推送的确认与失败回滚
消息落地之后,推送服务拿着消息里的user_id去Redis里查路由信息,如果Redis里存在该用户的路由记录(说明用户在线),就把消息投递给对应的连接网关。连接网关收到消息后,写入客户端的SendCh通道,由连接goroutine真正写WebSocket帧。这里我会让网关在写成功之后返回一个ack给推送服务,推送服务收到ack才把消息状态更新为“已推送”。
如果投递失败,比如网关返回“连接不存在”,说明用户已经掉线了,推送服务就把消息状态保持为“待推送”,等用户下次上线后走离线补偿流程。这里要注意一个并发时序问题:推送服务查到Redis里用户在线,但消息投递到网关的时候连接刚好断了,网关返回失败。这种极端情况怎么办?我的对策是网关在清理连接的时候,把该连接上还未来得及发送的消息重新还给推送服务,让推送服务重新处理,而不是直接丢弃。实现上可以给每条消息加一个retry_count,超过3次重试还没成功就标记为“待补偿”。
4.3 阶段三:离线补偿与消息乱序处理
用户再次建立WebSocket连接成功之后,客户端会主动上报“我当前收到的最后一条消息ID”。推送服务收到这个上报后,查库里这个用户所有状态为“待推送”且ID大于上报ID的消息,批量拉出来推给客户端。这个方案叫“增量拉取”,逻辑简单且不容易漏消息。
时序问题在这个阶段最容易翻车:如果用户在线期间消息A、B、C连续到达,A先推成功了,B推送失败回滚,C又推成功了,那客户端收到的顺序是A、C,等下次补偿把B补过来的时候,客户端会看到A、C、B,乱序了。解决乱序的办法是客户端在渲染消息的时候不依赖网络到达顺序,而是依赖消息里的seq序号(或者业务方的时间戳+自增ID),在本地做一次按序排序再渲染。IM类系统更是需要在本地做一个消息缓冲区,等前面的空洞补齐之后再展示。我在项目里简单处理:给每条消息在业务系统里生成全局递增的seq,客户端收到消息后放进本地数组,等队列头部序号连续时才渲染到界面上。这样用户感知到的消息顺序始终是正确和连续的。
4.4 幂等消费:消息中间件场景下的同一消息不要推两遍
如果推送服务本身是消费消息队列(Kafka/RabbitMQ)来触发推送的,还有一个经典问题:消费者可能重复消费。Kafka在极端情况下会重复投递消息,消费者的逻辑必须做幂等。幂等的实现方式就是前面提到的biz_id在消息表里做唯一约束,重复消费的时候INSERT会报主键或唯一索引冲突,直接catch住跳过。如果用的是Redis做消息去重,可以用SET NX指令,把消息唯一ID塞进一个带过期时间的Set里,重复塞进去会失败,也达到了同样的效果。
5. 性能与集群扩展:一台机器撑不住之后怎么设计
推送系统早期用户量小,一台虚拟机把连接网关、Redis、MySQL全跑起来也没问题。但连接数过万之后,单机瓶颈很快就来了,到时候再想加机器,就得提前把集群扩展的架构基础打好。这个部分我分享的是自己实践过的三件套:路由中心、连接网关水平扩展、推送服务与网关的投递解耦。
5.1 路由中心:用户在线状态的“通讯录”
路由中心我选Redis,数据结构上用String就够了,key是user_id,value是网关节点标识(比如gateway-node-01)。为什么不用Set存多端?因为一个用户可能同时在手机和电脑上登录,需要区分设备,所以我的key设计是ws_route:{user_id}:{device_id},value是网关节点标识。用户在某个端上线,就写一条路由记录;某个端下线,就删掉对应记录;连接超过心跳阈值被服务端断开也要清掉这条记录。
查询在线状态时,EXISTS ws_route:{user_id}:{device_id}就能判断。如果业务方想知道用户是否“完全在线”(任意端在线),就把这类key全部扫一遍,但Redis的scan要去重,不要用keys。更简单的做法是额外维护一个ws_online:{user_id}的Set,每次上线往Set里加device_id,下线从Set里移除device_id,Set为空说明用户全部端都离线了。
5.2 连接网关的水平扩展:从单机到多节点
连接网关是状态化服务(维护着大量TCP连接),所以它没法像无状态服务那样随便扩缩容。客户端连接的时候,先经过Nginx的负载均衡,Nginx配置Upstream指向网关节点集群。这里有个问题:WebSocket连接是长连接,一旦连上之后Nginx会一直把这条连接保持到某个网关节点的固定TCP端口上,不会因为节点扩缩容而自动迁移。所以网关节点在重启或缩容的时候,必须做“优雅下线”:先把节点从Nginx配置里摘掉或通过健康检查标记为不可用,然后等待该节点上所有连接自然断开或主动通知客户端迁移到其他节点,最后才真正停止服务。
多节点之后还有一个问题:推送服务要投递消息给某个用户,怎么知道该用户挂在哪个节点上?答案就是查路由中心。推送服务从Redis拿到用户对应的网关节点标识,然后调用该节点对外暴露的HTTP API(或者通过内部消息队列发指令),把消息转给网关。我用的微服务内部HTTP调用,网关节点提供一个/push接口,接收消息体,内部根据user_id从自身的userConns找出对应连接,写入SendCh。如果网关发现本地没有该连接(路由记录过期了),就返回失败,推送服务重新查路由或者标记为离线补偿。
5.3 消息推送的高并发削峰:为什么中间要加一层消息队列
推送流量不一定是平稳的。比如运营人员批量给10万用户推送活动通知,如果推送服务直接同步遍历10万个用户,逐条调Redis、逐条HTTP投递给网关,这个瞬时压力非常大,Redis和网关都会被冲垮。我在这里加了一层Kafka:业务系统把要推送的消息体扔进Kafka的某个topic,推送服务作为消费者,以固定的消费速率来处理消息。消息队列天然做了流量削峰,推送服务即使被积压,最多就是延迟推送,不会打垮下游。
消费并发度要结合下游网关的能力来调。假设网关单节点每秒能稳处理5000条下行推送,HTTP投递接口的P99在10ms以内,那推送服务的消费者并发数就可以设置在5~10之间(每条消息串行投递),不要一味调高并发,否则下游扛不住反而把消费速度拖垮。实测下来,推送服务消费Kafka的并发数调到8,单条消息Redis查询+HTTP投递链路能稳定在20ms内完成,10万条消息大约1~2分钟全部推完,在可接受范围内。
5.4 测试环境验证集群链路
我每次搭完这套架构都会先做一次小规模的链路验证:起两个网关节点(一前一后),起一个Redis,起一个Kafka单节点,再写一个模拟客户端程序,同时建立5000个WebSocket连接均匀落在两个网关上。然后用一个测试生产者往Kafka里投2000条消息,观察推送服务消费速度、网关节点的下行推送速率、Redis连接数。跑完看三个指标:一是消息是否全部到达客户端且顺序正确;二是客户端断开100个连接后,Redis路由是否及时清理;三是如果客户端重连,能否通过上报最后一条消息ID把离线期间的消息补齐。这个流程跑通,系统才算基本可用。
6. 生产环境排障:线上推送丢消息的排查链路
最后这部分我想聊聊排障。推送系统一上线,最先遇到的往往不是功能问题,而是“消息时好时坏”这种玄学问题。我整理了几个实际踩过的坑,每一个背后都是一段痛苦的排查经历。
6.1 坑一:客户端明明在线,消息就是收不到
现象是用户那边状态显示在线,但推送的消息一直收不到。我先查推送服务日志,发现消息确实投递给了网关节点A,网关A也返回了“成功”。但用户反馈没收到。后来一查才发现,客户端连接的是网关节点B,而Redis里这条用户路由记录指向的是网关节点A。
根本原因是用户在断线重连的时候,路由记录没更新到新节点上。为什么没更新?因为旧连接断开时,网关A清理路由的定时任务还没执行(心跳超时周期是90秒),而客户端已经在10秒内重连到了网关B,写入了新路由。这时候Redis里既有旧路由又写了新路由,如果清理线程先把新路由删了,再把旧路由删了,最终就是没路由。而更隐蔽的是,如果客户端在短时间内重连了两次,第一次连到A,第二次连到B,A节点上残留的连接在心跳超时后,如果清理逻辑写得不严谨,可能把B节点刚写入的路由给误删了。
修法很明确:清理路由之前要判断“这条路由是否还指向自己”。Redis删除的时候用Lua脚本做一个原子性的比较删除,伪代码如下:
lua复制if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
这样只有路由标识还指向当前网关节点时才删除,从根上避免了误删其他节点的新路由。
6.2 坑二:心跳超时时间设得太短,频繁断连
一开始我心跳超时设的是30秒,客户端30秒发一次心跳,服务端90秒没收到就断开。但实测发现,在移动网络下,网络切换导致的暂时性丢包会让客户端明明还活着,只是中间有几秒数据没传过来,服务端就在90秒后把连接断了。客户端收到断开通知后又要重连,重连又触发一次鉴权、路由更新、离线消息补偿,一连串操作下来,用户体验就变得很糟糕。
后来我把超时时间放宽到120秒,客户端心跳间隔30秒不变。为什么30秒这个间隔合适?因为30秒内如果网络断了,TCP层通常会在更短时间内感知到,但服务端为了包容短暂的网络抖动,留出4倍冗余已经足够。心跳太频繁会白白消耗流量和电量,太稀疏又会让僵尸连接存活过久。权衡下来30秒心跳、120秒超时是一个相对平衡的参数。同时,客户端应该用“应用层心跳”配合“网络状态监听”:在浏览器里监听online/offline事件,在APP里监听网络切换广播,网络恢复时立即主动重连,而不是干等心跳超时。
6.3 坑三:消息补偿接口被高频重连打爆
前面说客户端每次重连成功后要上报最后一条消息ID,服务端负责补偿离线消息。但有一个下午,某个用户量较大的渠道整体网络波动,大量客户端同时断线重连,补偿接口瞬间收到上万条请求,直接打满了数据库连接池。原因是我没给补偿接口做限流和合并查询。
解决思路是加上缓存和合并:同样的user_id在短时间内的补偿请求合并成一次,补偿查询加一层Redis缓存(把用户最近100条消息缓存10秒)。更重要的是在推送服务这一侧加了一个分布式限流,按用户维度做每分钟查询次数限制,超过阈值直接返回错误码让客户端退避重试。经过这样调整之后,即使遇到大面积网络波动,也不会再拖垮数据库了。
6.4 附一张日常排查问题清单
| 现象 | 排查方向 | 常见根因 |
|---|---|---|
| 用户在线但收不到消息 | Redis路由是否指向当前连接节点;网关本地连接是否存在 | 路由未及时更新;旧连接误删新路由 |
| 客户端频繁断连重连 | 心跳间隔和超时配置;网络切换事件 | 超时时间过短;重连策略没有退避 |
| 消息推送延迟高 | 推送服务消费速度;Kafka积压量;网关HTTP投递耗时 | 消费并发过低;下游网关被压垮 |
| 重复消息 | 消息表biz_id唯一约束;Kafka手动提交offset | 消费者重复消费;ack确认机制不严谨 |
| 内存/goroutine持续上涨 | 连接是否泄漏;SendCh是否阻塞 | 僵尸连接未清理;通道未关闭导致goroutine无法退出 |
7. 一些不一定写进文档但很实用的小建议
推送系统做到后面,你会发现核心逻辑大家都差不多,拉开差距的反而是各种细节。我就把最近几年实践下来觉得最有用、但通常不会写进技术文档的几个点列出来,算是给后面接手这类系统的同学留一点私货。
第一,连接网关的内存模型最好预留一个监控画板。每个连接有独立的SendCh,如果消费者速度跟不上生产者速度,SendCh越积越大,内存慢慢就涨上去了。所以我在每个Connection上加了SendCh长度的监控,一旦超过阈值(比如1000条),就说明客户端消费能力跟不上,这时候应该丢弃非关键消息或者主动断开让客户端重连,而不是让网关无限制堆积消息把内存打爆。
第二,要区分“消息推送成功”和“消息已被用户看到”。推送成功只是服务端把帧写到了TCP连接,客户端拿到消息之后本地UI渲染失败、或者用户根本不在聊天页面没看到,推送方是不知道的。需要做“已读回执”的业务,客户端要在UI真正展示之后主动上报ack,服务端在数据库标记已读状态。这一步不做,后续的未读角标、消息撤回、多端同步都会出各种奇奇怪怪的bug。
第三,凌晨发版的时候最容易被忽略的就是长连接服务。普通的REST接口发版因为无状态,滚动发布很安全。但网关节点是全状态的,发版之前一定要做优雅下线,否则连接直接断掉,大量客户端会同时重连,形成重连风暴。我的建议是给网关增加一个“排空模式”的管理接口:运维在发布前先调用这个接口,网关停止接收新连接,同时通知已连接客户端去连其他节点,等所有连接排空后再kill进程。实测这个流程能把发版期间的客户端重连请求降低90%以上。
第四,如果团队预算有限,不想上Kafka这么重的组件,Redis的Pub/Sub可以做推送服务的削峰,但要清楚它的局限:Pub/Sub消息不持久化,消费者掉线期间的消息会直接丢。如果是通知类消息,丢了问题不大;如果是IM聊天消息,绝对不能丢,必须用带持久化的消息队列。我在一个预算紧张的项目里用的是Redis Stream替代Kafka,既有持久化又有消费组,单机容量也足够支撑中小规模推送,算是性价比很高的折中方案。
实时消息推送做到最后,本质上就是一个分布式系统里“状态管理和消息确认”的问题。连接网关管的是连接状态,路由中心管的是用户和连接的映射关系,消息表管的是消息的可靠存储,推送服务管的是怎么把消息从存储层可靠地送到连接层。四者协作顺畅,推送自然又快又稳。希望这篇基于我真实实践整理的内容能帮你少踩几个坑,也欢迎有类似经验的同学一起交流不同的解法。
