消息推送系统设计与WebSocket实时通信实战经验
项目初期的技术选型
去年底接手一个后台管理系统,需求里有一条:用户操作后需要实时收到通知,比如审批通过、数据导出完成之类。一开始我直接想到 WebSocket,但团队里有人提了 Server-Sent Events(SSE),说更轻量、兼容性也不错。我一想,确实——我们不需要双向通信,只是服务端往前端推消息,用 SSE 完全够用,还能省掉心跳包和连接管理的麻烦。
于是定了方案:后端用 Node.js + Express 搭个 SSE 接口,前端用 EventSource 接收。看起来简单,结果后面踩了一堆坑……
核心代码就这几行?别信
刚开始写的前端代码特别理想化:
const eventSource = new EventSource('/api/notifications');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
showNotification(data.title, data.content);
};
本地跑起来没问题,消息一来就弹。但上线后问题来了:用户切换标签页或者锁屏回来,消息就收不到了。查了文档才知道,EventSource 默认在页面不可见时会暂停接收,虽然规范没强制要求,但 Chrome 和 Safari 都这么干。
更头疼的是,如果用户长时间挂着页面,连接可能因为 Nginx 超时断开(默认 60 秒),而 EventSource 的重连机制是指数退避,第一次断开 3 秒重试,第二次 6 秒,第三次 12 秒……用户等 12 秒才收到“订单已发货”这种消息,体验肯定崩了。
最大的坑:性能问题
真正让我熬夜的是性能问题。测试环境只有几个人用,一切正常。但一上线,几百人同时在线,Node.js 服务直接内存飙升。原因很简单:每个 EventSource 连接都会占用一个 HTTP 连接,而 Express 默认是单线程,大量长连接把事件循环堵死了。
后来加了日志才发现,有些用户开了多个标签页,每个标签页都建了一个 SSE 连接,一个人占了 3-4 个连接。再加上移动端网络不稳定,频繁断开重连,连接数雪球越滚越大。
折腾了半天,最后做了三件事:
- 前端加了连接复用逻辑:同一个浏览器只允许一个活跃的 SSE 连接(通过 localStorage 锁)
- 后端改用 cluster 模式,多进程分担连接压力
- 在 Nginx 层调大 proxy_read_timeout 到 300 秒,避免过早断开
但最有效的还是前端限制。代码大概是这样:
// 防止多标签页重复建立连接
const CONNECTION_KEY = 'sse_connection_id';
const currentId = Date.now().toString();
if (!localStorage.getItem(CONNECTION_KEY)) {
localStorage.setItem(CONNECTION_KEY, currentId);
const eventSource = new EventSource('/api/notifications');
// 页面卸载时清理
window.addEventListener('beforeunload', () => {
localStorage.removeItem(CONNECTION_KEY);
eventSource.close();
});
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
showNotification(data.title, data.content);
};
// 监听 localStorage 变化,应对其他标签页关闭
window.addEventListener('storage', (e) => {
if (e.key === CONNECTION_KEY && !e.newValue) {
// 其他标签页释放了连接,本页可以尝试接管
// 实际没做这么复杂,先保证单连接就行
}
});
}
这个方案其实不完美——如果用户直接关掉所有标签页,localStorage 不会触发清理,下次打开要等 5 分钟超时才能重建连接。但我们评估后觉得影响不大,毕竟不是高频操作。
认证和安全怎么搞
另一个头疼的是鉴权。SSE 用的是标准 HTTP,所以可以用 Cookie 或 Authorization header。但 EventSource 构造函数不支持自定义 header,只能靠 URL 带 token:
const token = getAuthToken(); // 从 localStorage 或 cookie 读
const eventSource = new EventSource(/api/notifications?token=${encodeURIComponent(token)});
但这样 token 会出现在服务器日志和浏览器历史里,不太安全。后来跟后端商量,改成用 Cookie 鉴权(SameSite=Strict),前端不用传 token,后端从 session 里取用户信息。这样更干净,也符合我们已有的登录体系。
不过要注意:如果用户登出,得主动关闭 EventSource 并清理连接,否则后端还在往一个无效会话推消息,浪费资源。
最终的解决方案
综合下来,我们的完整实现是这样的:
class NotificationManager {
constructor() {
this.eventSource = null;
this.connectionId = null;
}
start() {
// 单实例保护
if (this.eventSource || localStorage.getItem('sse_active')) return;
this.connectionId = Date.now().toString();
localStorage.setItem('sse_active', this.connectionId);
this.eventSource = new EventSource('/api/notifications');
this.eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.handleMessage(data);
} catch (e) {
console.error('Invalid SSE message', e);
}
};
this.eventSource.onerror = () => {
// 简单重连:5秒后重试(避免指数退避)
setTimeout(() => {
if (this.eventSource?.readyState === EventSource.CLOSED) {
this.restart();
}
}, 5000);
};
window.addEventListener('beforeunload', () => this.stop());
}
stop() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
if (localStorage.getItem('sse_active') === this.connectionId) {
localStorage.removeItem('sse_active');
}
}
restart() {
this.stop();
this.start();
}
handleMessage(data) {
// 实际业务逻辑,比如更新小红点、弹 toast
showNotification(data.title, data.content);
}
}
// 初始化
const notifier = new NotificationManager();
notifier.start();
后端也配合做了优化:每个用户只维护一个推送通道,新消息合并发送,避免频繁触发。API 地址就用 https://jztheme.com/api/notifications 这种格式,纯粹示例用。
回顾与反思
这套方案上线三个月,基本稳定。消息延迟在 1 秒内,用户反馈不错。但有几个遗憾:
- 移动端 Safari 有时会静默断开连接,还没找到完美解法,目前靠前端定时检测
readyState强制重连 - 没有做消息持久化,如果用户离线期间有消息,回来后收不到。但产品说优先级不高,先不做
- SSE 在 IE 上完全不支持,不过我们系统本来就不支持 IE,所以无所谓
回头想想,如果现在重做,可能会考虑用 WebSocket + 心跳保活,虽然重一点,但控制更精细。不过对当前场景来说,SSE 确实够用,而且开发成本低。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的 SSE 实践,或者遇到类似问题,欢迎评论区交流!

暂无评论