React SSE 流式日志展示性能问题与批量更新优化实践
流式日志展示性能问题与批量更新优化实践
1. 背景#
项目中存在一个基于 SSE(Server-Sent Events) 的实时日志展示功能。
服务端会持续通过 SSE 推送日志,前端接收到消息后将日志追加到页面中,实现类似终端、任务执行日志、AI Streaming Output 的实时展示效果。
最初的实现方式比较直接:
SSE Event ↓收到一条日志 ↓setState / 更新日志列表 ↓React Render ↓页面更新即:
每收到一个 SSE Event,就立即触发一次 React 状态更新。
在短时间运行时没有明显问题,但 SSE 持续运行十几分钟甚至几十分钟以后,浏览器开始出现明显的性能下降。
2. 问题现象#
实际运行过程中观察到了以下现象:
初始实现:Event 到达立即更新#
eventSource.onmessage = (event) => { const log = event.data;
setLogs((prev) => [...prev, log]);};随着 SSE 持续运行:
-
页面越来越卡
-
CPU 占用逐渐升高
-
浏览器标签页内存持续增长
-
日志越多,更新一次页面的成本越高
-
页面滚动、点击等交互开始出现延迟
-
长时间运行后甚至可能出现明显掉帧或卡顿
问题并不一定来自 SSE 协议本身。
真正的问题更多出现在:
SSE 消息产生的频率,直接变成了 React UI 更新频率。
例如服务端:
100 Event / 秒如果每个 Event 都执行一次 setState,理论上就可能产生:
100 次状态更新 / 秒持续 30 分钟就是:
100 × 60 × 30= 180,000 次消息处理而日志数组还会不断变大。
3. 第一版架构的问题#
原来的数据流实际上是:
Server │ │ SSE ▼Browser │ ▼onmessage │ ▼setLogs() │ ▼React Reconciliation │ ▼Component Render │ ▼DOM Update / Layout / Paint最大的问题是:
数据接收频率和 UI 渲染频率被绑定在了一起。
但实际上这两个频率没有必要保持一致。
假设:
服务端日志:200 条 / 秒用户并不需要浏览器:
渲染 200 次 / 秒因为显示器通常也只有:
60Hz / 120Hz甚至对于日志这种场景:
1~10 次 UI 更新 / 秒通常就已经能够提供足够好的实时体验。
4. 优化方案:Buffer + Batch Update#
后来将实现方式调整为:
SSE Event 到达时不立即更新 React State,而是先写入
ref中的 Buffer。
例如:
const bufferRef = useRef([]);
eventSource.onmessage = (event) => { bufferRef.current.push(event.data);};然后每隔一段时间,例如:
1000ms统一将 Buffer 中积累的数据提交给 React:
setInterval(() => { if (bufferRef.current.length === 0) { return; }
const batch = bufferRef.current;
bufferRef.current = [];
setLogs((prev) => [...prev, ...batch]);}, 1000);数据流因此变成:
┌── Event │Server ── SSE ──┼── Event │ ├── Event │ └── Event │ ▼ Buffer(ref) │ │ 每 1 秒 ▼ Batch Update │ ▼ setState │ ▼ React Render核心变化就是:
以前:
1 Event =1 State Update =1 次潜在 Render变成:
现在:
N Events ↓Buffer ↓1 Batch =1 State Update5. 实际效果#
修改为批量更新之后,性能表现出现了非常明显的变化。
其中一个非常值得记录的现象是浏览器标签页内存。
优化前:
SSE 持续运行 ↓内存持续增加 ↓页面越来越卡优化后:
SSE 持续运行 ↓内存上升 ↓达到约 1.3 GB ↓不再持续上涨 ↓继续运行 ↓内存甚至逐渐下降 ↓例如下降到约 1.1 GB也就是说出现了类似:
Memory
1.4G | |1.3G | ┌───────┐ | / ╲1.2G | / ╲ | / ╲1.1G | / ────── |1.0G | +--------------------------------→ Time这与之前:
Memory
1.4G | / | /1.3G | / | /1.2G | / | /1.1G | / | /1.0G |_________/ +--------------------------------→ Time形成了明显区别。
6. 为什么批量更新会产生这么大的改善?#
核心原因并不仅仅是:
setState调用次数减少了。
更准确地说,是整个浏览器渲染链路的压力都下降了。
6.1 React 更新频率下降#
假设 SSE:
200 Event / 秒原方案可能接近:
200 次 setState / 秒修改之后:
200 Event ↓ref Buffer ↓每秒 flush ↓1 次 setState / 秒状态提交频率理论上可以从:
200 次 / 秒降低到:
1 次 / 秒降低约:
99.5%需要注意,这并不意味着 React 一定进行了完全相同数量的 DOM Commit,因为 React 自身存在 batching 等机制。
但是在 SSE 这种持续异步、高频消息场景下:
主动进行业务层 Buffer,仍然可以显著减少状态更新、调度、数组创建和渲染压力。
7. ref 为什么适合做 Buffer?#
React 中:
useState和:
useRef最大的区别之一就是:
修改 state ↓可能触发 Render而:
修改 ref.current ↓不会因为这个修改本身触发 Render因此:
bufferRef.current.push(log);非常适合处理:
高频输入低频消费这种数据。
架构可以理解为:
高频SSE ─────────────────→ Buffer
│ │ 低频 ▼
React UI这实际上是在前端增加了一层:
数据缓冲层。
8. 为什么内存达到 1.3 GB 后反而下降?#
这个现象非常重要。
看到:
1.3 GB → 1.2 GB → 1.1 GB通常说明至少有一部分之前分配的内存已经变成:
可回收对象。
随后被 JavaScript GC 或浏览器内部的内存管理机制回收或释放。
浏览器内存不是:
申请 10MB释放 10MB标签页立即下降 10MB这么简单。
实际更接近:
对象不断创建 ↓部分对象失去引用 ↓成为 Garbage ↓等待 GC ↓GC 判断时机 ↓回收部分内存因此非常容易看到:
900MB ↓1.0GB ↓1.15GB ↓1.3GB ↓GC / 内存整理 ↓1.18GB ↓1.1GB这种波动本身并不异常。
9. 这说明之前一定存在内存泄漏吗?#
不能仅凭这个现象直接判断。
这是本次排查中一个非常重要的结论。
之前看到:
标签页内存一直增长第一反应很容易是:
Memory Leak但持续增长并不等价于真正的内存泄漏。
原方案中还可能存在:
高频 Event ↓大量 setState ↓大量临时 Array / Object ↓React Fiber 工作 ↓DOM 更新 ↓Layout / Paint ↓浏览器内部缓存 ↓GC 来不及处理也就是说:
对象产生速度可能长期高于浏览器的回收和处理速度。
此时就可能出现一种类似“内存泄漏”的表现:
内存一直上涨但本质可能是:
内存分配压力过高 + GC 回收滞后 + 渲染任务积压。
修改为 Batch Update 后:
对象产生速度下降+React 更新减少+DOM 操作减少+主线程压力下降+GC 获得更多执行机会于是就可能看到:
Memory Peak ↓GC ↓Memory Drop这与真正存在强引用、对象永远无法回收的 Memory Leak 有明显区别。
10. 一个更准确的性能模型#
原来的模型:
SSE Event Rate │ ▼State Update Rate │ ▼Render Rate │ ▼DOM Work基本是:
Event Rate ≈ Update Rate当:
Event Rate ↑整个系统压力都会上升。
优化以后:
Event Rate │ ▼ Buffer │ │ Throttle / Batch ▼ Flush Rate │ ▼ React Render于是:
Event Rate和:
Render Rate被成功解耦。
这是这次优化最核心的设计变化。