sse连接问题(缓存)
SSE连接缓存与压缩问题排查
SSE 连接问题(缓存与压缩)
[!info] related notes
- 所属 MOC: http-and-frontend-networking-moc
- 概念: sse
- 流式处理: frontend-streaming-data-handling
- DevTools 路线: 流式连接排查路线, SSE 与 WebSocket 调试
问题详情
现象描述
在开发环境中使用 Server-Sent Events (SSE) 实现实时通知推送时,遇到了以下问题:
-
前端连接状态异常
- EventSource 的
readyState一直停留在0(CONNECTING 状态) - 无法触发
onopen事件 - 连接超时后自动断开
- EventSource 的
-
后端无法推送消息
- 前端显示”连接成功”
- 后端日志显示”连接不存在”
[WARN] [SSERoutes] [SSE Manager] 连接不存在 [WARN] [NotificationApplicationService] ⚠️ [SSE推送] 用户未连接SSE,推送失败 -
网络层问题
- 响应头显示
content-encoding: br(Brotli 压缩) - SSE 流被压缩后无法正常工作
- 跨域请求问题(localhost:5173 → localhost:3888)
- 响应头显示
根本原因
SSE 连接失败的核心问题是 多层压缩和缓冲:
- Vite 开发服务器压缩:Vite 默认启用压缩中间件,会对代理后的响应进行 Brotli 压缩
- Express 后端压缩:Express 使用 compression 中间件压缩所有响应
- Node.js 响应缓冲:Node.js 默认会缓冲响应数据,不会立即发送
- 跨域问题:前端 5173 端口直接请求后端 3888 端口触发 CORS
这些因素导致:
- SSE 的
text/event-stream流被压缩破坏 - 初始连接消息被缓冲,无法触发
onopen事件 - 后端虽然创建了连接对象,但由于流被破坏,连接立即失效
- 前端看到”连接成功”消息(因为最终收到了数据),但后端连接已经丢失
解决方案
1. 配置 Vite 代理避免跨域
文件:apps/web/vite.config.ts
export default defineConfig(({ mode }) => {
return {
server: {
port: 5173,
// 🔑 关键:完全禁用 Vite 的压缩中间件
compress: false,
proxy: {
'/api': {
target: 'http://localhost:3888',
changeOrigin: true,
secure: false,
ws: true,
// 禁用代理层的压缩
compress: false,
// SSE 特定配置
onProxyRes: (proxyRes: any, req: any, res: any) => {
if (req.url?.includes('/sse/')) {
// 删除可能存在的压缩相关头
delete proxyRes.headers['content-encoding'];
// 防止下游再次压缩
proxyRes.headers['x-no-compression'] = 'true';
}
},
},
},
},
};
});
关键点:
server.compress: false- 完全禁用 Vite 服务器的压缩proxy['/api'].compress: false- 禁用代理层的压缩onProxyRes回调中删除content-encoding头
2. 后端过滤 SSE 路由的压缩
文件:apps/api/src/app.ts
// 压缩中间件 - 排除 SSE 路由
app.use(
compression({
filter: (req: Request, res: Response): boolean => {
// SSE 路由不压缩
if (req.path.includes('/sse/')) {
return false;
}
// 其他路由使用默认压缩策略
return compression.filter(req, res);
},
})
);
原理:通过 filter 函数让 compression 中间件跳过所有 /sse/ 路径的请求。
3. 性能监控中间件跳过 SSE
文件:apps/api/src/middleware/performance.middleware.ts
export const performanceMiddleware = (
req: Request,
res: Response,
next: NextFunction
): void => {
// SSE 路由直接放行,避免在响应结束后设置 header
if (req.path.includes('/sse/')) {
return next();
}
// ... 正常的性能监控逻辑
};
原因:SSE 连接是长连接,响应不会结束,性能监控中间件在连接关闭后设置 header 会报错。
4. 强制刷新响应缓冲
文件:apps/api/src/modules/notification/interface/http/sseRoutes.ts
router.get('/events', authenticateJWT, async (req: Request, res: Response) => {
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
// 🔑 关键:立即发送响应头,不等待缓冲
res.flushHeaders();
// 发送初始连接事件
res.write(`event: connected\n`);
res.write(`data: ${JSON.stringify({ message: '连接成功', accountUuid })}\n\n`);
// 🔑 关键:立即刷新数据,触发前端 onopen 事件
res.flush();
// 注册连接到 SSE Manager
sseManager.addConnection(accountUuid, res);
// ... 处理连接关闭
});
关键点:
res.flushHeaders()- 立即发送 HTTP 响应头,不等待 Node.js 缓冲res.flush()- 立即刷新数据到客户端,触发onopen事件
5. 前端使用相对路径
文件:apps/web/src/modules/notification/infrastructure/sse/SSEClient.ts
export class SSEClient {
connect(force = false): void {
const token = this.authService.getAccessToken();
if (!token) return;
// 使用相对路径,通过 Vite 代理访问
const url = `/api/v1/sse/notifications/events?token=${token}`;
this.eventSource = new EventSource(url);
// 添加连接超时处理
this.connectionTimeout = window.setTimeout(() => {
if (this.eventSource?.readyState === EventSource.CONNECTING) {
console.warn('[SSE] 连接超时,尝试重连');
this.eventSource?.close();
this.reconnect();
}
}, 10000);
// ... 事件处理
}
}
改进点:
- 使用相对路径
/api/...而不是http://localhost:3888/... - 通过 Vite 代理避免跨域
- 添加 10 秒连接超时机制
实战经验
调试技巧
1. 使用独立测试页面
创建 sse-test.html 直接测试连接:
<!DOCTYPE html>
<html>
<head>
<title>SSE Test</title>
</head>
<body>
<h1>SSE Connection Test</h1>
<div id="status">未连接</div>
<div id="messages"></div>
<script>
const token = 'YOUR_TOKEN_HERE';
const url = `http://localhost:3888/api/v1/sse/notifications/events?token=${token}`;
const eventSource = new EventSource(url);
eventSource.onopen = () => {
document.getElementById('status').textContent =
'✅ 连接成功 - readyState: ' + eventSource.readyState;
};
eventSource.addEventListener('connected', (e) => {
console.log('Connected:', e.data);
const div = document.createElement('div');
div.textContent = `[connected] ${e.data}`;
document.getElementById('messages').appendChild(div);
});
eventSource.onerror = (e) => {
console.error('SSE Error:', e);
document.getElementById('status').textContent =
'❌ 错误 - readyState: ' + eventSource.readyState;
};
</script>
</body>
</html>
2. 创建 Token 快速获取脚本
get-new-token.sh:
#!/bin/bash
# 登录获取 token
RESPONSE=$(curl -s -X POST http://localhost:3888/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "your_password"
}')
# 提取并显示 token
echo $RESPONSE | jq -r '.data.accessToken'
3. 检查网络层压缩
在浏览器 DevTools Network 面板中:
- ✅ 正确:
content-type: text/event-stream,无content-encoding头 - ❌ 错误:
content-encoding: br或gzip存在
4. 监控 SSE 连接状态
// 在浏览器控制台实时监控
setInterval(() => {
const es = window.eventSource; // 需要暴露 EventSource 实例
console.log('SSE State:', {
readyState: es?.readyState,
states: {
0: 'CONNECTING',
1: 'OPEN',
2: 'CLOSED'
}[es?.readyState],
url: es?.url
});
}, 2000);
常见陷阱
❌ 陷阱 1:只配置了代理层的 compress: false
// ❌ 不够
proxy: {
'/api': {
compress: false // 仅禁用代理层压缩
}
}
// ✅ 正确
server: {
compress: false, // 禁用 Vite 服务器压缩
proxy: {
'/api': {
compress: false // 禁用代理层压缩
}
}
}
❌ 陷阱 2:只在后端禁用压缩,忽略 Vite
即使后端不压缩,Vite 开发服务器也会在代理响应时添加压缩。
❌ 陷阱 3:忘记调用 res.flush()
// ❌ 不会触发 onopen
res.write(`data: hello\n\n`);
// ✅ 立即触发 onopen
res.write(`data: hello\n\n`);
res.flush();
❌ 陷阱 4:使用绝对 URL 导致跨域
// ❌ 跨域问题
const url = 'http://localhost:3888/api/v1/sse/...';
// ✅ 通过代理避免跨域
const url = '/api/v1/sse/...';
验证清单
成功建立 SSE 连接需要满足:
- 前端
readyState为1(OPEN) - 触发
onopen事件 - 收到
connected事件 - 响应头无
content-encoding - 响应头有
content-type: text/event-stream - 后端日志显示”连接已添加”
- 后端可成功推送消息(“消息已发送”)
- 前端收到推送的通知
经验总结
核心原则
-
SSE 流不能压缩
- Brotli/Gzip 压缩会破坏
text/event-stream格式 - 必须在所有中间件层禁用压缩
- Brotli/Gzip 压缩会破坏
-
SSE 流不能缓冲
- Node.js 默认缓冲响应
- 必须显式调用
flushHeaders()和flush()
-
SSE 流不能跨域(开发环境)
- 使用 Vite 代理避免 CORS 预检请求
- 生产环境需正确配置 CORS 头
-
SSE 是长连接
- 不要在性能监控等中间件中处理 SSE 路由
- 避免在连接关闭后设置响应头
架构建议
开发环境
Frontend (5173) → Vite Proxy → Backend (3888)
(no compress) (no compress for /sse/)
生产环境
Frontend → Nginx → Backend
(no gzip for /sse/)
(proxy_buffering off)
Nginx 配置示例:
location /api/v1/sse/ {
proxy_pass http://backend:3888;
# 禁用代理缓冲
proxy_buffering off;
# 禁用 gzip 压缩
gzip off;
# SSE 特定头
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding on;
# 超时设置
proxy_read_timeout 24h;
proxy_send_timeout 24h;
}
性能考虑
-
连接数限制
- 浏览器对同一域名限制 6 个并发连接(HTTP/1.1)
- SSE 会占用一个连接
- 考虑使用 HTTP/2(单域名支持多路复用)
-
心跳机制
// 后端定期发送心跳,保持连接活跃 const heartbeat = setInterval(() => { res.write(': heartbeat\n\n'); res.flush(); }, 30000); // 每 30 秒 -
重连策略
// 前端指数退避重连 private reconnect(): void { const delay = Math.min(1000 * 2 ** this.retryCount, 30000); setTimeout(() => this.connect(), delay); this.retryCount++; }
调试工具推荐
-
浏览器 DevTools
- Network 面板查看响应头和数据流
- Console 监控 EventSource 状态
-
curl 测试
curl -N -H "Accept: text/event-stream" \ "http://localhost:3888/api/v1/sse/notifications/events?token=YOUR_TOKEN" -
自定义调试组件 创建 SSE 监控页面实时显示连接状态和消息
信息参考
技术文档
-
MDN - Server-sent events
- https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
- EventSource API 完整文档
-
HTML Living Standard - Server-sent events
-
Node.js Stream API
- https://nodejs.org/api/stream.html
- 理解
flush()和响应流
-
Vite Server Options
- https://vitejs.dev/config/server-options.html
- Vite 代理和压缩配置
-
Express compression
- https://github.com/expressjs/compression
- 压缩中间件 filter 选项
相关问题
-
Stack Overflow: EventSource stuck in CONNECTING
- 关键词:Node.js SSE response buffering
- 解决方案:
res.flushHeaders()+res.flush()
-
GitHub Issues: Vite proxy compresses SSE
- vitejs/vite#xxxx
- 解决方案:
server.compress: false
-
Nginx SSE Configuration
- 关键词:nginx proxy_buffering SSE
- 配置:
proxy_buffering off,gzip off
调试历程
本问题的完整调试过程:
- ✅ 发现
readyState停留在 0 - ✅ 添加连接超时和强制重连机制
- ✅ 配置 Vite 代理解决跨域
- ✅ 发现响应被压缩(
content-encoding: br) - ✅ 在后端过滤 SSE 路由的压缩
- ✅ 修复性能监控中间件干扰
- ✅ 添加
res.flush()解决缓冲问题 - ✅ 前端连接成功但后端显示”连接不存在”
- ✅ 发现 Vite 服务器仍在压缩
- ✅ 添加
server.compress: false完全禁用压缩
测试文件
本次调试创建的测试工具:
sse-test.html- 独立测试页面get-new-token.sh- Token 快速获取test-sse-connection.sh- curl 测试脚本apps/web/src/pages/SSEMonitor.vue- 监控组件
文档版本:v1.0
最后更新:2025-11-09
作者:AI Assistant
状态:✅ 问题已解决