sse连接问题(缓存)

SSE连接缓存与压缩问题排查

#type / howto #status / growing #tech / network #platform / browser #resource / http #resource / javascript

SSE 连接问题(缓存与压缩)

[!info] related notes

问题详情

现象描述

在开发环境中使用 Server-Sent Events (SSE) 实现实时通知推送时,遇到了以下问题:

  1. 前端连接状态异常

    • EventSource 的 readyState 一直停留在 0(CONNECTING 状态)
    • 无法触发 onopen 事件
    • 连接超时后自动断开
  2. 后端无法推送消息

    • 前端显示”连接成功”
    • 后端日志显示”连接不存在”
    [WARN] [SSERoutes] [SSE Manager] 连接不存在
    [WARN] [NotificationApplicationService] ⚠️ [SSE推送] 用户未连接SSE,推送失败
  3. 网络层问题

    • 响应头显示 content-encoding: br(Brotli 压缩)
    • SSE 流被压缩后无法正常工作
    • 跨域请求问题(localhost:5173 → localhost:3888)

根本原因

SSE 连接失败的核心问题是 多层压缩和缓冲

  1. Vite 开发服务器压缩:Vite 默认启用压缩中间件,会对代理后的响应进行 Brotli 压缩
  2. Express 后端压缩:Express 使用 compression 中间件压缩所有响应
  3. Node.js 响应缓冲:Node.js 默认会缓冲响应数据,不会立即发送
  4. 跨域问题:前端 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: brgzip 存在

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 连接需要满足:

  • 前端 readyState1(OPEN)
  • 触发 onopen 事件
  • 收到 connected 事件
  • 响应头无 content-encoding
  • 响应头有 content-type: text/event-stream
  • 后端日志显示”连接已添加”
  • 后端可成功推送消息(“消息已发送”)
  • 前端收到推送的通知

经验总结

核心原则

  1. SSE 流不能压缩

    • Brotli/Gzip 压缩会破坏 text/event-stream 格式
    • 必须在所有中间件层禁用压缩
  2. SSE 流不能缓冲

    • Node.js 默认缓冲响应
    • 必须显式调用 flushHeaders()flush()
  3. SSE 流不能跨域(开发环境)

    • 使用 Vite 代理避免 CORS 预检请求
    • 生产环境需正确配置 CORS 头
  4. 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;
}

性能考虑

  1. 连接数限制

    • 浏览器对同一域名限制 6 个并发连接(HTTP/1.1)
    • SSE 会占用一个连接
    • 考虑使用 HTTP/2(单域名支持多路复用)
  2. 心跳机制

    // 后端定期发送心跳,保持连接活跃
    const heartbeat = setInterval(() => {
      res.write(': heartbeat\n\n');
      res.flush();
    }, 30000); // 每 30 秒
  3. 重连策略

    // 前端指数退避重连
    private reconnect(): void {
      const delay = Math.min(1000 * 2 ** this.retryCount, 30000);
      setTimeout(() => this.connect(), delay);
      this.retryCount++;
    }

调试工具推荐

  1. 浏览器 DevTools

    • Network 面板查看响应头和数据流
    • Console 监控 EventSource 状态
  2. curl 测试

    curl -N -H "Accept: text/event-stream" \
      "http://localhost:3888/api/v1/sse/notifications/events?token=YOUR_TOKEN"
  3. 自定义调试组件 创建 SSE 监控页面实时显示连接状态和消息

信息参考

技术文档

  1. MDN - Server-sent events

  2. HTML Living Standard - Server-sent events

  3. Node.js Stream API

  4. Vite Server Options

  5. Express compression

相关问题

  1. Stack Overflow: EventSource stuck in CONNECTING

    • 关键词:Node.js SSE response buffering
    • 解决方案:res.flushHeaders() + res.flush()
  2. GitHub Issues: Vite proxy compresses SSE

    • vitejs/vite#xxxx
    • 解决方案:server.compress: false
  3. Nginx SSE Configuration

    • 关键词:nginx proxy_buffering SSE
    • 配置:proxy_buffering off, gzip off

调试历程

本问题的完整调试过程:

  1. ✅ 发现 readyState 停留在 0
  2. ✅ 添加连接超时和强制重连机制
  3. ✅ 配置 Vite 代理解决跨域
  4. ✅ 发现响应被压缩(content-encoding: br
  5. ✅ 在后端过滤 SSE 路由的压缩
  6. ✅ 修复性能监控中间件干扰
  7. ✅ 添加 res.flush() 解决缓冲问题
  8. ✅ 前端连接成功但后端显示”连接不存在”
  9. ✅ 发现 Vite 服务器仍在压缩
  10. ✅ 添加 server.compress: false 完全禁用压缩

测试文件

本次调试创建的测试工具:

  1. sse-test.html - 独立测试页面
  2. get-new-token.sh - Token 快速获取
  3. test-sse-connection.sh - curl 测试脚本
  4. apps/web/src/pages/SSEMonitor.vue - 监控组件

文档版本:v1.0
最后更新:2025-11-09
作者:AI Assistant
状态:✅ 问题已解决

创建于 2025/1/1 更新于 2026/5/27