
最近群里又炸了一次锅起因特别简单一位老哥把项目从前端脚手架到后端网关全部配好了H5 里 WebSocket 连得稳稳的结果打包成 App 之后客户端疯狂报[websocket] onclose, code: 1006。他在群里连着刷了十几条消息最后才发现是服务端对空 Origin 做了拦截——浏览器会自动携带 OriginApp 的 WebView 在某些安卓版本里却不带这一下就把连接掐死了。这种问题单拎出来都不难难的是很多同学把跨域、SSE、WebSocket 这三套东西割开学遇到组合场景就手足无措。我做了十几年前端从最早的 JSONP 年代一路走到 WebSocket、SSE 成为标配这篇就把从跨域方案到 SSE、再到双向实时通信的完整链路和踩坑记录一次讲透适合正在写前端、写全栈或者需要维护 Nginx/网关的兄弟收藏。1. 先把跨域这件事说透同源策略到底拦截了什么1.1 判断跨域的三个硬性条件协议、域名、端口跨域不是玄学它背后只有一条规则浏览器发起的网络请求如果协议、域名、端口三者中任意一个跟当前页面不同就构成跨域。很多新手会在 localhost 上栽跟头前端跑在http://localhost:5173后端跑在http://localhost:8080从浏览器角度看这俩就不是一个源因为端口不一样属于跨域。还有兄弟把127.0.0.1和localhost混着用浏览器同样认为它们不同源哪怕指向同一个本机。判断是不是跨域最土的办法就是直接看地址栏和接口地址协议、域名、端口逐项比对。这里有个容易误解的点移动端 App 里其实没有传统意义上的同源策略约束因为页面/WebView 的宿主不是一个浏览器的标签页很多规则是失效的。但开发调试阶段大量使用 H5 和浏览器又要在 App 里跑同样的代码于是浏览器里的跨域规则和App 里的网络行为就变成了两套需要同时兼容的逻辑这也是后文 WebSocket 1006 那个坑的根源之一。1.2 被拦截的是读响应不是发请求我对团队里新人重复最多的一句话就是同源策略拦截的是读不是发。浏览器允许你把请求发出去但如果是跨域响应且没有通过 CORS 校验脚本就拿不到响应内容。这也解释了为什么script、img、form这些标签可以跨域请求资源——它们本来就不是给脚本读取的浏览器也懒得拦。你要用脚本动态抓取另一个域名下的 JSON就必须走XMLHttpRequest或fetch这时同源策略才会生效。顺带解决一个常见困惑前端此图片未经允许不可引用怎么解决——这通常不是同源策略而是图片服务器做了 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 代理与 postMessage2.1 CORS 的预检请求与凭证携带规则CORS 是跨域问题最标准的答案但标准答案里全是细节。先说预检不是所有跨域请求都会触发 preflight。像GET、HEAD、POST且Content-Type是text/plain、multipart/form-data、application/x-www-form-urlencoded之一的叫简单请求浏览器直接发一旦你加了自定义请求头、改用application/json、或者用了 PUT/DELETE浏览器会先发一个OPTIONS请求服务端必须在响应里返回允许的源、方法、请求头真正的业务请求才会继续。我见过最普遍的问题是把Access-Control-Allow-Origin配成*然后又要求前端带Authorization。一旦请求携带凭证*就不合法了浏览器直接拦截。更隐蔽的是跨域带 Cookie后端要返回具体的Origin同时设置Access-Control-Allow-Credentials: true前端fetch还要显式写credentials: includeaxios 也要开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 srchttps://api.example.com/user?callbackjsonpCallback/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 该配还得配。接着说两个针对性细节。第一是 WebSocketNginx 默认不识别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; }第二是 SSENginx 默认开启缓冲会把服务端推过来的数据攒到一定量再一次性发给客户端。对 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 用法SSEServer-Sent Events被严重低估它是基于 HTTP 的服务器推送方案服务端把Content-Type设为text/event-stream然后持续往同一个响应里写入消息。SSE 的消息格式很简洁每一条消息用空行分隔data:开头是数据event:指定事件名id:是消息 IDretry:设置重连间隔。比如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 校验。缺点也很明显网关日志里会带上完整 URLtoken 有泄露风险生产环境必须配合 HTTPS 和日志脱敏。第二种依赖 Cookie。接着上面 CORS 凭证那套配置走SSE 请求时会自动带上同源 Cookie服务端解析会话。这种方式干净但跨域场景下 Cookie 的 SameSite、Secure、跨域凭证设置一个不对就失效。第三种绕开 EventSource用fetchReadableStream自己解析。这样能设置任何请求头还能做 POSTconst 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 在浏览器里会自动带上OriginApp 里没有这样的机制。需要特别强调的是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: SetMsgHandler 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如果连续重连次超过阈值自动降级到 SSESSE 也不稳就退回普通 HTTP 轮询。业务层不感知底层通道都走同一个事件回调。这样虽然延迟可能从秒级变成几十秒但用户至少不会看到连接失败的硬错误。补偿机制同样重要。断线期间的消息不会因为通道恢复就自动补齐所以要给每一条业务消息加seq或id客户端重连成功后把最近一条消息的 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 出问题时快速定位到是握手被拦了、代理断开了还是客户端保活没做好。这套链路我已经在多个项目里验证过稳定运行了大半年今天把这套完整配置和踩坑记录分享出来希望对正在折腾实时通信的你有点帮助。