← 所有文章

React SSE 流式日志展示性能问题与批量更新优化实践

流式日志展示性能问题与批量更新优化实践

1. 背景#

项目中存在一个基于 SSE(Server-Sent Events) 的实时日志展示功能。

服务端会持续通过 SSE 推送日志,前端接收到消息后将日志追加到页面中,实现类似终端、任务执行日志、AI Streaming Output 的实时展示效果。

最初的实现方式比较直接:

SSE Event
↓
收到一条日志
↓
setState / 更新日志列表
↓
React Render
↓
页面更新

即:

每收到一个 SSE Event,就立即触发一次 React 状态更新。

在短时间运行时没有明显问题,但 SSE 持续运行十几分钟甚至几十分钟以后,浏览器开始出现明显的性能下降。

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 Update

SSE优化更新

5. 实际效果#

修改为批量更新之后,性能表现出现了非常明显的变化。

其中一个非常值得记录的现象是浏览器标签页内存。

优化前:

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

被成功解耦。

这是这次优化最核心的设计变化。