ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

实时消息推送系统设计:从WebSocket连接管理到消息必达架构

实时消息推送系统设计:从WebSocket连接管理到消息必达架构 做后端这些年真正让我觉得“学过的网络知识全还给老师了”的项目就是实时消息推送系统。乍一看不就是客户端起个定时器服务端写个接口吗等我把30万在线的推送系统扛到线上才发现自己一开始就搞反了一件事——在 TCP/IP 的世界里服务器永远不可能主动敲开客户端的门。所谓推送本质上是客户端先来敲我们的门把门留着不要关我们才能随时往屋里递东西。这篇文章想聊聊做实时消息推送系统时我反复跟团队强调的几个关键点协议为什么选 WebSocket 而不是轮询、连接生命周期怎么管、服务端架构怎么从单机扩展到多机、消息怎么做到尽量不丢以及那些不亲自踩一遍根本不会信的坑。如果你正准备从零搭一套推送服务或者线上推送总出问题又找不到根因这篇应该能帮你省掉不少弯路。1. 先认清一个“伪命题”服务器从没有主动连接过客户端很多第一次设计推送系统的人会陷入一个思维误区以为推送就是服务端找个办法主动发起请求到客户端。实际上在目前的网络模型下服务端不可能主动建立一条到客户端的 TCP 连接因为客户端通常在 NAT 后面、在防火墙后面、在移动基站后面没有公网可达的 IP 和端口。服务器唯一能做的事情是等待着一条已经被客户端建立好的连接然后在上面写数据。1.1 为什么说轮询不是推送最简单的方案是短轮询客户端每隔几秒调一次接口问“有没有新消息”。这个方案在数据量小、实时性要求不高的内部系统里完全够用但它有两个硬伤每次请求都要携带完整的 HTTP 头几十个连接还好几十万客户端按 5 秒一次的频率轮询网关和业务服务会被大量无效请求打满真正的业务请求反而排不上队。实时性受轮询间隔限制。间隔设 1 秒消息最多延迟 1 秒间隔设 10 秒用户明显能感知到“消息来得慢”。间隔继续缩小服务端就会被轮询流量压垮。后来很多人会用长轮询优化客户端发起请求后服务端先把这个请求挂着等有消息了再返回客户端收到结果后立刻发起下一次请求。长轮询解决了“无效请求太多”的问题也把延迟降到了秒级以内但它依然是在用 HTTP 协议硬撑连接断开重建的开销并不小。1.2 实时性需求要先分级别一上来就选型我见过不少项目一说到推送就直接上 WebSocket但实际业务可能就是“订单状态变了通知一下用户”对实时性要求没那么苛刻。做架构之前先把实时性需求分个级你会省掉很多不必要的复杂度。业务场景可接受的延迟客户端状态推荐方案后台管理系统通知秒级页面在线短轮询或 SSE订单状态、物流进度秒级App 前台SSE / WebSocketIM 聊天、弹幕、协作编辑毫秒级前台后台都在线WebSocket行情报价、实时监控大盘毫秒级且要求低开销前台在线WebSocket考虑增量推送账号下线、异地登录提醒秒级App 可能被杀厂商推送 长连接兜底我个人的判断标准很简单如果只是服务端单向给客户端发通知客户端不需要频繁回传数据SSE 足够因为它基于 HTTP天然支持自动重连部署也省心如果客户端和服务端需要频繁双向交互再考虑 WebSocket。至于离线消息要不要补推那是另一套逻辑后面单独讲。2. 四类方案横评轮询、长轮询、SSE、WebSocket既然要搭实时消息推送系统选对承载协议是第一步。市面上常听到的就这四类我把原理、优劣势和适用场景一次说清楚。2.1 短轮询和长轮询的实现逻辑短轮询不用多说就是普通的 HTTP 接口客户端用 setTimeout 或 while 循环不断调。实际项目中我见过有人为了“省事”直接用定时器每 3 秒请求一次后来接口 QPS 从 2000 涨到了 2 万服务端被迫加机器这就是典型的“用成本换简单”。长轮询可以看作短轮询的改良。客户端发请求后服务端把请求挂起设置一个超时时间比如 30 秒。这段时间内如果有新消息就立刻返回如果超时还没有消息也返回一个空结果客户端接到空结果后马上发起下一次请求。长轮询的好处是实时性提升到了“消息触发即返回”坏处是服务端必须维护大量挂起的请求线程或协程占用高代理层和网关还需要配合调整超时时间否则请求会在中间层被掐断。2.2 SSE 和 WebSocket 的本质区别SSEServer-Sent Events是 HTML5 标准里的服务端推送方案客户端通过 EventSource 接口建立一个 HTTP 连接服务端可以持续往这个连接里写数据。它的核心特点是单向只能服务端往客户端推客户端想传数据得另走接口。WebSocket 则是全双工客户端和服务端都可以随时发数据。从协议层看WebSocket 握手时通过 HTTP Upgrade 头切换到 WebSocket 协议之后数据帧开销极小只有 2 字节到 14 字节的头相比 HTTP 每次带一堆头的成本省了太多。我把四类方案放在一起对比过指标短轮询长轮询SSEWebSocket实时性取决于轮询间隔秒级毫秒级毫秒级方向单向请求/响应单向请求/响应服务端到客户端单向双向连接开销高中低最低自动重连无无内置需自行实现服务端复杂度最低中低高典型场景低频数据刷新兼容旧系统的过渡方案通知、消息流、日志IM、协同、实时互动我们最终选 WebSocket是因为业务里既有服务端推消息给客户端也有客户端频繁上报状态已读、在线状态、正在输入等双向通信能省掉一半的接口设计和一半的请求量。如果你只有单向推送需求SSE 会更轻不用自己实现重连、不用处理协议帧部署也更简单。2.3 选型时容易被忽略的“中间件兼容性”这里提醒一个容易被忽略的点选协议之前先确认你的网关、负载均衡器、云厂商的 LB 是否支持 Upgrade 头。WebSocket 依赖 HTTP 的 Connection: Upgrade 和 Upgrade: websocket 头有些老旧的反向代理默认会吞掉这两个头或者对长连接设了超时时间导致连接稳定不下来。我见过一个项目在测试环境一切正常上生产后连接 1 分钟就断一次最后查出来是生产环境走了云 LB而云 LB 的长连接超时被设成了 60 秒。3. WebSocket 没你想的那么简单握手、帧与连接生命周期选定 WebSocket 之后真正的工程挑战才开始。很多人以为 WebSocket 就是“客户端 new WebSocket(url)服务端用 Netty 接收一下就完事了”真上线后才发现握手校验、心跳保活、连接关闭状态机、消息帧大小限制任何一个环节没做到位都会变成线上事故。3.1 握手过程和后端必须做的校验WebSocket 握手本质是一次 HTTP GET 请求客户端会在请求头里带上Upgrade: websocketConnection: UpgradeSec-WebSocket-Key: 一段随机 Base64 编码的 16 字节字符串Sec-WebSocket-Version: 13服务端拿到 Sec-WebSocket-Key 后把它和固定的 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接做 SHA-1 哈希再 Base64 编码作为Sec-WebSocket-Accept返回。客户端校验这个值一致握手才算成功。这个过程中我踩过的坑有两个服务端只校验路径不校验Sec-WebSocket-Key的合法性结果埋点数据里混进了大量非 WebSocket 请求它们是扫描器发送的普通 HTTP GET直接把连接数打满。后来在握手逻辑里加了完整的 Key 校验和请求头校验这类垃圾流量才被挡掉。缺失 Origin 校验。WebSocket 没有同源策略限制任何网页都能发起连接如果你的服务端不校验 Origin 头别人写个恶意网页就能往你的服务端灌数据或建立大量连接。上线前务必确认服务端只接受来自你自己域名的握手请求。3.2 连接状态机与心跳保活WebSocket 连接有四个状态CONNECTING、OPEN、CLOSING、CLOSED。客户端和服务端各自维护状态任何一端异常断开另一端都要有办法感知、清理并重连。最典型的“假死现象”就是客户端网络切换、App 被系统挂起、NAT 超时释放了映射但客户端进程还认为连接是好的服务端也迟迟不知道这条连接已经失效。TCP 层虽然提供了 keepalive 机制但它默认要等很久Linux 默认 2 小时才探测一次对推送系统来说完全不够用。所以必须要做应用层心跳客户端每隔固定间隔比如 30 秒发一个 Ping 帧服务端收到后回 Pong 帧或者服务端主动发 Ping客户端回 Pong。如果连续几次没收到 Pong就判定连接死亡触发清理流程。关于心跳间隔我的经验是间隔设置过短会增加无谓的流量和 CPU间隔设置过长则无法及时发现死连接。按 30 秒一次心跳、连续 3 次未响应判死即 90 秒内能发现死连接这个参数对绝大多数移动网络环境是合适的。心跳包里顺便携带一个客户端时间戳服务端可以用来统计链路 RTT这个数据对排查“消息到了但用户感知慢”的问题很有用。3.3 半包粘包、消息分片和帧大小限制WebSocket 协议本身自带帧和消息的概念应用层拿到的是完整消息不需要像 TCP 那样自己处理粘包问题但如果你直接基于 TCP 裸写自定义协议粘包半包就必须自己解决。对于大多数用 Netty 的场景WebSocketServerProtocolHandler 已经把帧解码做掉了你只需要关心单条消息大小限制。默认 Netty 有 maxFramePayloadLength 参数不设置的话大消息会被直接拒收。如果业务需要传图片、大 JSON就必须按实际场景调大同时设置内存保护上限防止有人用超大帧打爆内存。消息分片。WebSocket 的 Frame 分片由协议层处理但某些客户端 SDK 对分片支持不好会直接断连。实践里我一般限制客户端单条消息不能超过 256KB需要传大内容走独立的文件上传通道推送里只传文件 ID 和元信息。4. 服务端架构从单机连接到水平扩展聊完连接层就到了系统设计的核心怎么把“一台服务器挂几万个连接”变成“几万台服务器挂几百万个连接”还能稳定。这里牵涉连接管理、消息路由和容量估算三件事。4.1 连接层的无状态设计很多实时通讯系统早期都写成“有状态服务”用户连上哪台机器消息就只往那台机器上发。这样做在小规模没问题一旦扩容缩容、机器宕机连接迁移和消息转发就会变成灾难。正确思路是把连接层做成无状态的网关网关只负责“持有连接、收发帧、保证心跳”不负责业务逻辑。用户和连接的映射关系放到外部存储Redis、内存网格等或者网关内存里由上层路由层统一调度。网关本身不保存业务状态这样一台机器挂掉了用户重连到其他机器即可业务消息不会丢。我采用的方案是两层的架构接入层部署若干推送网关节点每个节点维护自己的连接集合对外提供连接接入能力。网关节点通过一致性哈希或简单的用户 ID 取模对外暴露路由信息客户端先通过一个 HTTP 接口查询自己该连哪台网关然后再建立 WebSocket。路由层网关把用户连接信息同步到 Rediskey 是 user_idvalue 是网关节点地址。业务服务推送时先从 Redis 查到目标网关再调用网关的内部接口下发消息。这个设计的核心是连接信息和业务信息彻底解耦任何一台网关挂了只会影响一部分用户的短暂重连不会影响业务数据。4.2 消息路由的三种方式与取舍业务服务产生一条消息后怎么把它送到对应网关有几种常见做法我按实际使用中的优先级排列直接 RPC 调用网关接口。延迟最低逻辑最简单但要求业务服务知道网关的地址列表耦合度高。适合内部服务间调用转发消息量不大时首选。Redis Pub/Sub。业务服务往某个 channel 发消息所有网关订阅这个 channel各自判断消息目标连接是否在本地。优点是实现简单、网关无状态缺点是 Redis 单线程模型下吞吐受限且 Pub/Sub 不持久化网关重启会丢消息。消息队列Kafka/RocketMQ。业务服务把消息写入队列每个网关消费队列里的消息再过滤出目标连接在自己本地的用户。优点是可以削峰、回溯、持久化缺点是链路多了一段延迟会略有增加约 5~20ms 级别。三种方案在大型系统里常常会混合使用内部高频消息直接 RPC跨机房走 MQ广播类消息走 MQ 加批量下发。如果项目开始阶段消息量不大直接用 RPC 调用网关接口最省事别为了架构而架构。4.3 容量估算内存、带宽和连接数关系先说内存。一个 WebSocket 连接在网关进程里除了 TCP 层缓冲区还有协议对象、会话对象、读缓冲、写队列。实测用 Netty 管理一个空闲连接内存开销大约在 2KB 到 5KB 之间具体取决于 JVM 堆设置和直接内存配置。单机规划 10 万连接预留 1GB 到 2GB 内存给连接管理是比较稳妥的。如果客户端每秒上报一次心跳以外的业务数据内存还要再往上加。再说带宽。心跳包很小设 50 字节30 秒一次单连接的带宽消耗约 1.7 字节/秒100 万连接大约 1.7MB/s这个量级还好。但推送消息本身才是大头假设 1 万条在线连接同时收到一条 2KB 的业务消息网关需要复用这条消息内容往每个连接写一次内存里要生成 1 万份副本框架层可以做零拷贝优化带宽消耗是 20MB。如果消息是广播类型那这个放大会更恐怖。我习惯在网关上线前算两个数单条消息平均大小、单连接每秒消息数。按这两个数去估算单机瓶颈是带宽还是 CPU再决定是否需要引入“批量推送合并”机制。所谓批量推送合并就是同一时间窗口内发给同一用户的若干条消息合并成一条推送下发能显著减少小包数量移动网络下尤其有效。5. 消息可靠性设计ACK、重试、去重在线推送和消息业务是两件事做了两年推送系统我最大的感受是把“在线推送”和“消息业务”混为一谈是小规模系统最常见的隐患。在线推送只管“现在是不是有一条连接能把消息送出去”而消息业务要求的是“这条消息用户最终一定看得到”两者差了十万八千里。5.1 一场推送会死在哪些环节一条实时消息从业务服务到用户眼睛中间至少经历业务服务到网关、网关到客户端网络链路、客户端进程到 UI 层、用户实际阅读。任何一环出问题消息就“看似推了但没到”。实际生产中推送丢失的常见原因包括客户端在后台被系统挂起网络连接还在但进程已休眠消息到了但 UI 没刷新。移动网络切换导致 TCP 连接默默断掉客户端没及时重连消息发到了死连接上。网关重启或发布连接被强制关闭客户端还没重连上来。服务端发送消息后认为发送成功但客户端可能断网丢包。5.2 必达机制ACK、超时重推、离线补偿让用户真正收到消息我的基本盘是三层保障第一层在线推送。网关把消息写到客户端客户端收到后回一个应用层 ACKACK 里带消息 ID。网关维护一个待确认队列消息发出后启动定时器比如 5 秒内没收到 ACK就判定送达失败。第二层重试策略。收到 ACK 之前网关按退避时间重推通常 1 分钟、5 分钟、30 分钟各重试一次最多三次。注意重推的消息不能改变消息 ID客户端靠 ID 去重。第三层离线补偿。如果重试三次都不成功说明用户已经不在线消息不能丢要落到“离线消息表”。用户下次建立连接时服务端把离线消息拉出来补发。离线消息表的设计我建议用这组字段字段说明msg_id消息唯一 ID客户端去重用user_id接收方payload消息内容create_time业务产生时间push_time最近一次推送时间ack_time客户端确认时间push_count已推送次数status待推送/推送中/已确认/终态推送网关定期扫描 status 待推送 且 create_time 在保留期内的记录按 user_id 聚合后续推。注意离线消息不要无限期保留一般系统设 7 天或 30 天超过保留期的大概率用户已经卸载保留只会拖累查询。5.3 去重和顺序问题去重一定要放在客户端做而不是服务端。原因很简单服务端重推时客户端可能刚好收到重复的推送客户端必须以 msg_id 为唯一键做幂等否则会出现重复弹窗这种低级事故。顺序问题比去重更麻烦。同一个发送方连续发多条消息如果走不同网关、不同线程到达顺序可能错乱。我的经验是单聊和群聊消息按会话维度路由保证同一会话的消息由同一个工作线程处理再给每条消息附一个单调递增的 seq客户端收到后发现 seq 跳号或乱序再触发补偿拉取。IM 类系统一般都要设计这套协议如果只是做业务通知推送顺序要求不高给 create_time 就能满足绝大多数场景。5.4 在线与离线之间还有一个“半在线”状态这里特别说一下移动端最常见的半在线状态App 还在前台但系统已经切了网络用户看着界面正常其实底层连接已经断了。如果推送系统只在“在线”和“离线”之间做判断很容易把消息发到一条半死连接上。处理办法是客户端要实时感知网络状态变化Wi-Fi 切 4G、4G 切 Wi-Fi、进出电梯隧道等场景都要主动触发重连并且重连成功后拉取一次离线增量消息。服务端不能只依赖心跳判活还要结合“最近一次消息 ACK 时间”判断连接的真实可用状态。我在监控里加了一个指标连接存在但 5 分钟内没有任何 ACK 的连接数这个数一旦异常上涨基本可以断定有一批半死连接没清干净。6. 上线后的踩坑记录三次故障和对应的排查链路推送系统上线半年内我碰到过的线上问题足够写满一本笔记。这里挑三个典型的把排查思路完整还原出来比直接给结论更有价值。6.1 连接数一直在涨在线率却上不去现象监控面板显示网关连接数从 5 万涨到 8 万但业务统计的日活用户没有涨推送送达率反而下降了。排查路径先看连接建立与断开的速率发现新建连接速率正常但断开速率异常低说明连接“只进不出”。再过了一遍网关日志发现大量连接长时间没有心跳包但也没触发断连。最后定位到客户端代码App 退到后台时Android 系统会冻结进程网络此时 TCP 连接并不会立刻发 FIN 包网关以为连接还活着等用户再切回前台系统恢复网络老连接已经失效客户端又重新建了一条新连接于是每切换一次前后台网关上的死连接就多一条越积越多。修复方案客户端在生命周期回调里主动发送 Close 帧并等系统回调确认后才允许进程挂起服务端同时加“空闲连接兜底回收”逻辑——超过 90 秒既没有收到心跳也没有业务帧的连接强制服务端主动关闭。两个措施一起上线后连接数回落到真实在线数的 1.1 倍左右。6.2 Nginx 默认配置把长连接断了现象客户端通过 Nginx 反向代理连接推送网关WebSocket 连接稳定运行大概 60 秒后必然断开断开后客户端重连又能好 60 秒。排查路径这个现象非常规律第一时间就怀疑到反向代理的超时配置。查了 Nginx 配置发现没有单独设置 HTTP 长连接超时参数默认 proxy_read_timeout 是 60 秒代理在 60 秒内没从上游读到数据就会断开连接。对于普通的 HTTP 请求这不影响但 WebSocket 连接建立后如果客户端 30 秒一次心跳心跳包走的是同一个连接理论上 60 秒内应该有数据流动后来发现客户端 SDK 的心跳间隔设成了 75 秒60 秒内没有任何数据包刚好被 Nginx 的默认超时掐断。修复方案客户端心跳间隔调整到 30 秒Nginx 关键配置加上 proxy_read_timeout 和 proxy_send_timeout 到 300 秒并且确保 proxy_set_header Upgrade、proxy_set_header Connection upgrade 都配置正确。这里要提示一下如果你的反向代理是云厂商 SLB也要去控制台检查长连接超时时间云产品和自建 Nginx 一个都不能漏。6.3 广播消息风暴导致服务端 GC 飙升现象某天运营做一次全量广播推送消息发出去后网关的 CPU 和内存同时飙升JVM 老年代出现连续 GC推送延迟从 200ms 涨到 5 秒用户端明显卡顿。排查路径广播推送的本质是“一条消息复制 N 份”100 万活跃连接就复制 100 万份。运营发的是一条带图片信息的富文本消息本身虽然不大但网关要把消息内容序列化后逐个写入每个连接写队列瞬间积压直接内存压力暴涨。再加上广播出口是所有连接同时写TCP 的拥塞控制导致大量小包堆积在发送缓冲区GC 频率进一步恶化。修复方案广播消息必须和点对点消息分流单独走低优先级线程池避免影响业务消息同时给广播消息加“分批发”策略按连接数分批每批 5000 个连接间隔 50ms拉平瞬时冲击。这之后广播功能再没出过问题而且用户感知的延迟其实差别不大因为广播本身允许秒级延迟。6.4 其他小坑清单SSL 握手超时。网关启用了 TLS但客户端握手的首包经常在代理层被丢弃设置 handshake timeout 为 10 秒以上可缓解。IPv6 兼容。部分移动网络只有 IPv6 地址网关没监听 IPv6用户连接失败率高上线前务必双栈监听。客户端时间戳偏差。做消息排序别用客户端时间要用服务端时间或服务端分配的序号否则用户的手机时间不对时消息顺序会乱。网关发布时的优雅下线。直接 kill 进程会让客户端全部同时重连造成重连风暴。发布流程里要先从路由层摘除节点再等待存量连接自然断开最后才停止进程。7. 监控和压测长连接系统怎么证明自己还活着HTTP 接口系统看 QPS、RT、成功率就差不多了长连接系统的指标体系完全不同。连接是持续性资源错误不是发生在一瞬间而是慢慢积累监控和压测的意义比普通业务系统更大。7.1 必盯的指标按重要性排在线连接数区分“已建连”和“活跃连接”“已建连”只是 TCP 层建立了“活跃连接”要定义成最近 90 秒内有任何业务帧或 ACK 的。真正该盯的是活跃连接数的走势。消息送达率发出的消息在超时时间内收到 ACK 的比例。这个指标低于 99% 就要开始排查。心跳响应率服务端发 Ping客户端回 Pong 的成功率。这个指标能提前暴露半死连接。推送耗时 P99从消息进入网关到客户端 ACK 的耗时包括网络 RTT 和客户端处理时间。消息积压数进入网关但还没发出的消息队列长度持续上涨说明消费速度跟不上生产速度。离线消息表扫描耗时离线用户多的时候这张表要持续扫描和推送要单独建索引并观察慢查询。7.2 压测方案压测长连接系统不能像 HTTP 那样一梭子打过去我建议按三个维度分别压连接数压测分批建立连接每秒新建 500 个持续涨到目标连接数观察内存和 GC。这一步主要目的是验证单机容量以及连接建连速率的瓶颈。消息吞吐压测固定连接数逐步增加每秒消息量观察送达率和 P99 延迟。注意压测消息要用模拟业务数据用真实尺度消息包不要用空包否则压不出内存瓶颈。重连风暴压测模拟所有连接同时断开并重连这是网关最脆弱的场景。要分批错峰重连否则网关会在建连、认证、拉取离线消息三个环节同时过载。压测工具如果项目有资源建议自研一个“压测桩”就是一个只做连接、收消息、回 ACK、不发业务数据的轻客户端比通用的 WebSocket 压测工具更能还原真实场景。没有资源自研的话也可以用开源的 WebSocket 压测工具但要注意分布式部署单机压测连接数上不去结果没有参考意义。最后如果回到最初我会先做什么做这个系统两年回头整理经验最深的感受是实时消息推送的难点从来不在某个单点技术而在连接生命周期、可靠性补偿、容量规划这些“平时看不见、出事全是大事”的地方。如果时间倒流我会在项目一开始就做好三件基础设施——全链路消息 ID 和 trace 透传、一个能模拟真实场景的压测桩、一套覆盖“活跃连接数、送达率、ACK 成功率、推送耗时”的监控大盘。有了这三样东西后面所有的选型和优化都能在数据指导下进行而不是出了事故才手忙脚乱地去翻日志。最后分享一个每一个老运维都懂的小技巧长连接系统的告警阈值一定不要用固定值要用“环比变化率”。比如“连接总数突然下跌 20%”比“连接数低于某个值”更能反映事故因为线上连接数会随业务自然波动固定阈值不是误报就是漏报。推送系统这个领域就是这样原理学起来简单真正的功力都藏在这些细节里。
返回列表