SSE消息推送避坑指南:从Chrome兼容性到Spring超时配置
最近在重构一个内部运营系统,需要实时展示订单状态变化。最初考虑用WebSocket,但评估后发现,大部分场景都是服务端单向推送,客户端只需要接收。这时候SSE(Server-Sent Events)就成了更合适的选择——基于HTTP长连接,实现简单,浏览器原生支持,还自带断线重连机制。
听起来很美好对吧?但真正在生产环境落地时,我踩过的坑比想象中多得多。从Chrome浏览器的连接数限制,到Nginx代理的缓冲配置,再到Spring Boot中SseEmitter的内存泄漏问题,每一个细节都可能让整个推送系统崩溃。
这篇文章就是我在多个项目中实践SSE后总结的避坑经验。我不会重复那些基础教程,而是聚焦于生产环境中真正会遇到的问题,以及如何系统性地解决它们。无论你是第一次接触SSE,还是已经在使用但遇到了稳定性问题,这里都有你需要的答案。
1. 浏览器兼容性与连接管理:不只是IE的问题
很多人以为SSE的兼容性问题只存在于IE浏览器,实际上现代浏览器也有自己的“脾气”。Chrome和Firefox对并发连接数的限制、Safari的自动断开机制,都需要特别处理。
1.1 Chrome的并发连接限制与应对策略
Chrome对同一域名下的并发HTTP连接数有限制,通常是6个。这意味着如果你的页面同时打开了多个SSE连接,超过限制的连接会被阻塞。更糟糕的是,这种阻塞是静默的——浏览器不会报错,只是连接永远不会建立。
// 错误的做法:每个模块都创建独立的SSE连接
class NotificationService {
constructor() {
this.orderEventSource = new EventSource('/api/sse/orders');
this.messageEventSource = new EventSource('/api/sse/messages');
this.systemEventSource = new EventSource('/api/sse/system');
// 如果还有其他连接,就可能触发Chrome的限制
}
}
解决方案是连接复用。不要为每个功能创建独立的SSE连接,而是通过一个统一的连接分发不同的事件:
// 正确的做法:单一连接,多事件类型
class UnifiedSSEClient {
constructor(url) {
this.eventSource = new EventSource(url);
this.handlers = new Map(); // 存储不同类型事件的处理器
this.eventSource.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
const handlers = this.handlers.get(data.type) || [];
handlers.forEach(handler => handler(data.payload));
});
}
// 注册特定类型事件的处理器
subscribe(eventType, handler) {
if (!this.handlers.has(eventType)) {
this.handlers.set(eventType, []);
}
this.handlers.get(eventType).push(handler);
// 返回取消订阅的函数
return () => {
const handlers = this.handlers.get(eventType);
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
};
}
}
// 使用示例
const sseClient = new UnifiedSSEClient('/api/sse/unified');
const unsubscribeOrders = sseClient.subscribe('ORDER_UPDATE', (data) => {
console.log('订单更新:', data);
});
const unsubscribeMessages = sseClient.subscribe('NEW_MESSAGE', (data) => {
console.log('新消息:', data);
});
// 需要时取消订阅
// unsubscribeOrders();
1.2 Safari的自动断开与心跳机制
Safari有个“特性”:如果SSE连接长时间没有数据传输,它会自动断开。这个时间阈值大约是30秒到2分钟,取决于具体的Safari版本和系统负载。
注意:Safari的自动断开机制是为了节省资源,但对我们来说就是连接不稳定。必须通过心跳机制来保持连接活跃。
心跳机制不仅仅是定期发送空消息那么简单,还需要考虑网络状况和服务器负载:
@Component
public class HeartbeatScheduler {
@Autowired
private SseService sseService;
private final ScheduledExecutorService scheduler =
Executors.newScheduledThreadPool(1);
// 心跳间隔,单位秒
private static final long HEARTBEAT_INTERVAL = 25;
@PostConstruct
public void init() {
// 每25秒发送一次心跳,略小于Safari的断开阈值
scheduler.scheduleAtFixedRate(() -> {
sseService.broadcastHeartbeat();
}, HEARTBEAT_INTERVAL, HEARTBEAT_INTERVAL, TimeUnit.SECONDS);
}
@PreDestroy
public void cleanup() {
scheduler.shutdown();
}
}
@Service
public class SseService {
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
public void broadcastHeartbeat() {
String heartbeatData = "{\"type\":\"HEARTBEAT\",\"timestamp\":" +
System.currentTimeMillis() + "}";
emitters.forEach((userId, emitter) -> {
try {
// 发送心跳,但设置较短的超时时间
emitter.send(SseEmitter.event()
.data(heartbeatData)
.id("heartbeat_" + System.currentTimeMillis())
.reconnectTime(5000L));
} catch (IOException e) {
// 发送失败,可能是连接已断开
log.warn("心跳发送失败,用户: {}, 错误: {}", userId, e.getMessage());
removeEmitter(userId);
}
});
}
// 发送业务数据时也更新最后活动时间
public void sendMessage(String userId, String message) {
SseEmitter emitter = emitters.get(userId);
if (emitter != null) {
try {
emitter.send(SseEmitter.event()
.data(message)
.id("msg_" + UUID.randomUUID())
.reconnectTime(30000L));
// 更新最后活动时间
updateLastActivity(userId);
} catch (IOException e) {
log.error("消息发送失败: {}", e.getMessage());
removeEmitter(userId);
}
}
}
}
1.3 移动端浏览器的特殊处理
移动端浏览器(特别是iOS的Safari和Android的Chrome)在以下场景下会主动断开SSE连接:
- 应用切换到后台时:为了省电,浏览器会降低网络活动
- 锁屏时:网络连接可能被暂停
- 网络切换时:从WiFi切换到移动数据
针对这些情况,我们需要在前端实现更智能的重连逻辑:
class ResilientEventSource {
constructor(url, options = {}) {
this.url = url;
this.options = {
retryDelay: 1000, // 初始重试延迟
maxRetryDelay: 30000, // 最大重试延迟
backoffFactor: 1.5, // 退避因子
maxRetries: 10, // 最大重试次数
...options
};
this.retryCount = 0;
this.isConnected = false;
this.eventSource = null;
this.reconnectTimeout = null;
this.connect();
// 监听页面可见性变化
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && !this.isConnected) {
this.reconnect();
}
});
// 监听网络状态变化
window.addEventListener('online', () => {
if (!this.isConnected) {
this.reconnect();
}
});
}
connect() {
if (this.eventSource) {
this.eventSource.close();
}
this.eventSource = new EventSource(this.url);
this.eventSource.onopen = () => {
console.log('SSE连接已建立');
this.isConnected = true;
this.retryCount = 0; // 重置重试计数
};
this.eventSource.onmessage = (event) => {
this.options.onMessage?.(event);
};
this.eventSource.onerror = (error) => {
console.error('SSE连接错误:', error);
this.isConnected = false;
this.eventSource.close();
// 使用指数退避算法进行重连
if (this.retryCount < this.options.maxRetries) {
const delay = Math.min(
this.options.retryDelay * Math.pow(this.options.backoffFactor, this.retryCount),
this.options.maxRetryDelay
);
this.reconnectTimeout = setTimeout(() => {
this.retryCount++;
this.connect();
}, delay);
}
};
}
reconnect() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
}
this.connect();
}
close() {
if (this.eventSource) {
this.eventSource.close();
}
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
}
}
}
2. Nginx代理配置:那些隐藏的坑
当SSE服务部署在Nginx后面时,默认配置会导致各种奇怪的问题。最常见的就是连接超时、数据延迟、甚至连接被意外关闭。
2.1 必须调整的Nginx配置参数
下面是一个针对SSE优化过的Nginx配置示例:
http {
# 关键配置:提升单个连接的超时时间
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
# 禁用代理缓冲,确保数据实时传输
proxy_buffering off;
# 禁用响应缓冲
proxy_buffer_size 0;
proxy_buffers 0;
# 保持连接活跃
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header Upgrade $http_upgrade;
# 禁用请求体缓冲
proxy_request_buffering off;
server {
listen 80;
server_name your-domain.com;
location /api/sse/ {
# 针对SSE的特殊配置
proxy_pass http://backend-server;
# 必须设置正确的Content-Type
proxy_set_header Content-Type 'text/event-stream; charset=utf-8';
# 禁用缓存
proxy_set_header Cache-Control 'no-cache';
# 保持连接
proxy_set_header Connection 'keep-alive';
# 禁用gzip压缩,SSE数据需要实时传输
proxy_set_header Accept-Encoding '';
# 增加缓冲区大小,避免大消息被截断
proxy_buffer_size 16k;
proxy_buffers 4 16k;
# 重要:禁用Nginx的proxy_temp_path写入
# 这可以避免磁盘IO导致的延迟
proxy_max_temp_file_size 0;
# 处理CORS(如果需要)
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
# 预检请求处理
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
}
}
2.2 Nginx负载均衡下的SSE问题
在负载均衡环境中,SSE连接可能被分配到不同的后端服务器,这会导致两个问题:
- 连接状态丢失:用户重连后可能连接到不同的服务器
- 广播消息困难:消息需要发送到所有服务器
解决方案:使用Redis进行连接状态同步
@Component
public class DistributedSseService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ApplicationEventPublisher eventPublisher;
private final Map<String, SseEmitter> localEmitters = new ConcurrentHashMap<>();
// Redis channel用于服务器间通信
private static fi


397

被折叠的 条评论
为什么被折叠?



