2026/9/28 11:08:08

WebSocket与SSE选型实战:心跳、推送与Django Channels

WebSocket与SSE选型实战:心跳、推送与Django Channels 搞WebSocket这几年我最大的感受是很多人不是不会用而是没搞清楚自己真正需要解决的到底是“连接”还是“推送”。WebSocket是一个协议但日常里我们用它的理由几乎都是同一件事——服务端随时有数据要往浏览器推。这篇文章我不打算写成文档翻译而是把我在实际项目里验证过的东西、踩过的坑、反复调过的参数都摊开讲。前后端都会有从最小可用的连接写法到Django Channels实时推送、反向WebSocket、心跳机制再到React里的选型和“连接正常但收不到消息”的排查思路一条线走完。1. 先想清楚你要的到底是“连接”还是“推送”1.1 WebSocket到底解决了我什么问题先说最底层的逻辑。HTTP协议是请求-响应模型客户端不发请求服务端就没有合法理由往客户端发数据。早期做实时功能只能轮询前端每隔几秒发一个请求问“有数据吗”服务端要么返回空要么返回新数据。这种做法浪费请求、延迟不可控而且服务器压力很大。后来有了长轮询、SSE再后来就是WebSocket。WebSocket的本质是在一次HTTP升级握手之后把连接升级成一条全双工的TCP长连接。全双工的意思是客户端和服务端都能随时往这条通道里写数据不用等对方先说话。你可以把它想成HTTP是“打电话问一句答一句”而WebSocket是“电话一直挂着谁想起什么随时说”。所以WebSocket真正解决的问题不是“推送”这个概念本身——SSE也能单向推送——而是“双向”“低延迟”“长期连接”这几个特性的组合。在在线聊天、协同编辑、行情推送、游戏对战、服务端日志实时输出这些场景里它是目前最顺手的选择。1.2 WebSocket、SSE、轮询怎么选不是所有实时场景都该上WebSocket。选型之前先确认你的需求到底落在哪个象限数据流向是不是双向的消息频率高不高客户端环境是否可控方案方向连接类型自动重连适用场景轮询只能客户端主动问HTTP短连接天然自愈低频、低延迟不敏感、方案最简单的场景长轮询服务端挂起请求等待数据HTTP“半长”连接每次结束后要重建兼容老环境的中间态方案SSE服务端单向推送HTTP长连接浏览器EventSource自带服务端到客户端的单向通知WebSocket双向实时通信升级后的TCP长连接需要自己实现聊天、协同、行情、远程控制等很多项目里“实时推送数据”其实只是服务端到前端单向比如文件变更监听、构建进度、订单状态更新。这种场景我优先用SSE因为EventSource自带重连和事件ID实现成本极低。WebSocket虽然也能做但你自己要handle断线重连、心跳、消息格式统一性价比不够。前端要双向交互才值得选WebSocket。比如一个监控页面不仅要看构建日志还要能随时点“暂停”“切换分支”那就用WebSocket。因为SSE只有“服务端推浏览器”一条路浏览器想给服务端发指令还是要发HTTP请求两边来回跳协议很别扭。1.3 握手与消息帧连接建立后发生了什么很多人调了几天连不上问题往往出在握手阶段。WebSocket连接的第一步不是TCP直连而是通过HTTP发起一个升级请求。请求头里有一个关键字段Sec-WebSocket-Key服务端拿到后拼接一个固定GUID做SHA-1哈希再加Base64编码得到Sec-WebSocket-Accept返回给客户端同时返回状态码101 Switching Protocols。这一步完成之后HTTP这个“壳”就丢掉了剩下的全是WebSocket帧。帧里面有几个字段决定了消息怎么解读FIN标记是不是最后一帧Opcode决定是文本帧、二进制帧还是Ping/Pong控制帧客户端发给服务端的帧必须做掩码处理这是协议强制要求。这些细节平时写业务代码用不到但抓包和排查问题时非常有用。我可以很负责地说协议层真正让初学者卡住的不是帧格式而是“服务端不知道该怎么发消息给某个特定客户端”。这个问题在实际工程里要靠连接标识、路由、房间分组来解决后面第三章讲Django推送时会展开。2. 从零搭一个能用的WebSocket前端接入与后端实现2.1 前端最小可用代码别急着上框架先别碰那些封装好的库原生WebSocket对象其实已经足够完成八成业务需求。浏览器端的用法非常固定创建实例、挂四个事件、用send()发消息、用close()关闭。const socket new WebSocket(ws://localhost:8000/ws/chat/); socket.addEventListener(open, () { console.log(连接打开); socket.send(JSON.stringify({ type: chat.message, content: hello server })); }); socket.addEventListener(message, (event) { // event.data 可能是字符串或 Blob取决于服务端发的是文本帧还是二进制帧 const data JSON.parse(event.data); console.log(收到消息:, data); }); socket.addEventListener(close, (event) { console.log(连接关闭, event.code, event.reason); }); socket.addEventListener(error, (err) { console.error(连接出错, err); });这里有个很容易踩的坑error事件之后通常紧跟着close事件很多人只在error里打日志、没在close里做重连导致排查时只看到“报错了”但不知道连接已彻底断开。真正的状态判断应该看readyState0是正在连接1是已打开2是正在关闭3是已关闭。生产环境一定要用wss://而不是ws://。浏览器在HTTPS页面里发起明文WebSocket会被直接拦截这个问题在本地开发时不明显一上测试环境就“莫名其妙连不上”。2.2 后端实现Node.js和Python两条路后端要快速搭一个可用的WebSocket服务我的首选分别是Node.js的ws库和Python的websockets库两个都很成熟踩坑少。// Node.js ws const { WebSocketServer } require(ws); const wss new WebSocketServer({ port: 8080 }); wss.on(connection, (ws, req) { console.log(客户端连接, req.headers[sec-websocket-protocol]); ws.on(message, (data) { console.log(收到:, String(data)); ws.send(pong: data); }); ws.on(close, () { console.log(客户端断开); }); });# Python websockets 库 import asyncio import websockets async def handler(websocket): async for message in websocket: print(收到:, message) await websocket.send(pong: message) async def main(): async with websockets.serve(handler, 0.0.0.0, 8765): await asyncio.Future() if __name__ __main__: asyncio.run(main())两个示例的套路是一样的监听连接、处理消息、回发数据。区别在于语言生态。Node.js做实时服务的并发处理能力在长连接场景下表现很好Python则胜在和数据开发、机器学习链路衔接方便。如果你的业务栈已经是Django想在不脱离ORM、不拆微服务的前提下加实时能力那就直接看第三章的Django Channels。2.3 websocket test client测连接是第一步我调试WebSocket时几乎从不用“先写两端代码再联调”的方式一定是先用一个独立的测试客户端把服务端连接验证通了再去写前端页面。这能把“协议问题”和“代码问题”分开。命令行工具wscat是最快的选择npm install -g wscat wscat -c ws://localhost:8080 # 连接成功后直接输入消息 hello pong: hello如果没有Node环境用Python也能快速写一个测试脚本import asyncio import websockets async def test(): async with websockets.connect(ws://localhost:8765) as ws: await ws.send(hello) response await ws.recv() print(收到:, response) if __name__ __main__: asyncio.run(test())浏览器调试也有个很省事的办法直接打开控制台手动new WebSocket(...)然后挨个在控制台调用send()观察onmessage回调有没有触发。这样不用修改任何业务代码就能验证服务端能不能连通。注意ws://echo.websocket.org这类公共测试服务现在很不稳定时好时坏。你别拿它当服务端可用性的依据最好本地起一个简单的echo服务或者就用上面两个脚本直接测你自己的后端。3. Python Django 实时推送实战后台有数据就往前端推3.1 Django Channels给同步世界打开的异步窗口Django本身的请求-响应模型里没有“长连接”这个概念一个请求进来处理完响应返回连接结束。要在Django里跑WebSocket就得引入channels。它的核心思路是把Django从WSGI的世界推进到ASGI的世界ASGI允许一个进程同时处理HTTP和WebSocket还能在事件循环里跑异步任务。先看项目里最基础的配置长什么样。asgi.py里要配置一个协议类型路由器告诉服务器HTTP请求走原来的Django处理逻辑WebSocket连接走URL路由。# asgi.py import os from django.core.asgi import get_asgi_application os.environ.setdefault(DJANGO_SETTINGS_MODULE, myproject.settings) django_asgi_app get_asgi_application() from channels.routing import ProtocolTypeRouter, URLRouter from chat import routing application ProtocolTypeRouter({ http: django_asgi_app, websocket: URLRouter(routing.websocket_urlpatterns), })对应的routing.py负责把WebSocket路径映射到Consumer# routing.py from django.urls import path from chat.consumers import ChatConsumer websocket_urlpatterns [ path(ws/chat/, ChatConsumer.as_asgi()), ]Consumer是处理WebSocket连接的视图层里面通过connect、receive、disconnect三个回调管理生命周期。和普通视图最大的区别是它可以写异步代码可以使用channel_layer做多客户端之间的消息路由。3.2 后台数据推送到前端从任务到浏览器的完整链路我见过太多Django项目“后台有数据但前端收不到”的场景根因几乎都是不理解channel_layer的用法。channel_layer可以理解成一套跨进程消息总线常见实现是Redis。它的核心操作是group_add和group_send先把某个WebSocket连接加到一个组然后往整个组广播消息。一个从后台任务推数据到前端的完整链路应该这样设计# consumers.py import json import asyncio from channels.generic.websocket import AsyncWebsocketConsumer class StreamConsumer(AsyncWebsocketConsumer): async def connect(self): self.group_name stream await self.channel_layer.group_add(self.group_name, self.channel_name) await self.accept() async def disconnect(self, close_code): await self.channel_layer.group_discard(self.group_name, self.channel_name) async def receive(self, text_dataNone, bytes_dataNone): # 前端如果发消息可以在这里处理控制指令 pass async def data_push(self, event): # 注意方法名必须是 data_push对应 group_send 消息里的 type await self.send(text_datajson.dumps(event[payload]))往前端推送的动作可以由Celery任务、Redis订阅、定时脚本、甚至是另一个WebSocket客户端触发。通过Celery任务推送的写法如下# tasks.py from celery import shared_task from asgiref.sync import async_to_sync from channels.layers import get_channel_layer shared_task def push_stream_data(payload): channel_layer get_channel_layer() async_to_sync(channel_layer.group_send)( stream, { type: data.push, payload: payload, } )这就把“后台有数据”和“前端收到数据”之间的缝隙补上了。后台任务不管来自队列还是定时器只要调一次group_send所有连接了stream组的WebSocket都会收到消息。一个新手特别容易犯的错误把推送逻辑写在Consumer的receive里以为前端发一条消息后服务端就能“顺便”推别的数据。这样做在单连接单进程时好像能跑但一旦多实例部署消息就乱了。推送必须通过channel_layer走组的广播机制不要依赖某个具体的连接对象。3.3 反向WebSocket让Django后台去连接外部推送服务热词里有个“python反向websocket”这个叫法其实不太严谨但意思很明确你的后端不是WebSocket的服务端而是作为客户端去连接另一个WebSocket服务然后把收到的数据再转发给浏览器。典型场景是行情推送、第三方事件流、IoT网关数据接入。import asyncio import websockets import json from channels.generic.websocket import AsyncWebsocketConsumer class ExternalBridgeConsumer(AsyncWebsocketConsumer): async def connect(self): self.group_name bridge await self.channel_layer.group_add(self.group_name, self.channel_name) await self.accept() self.external_task asyncio.create_task(self.listen_external()) async def disconnect(self, close_code): if hasattr(self, external_task): self.external_task.cancel() await self.channel_layer.group_discard(self.group_name, self.channel_name) async def listen_external(self): uri wss://example.com/events while True: try: async with websockets.connect(uri) as ws: async for raw in ws: await self.channel_layer.group_send( self.group_name, {type: external.event, raw: raw} ) except Exception as exc: # 断线重连千万不要裸奔 print(外部连接断开3秒后重连:, exc) await asyncio.sleep(3) async def external_event(self, event): await self.send(text_dataevent[raw])这段代码的核心思路是每个浏览器连接进来后后端在同一个Consumer里额外开一个协程去连外部服务外部服务推来的消息经过channel_layer转发给组内所有浏览器。简单项目这样演示够用但真要上生产强烈建议把“外部连接”抽成独立的守护进程不要让一个外部连接跟着浏览器连接一起销毁。否则每多一个浏览器就多一条外向连接服务端压力会成倍上涨。4. 心跳机制为什么连接总是“假死”4.1 心跳到底防的是什么三种典型的“静默死亡”一个WebSocket连接看起来是通的实际上早就“死”了这种问题在真实环境里非常常见因为中间隔着太多东西。第一种是网络设备超时清理。办公室路由器、云厂商的负载均衡、Nginx反代默认都可能把空闲太久的TCP连接默默回收掉。空闲时间通常在2到5分钟超过这个时间连接就从设备状态表里被删了但对两端来说这个断开动作根本没被感知因为没有任何一端主动发RST。第二种是半开连接。一端宕机、断电、断网另一端不会立刻收到消息。TCP是“尽力而为”的只有你试着发送数据并且等不到ACK你才会发现对方没了。如果业务里一直没人说话两端会永远维持一个看似健康但实际已经断裂的连接。第三种是服务端主动清理。很多框架会把长时间不活动的连接标记为僵尸连接定时清理。如果你的客户端没有任何保活机制服务端就会觉得这个客户端“不活跃”直接踢掉。所以心跳不是可选项而是长连接的必需品。它的目的只有一个让双方定期确认“对方还活着”。4.2 谁来ping谁来pong协议层和业务层的心跳实现WebSocket协议本身定义了Ping和Pong控制帧但这里有个绝大多数前端开发者不知道的坑浏览器的WebSocket API没有暴露发Ping帧的方法。服务端可以向浏览器发Ping浏览器必须自动回Pong这是协议强制行为但浏览器不能主动发Ping想发只能通过业务层自己定义一套“心跳消息”。所以形成了两套方案。方案一是服务端发起协议层心跳服务端每隔一段时间发一个Ping帧浏览器由协议栈自动回复Pong。方案二是业务层心跳前后端约定一种消息类型比如前端发{type:ping}后端回{type:pong}。我在Django Channels里实践比较多的是业务层心跳因为Channels对底层控制帧的接入没有直接暴露业务层心跳反而最可控。import asyncio import json from channels.generic.websocket import AsyncWebsocketConsumer class HeartbeatConsumer(AsyncWebsocketConsumer): async def connect(self): await self.accept() self.last_seen asyncio.get_event_loop().time() self.hb_task asyncio.create_task(self.heartbeat_loop()) async def disconnect(self, close_code): if hasattr(self, hb_task): self.hb_task.cancel() async def heartbeat_loop(self): while True: await asyncio.sleep(20) if asyncio.get_event_loop().time() - self.last_seen 45: # 超过45秒没收到任何消息认为连接已死 await self.close(code4001, reasonheartbeat timeout) return await self.send(text_datajson.dumps({type: ping})) async def receive(self, text_dataNone, bytes_dataNone): data json.loads(text_data or {}) if data.get(type) pong: self.last_seen asyncio.get_event_loop().time() else: # 处理正常业务消息 pass前端对应的心跳代码let heartbeatTimer null; const HEARTBEAT_INTERVAL 20000; socket.addEventListener(open, () { heartbeatTimer setInterval(() { if (socket.readyState WebSocket.OPEN) { socket.send(JSON.stringify({ type: pong, ts: Date.now() })); } }, HEARTBEAT_INTERVAL); }); socket.addEventListener(close, () { clearInterval(heartbeatTimer); });我这里的实现逻辑是后端发ping前端收到后回复pong后端通过last_seen判断是否超时。其实也可以反过来设计前端主动发心跳、后端被动响应甚至什么都不响应只要超时能检测出来就行。关键在于心跳消息本身要轻消息体不要塞业务数据心跳定时器要管理好连接关闭后一定要清理否则会泄漏。4.3 心跳参数与重连策略心跳参数没有银弹但有两个参考区间。时间间隔一般选20到30秒比中间网络设备的空闲超时时间短一半以上太长起不到保活效果太短则白白消耗带宽和CPU。超时判断不要只看一次Pong没回就断建议连续2到3次无响应再断开避免网络抖动误杀正常连接。重连不能用“断线后立刻重连”因为如果服务端正在重启或网络正在故障立刻重连只会连续失败。指数退避是常见做法第一次等1秒第二次2秒第三次4秒最多30秒封顶同时加入随机抖动避免大量客户端同时重连造成服务端冲击。function connectWithRetry(url, maxDelay 30) { let attempt 0; function connect() { const socket new WebSocket(url); socket.addEventListener(close, () { const delay Math.min(maxDelay, Math.pow(2, attempt)) Math.random() * 1000; attempt 1; setTimeout(connect, delay); }); socket.addEventListener(open, () { attempt 0; }); } connect(); }注意一个连接只能有一个心跳任务。我曾经在重连逻辑里没有取消旧心跳导致同一个浏览器连接上出现两个心跳循环服务端收到的pong频率翻倍吓坏了一堆监控告警。重连成功时一定要把旧的定时器清干净。5. 真实项目里的推送场景与异常排查5.1 React WebSocket/SSE轮询文件变化到底怎么选很多人搜“react sse/websocket 轮询文件变化”其实想要的场景很清晰前端要展示文件系统的变化、构建过程的状态或者CI任务里的日志输出。这种场景数据流向是服务端到前端单向首选应该是SSE。用EventSource的好处是浏览器自动重连后端断开时会自动尝试重新建立连接不需要前端写任何心跳逻辑。服务端往客户端推事件时还可以带上事件ID浏览器断线重连后能基于ID做增量补发这是WebSocket方案里要自己实现的。const eventSource new EventSource(/api/file-change-stream); eventSource.addEventListener(file-change, (event) { const data JSON.parse(event.data); console.log(文件变化:, data.path); }); eventSource.onerror () { // 浏览器会自动重连这里只需要处理UI提示 console.log(连接已断开浏览器正在重试); };如果这个页面还需要“暂停监听”“只监听某个目录”“手动触发一次扫描”这类双向控制那就改回WebSocket。因为这时候前端的每个控制操作都是要发给服务端的一条指令SSE没有上行能力。React里用WebSocket有个非常经典的坑useEffect的依赖数组导致事件监听器拿着旧闭包。解决方法是把消息处理函数放进useRef或者在useEffect里重新挂监听的时候一并更新。function useWebSocket(url, onMessage) { const socketRef useRef(null); const onMessageRef useRef(onMessage); onMessageRef.current onMessage; useEffect(() { const socket new WebSocket(url); socketRef.current socket; socket.onmessage (event) { onMessageRef.current(event.data); }; socket.onclose () { console.log(连接关闭); }; return () socket.close(); }, [url]); return socketRef; }SSE和WebSocket边界其实不复杂单向推送选SSE双向交互选WebSocket轮询留给低频场景。不要因为WebSocket听起来“更实时”就盲目选它选型不是炫技是减少长期维护成本。5.2 连接上了但收不到消息7个排查点“WebSocket连接成功但前端就是收不到消息”这个现象是后台开发里出现频率极高的问题我自己的排查顺序基本是下面这张表排查点可能原因检查方式连接状态连接其实没进入OPEN状态页面误判看readyState是否等于1服务端发送目标消息发到了别的group或channel_name看group_send里的组名是否和group_add一致代理层缓冲Nginx/CDN缓冲了消息客户端没及时收到检查反代配置里是否禁用缓冲、是否正确配置Upgrade头前端监听onmessage未注册或被后续代码覆盖确认只有一个addEventListener(message)消息帧类型服务端发二进制帧前端按文本解析检查event.data的类型Blob需要转文本服务端并发单事件循环被阻塞消息堆积看服务端日志确认send是否真实执行业务侧握手消息连接后必须等某条业务消息才开始推数据抓包看首条业务消息是否已经发出最有效的定位手段是拉一个独立的命令行客户端比如wscat挂同一个地址。如果wscat能收到而浏览器收不到问题大概率在前端代码如果wscat也收不到问题就在服务端或代理层。遇到过最奇怪的一例是Nginx代理WebSocket时没有设置proxy_read_timeout默认60秒超时后Nginx主动断开连接页面表现就是“连上了一会儿之后就彻底静默”。这种问题从浏览器端看就是连接被关闭从业务日志看又没有任何异常。排查时一定要把代理层的超时时间一并纳入考虑。5.3 “通过WebSocket发送POST请求”到底是什么玩法这个热词很有意思。正常的WebSocket消息本质上是“事件”不是“请求-响应”但确实有很强的需求想把HTTP语义搬到长连接里复用已有的REST接口用WebSocket统一进出通道甚至解决部分受限环境下HTTP请求不方便的问题。实现思路是在WebSocket消息里规范化封装一个“HTTP请求”{ id: req-20240520-001, action: http.request, method: POST, url: /api/order, headers: { Authorization: Bearer eyJhbGciOi... }, body: { sku: A100, count: 2 } }服务端收到后解析出method、url、headers、body调用内部HTTP API再把响应通过WebSocket发回去。这里有个关键设计必须带id因为WebSocket是异步的客户端发多个请求时响应可能乱序回来只有通过id才能把响应和请求对上。import httpx import json async def handle_http_request(self, message): async with httpx.AsyncClient(base_urlhttps://internal.api) as client: resp await client.request( message[method], message[url], jsonmessage.get(body), headersmessage.get(headers, {}), ) await self.send(text_datajson.dumps({ id: message[id], status: resp.status_code, body: resp.text, }))这种方式看着很爽但有几个坑要提前说清楚。第一是不要把大文件、长轮询接口塞进WebSocket因为一条消息被卡住会阻塞这条连接后续所有消息。第二是要做好超时管理客户端发的请求如果服务端迟迟不返回要有对应的timeout和取消机制。第三是鉴权不要只依赖首条消息因为WebSocket连接建立之后很少重新鉴权Token有效期管理会比HTTP请求复杂得多。6. 项目中最容易翻车的地方与我的经验6.1 连接数上去了一切开始变慢长连接服务和短连接服务最大的不同在于连接的生命周期。HTTP短连接处理完就释放天然没压力WebSocket一旦建立就长期占据一个文件描述符和一部分内存。当连接数超过几千甚至上万的时候最先扛不住的不一定是应用进程而是操作系统的文件描述符上限和负载均衡的连接数上限。排查思路是先把ulimit -n、Nginx的worker_connections、云厂商负载均衡的连接数配额全查一遍。我见过一个项目线上突然大面积连不上最后发现是Nginx默认worker_connections只有1024而前端页面每个标签页都开了两个WebSocket两个标签页就把一台机器的连接额度吃完了。6.2 短连接轰炸比长连接更可怕长连接建立时有HTTP升级握手CPU消耗比普通HTTP请求高。如果前端代码有“断线立刻重连”的逻辑服务端重启时会迎来一波重连风暴几千个客户端同时发起握手瞬间把服务端CPU占满结果就是服务端根本起不来形成恶性循环。应对方法就是前面讲的指数退避必须把重连间隔拉开。哪怕只是简单的“1秒、2秒、4秒、8秒”也能把瞬时压力摊开。服务端侧还可以加握手频率限制对同一个IP做并发连接数限制。6.3 日志里要有“连接生命周期”不要只看收到消息排WebSocket的问题最怕日志里只有“收到消息”没有连接建立和关闭的记录。后来我在所有Consumer里都会打三条日志connect时记录channel_name、客户端IP、连接时间disconnect时记录close_code和在线时长receive时记录关键业务事件而不是每条心跳都打。这个习惯帮了大忙。有一次前端反馈“推送偶尔延迟”我看日志发现某个连接的channel_name在很长一段时间内只出现在disconnect里说明它建连极短不断重连。顺着这个线索查下去发现是前端在页面切换时没有正确关闭旧连接。如果没有连接生命周期的日志这个问题可能还要排查很久。WebSocket做熟练之后你会发现真正的成本不在建立连接的那几行代码而在连接建立之后怎么维护它、怎么管理它、怎么发现问题。心跳参数调一调连接生命周期记一记重连策略做做退避这些才是撑起一个稳定长连接服务的关键。