news 2026/10/4 7:45:21

服务端推送技术全景:从轮询到 WebSocket

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
服务端推送技术全景:从轮询到 WebSocket

服务端推送技术全景:从轮询到 WebSocket,一文讲透四种方案的原理与选型

📌 本文是我在视频推流项目(含秒杀优惠券系统)中做"库存实时刷新、秒杀结果通知"时整理的完整技术调研 + 项目实战。

配套源码仓库:github.com/JiaqiChen3518/video_stream

做秒杀系统时我遇到一个典型需求:库存每被抢走一张,所有在线用户的页面要立即看到最新库存;用户秒杀成功后,页面要立即弹出"恭喜抢购成功"。

传统的 HTTP 是"请求-响应"模型——客户端不请求,服务器永远发不出一个字节。为了"让服务器主动说话",前后演化出了四种方案。这篇文章把它们的原理、代码、优缺点和选型标准一次讲清,最后用我项目里真实的 WebSocket 实现收尾。

一、方案总览:一张表先看懂

特性轮询长轮询SSEWebSocket
通信方向客户端拉客户端拉服务端推(单向)全双工
协议HTTPHTTPHTTP (event-stream)ws/wss(独立协议)
实时性差(看间隔)近实时很好最好
数据格式任意任意仅 UTF-8 文本文本 + 二进制
服务端压力高(大量空请求)中(挂起连接)低低(长连接复用)
自动重连手动手动浏览器内置手动/框架
典型场景极低频内部系统简单聊天、通知行情、日志流游戏、协作、实时大屏

下面逐一拆解。

二、轮询(Polling):用频繁请求模拟实时

原理:客户端按固定间隔(如 1 秒)请求一次"有没有新数据",服务器无论有没有都立即返回。

