服务端推送技术全景:从轮询到 WebSocket,一文讲透四种方案的原理与选型
📌 本文是我在视频推流项目(含秒杀优惠券系统)中做"库存实时刷新、秒杀结果通知"时整理的完整技术调研 + 项目实战。
配套源码仓库:github.com/JiaqiChen3518/video_stream
做秒杀系统时我遇到一个典型需求:库存每被抢走一张,所有在线用户的页面要立即看到最新库存;用户秒杀成功后,页面要立即弹出"恭喜抢购成功"。
传统的 HTTP 是"请求-响应"模型——客户端不请求,服务器永远发不出一个字节。为了"让服务器主动说话",前后演化出了四种方案。这篇文章把它们的原理、代码、优缺点和选型标准一次讲清,最后用我项目里真实的 WebSocket 实现收尾。
一、方案总览:一张表先看懂
| 特性 | 轮询 | 长轮询 | SSE | WebSocket |
|---|---|---|---|---|
| 通信方向 | 客户端拉 | 客户端拉 | 服务端推(单向) | 全双工 |
| 协议 | HTTP | HTTP | HTTP (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 原子扣库存,从超卖问题讲起》——库存扣减的三步操作,为什么在并发下必然出事?
如果这篇对你有帮助,欢迎点赞收藏关注 👋