消息推送系统设计与WebSocket实时通信实战经验

东方光纬 前端 阅读 1,698
赞 16 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

去年底接手一个后台管理系统,需求里有一条:用户操作后需要实时收到通知,比如审批通过、数据导出完成之类。一开始我直接想到 WebSocket,但团队里有人提了 Server-Sent Events(SSE),说更轻量、兼容性也不错。我一想,确实——我们不需要双向通信,只是服务端往前端推消息,用 SSE 完全够用,还能省掉心跳包和连接管理的麻烦。

消息推送系统设计与WebSocket实时通信实战经验

于是定了方案:后端用 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 实践,或者遇到类似问题,欢迎评论区交流!

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论