@GetMapping("/poll")publicMap<String,Object>poll(@RequestParamlongclientTime){Map<String,Object>result=newHashMap<>();if(lastDataTime>clientTime){// 模拟:数据有更新result.put("data","新数据");result.put("timestamp",lastDataTime);}else{result.put("data",null);// 没有也得返回,白跑一趟}returnresult;}
letlastTimestamp=0;setInterval(async()=>{constjson=await(awaitfetch(`/poll?clientTime=${lastTimestamp}`)).json();if(json.data){console.log("收到:",json.data);lastTimestamp=json.timestamp;}},1000);

评价:实现最简单、兼容性最好,但实时性和服务器压力是一对不可调和的矛盾——间隔设 1 秒,服务器就要扛 1 秒一次的无效请求,还要为每次请求付出完整的 TCP/TLS/HTTP 头开销。只适用于实时性要求极低(分钟级)的内部小系统。

三、长轮询(Long Polling):让请求"挂起"到有数据

原理:客户端发起请求后,服务器不立即返回,而是把请求挂起,直到有新数据(或超时)才响应。客户端收到响应后立刻发起下一次长轮询,形成循环。

Spring 里用DeferredResult可以优雅地挂起请求,不占着 Tomcat 的工作线程:

@RestControllerpublicclassLongPollingController{// 存放"等待被唤醒"的请求privatefinalBlockingQueue<DeferredResult<String>>waiters=newLinkedBlockingQueue<>();@GetMapping("/long-poll")publicDeferredResult<String>longPoll(){DeferredResult<String>result=newDeferredResult<>(30000L);// 30秒超时兜底result.onTimeout(()->result.setResult("no_data"));waiters.offer(result);// 登记,挂起returnresult;}// 数据到达时(比如 MQ 消费者里)唤醒一个等待中的请求publicvoidpush(Stringdata){DeferredResult<String>waiter=waiters.poll();if(waiter!=null&&!waiter.isSetOrExpired()){waiter.setResult("new data: "+data);}}}
asyncfunctionloop(){consttext=await(awaitfetch('/long-poll')).text();console.log("收到:",text);loop();// 收到响应立即发起下一次}loop();

评价:比轮询大幅减少无效请求,有数据即推、近实时;仍是普通 HTTP,穿透代理和防火墙毫无压力。代价是服务器要维护大量挂起连接(现代容器用 NIO 异步处理,压力可控但不为零)。适合实时性要求中等、又不想引入 WebSocket 复杂度的场景。

四、SSE(Server-Sent Events):服务器单向推送的标准答案

原理:HTML5 规范的一部分。客户端用EventSource发起一次请求后,服务器保持这个 HTTP 连接不关闭,持续向客户端写data:开头的消息块。浏览器原生处理重连和事件 ID。

@GetMapping("/sse")publicSseEmittersubscribe(){SseEmitteremitter=newSseEmitter(60_000L);emitter.onCompletion(()->emitters.remove(emitter));emitter.onTimeout(()->emitters.remove(emitter));emitters.add(emitter);returnemitter;}// 服务端主动推送(定时任务/MQ 触发)publicvoidbroadcast(Stringmessage){for(SseEmitteremitter:emitters){try{emitter.send(SseEmitter.event().name("message").data(message));}catch(IOExceptione){emitters.remove(emitter);// 顺手清理死连接}}}
constsource=newEventSource('/sse');source.addEventListener('message',e=>console.log('收到:',e.data));source.onerror=err=>console.log('断线,浏览器会自动重连');

评价:轻量、自动重连、浏览器原生支持,是"服务端单向推文本"的最优解(股票行情、日志流、通知中心)。短板:只能单向(客户端想给服务端发消息得另发 HTTP 请求)、只支持文本、HTTP/1.1 下每域 6 个连接数限制(HTTP/2 可解)。

五、WebSocket:真正的全双工

原理:先做一次 HTTP 握手,请求头带Upgrade: websocket,服务器返回101 Switching Protocols后,协议切换为 ws。之后双方可以随时互相发送文本或二进制帧,没有 HTTP 头开销,延迟极低。

我的秒杀系统最终选择了 WebSocket,这里用项目里的真实代码讲四个工程上必须解决的问题。

5.1 问题一:WebSocket 怎么做登录鉴权?

浏览器原生WebSocketAPI不能自定义请求头,所以 token 只能走 query 参数:

// 前端:连接时拼上 tokenconstws=newWebSocket(`ws://localhost:8080/ws/seckill?token=${encodeURIComponent(token)}`);

后端在握手阶段用ServerEndpointConfig.Configurator拦截、解析 token,解析不出 userId 就直接拒绝连接:

@ServerEndpoint(value="/ws/seckill",configurator=WebSocketConfigurator.class)publicclassSeckillWebSocketEndpoint{@OnOpenpublicvoidonOpen(Sessionsession,EndpointConfigconfig){// userId 是 Configurator 在握手时从 token 解析好放进来的LonguserId=(Long)config.getUserProperties().get("userId");if(userId==null){// 未登录:直接关闭,不给进session.close(newCloseReason(CloseReason.CloseCodes.VIOLATED_POLICY,"未登录用户无法连接"));return;}webSocketConfig.addSession(userId,session);// 注册用户会话}}

5.2 问题二:海量连接怎么管理会话?

// userId → 该用户的所有会话(一个用户可能开多个标签页)Map<Long,CopyOnWriteArraySet<Session>>userSessions;// sessionId → userId,连接关闭时反向查找用Map<String,Long>sessionUserIdMap=newConcurrentHashMap<>();

两个细节:

  • ConcurrentHashMap+CopyOnWriteArraySet:推送和连接/断开是并发进行的。CopyOnWriteArraySet写时复制,遍历(广播)时不用加锁,天生适合"读多写少"的推送场景;
  • 广播时顺手清理死会话:sendText抛异常的会话从注册表中移除,否则注册表会被僵尸连接越撑越大。

5.3 问题三:为什么必须用心跳?

一个 WebSocket 连接可能几小时没有消息,中间的 Nginx、防火墙、NAT 会悄悄把空闲连接掐掉,而两端还以为连接活着。解法:前端每 30 秒发一个心跳,后端回 pong;连续收不到响应就判定断线。

// 前端:30 秒一次心跳startHeartbeat(){this.heartbeatTimer=setInterval(()=>{if(this.ws.readyState===WebSocket.OPEN){this.ws.send(JSON.stringify({type:'HEARTBEAT',timestamp:Date.now()}));}},30000);}
// 后端:收到心跳回 pongcaseHEARTBEAT_RESPONSE:sendMessage(session,WebSocketMessageDTO.heartbeat());break;

5.4 问题四:断线重连怎么做才不"惊动"用户?

网络闪断不可避免。我的前端客户端实现了指数退避重连:

scheduleReconnect(){if(this.reconnectAttempts>=this.maxReconnectAttempts)return;// 最多 5 次,放弃// 3s → 6s → 12s → 24s → 48s,外加随机抖动防止所有客户端同时重连constbackoff=3000*Math.pow(2,this.reconnectAttempts)+Math.random()*1000;this.reconnectAttempts++;setTimeout(()=>this.connect(this.url),backoff);}

三个设计点:①指数退避——服务器刚重启时,如果所有客户端同时立即重连,会造成重连风暴;②随机抖动——打散重连时点;③isManualClose标记——用户主动关闭(退出登录)时不再重连,否则登出后客户端还在后台疯狂重连。

六、选型决策:记住这几条就够了

  • 需要低延迟、双向频繁通信→ WebSocket(聊天、游戏、协同、秒杀状态同步)
  • 只要服务端单向推文本→ SSE(行情、日志、通知),比 WebSocket 简单一半
  • 单向推送但要兼容老环境→ 长轮询
  • 轮询:只在内部小系统、分钟级实时性时考虑,生产环境慎用

一句话总结演进逻辑:在"实时性"和"资源成本"之间找平衡——轮询用带宽换简单,长轮询用挂起换实时,SSE 用单向换轻量,WebSocket 用复杂度换全双工。

七、面试快问快答

Q1:WebSocket 的握手过程是怎样的?
客户端发起 HTTP 请求,带Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key等头;服务器校验后返回101 Switching Protocols,之后双方都按 WebSocket 帧协议通信。本质是一次"协议升级"。

Q2:为什么 WebSocket 鉴权要把 token 放 query 参数?
浏览器原生 WebSocket API 不支持自定义请求头,只能把凭证拼在 URL 上(生产环境务必用 wss + 一次性票据,避免 token 出现在日志里)。

Q3:心跳的作用是什么?不心跳会怎样?
空闲连接会被 Nginx/防火墙/NAT 静默断开,两端状态不一致。心跳让两端感知连接存活,客户端还能借此触发重连。

Q4:SSE 和 WebSocket 的本质区别?
SSE 是"服务端 → 客户端"的单向流,基于普通 HTTP,浏览器原生重连;WebSocket 是协议升级后的全双工信道。单向通知选 SSE,双向互动选 WebSocket。

Q5:长轮询和 WebSocket 相比,主要劣势是什么?
每次响应仍有完整 HTTP 头开销;本质仍是"一问一答"循环,服务器要维护挂起请求的状态;不适合高频、双向的通信场景。


📎 本文涉及的完整实现:SeckillWebSocketEndpoint.java | 前端 WebSocket 客户端

🔔 下一篇预告:《秒杀系统①:Redis + Lua 原子扣库存,从超卖问题讲起》——库存扣减的三步操作,为什么在并发下必然出事?

如果这篇对你有帮助,欢迎点赞收藏关注 👋

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/4 7:42:02

JAVA游戏支付源码拆解:免签支付平台的核心机制与实战避坑

简介&#xff1a;一份可直接部署运行的JAVA游戏通用支付平台源码&#xff0c;面向游戏开发者、站长及支付集成需求方&#xff0c;已对接正在运营的免签支付平台。使用个人支付宝或微信收款二维码即可完成自动发货&#xff0c;支持mysql/sqlserver数据库&#xff0c;内置免签支付…

作者头像 李华
网站建设 2026/10/4 7:41:19

Java Swing实战:从零开发打飞机小游戏(架构、碰撞检测与避坑)

我很久没碰Swing写小游戏了&#xff0c;这次趁周末把打飞机重新撸了一遍。说实话&#xff0c;用Java写这种经典小游戏远没有想象中那么简单&#xff0c;光是线程同步和碰撞检测就能绕晕一堆刚入门的同学。但正因为如此&#xff0c;它是个非常好的练手项目——麻雀虽小&#xff…

作者头像 李华
网站建设 2026/10/4 7:40:28

PIC18F47K40+MRAM:解决工业EEPROM写寿命痛点

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/4 7:39:02

MRAM替代Flash:工业现场高频数据存储不掉电实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华