消息队列实战:从选型到高可用架构的完整指南
优化前:卡得不行
上周我们项目里有个消息推送功能,用户一进页面就疯狂弹通知,结果页面直接卡成PPT。不是夸张,是真的——滚动都卡,点击按钮要等1秒才响应,连 console.log 都打不出来。我打开 DevTools 一看,Performance 面板里全是红色的长任务(Long Task),主线程被占满了。
问题出在哪儿?其实很简单:我们用了一个“伪消息队列”——每次有新消息,就直接 push 到一个数组里,然后立刻触发 UI 更新。但用户如果短时间内收到 50 条消息(比如刚登录时同步历史通知),那就会连续触发 50 次 React 渲染,或者 Vue 的响应式更新。更糟的是,有些消息还带动画,每条都 requestAnimationFrame,直接把帧率干到个位数。
当时同事还说:“是不是手机太差了?” 我试了下 Mac 上的 Chrome,照样卡。这明显是代码问题,不是设备问题。
找到瓶颈了!
我先用 Chrome DevTools 的 Performance 录了一次加载过程。果然,主线程上一堆连续的 Function Call 和 Recalculate 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 或者更高级的调度策略,我也在持续学习中。
