news 2026/9/16 8:01:44

跨域、SSE与WebSocket实战:Nginx配置与实时通信避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
跨域、SSE与WebSocket实战:Nginx配置与实时通信避坑指南

最近群里又炸了一次锅,起因特别简单:一位老哥把项目从前端脚手架到后端网关全部配好了,H5 里 WebSocket 连得稳稳的,结果打包成 App 之后,客户端疯狂报[websocket] onclose, code: 1006。他在群里连着刷了十几条消息,最后才发现是服务端对空 Origin 做了拦截——浏览器会自动携带 Origin,App 的 WebView 在某些安卓版本里却不带,这一下就把连接掐死了。这种问题单拎出来都不难,难的是很多同学把跨域、SSE、WebSocket 这三套东西割开学,遇到组合场景就手足无措。我做了十几年前端,从最早的 JSONP 年代一路走到 WebSocket、SSE 成为标配,这篇就把从跨域方案到 SSE、再到双向实时通信的完整链路和踩坑记录一次讲透,适合正在写前端、写全栈,或者需要维护 Nginx/网关的兄弟收藏。

1. 先把跨域这件事说透:同源策略到底拦截了什么

1.1 判断跨域的三个硬性条件:协议、域名、端口

跨域不是玄学,它背后只有一条规则:浏览器发起的网络请求,如果协议、域名、端口三者中任意一个跟当前页面不同,就构成跨域。

很多新手会在 localhost 上栽跟头:前端跑在http://localhost:5173,后端跑在http://localhost:8080,从浏览器角度看这俩就不是一个源,因为端口不一样,属于跨域。还有兄弟把127.0.0.1localhost混着用,浏览器同样认为它们不同源,哪怕指向同一个本机。判断是不是跨域,最土的办法就是直接看地址栏和接口地址,协议、域名、端口逐项比对。

这里有个容易误解的点:移动端 App 里其实没有传统意义上的同源策略约束,因为页面/WebView 的宿主不是一个"浏览器的标签页",很多规则是失效的。但开发调试阶段大量使用 H5 和浏览器,又要在 App 里跑同样的代码,于是"浏览器里的跨域规则"和"App 里的网络行为"就变成了两套需要同时兼容的逻辑,这也是后文 WebSocket 1006 那个坑的根源之一。

1.2 被拦截的是"读响应",不是"发请求"

我对团队里新人重复最多的一句话就是:同源策略拦截的是"读",不是"发"。浏览器允许你把请求发出去,但如果是跨域响应且没有通过 CORS 校验,脚本就拿不到响应内容。

这也解释了为什么<script><img><form>这些标签可以跨域请求资源——它们本来就不是给脚本读取的,浏览器也懒得拦。你要用脚本动态抓取另一个域名下的 JSON,就必须走XMLHttpRequestfetch,这时同源策略才会生效。

顺带解决一个常见困惑:"前端此图片未经允许不可引用怎么解决"——这通常不是同源策略,而是图片服务器做了 Referer 防盗链,服务端检查请求头里的 Referer,发现不是白名单域名就返回 403。虽然都叫跨域相关,但一个是浏览器行为,一个是服务端行为,排查方向完全不同。别把两者混为一谈。

1.3 为什么实时通信项目里跨域总是绕不开

因为现代前端架构本来就是"多个域并存"的:静态资源放在 CDN,业务 API 挂在一个域名,WebSocket 网关可能又是另一个域名。一个管理后台,页面在admin.example.com,接口在api.example.com,长连接在ws.example.com,这已经是常规操作。跨域和实时通信不是偶尔相遇,而是必然共存。

而且 SSE 和 WebSocket 在跨域问题上的表现还不一样。SSE 走的是标准 HTTP,完全吃 CORS 那一套;WebSocket 握手虽然是 HTTP,但握手之后的连接不是普通 HTTP 响应,浏览器不会为它触发 CORS 预检,而是由服务端校验握手请求里的Origin字段来决策放行与否。所以你会看到一种情况:接口 CORS 都配好了,SSE 也通了,WebSocket 却仍然报错——因为后端 WebSocket 的跨域校验是另一套配置。

2. 跨域方案的横向拆解:CORS、JSONP、Nginx 代理与 postMessage

2.1 CORS 的预检请求与凭证携带规则

