消息队列实战:从选型到高可用架构的完整指南

程序员艳清 交互 阅读 2,768
赞 19 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周我们项目里有个消息推送功能,用户一进页面就疯狂弹通知,结果页面直接卡成PPT。不是夸张,是真的——滚动都卡,点击按钮要等1秒才响应,连 console.log 都打不出来。我打开 DevTools 一看,Performance 面板里全是红色的长任务(Long Task),主线程被占满了。

消息队列实战:从选型到高可用架构的完整指南

问题出在哪儿?其实很简单:我们用了一个“伪消息队列”——每次有新消息,就直接 push 到一个数组里,然后立刻触发 UI 更新。但用户如果短时间内收到 50 条消息(比如刚登录时同步历史通知),那就会连续触发 50 次 React 渲染,或者 Vue 的响应式更新。更糟的是,有些消息还带动画,每条都 requestAnimationFrame,直接把帧率干到个位数。

当时同事还说:“是不是手机太差了?” 我试了下 Mac 上的 Chrome,照样卡。这明显是代码问题,不是设备问题。

找到瓶颈了!

我先用 Chrome DevTools 的 Performance 录了一次加载过程。果然,主线程上一堆连续的 Function CallRecalculate Style,每个都几十毫秒,加起来超过 3 秒。再点开看,发现是同一个函数反复执行:handleNewMessage

接着我在代码里加了个计数器:

let messageCount = 0;
function handleNewMessage(msg) {
  console.log('收到第', ++messageCount, '条消息');
  // 原始逻辑:直接更新状态
  setState(prev => [...prev, msg]);
}

刷新页面,控制台瞬间刷出 68 条日志。好家伙,一次性来了 68 条消息。这不卡才怪。

所以问题很明确:**高频消息触发高频 UI 更新,导致主线程过载**。解决方案就是——别让每条消息都立刻更新 UI,得用队列 + 批处理。

试了几种方案,最后这个效果最好

我一开始想用 setTimeout 合并更新,比如每 100ms 处理一次队列。但这样会有延迟,用户可能看到“消息来了但没显示”的空档期,体验不好。

后来想到用 requestIdleCallback,但兼容性太差,而且它只在浏览器空闲时才执行,在高负载场景下可能根本轮不到它跑。

最后决定用 **微任务 + 防抖 + 批量提交** 的组合拳。核心思路是:消息先入队,但不立刻更新 UI;等当前宏任务结束、微任务队列清空前,统一处理所有积压消息,只触发一次渲染。

关键代码如下:

class MessageQueue {
  constructor() {
    this.queue = [];
    this.isPending = false;
  }

  enqueue(message) {
    this.queue.push(message);
    if (!this.isPending) {
      this.isPending = true;
      // 使用微任务,在当前宏任务结束后、UI 更新前执行
      Promise.resolve().then(() => {
        this.flush();
      });
    }
  }

  flush() {
    if (this.queue.length === 0) {
      this.isPending = false;
      return;
    }

    const batch = [...this.queue];
    this.queue = [];
    this.isPending = false;

    // 只触发一次状态更新
    this.onBatchProcessed(batch);
  }

  onBatchProcessed(batch) {
    // 这里交给 UI 层,比如 React 的 setState
    updateUI(batch);
  }
}

// 使用
const msgQueue = new MessageQueue();

// 原来的 handleNewMessage 改成:
function handleNewMessage(msg) {
  msgQueue.enqueue(msg);
}

这里注意我踩过好几次坑:一开始用 queueMicrotask,但老版本浏览器不支持,所以改用 Promise.resolve().then(),效果一样,兼容性更好。

另外,flush 里一定要清空 queue 并重置 isPending,否则新消息进来会漏掉。有一次我就忘了清空 queue,结果第二批消息没触发更新,调试了半小时才发现。

核心代码就这几行,但效果立竿见影

上面那个 MessageQueue 类,其实核心就 20 行。但它解决了两个关键问题:

  • 合并高频更新:无论 10 条还是 100 条,只触发一次 UI 更新
  • 时机精准:利用微任务,在当前 JS 执行完、浏览器渲染前插入,既不阻塞主线程,又不会延迟太久

对比一下优化前后的代码差异:

优化前(直接更新):

// 每条消息都触发一次 setState
function onMessage(msg) {
  setMessages(prev => [...prev, msg]);
}

优化后(走队列):

const msgQueue = new MessageQueue();
msgQueue.onBatchProcessed = (batch) => {
  setMessages(prev => [...prev, ...batch]); // 一次加 N 条
};

function onMessage(msg) {
  msgQueue.enqueue(msg); // 只入队,不更新
}

就这么简单。但别小看这改动,它把“N 次渲染”变成了“1 次渲染”,省下的计算量是指数级的。

性能数据对比

我在本地模拟了 80 条消息同时到达的场景,用 Lighthouse 和手动 Performance 录制测了三次取平均值:

  • 优化前:主线程阻塞 4.2 秒,FCP(First Contentful Paint)5.1 秒,TTI(Time to Interactive)6.3 秒,帧率最低跌到 8 FPS
  • 优化后:主线程阻塞 0.7 秒,FCP 1.2 秒,TTI 1.5 秒,帧率稳定在 58-60 FPS

加载时间从 5 秒多降到 800 毫秒内,交互响应几乎无延迟。最关键的是,用户再也感觉不到“卡顿”了——滚动流畅,点击即响应。

当然,这个方案也不是万能的。比如如果你的消息需要严格按顺序逐条显示(比如聊天室),那可能还得配合动画队列。但我们这个场景是“通知中心”,顺序不重要,批量显示完全没问题。

踩坑提醒:这三点一定注意

1. 别在 flush 里再 enqueue:如果 onBatchProcessed 里又调了 enqueue,可能会无限循环。我加了 isPending 标志就是为了避免这个问题。

2. 错误处理别忘:如果 onBatchProcessed 抛错,整个队列就卡住了。建议加个 try-catch:

flush() {
  try {
    const batch = [...this.queue];
    this.queue = [];
    this.isPending = false;
    this.onBatchProcessed(batch);
  } catch (err) {
    console.error('消息队列处理失败', err);
    // 可选:重试 or 上报
  }
}

3. 内存别泄漏:如果页面长期不刷新,queue 可能无限增长。虽然我们项目里消息最多几百条,但保险起见,可以在 flush 后加个长度限制,比如超过 1000 条就截断。

结尾:以上是我的优化经验,有更好的方案欢迎交流

这个消息队列优化方案,我已经在三个项目里复用了,效果都很稳。它不复杂,但特别实用——尤其适合那种“突发大量事件”的场景,比如 WebSocket 推送、日志上报、实时数据流等。

当然,如果你的框架本身支持批量更新(比如 React 18 的自动批处理),那可能不需要自己写队列。但我们项目还在用 React 16,所以只能自己动手。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流,比如用 Web Worker 或者更高级的调度策略,我也在持续学习中。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
码农玉萱
这些新思路让我在面对复杂问题时,不再局限于固有的方法。
点赞
2026-03-02 15:25