多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

WebSocket实时推送实战:从HTTP轮询到长连接心跳调优

WebSocket实时推送实战:从HTTP轮询到长连接心跳调优 1. 从一个真实的需求说起HTTP 为什么做不到实时推送如果你做过需要“后端一有数据前端立刻知道”的功能多半经历过这么几个阶段先是定时器轮询每几秒打一次接口接着发现延迟和服务器压力都受不了开始研究长轮询到最后实在绕不过去了才搞清楚有个叫 WebSocket 的东西。先讲一个我实际遇到的场景。一个后台管理系统里有任务列表任务跑完要把结果实时推给页面。最初用 setInterval 每 3 秒调一次查询接口数据量小的时候还好等任务多了、页面一多后端日志里全是查询请求数据库连接池频频告急。后来改成 HTTP 长轮询总算省了点请求量但请求挂起期间连接占用依然严重而且网关层默认超时时间一到请求就被掐断前端拿到超时错误还得重新发。折腾一圈之后换上 WebSocket才真正把“服务端主动推数据”这件事落地了。1.1 先理解轮询到底浪费了什么HTTP 协议的设计是“请求-响应”模式客户端发请求服务端给响应之后连接基本就结束了。哪怕用 Connection: keep-alive 复用连接语义上也还是客户端问一次、服务端答一次。问题就出在这里如果服务端想主动告诉客户端“数据变了”光靠 HTTP 是做不到的。轮询的本质是用多次请求模拟实时性。你设定一个时间窗口每隔几秒去问一次“变了没有”。带来的问题很直接大量请求在没有数据变化时白白打到服务器上带宽和 CPU 都浪费了。实时性和成本互相制约。轮询间隔短延迟低但压力大间隔长压力小但用户看到的数据滞后。页面一多每个页面都定时请求请求量会成倍上涨。我自己在优化的时候算过一笔账假设场景只有 100 个在线页面每个页面 3 秒轮询一次每秒就有约 33 个请求如果是 1000 个页面每秒超过 330 个请求。而同样的业务用 WebSocket只要 1000 条空闲连接摆在那里数据变了才推送平时几乎没有请求开销。1.2 WebSocket 真正改变的连接模型WebSocket 不是 HTTP 的替代品它是在 HTTP 基础上“升级”出来的一个长连接协议。关键点在于它把“一来一回”的通信模型变成了“一条通道双向自由流通”。这里可以打个比方。HTTP 轮询就像你每隔几分钟给快递站打个电话问“我的包裹到了没”每次都要重新拨号、说一遍你是谁。WebSocket 则是电话一直不挂断快递站到了包裹直接喊你一声你也能随时跟对方说话两边都不用反复确认身份。协议层面的变化带来了三个直接好处服务端可以主动推送不再需要前端“猜”什么时候有数据。连接只建立一次后续通信不需要重复携带大量 HTTP 头部信息。双向通信变得自然前端也能随时把消息发给服务端适合聊天、协作编辑这类的场景。需要提醒的是WebSocket 适合的是“实时性要求高、消息频繁”的场景。如果你只是每天查几次数据或者几分钟才有一次更新普通的 HTTP 轮询反而更省事没必要为了“实时”两个字强行上 WebSocket。架构选型最怕跟风搞清楚自己的业务场景再决定。2. 协议层面的关键细节从握手到数据帧我第一次手动抓 WebSocket 包的时候还以为会看到很复杂的东西实际拆开看会发现它比 HTTP 更“轻”。整个建立过程可以分成两段先是 HTTP 握手然后是二进制帧的通信。2.1 一次握手两条通道WebSocket 连接建立时客户端会发起一个带特殊头部的 HTTP 请求通常长这样GET /ws/chat/ HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13服务端看到 Upgrade: websocket就知道客户端想切换协议。它会拿到 Sec-WebSocket-Key拼上一个固定 GUID然后算 SHA-1 摘要再做 Base64 编码作为 Sec-WebSocket-Accept 返回HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbKxOo这个 Key 的计算过程其实就相当于握手校验保证两边都确实理解 WebSocket 协议而不是误打误撞发了请求。101 状态码响应之后这个 TCP 连接就不再走 HTTP 语义了而是变成 WebSocket 的双向帧通道。理解这个过程很重要因为所有 WebSocket 服务端框架不管是什么语言实现的底层都要处理这同一个握手逻辑。你如果遇到“连接一直 pending 然后失败”的问题第一件事就是确认这个 101 响应有没有正常返回。实践中很多连接失败的问题根子都在反向代理层没有配置 Upgrade 头导致协议切换被拦住了。2.2 数据帧格式与掩码规则WebSocket 的数据帧长得非常紧凑不像 HTTP 那样一堆头部。一个帧的简化结构是FIN: 是不是最后一帧Opcode: 这一帧是什么类型文本、二进制、关闭、ping、pongMask: 客户端发来的帧必须掩码Payload length: 数据长度Masking key: 掩码密钥4 个字节Payload data: 真正的数据这里有一个非常容易踩坑的设计客户端发往服务端的帧必须掩码服务端发往客户端的帧不需要掩码。当初设计者这么定主要是为了安全考虑防止恶意客户端往中间代理里注入伪造数据。你在写前端代码时感受不到这个规则因为浏览器底层自动处理了但如果你用 Node.js 自己实现一个简易 WebSocket 客户端忘了做掩码服务端会直接拒绝连接或解析出错。我建议前端同学不用死记帧格式但要对“帧”的概念有印象。因为调试的时候你打开浏览器开发者工具的 Network 面板WebSocket 选项卡里的 Message 列表其实就是按帧展示的。看到它们一帧帧发过去比抓包去数二进制字节要直观得多。2.3 为什么心跳机制是必修课WebSocket 连接看起来是“长连接”但它本质上还是跑在 TCP 之上。TCP 连接在长时间没有数据流动时可能被中间的网络设备默默回收掉比如企业的防火墙、云厂商的负载均衡经常会掐断空闲太久的连接。但问题是掐断的时候两端都不知道。我们线上就出过这么一回事前端页面显示 WebSocket 状态一直是 OPEN后端连接也还在但消息就是推不到前端。查半天才发现运营商层面的 NAT 表超时把连接清掉了两边都没有立刻收到 RST于是连接变成“僵尸连接”。解决方式就是心跳机制。简单说连接建立后定时发送一个很小的控制帧ping对端收到后回一个 pong就能确认链路还活着。浏览器 WebSocket API 本身没有直接暴露发送 ping 帧的方法所以很多团队的做法是应用层自己定一个“心跳消息”比如每 30 秒发一段 JSON{type: ping}服务端收到后回{type: pong}。如果前端连续几次没收到任何消息就主动触发重连。我自己比较推荐的做法是双重心跳前端定时发 ping服务端也定时检查每条连接最后活跃时间超过阈值就直接关闭。这样即使客户端那边因为页面切后台、JS 被挂起等原因乱了节奏服务端也能主动把死连接清掉释放资源。3. 前端接入浏览器 API 与工程化注意点浏览器端接入 WebSocket 非常简单API 就那么几个但没有经验的人用起来还是容易翻车。下面这段是核心的最小可用代码const socket new WebSocket(wss://example.com/ws/task/); socket.addEventListener(open, () { console.log(连接建立成功); socket.send(JSON.stringify({ type: subscribe, channel: task_1001 })); }); socket.addEventListener(message, (event) { const data JSON.parse(event.data); console.log(收到推送, data); }); socket.addEventListener(close, (event) { console.log(连接关闭code , event.code); }); socket.addEventListener(error, (error) { console.error(连接出错, error); });这段代码看着简单但真正放到项目里你需要处理的重连、鉴权、消息确认、异常恢复逻辑加起来可能比业务逻辑还多。3.1 鉴权信息应该放在哪里WebSocket 的 URL 不支持自定义头部浏览器也不允许你在握手阶段通过 API 添加 Authorization 头。常见的方案有两个在 URL 上带 token 参数比如wss://example.com/ws/chat/?tokenxxx。在握手完成后的第一条消息里带上凭证服务端校验不过就主动关闭。URL 带 token 的缺点是 token 会出现在日志里尤其是 Nginx 的 access log所以要注意打码。我建议优先级高一点的场景用短时 token过期时间设短一些连接失败重新获取降低泄露风险。靠首条消息鉴权的话服务端必须在有限时间内判断合法性超时直接断开不然会让一堆未鉴权连接挂着占资源。3.2 断线重连与消息幂等WebSocket 连接在移动端网络上尤其脆Wi-Fi 切换、信号不稳定、应用切后台都会触发断线。所以断线重连几乎是标配。但重连本身不难难在重连期间的业务状态怎么恢复。我见过一个典型的坑前端收到推送后更新页面上的“任务进度”但断线期间服务端推送的几条消息都丢了重新连上后页面进度停在旧位置。解决思路是重连成功后向服务端发一条同步请求服务端把断线期间的增量消息重新下发。条件允许的话可以让消息带上序号前端根据序号做去重防止重复处理。浏览器对 WebSocket 有个隐藏限制需要注意ping/pong 的控制帧是自动处理的业务代码里一般读取不到。你如果要靠“收到 pong”判断心跳需要自己在应用层实现消息类型比如前面说的ping/pongJSON 结构。3.3 文件变化这种场景真的适合连 WebSocket 吗有人提过一个典型需求React 项目里做文件变化监听到底是选 SSE 还是 WebSocket。这两种方案我都用过可以分享下体会。SSEServer-Sent Events是单向的只能服务端往客户端推协议基于普通 HTTP使用简单自动重连机制也是内置的。文件变化、构建状态这类“只是让前端知道结果”的场景用 SSE 非常合适不需要 WebSocket 的双向能力。WebSocket 的适用场景是“前后端都要主动说话”聊天、多人协作、实时联机游戏。别把方向搞反了这会让服务端和客户端代码都复杂不少。特性SSEWebSocket方向服务端→客户端双向协议HTTP独立帧协议自动重连内置需自行实现传输格式文本为主文本/二进制浏览器支持广泛广泛适用场景新闻推送、文件变化、进度通知聊天、协作、游戏如果你的需求只是文件变化监听我建议直接用 SSE代码能少写一半。4. 后端接入以 Django Channels 为例的实时推送后端接入 WebSocket 比前端要复杂不少。最核心的问题是普通 Web 框架的请求-响应模型无法直接处理长连接。拿 Django 举例一个经典的 Django View 接收请求、返回响应之后就结束了根本没有“保持连接持续收发”的概念。所以我们需要一个异步层来处理 WebSocket。4.1 为什么 Django 单独处理不了 WebSocketDjango 默认是同步模型每个请求由一个 worker 处理处理完就释放。WebSocket 连接建起来之后会一直挂在那里如果让一个 worker 去“等”这条连接上的消息这个 worker 就被占住了没法再接其他请求。所以 Django 社区给出的方案是 Django Channels。它把 WebSocket 请求转成类似事件处理的模型底层用 ASGI 协议配合 Channel Layer 做跨进程通信。这样单个进程可以同时管理大量连接不阻塞。简单理解这件事传统 Django 是“一个人服务完一个顾客再叫下一个”Channels 是“一个前台同时盯着一堆聊天窗口”消息来了才处理不来就等着。4.2 用 Channels 实现一个简易消息推送服务安装依赖pip install channels channels-redis这里的 channels-redis 是必须的如果你要部署多个服务进程Channel Layer 需要一个消息队列中转层来做进程间通信。开发环境可以用 InMemory 的 layer但生产环境请务必换成 Redis。项目配置中需要注册 ASGI 应用# settings.py INSTALLED_APPS [ daphne, channels, # ...其他应用 ] ASGI_APPLICATION myproject.asgi.application CHANNEL_LAYERS { default: { BACKEND: channels_redis.core.RedisChannelLayer, CONFIG: { hosts: [(127.0.0.1, 6379)], }, }, }然后定义 WebSocket 的消费器处理连接和消息# chat/consumers.py import json from channels.generic.websocket import AsyncWebsocketConsumer class TaskConsumer(AsyncWebsocketConsumer): async def connect(self): self.user self.scope[user] if not self.user.is_authenticated: await self.close(code4001) return self.group_name ftask_{self.user.id} await self.channel_layer.group_add(self.group_name, self.channel_name) await self.accept() # 连接建立后先推送一条当前状态 await self.send(text_datajson.dumps({ type: connection.established, message: ok })) async def disconnect(self, close_code): if hasattr(self, group_name): await self.channel_layer.group_discard(self.group_name, self.channel_name) async def receive(self, text_dataNone, bytes_dataNone): # 前端发来的心跳和订阅消息都在这里处理 data json.loads(text_data) if data.get(type) ping: await self.send(text_datajson.dumps({type: pong})) # 这个方法名会被 channel_layer 自动匹配 async def task_update(self, event): await self.send(text_datajson.dumps(event[data]))这里的核心是 group 的概念。一条 WebSocket 连接加入某个 group 之后你可以在任何地方通过channel_layer.group_send(group_name, {...})往这个组里的所有连接推送消息。比如视图层执行完一个任务后from channels.layers import get_channel_layer from asgiref.sync import async_to_sync def notify_task_finished(user_id, result): channel_layer get_channel_layer() async_to_sync(channel_layer.group_send)( ftask_{user_id}, { type: task.update, data: {task_id: 1, status: finished, result: result} } )这样就实现了“后台有数据前端自动收到推送”的效果。注意type字段就对应 consumer 里的方法名task.update对应task_update方法Django Channels 会自动把点号转成下划线再去调用。4.3 连接管理别让所有逻辑都堆在 Consumer 里我建议你尽早做两件事第一把连接对象和业务用户解耦。Consumer 里只负责协议层的事情连接、断开、心跳不要直接写业务逻辑。业务逻辑放到单独的服务函数里Consumer 只做转发。第二给每条连接分配一个唯一标识。这个听起来很基础但很多人会忽略。你在排查问题的时候没有连接 ID 会非常痛苦日志里全是“用户 123 发来消息”但同一个用户可以开着多个页面、多条连接根本分不清是哪条连接出了问题。5. 常见问题与性能调优实录WebSocket 上线之后问题主要集中在连接稳定性、系统资源、消息可靠性这三个层面。我把实际踩过的坑整理成一张速查表下面再展开说明有代表性的几个。现象可能原因解决办法连接建立失败反代没配置 Upgrade 头确认 Nginx/Caddy 的 Upgrade 配置连接一会被断开代理空闲超时定期心跳缩短心跳间隔后端连接数上不去文件描述符限制调大 ulimit 和系统参数消息推不到前端僵尸连接未清理双重心跳加超时校验服务端 CPU 飙升心跳消息太多太密拉长心跳间隔或者批量检测前端重复处理消息断线重连后重复推送消息序号去重前端做幂等5.1 连接数上不去先查操作系统限制WebSocket 是长连接每一条连接都会占用一个文件描述符。默认情况下Linux 系统对单个进程的文件描述符限制可能只有 1024意味着你这个进程在同一时间最多只能维持大约一千个连接。在压力测试中连接数一到 1024 附近就开始大量报错排查 Go 的 goroutine、Django 的 consumer 都正常到最后才想到跑到服务器上执行ulimit -n结果只有 1024。临时改一下ulimit -n 65535这只是当前终端的有效永久生效要改/etc/security/limits.conf。同时你还要检查系统级参数比如fs.file-max以及net.ipv4.ip_local_port_range因为大量主动连接需要足够多的本地端口可用。5.2 推送风暴怎么防合并与削峰实时推送最怕的不是没人连而是突发海量消息瞬间打过来。比如一个管理员点了“全量刷新”后端循环给一万个用户发通知直接把消息队列打爆。我在实际项目中用过两个策略效果都不错合并发送。把短时间内针对同一个用户的多个消息合并成一条批量消息推送减少帧数和网络开销。削峰限流。后端对推送做统一调度比如每秒最多推 100 条超出的排队到下个周期处理。页面看到数据晚一两秒没太大关系但服务端不能被冲垮。还有一个细节容易被忽略不要把消息直接序列化成大 JSON 再发送前端如果只关心某个字段可以把消息拆细点减少无效传输。5.3 WebSocket 连接“连接上但不收消息”怎么排查热词里有个现象很典型WebSocket 连接能建立但前端一直收不到后端消息。排查这个问题的顺序我有比较固定的套路先确认连接有没有断。看浏览器 Network 面板里的 WebSocket 连接状态如果已经 close那问题在连接稳定性。确认服务端有没有真往这条连接里推送。查后端日志看你调用group_send之后Consumer 里的task_update方法有没有被触发。确认 group 名称是否匹配。实际排查里很多次都是因为 group 名称拼错了连接加入的是task_1001后端往task_1002推送自然收不到。确认 Redis Channel Layer 是否有消息积压或断连。channels-redis 挂了就会出现“看似正常、实际收不到”的诡异现象重启 Redis 后一切恢复。5.4 消息顺序与重复比你想象的更复杂TCP 本身保证字节流不乱序但 WebSocket 消息经过多个进程、多个队列之后顺序可能会被打乱。尤其是你用 Redis Channel Layer 的时候消息发布到多个 Redis 实例、多个 consumer 进程消费顺序就完全不能保证。如果真的对顺序敏感比如聊天工具的聊天记录建议给消息带上自增序号前端收到后先缓存再按照序号排序渲染。前端做重连后的补拉数据时也要用好“幂等”这个原则——同一条消息收到两三次不应该造成页面内容重复。6. 踩坑总结与个人经验这几年用 WebSocket 做过任务推送、消息中心、在线客服踩过的坑不少。这里分享几条个人觉得含金量最高的经验。第一WebSocket 不是 silver bullet。它适合高实时性、双向通信的场景但如果你只是“后端有数据提醒一下”SSE 往往更简单可靠。不要为了用新技术而上新技术。第二连接治理要提前做。心跳、超时清理、连接数统计、断线重连这些能力最好在项目一开始就放进架构里后面再补会很麻烦。尤其是心跳机制没有它线上问题排查起来就像大海捞针。第三生产环境一定不要用 InMemory Channel Layer。我在开发环境用 InMemory 很舒服部署上线后多进程一启动问题全来了——有的进程连上了消息队列有的进程没连上推送消息只在同一个进程里能收到。换成 Redis 之后水到渠成。第四做好日志和监控。每条连接的建立、断开、心跳超时都要有日志。连接数实时指标要接进监控系统。没有监控等客户投诉页面不更新的时候你连从哪开始查都不知道。最后说一个小技巧给消息加一个 requestId 或者消息序号前后端都打日志。出了问题你用同一个 requestId 去前端日志、后端日志、Redis 消息轨迹里串起来排查速度快很多。这个习惯帮我解决过不少棘手的线上问题。
返回列表