CORS 是跨域问题最标准的答案,但标准答案里全是细节。先说预检:不是所有跨域请求都会触发 preflight。像GETHEADPOSTContent-Typetext/plainmultipart/form-dataapplication/x-www-form-urlencoded之一的叫简单请求,浏览器直接发;一旦你加了自定义请求头、改用application/json、或者用了 PUT/DELETE,浏览器会先发一个OPTIONS请求,服务端必须在响应里返回允许的源、方法、请求头,真正的业务请求才会继续。

我见过最普遍的问题是把Access-Control-Allow-Origin配成*,然后又要求前端带Authorization。一旦请求携带凭证,*就不合法了,浏览器直接拦截。更隐蔽的是跨域带 Cookie:后端要返回具体的Origin,同时设置Access-Control-Allow-Credentials: true,前端fetch还要显式写credentials: 'include',axios 也要开withCredentials: true,三层缺一不可。

后端配置我直接给可用例子。Node Express 用cors中间件:

app.use(cors({ origin: ['https://admin.example.com'], credentials: true, allowedHeaders: ['Content-Type', 'Authorization'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] }));

Spring Boot 可以这样写:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("https://admin.example.com") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowCredentials(true); } }

还有一个被反复问的"谷歌浏览器跨域登录"问题。典型场景是登录接口返回Set-Cookie,但跨域请求下第三方 Cookie 默认不被接受,导致前端请求始终不带凭证。这种情况除了上面这些 CORS 配置,后端还得把SameSite设为None并加上Secure属性,同时保证页面是 HTTPS,否则浏览器依然不吃这一套。

2.2 JSONP 的原理和边界:只适合 GET 的旧方案

JSONP 算上一代开发者的肌肉记忆,核心思路是利用<script>标签不受同源策略限制这一点,把请求变成"加载一个 JS 文件"。服务端返回的不是纯 JSON,而是一段可执行脚本,把数据作为参数塞进回调函数里。

<script> function jsonpCallback(res) { console.log(res); } </script> <script src="https://api.example.com/user?callback=jsonpCallback"></script>

服务端返回jsonpCallback({ "id": 1 }),浏览器执行这段脚本,数据就进到你的回调里了。

但 JSONP 的局限性非常硬:只能 GET,因为<script>发不了 POST;错误处理几乎没有标准方案,接口挂了前端只能靠超时猜测;而且返回的是一段可执行代码,一旦第三方接口被篡改,直接把恶意脚本注到你的页面里,XSS 风险极高。所以我的态度很明确:新项目能不用就不用,只有对接一些只支持 JSONP 的老第三方接口时才搬出来。更要认清的一点是,它对 SSE 和 WebSocket 完全没有任何帮助,JSONP 只是当年无奈之下绕过浏览器限制的 hack,不是实时通信的基础设施。

2.3 Nginx 反向代理:连 WebSocket 和 SSE 一起解决

如果后端和前端都归你管,Nginx 反向代理是把跨域问题直接抹掉的方案。原理很简单:浏览器只认一个同源地址,所有跨域请求都由同源的 Nginx 转发到后端,从源头消灭跨域判断。

一个基础的反向代理配置:

server { listen 443 ssl; server_name admin.example.com; location /api/ { proxy_pass http://192.168.1.10:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

这时候后端不需要再为前端单独配 CORS,因为浏览器看到的请求始终是同源的。但如果你绕开 Nginx 直连后端,或者后端还被其他外部系统直接调用,那 CORS 该配还得配。

接着说两个针对性细节。第一是 WebSocket:Nginx 默认不识别Upgrade头,必须显式指定,否则握手直接失败:

location /ws/ { proxy_pass http://192.168.1.10:8080/ws/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 3600s; }

第二是 SSE:Nginx 默认开启缓冲,会把服务端推过来的数据攒到一定量再一次性发给客户端。对 SSE 来说这是灾难,你会看到消息延迟好几秒甚至看起来像断线。必须把缓冲关掉,同时把Connection清空:

location /sse/ { proxy_pass http://192.168.1.10:8080/sse/; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_set_header Host $host; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; chunked_transfer_encoding off; }

顺便回应一个热搜问题:"windows nginx 部署网页还需要跨域吗"。答案不取决于 Windows 还是 Linux,而取决于你的页面和接口是不是同一个域名加端口。如果页面直接 fetch 另一个 IP/域名的接口,照样跨域;只要在同一个 Nginx 里把两者代理成同源,就不需要额外处理跨域。

2.4 postMessage 解决 iframe 与微前端通信

CORS、JSONP、Nginx 都是解决"页面与服务器"之间的跨域通信,但还有一个场景是"页面与页面":主站嵌了第三方 iframe,或者微前端里两个应用窗口互相传数据。这时候要用window.postMessage,它是浏览器专门为跨窗口通信设计的 API。

父页面给 iframe 发消息:

const iframe = document.getElementById('child'); iframe.contentWindow.postMessage({ type: 'theme', value: 'dark' }, 'https://child.example.com');

iframe 内部监听,记得校验来源:

window.addEventListener('message', (event) => { if (event.origin !== 'https://parent.example.com') return; console.log(event.data); });

postMessage不能替代接口调用,它只解决"两个同页面上下文之间"的数据传递。很多新人误以为它能用来跨域调 API,这是方向性错误。它在微前端沙箱、客服悬浮窗、第三方登录弹窗这类场景里非常有用,但服务端推送、业务数据读写还是要回到 HTTP、SSE、WebSocket 这条线。

2.5 四种方案对照:什么时候选哪个

方案支持的请求方式是否需要后端配合生产环境推荐度典型场景
CORS所有 HTTP 方法需要高,但要注意细节前后端完全分离,接口直接暴露
JSONP仅 GET需要不推荐新项目对接老旧第三方接口
Nginx 反向代理所有 HTTP 方法 + WebSocket + SSE一般不需要改业务非常高统一网关、WebSocket/SSE 代理
postMessage页面间消息,非 HTTP一般不需要特定场景使用iframe 嵌入、微前端通信

3. SSE:基于 HTTP 的服务器推送,有哪些真正值得用的细节

3.1 text/event-stream 的消息格式与 EventSource 用法

SSE(Server-Sent Events)被严重低估,它是基于 HTTP 的服务器推送方案,服务端把Content-Type设为text/event-stream,然后持续往同一个响应里写入消息。

SSE 的消息格式很简洁,每一条消息用空行分隔,data:开头是数据,event:指定事件名,id:是消息 ID,retry:设置重连间隔。比如:

event: notify data: {"type": "order", "id": 123} id: 1 retry: 3000

浏览器端用法非常省心:

const es = new EventSource('/api/sse/notify'); es.onopen = () => { console.log('SSE 已连接'); }; es.onmessage = (event) => { console.log(JSON.parse(event.data)); }; es.addEventListener('notify', (event) => { // 只处理 event: notify 的消息 });

SSE 相比轮询的优势是一看就懂的:一次 HTTP 连接,服务端可以持续推流,客户端不需要反复发起请求。而且 EventSource 内置断线重连,服务端通过id字段告诉客户端最后一条消息的 ID,浏览器重连时会自动带上Last-Event-ID请求头,服务端可以从此处继续推,不需要自己维护复杂的长连接状态。

3.2 SSE 的鉴权姿势与重连机制

SSE 最大的坑在第一行代码就可能踩到:EventSource不支持自定义请求头。如果你的接口需要Authorization: Bearer xxx,直接用new EventSource(url)根本没法传。常见解法有三种:

第一种,也是实际项目里最常见的,把 token 放到 query 参数里:

const es = new EventSource(`/api/sse/notify?token=${encodeURIComponent(token)}`);

服务端从 query 里取 token 校验。缺点也很明显,网关日志里会带上完整 URL,token 有泄露风险,生产环境必须配合 HTTPS 和日志脱敏。

第二种,依赖 Cookie。接着上面 CORS 凭证那套配置走,SSE 请求时会自动带上同源 Cookie,服务端解析会话。这种方式干净,但跨域场景下 Cookie 的 SameSite、Secure、跨域凭证设置一个不对就失效。

第三种,绕开 EventSource,用fetch+ReadableStream自己解析。这样能设置任何请求头,还能做 POST:

const res = await fetch('/api/sse/stream', { headers: { 'Authorization': `Bearer ${token}` } }); const reader = res.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); // 自己按 \n\n 分隔解析 SSE 数据 }

代价是自动重连没有了,消息解析要自己写。我的建议是:能接受 query 放 token 就先用方案一,别一上来就搞 fetch 流解析,维护成本不是一个量级。

再一个高频坑是代理空闲超时,对应热搜里那句before completion: idle timeout waiting for sse。很多负载均衡、API 网关会在连接空闲一段时间后主动断开长连接。SSE 连接建立后如果业务数据很久不来一条,就被中间件当成闲置连接回收了。解决办法非常简单,服务端开个定时器,每隔 15 秒写一行注释,SSE 规范里注释行以冒号开头,客户端会直接忽略:

const timer = setInterval(() => { res.write(':ping\n\n'); }, 15000);

只要线上有中间层,这句话基本是必须的,别等报错了才补。

3.3 流式输出与通知推送:SSE 最值得用的两个场景

SSE 这两年重新火起来,很大程度是因为 AI 大模型流式输出。大模型接口返回的 token 是一段一段蹦出来的,服务端把它转成 SSE 流,前端就能实现打字机效果。很多大模型厂商的官方 SDK 本身就是基于 SSE 或类似的流协议,只是做了封装。

另一个典型场景是管理后台的通知中心、部署日志、订单状态变化。这类场景有一个共同特征:数据流向是单向的,服务端往客户端推,客户端几乎不需要往服务端发消息。用 WebSocket 当然也能做,但要多写一半的心跳、重连、消息路由代码,属于杀鸡用牛刀。

我自己实测下来,SSE 还有一层隐形优势:因为就是普通 HTTP,它可以复用现有的鉴权体系、跨域策略、网关日志,不需要为长连接单独做一套状态管理和连接编排。团队如果只是要"服务端主动推几条消息",先想想 SSE,别一上来就 WebSocket。

4. WebSocket 双向通道实战:握手、鉴权、心跳与断线重连

4.1 一次握手定生死:Sec-WebSocket-Key 与 Origin 校验

WebSocket 连接从一次 HTTP 握手开始,浏览器发一个带Upgrade头的 GET 请求:

GET /ws HTTP/1.1 Host: api.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw== Sec-WebSocket-Version: 13 Origin: https://admin.example.com

服务端验证通过后返回101 Switching Protocols,连接升级成 WebSocket,之后双方可以随时互发消息。注意Sec-WebSocket-Key不是密钥,它只是一个随机值,服务端把它拼上一个固定 GUID 再算 SHA-1,用于证明"这确实是一个 WebSocket 握手响应",防止普通 HTTP 缓存代理误响应。

跨域判定在这个阶段发生在服务端:浏览器不带 Cookie 之类的复杂校验,主要看Origin头,后端框架会验证 Origin 是否在白名单里。问题就出在有些非浏览器客户端(比如 WebView、小程序、原生 App)根本不发 Origin,或者发的是null,服务端一校验就把连接拒了。这基本是"H5 能连、打包 App 连不上"的头号原因——H5 在浏览器里会自动带上Origin,App 里没有这样的机制。

需要特别强调的是,CORS 配置对 WebSocket 握手不完全适用。很多后端框架虽然也提供allowedOrigins这类配置,但它不会像普通 HTTP 那样触发浏览器的 CORS 预检,而是由服务端直接对Origin做判断。所以如果 WebSocket 连接报跨域相关错误,优先查 WebSocket 网关的起源校验配置,而不是全局 CORS。

4.2 前端封装长连接:心跳保活与指数退避重连

WebSocket 与 SSE 最大的差别之一,是它所有可靠性机制都得自己写。连接会断,代理会回收空闲连接,移动端切网络更是家常便饭。我维护过多个在线客服项目,前端长连接模块都是同一套骨架:心跳 + 指数退避重连。

这里给一个可直接用的 TypeScript 封装,要点都写在注释里:

type MsgHandler = (data: any) => void; class RealtimeClient { private ws: WebSocket | null = null; private url: string; private token: string; private handlers: Set<MsgHandler> = new Set(); private heartbeatTimer: number | null = null; private retryCount = 0; private maxRetry = 10; private manualClosed = false; constructor(url: string, token: string) { this.url = url; this.token = token; } connect() { this.manualClosed = false; const fullUrl = this.url.includes('?') ? `${this.url}&token=${encodeURIComponent(this.token)}` : `${this.url}?token=${encodeURIComponent(this.token)}`; this.ws = new WebSocket(fullUrl); this.ws.onopen = () => { this.retryCount = 0; this.startHeartbeat(); }; this.ws.onmessage = (ev) => { const msg = JSON.parse(ev.data); this.handlers.forEach((handler) => handler(msg)); }; this.ws.onclose = (ev) => { this.stopHeartbeat(); // code 1000 是正常关闭,1006 是非正常关闭 if (!this.manualClosed && this.retryCount < this.maxRetry) { const delay = Math.min(30000, 1000 * Math.pow(2, this.retryCount)); this.retryCount++; setTimeout(() => this.connect(), delay); } }; this.ws.onerror = () => { // 触发 onerror 后浏览器紧接着会触发 onclose,不需要在这里重连 }; } private startHeartbeat() { this.stopHeartbeat(); this.heartbeatTimer = window.setInterval(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send('ping'); } }, 30000); } private stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } send(data: any) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); } } onMessage(cb: MsgHandler) { this.handlers.add(cb); } close() { this.manualClosed = true; this.stopHeartbeat(); this.ws?.close(1000, 'client close'); } }

几个关键设计点拆开讲。

心跳为什么必须做?因为 Nginx、云负载均衡、运营商 NAT 都会默默回收空闲连接。假如客户端 5 分钟没发消息,中间件可能已经把连接断掉了,但两端都不知道,直到下一次发送才发现。心跳就是每 30 秒发一个极小的报文把链路活性维持住,同时让服务端知道客户端还活着。

为什么重连要指数退避?如果没有退避,几千个客户端同时掉线会同时重连,服务端瞬间被打爆,这就是"重连风暴"。正常做法是第一次 1 秒、第二次 2 秒、第三次 4 秒,封顶 30 秒,第 10 次之后不再自动重连,让业务层决定下一步。

code 1006是什么意思?WebSocket 关闭码里,1000 代表正常关闭,1006 代表"非正常关闭"。说白了就是连接没有任何 Close 帧就断了,常见原因包括网络中断、代理断开、服务端进程崩溃。前端拿到 1006 的时候,能确定的是"连接没了",但没法区分是网络问题还是服务端主动踢人,所以业务上不要直接把它当"登录失效"处理。要判断是否被踢,得由服务端在关闭前发一条应用层消息,比如{ type: 'force_logout' },前端收到后再决定是否清理登录态。

4.3 鉴权放握手还是放业务层

WebSocket 鉴权的做法没有标准答案,我按实际场景排个优先级。

第一,query 参数传 token,服务端在握手阶段校验。实现最简单,和后端框架的握手拦截器配合得很顺。缺点是 token 会出现在访问日志里,所以生产环境必须 HTTPS,并且要注意日志脱敏。

第二,通过Sec-WebSocket-Protocol子协议传。前端这样写:

const ws = new WebSocket('wss://api.example.com/ws', ['chat_v1', token]);

服务端从握手头的子协议列表里把 token 解析出来,校验通过后在响应头里也要回显某个子协议,否则浏览器认为握手失败。这个方式比 query 参数隐蔽一些,但浏览器限制依然存在,而且有些老网关对多子协议支持不好,需要测试。

第三,Cookie 传递,服务端靠会话判断。这种方式在浏览器环境里最省心,因为 Cookie 会自动带上,但对非浏览器客户端不友好,App 里维护 Cookie 很别扭。

第四,连接建立后先发一条鉴权消息。比如:

{ "type": "auth", "token": "xxx" }

服务端收到后校验,失败就主动关闭连接。这种方式的好处是业务层可控,适合房间、群组、多端互踢这种需要动态权限的场景。坏处是握手本身就放开了,服务端需要有额外的消息状态机。

语音长连接之类的场景,我的建议是握手阶段做基础认证(方案一或三),业务层再做细粒度鉴权(方案四),双层配合。别指望一个方案通吃所有需求。

4.4 H5 能连、App 连不上怎么排查

这是 WebSocket 场景问题被问得最多的一组:浏览器里一切正常,打包成 App 就报错。按概率排序,我建议按下面这条线排查。

先查 Origin。App 里的 WebView 可能不发 Origin,也可能发null,如果服务端 WebSocket 校验了 Origin 白名单且没放行 null,握手就挂了。这个在 H5 里完全测不出来,因为你一打开浏览器它就自动带了正确的 Origin。

再查明文流量限制。Android 9 开始默认禁止明文 HTTP 流量,如果你连的是ws://而不是wss://,直接在网络层被系统拦掉,表现为连接秒断。需要在 Android 的网络安全配置里打开usesCleartextTraffic,或者全部换 HTTPS/WSS。

接着查打包工具的域名白名单。很多跨端框架(uni-app、APICloud 等)在打包时会有自己的网络权限配置,漏配 WebSocket 域名会导致连接被拦截。

最后查证书校验。App 里如果做了 SSL Pinning,证书稍有变动连接就会失败;H5 的浏览器反而不受这个逻辑影响。这也是"H5 好端端、App 秒断"的常见原因。

5. SSE 与 WebSocket 的取舍:各自的适用场景与容灾降级

5.1 协议与 API 层面的差异:不是同一个东西

表格是最好用的对比方式:

维度SSEWebSocket
底层协议HTTP,单向TCP,全双工
数据方向服务端 → 客户端双向
浏览器 APIEventSource,自带重连WebSocket,需手写重连
自定义请求头不支持支持
二进制数据支持有限,主要走文本原生支持二进制帧
消息类型通过 event 字段区分需要自己在协议里约定
自动重连内置,支持 Last-Event-ID无,需要手写
服务端实现难度低,普通 HTTP 接口即可高,需要处理长连接状态
典型扩展STOMP、Socket.IO、MQTT over WebSocket

一个经常被忽略的本质区别是:SSE 复用 HTTP 语义,所以它和现有网关、鉴权、日志体系是无缝衔接的;WebSocket 是一条独立的 TCP 隧道,一旦建立,你在普通 HTTP 层做的很多东西(比如请求级别的日志、负载均衡的健康检查)默认都不再适用于这条连接,需要单独处理。

5.2 按业务场景选型:单向通知选 SSE,双向交互选 WebSocket

我的选型标准很简单:服务端要主动推、客户端主要被动收的场景,选 SSE;两端都要频繁发消息的场景,选 WebSocket。

通知中心、工单流转、订单状态、AI 流式输出、实时日志,这些典型单向推流场景我用 SSE 就能干净解决,写起来和普通接口差不多,还不容易出 1006 这种玄学问题。

聊天室、在线客服、多人白板、协同编辑、游戏对战,这些场景客户端要持续上报操作、发送文本或二进制数据,就必须上 WebSocket。语音通话场景更是如此,音频帧走二进制消息,双向低延迟,SSE 完全做不了。

物联网场景我要多说一句。设备上报数据往往用的是 MQTT over WebSocket 而不是裸 WebSocket,因为 MQTT 协议天然带主题订阅、QoS 等级、遗嘱消息这些物联网需要的语义。而且设备量一大,"连接能不能建立"只是最低要求,你还要考虑设备身份可信、消息防重放、防止批量假设备来薅连接资源这类安全设计。前端同学做 H5 调试时不用深钻这些,但要有个意识:服务端的鉴权不会因为握手完成就结束,业务层还要持续校验。

5.3 弱网环境下的降级与补偿

实时通信最容易翻车的是弱网环境:电梯里、地铁上、跨运营商网络切换,连接说断就断。纯靠 WebSocket 一条路走到黑,体验会非常糟。

我的做法是分级降级。首选 WebSocket;如果连续重连次超过阈值,自动降级到 SSE;SSE 也不稳,就退回普通 HTTP 轮询。业务层不感知底层通道,都走同一个事件回调。这样虽然延迟可能从秒级变成几十秒,但用户至少不会看到"连接失败"的硬错误。

补偿机制同样重要。断线期间的消息不会因为通道恢复就自动补齐,所以要给每一条业务消息加seqid,客户端重连成功后把最近一条消息的 id 发给服务端,服务端拉取断点之后的消息推回来。消息消费要做幂等,防止重试导致的重复处理。这一套在实时聊天和通知场景里是必备的,别等线上丢消息了才补。

6. 落地实例:一个实时通知+在线客服的配置全记录

6.1 场景与拓扑:管理后台通知中心 + 在线客服

用一个真实项目做模板:管理后台,页面是 Vue3 + Vite,部署在 Nginx 上,后端是 Spring Boot,部署在内网 192.168.1.10:8080。系统里有两个实时需求:一个是通知中心,需要服务端推送工单流转、告警事件,单向推送,选 SSE;一个是在线客服聊天,客服和用户要双向发消息,选 WebSocket。

页面统一通过https://admin.example.com访问,Nginx 把/api代理到后端普通接口,/sse代理到 SSE 接口,/ws代理到 WebSocket。这样一个域名全搞定,跨域问题在浏览器层面直接不存在。

6.2 Nginx 上同时配置普通接口、SSE、WebSocket

完整配置如下,顺序无所谓,但路径别写重:

server { listen 443 ssl; server_name admin.example.com; # 普通接口 location /api/ { proxy_pass http://192.168.1.10:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # SSE 接口:关缓冲、清空 Connection location /sse/ { proxy_pass http://192.168.1.10:8080/sse/; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_set_header Host $host; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; chunked_transfer_encoding off; } # WebSocket 接口:必须带 Upgrade location /ws/ { proxy_pass http://192.168.1.10:8080/ws/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 300s; } }

几个参数解释清楚。proxy_read_timeout 300s是 Nginx 等待后端响应的超时时间,这里不是"5 分钟后必然断",而是"5 分钟没有任何数据交换才断"。心跳间隔 30 秒,远小于 300 秒,所以安全。如果你后端心跳周期超过 Nginx 的 read timeout,那 WebSocket 和 SSE 都会被 Nginx 掐断。

Spring Boot 端,SSE 用SseEmitter需要注意超时回调:

SseEmitter emitter = new SseEmitter(0L); // 0 表示不超时 emitter.onCompletion(() -> log.info("SSE completed")); emitter.onTimeout(() -> { log.warn("SSE timeout"); emitter.complete(); }); emitter.send(SseEmitter.event().name("notify").data(payload));

WebSocket 端,Spring 的registerWebSocketHandlers要把允许的 Origin 配清楚,别用*加凭证那一套,WebSocket 这里直接写具体来源:

registry.addHandler(chatHandler, "/ws") .setAllowedOrigins("https://admin.example.com");

6.3 前端实时模块代码:EventSource 与 WebSocket 如何共处

前端我把两套通道封装成一个统一入口。通知走 EventSource,聊天走上面的 RealtimeClient,对外暴露一个onMessage,业务组件不需要关心底层用的是哪种通道。

通知部分:

let es = null; let sseRetryCount = 0; function connectSSE(token) { es = new EventSource(`/sse/notify?token=${encodeURIComponent(token)}`); es.addEventListener('notify', (event) => { const payload = JSON.parse(event.data); eventBus.emit('notify', payload); }); es.onopen = () => { sseRetryCount = 0; }; es.onerror = () => { es.close(); if (sseRetryCount < 5) { sseRetryCount++; setTimeout(() => connectSSE(token), 3000 * sseRetryCount); } else { // 降级为 HTTP 轮询 startPollingNotify(); } }; }

核心思路是:能走 SSE 就走 SSE,连续失败多次就降级到轮询。轮询用普通的setInterval拉取最近通知,定时清掉,这个降级逻辑能让服务端推送挂了的时候页面看起来"还是正常的",无非延迟高一点。

聊天的 WebSocket 连接就接入上面 6.3 的 RealtimeClient,收到消息后也丢进同一个eventBus,页面层的代码完全感知不到连接方式的变化:

const chatClient = new RealtimeClient('wss://admin.example.com/ws', token); chatClient.onMessage((msg) => { eventBus.emit('chat', msg); });

实际项目里还有一个开发环境问题:Vite 的 proxy 默认不转发 WebSocket,需要在vite.config.ts里显式开启ws: true,否则本地开发一切正常,一打包到生产就完全不同的情况,源头往往就在这里。

server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true }, '/ws': { target: 'ws://localhost:8080', ws: true } } }

6.4 踩坑清单:从 Vite 代理到服务端主动关闭

列一下我在这个项目里实际踩过的坑,每条都是花过时间去排查的。

第一,CORS 预检失败时,浏览器报的是 blocked by CORS,但后端日志里可能完全看不到请求。原因在于 OPTIONS 请求在到达业务代码之前就被安全框架拦了。要先把 Nginx、Spring Security、Shiro 这类安全组件的 OPTIONS 放行逻辑检查一遍,否则你会以为问题出在响应头,实际请求根本没到后端。

第二,Spring Security 开着 CSRF 防护时,WebSocket 握手和 SSE 都会受影响。SSE 这类 GET 请求要按需放行;WebSocket 的握手路径也要加入忽略列表。不然你配好了所有连接,刷新页面后突然断开,一查是安全配置把握手当成非法请求了。

第三,Nginx 关掉 SSE 缓冲后,chunked_transfer_encoding off这句也很关键。有些版本不关这个,SSE 流仍然会走 chunked,表现还是延迟。两个配置要一起上。

第四,WebSocket 服务端主动关闭时,一定要发一个正常的关闭帧并带上 code。比如用户被踢下线,服务端要发1008+ 一条业务消息。如果不发关闭帧直接断开 TCP,客户端收到的就是 1006,等于把"账号被踢"和"网络断了"混在一起,前端重连逻辑根本没法区分,就会出现被踢了还在疯狂重连的尴尬场面。

第五,前端的心跳消息不要写在setInterval里就完事,要判断readyState === WebSocket.OPEN再发。有些同学没判断,连接关闭后定时器还在跑,ws.send抛异常,控制台一堆红,还会干扰重连逻辑。

第六,多实例部署时,WebSocket 连接的负载均衡要开会话保持(sticky session),否则两个请求被分到不同节点,聊天消息会乱掉。SSE 同样有这个要求。云端负载均衡默认的轮询策略对长连接不友好,这个配置忘掉的团队真不少。

最后说一点个人体会。很多人问我,既然 WebSocket 这么强大,为什么还要花大篇幅讲跨域、讲 SSE?我的回答是:实时通信项目里,真正让线上出问题的往往不是 WebSocket 本身,而是它前面的那几层——浏览器同源策略、Nginx 代理、握手鉴权、网络空闲回收。把跨域和 SSE 这些"老东西"吃透,你才能在 WebSocket 出问题时快速定位到是握手被拦了、代理断开了还是客户端保活没做好。这套链路我已经在多个项目里验证过,稳定运行了大半年,今天把这套完整配置和踩坑记录分享出来,希望对正在折腾实时通信的你有点帮助。

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

零基础Proteus仿真STM32点亮LED:从电路到GPIO编程全解析

简介&#xff1a;基于Proteus无实物仿真STM32的入门教程资源包&#xff0c;面向零基础学习者&#xff0c;以STM32F103R6点亮并闪烁LED为核心项目&#xff0c;帮助无开发板用户通过仿真完整走通IO输出初始化、延时函数编写与硬件连接流程。资源包共163个文件&#xff0c;约2.74M…

作者头像 李华
网站建设 2026/9/16 8:00:32

Linux文件管理进阶:inode、权限、磁盘回收与rsync排障手册

接手《Linux文件管理》这个系列的时候&#xff0c;我特意把“下篇”的选题范围往系统层面压。上篇大家普遍会把ls、cd、cp、mv、rm、mkdir这些高频操作讲透&#xff0c;但实际到了生产环境或者稍微复杂的个人服务器场景&#xff0c;你会发现真正卡人的往往不是命令记不记得住&a…

作者头像 李华
网站建设 2026/9/16 8:00:14

Colibri:纯C实现的MoE推理引擎,面向边缘与裸金属部署

1. 项目概述&#xff1a;Colibri 是什么&#xff0c;它解决的到底是什么问题&#xff1f;Colibri 这个名字乍一听像某种蜂鸟——轻盈、敏捷、高频振翅。但放在当前大模型推理的语境里&#xff0c;它指的是一套用纯 C 语言实现的、专为 MoE&#xff08;Mixture of Experts&#…

作者头像 李华
网站建设 2026/9/16 7:59:26

Colibri模块嵌入式Linux实战:从选型、Yocto构建到容器化部署

很多年前第一次看到 Colibri 这个型号时&#xff0c;我的第一反应是这名字取得真贴切。西班牙语里 colibri 就是蜂鸟&#xff0c;个头小、动作快、悬停精准&#xff0c;用在嵌入式计算机模块上再合适不过。巴掌大的核心板&#xff0c;跑着完整的 Linux 系统&#xff0c;串口、网…

作者头像 李华
网站建设 2026/9/16 7:58:50

S7-300在铝加工横切机中的毫秒级同步控制实现

简介&#xff1a;本资源是一套面向工业自动化初学者与工程实践者的西门子S7-300 PLC铝加工横切机控制程序源码包&#xff0c;适用于课程设计、毕业设计及小型产线控制系统开发参考。程序完整覆盖横切机逻辑控制、信号采集、动作时序与安全联锁等核心功能&#xff0c;可帮助用户…

作者头像 李华
网站建设 2026/9/16 7:58:42

STM32示波法血压测量系统:从传感器到算法的嵌入式实现

简介&#xff1a;本资源是一套基于STM32的脉搏电子血压计完整嵌入式开发工程&#xff0c;面向嵌入式初学者与硬件爱好者&#xff0c;解决健康监测类小型医疗设备从原理设计到软硬联调的实践难题。项目以STM32F103为核心控制器&#xff0c;融合压力传感器与光电脉搏检测模块&…

作者头像 李华