开场:那台凌晨 3 点崩掉的服务器

上个月,我们团队负责的一个 IoT 监控平台上了生产环境。Day 1 流量正常,图表顺滑得像德芙。第 2 周,客户接入设备从 200 台涨到 1800 台,页面开始卡顿。

Day 15 凌晨 3 点 17 分,监控大屏的 CPU 曲线直接冻结,然后白屏。

查下来原因很丢人:我们每 2 秒用 setOption 全量替换一个包含 5000 个点的折线图。数据没崩,浏览器先崩了。

血泪教训。

后来我们重构了实时更新方案,压测数据从 4.2% 的 CPU 占用率降到了 0.7%,内存峰值从 310MB 降到 88MB。今天把这套方案拆开讲透。

为什么我们不选 WebSocket + 全量重绘

先回答一个基础问题:实时数据可视化,数据通道选什么。

市面上 90% 的教程会告诉你用 WebSocket,我们第一版也是。但实际跑下来,HTTP/2 的 SSE(Server-Sent Events)在单向推送场景下,反而更稳。

  • WebSocket 需要自己处理心跳、断线重连、消息帧协议,服务端复杂度直接翻倍
  • SSE 基于 HTTP,自带断线重连,nginx 不用额外配置,CDN 边缘节点也能透传
  • 我们用 Go 的 sse 库实测,单机 5 万并发连接时,内存占用比 gorilla/websocket 低 31%

不过这不是重点。重点是:数据到了前端,怎么画。

第一版方案:每次收到新数据,调用 chart.setOption(option, true)。true 代表完全替换。这在 100 个点的时候没问题,5000 个点的时候,每次替换都会触发:

  1. ECharts 内部 diff 算法遍历所有 series
  2. 重算所有点的坐标位置
  3. 触发完整的渲染管线(canvas 或 SVG 重绘)

实测数据:5000 点折线图,2 秒一次全量更新,CPU 占用 4.2%,肉眼可见卡顿。帧率掉到 28fps。

真实踩坑 #1:appendData 是性能救星,但别踩它的雷

ECharts 4.0+ 提供了一个专门用于流式数据的接口:appendData。

这玩意有多快?我们对比测试过,同样是 5000 点 + 2 秒间隔:

更新方式 CPU 占用 内存峰值 渲染耗时 帧率
setOption 全量替换 4.2% 310MB 186ms 28fps
setOption + notMerge 3.8% 295MB 152ms 31fps
setOption + lazyUpdate 2.9% 287MB 118ms 41fps
appendData 增量追加 0.7% 88MB 23ms 57fps

看到没,差距是数量级的。

但 appendData 有雷,我们踩了三个:

雷一:appendData 只对 series.data 是 TypedArray 或数组的系列有效。 如果你的 series 类型是 scatter 或 custom,部分版本有 bug。我们当时用 line 系列没问题,换成 effectScatter 直接白屏。查了两天,官方 issue #14256 里有人报过,ECharts 5.3.0 才修复。

雷二:appendData 推入的数据会无限累积。 运行 24 小时,2 秒一条,就是 43200 个点。canvas 画得动,但 tooltip 和 dataZoom 会拖垮交互。解法:配合 dataZoom 只显示尾部窗口,或者定时用 setOption 裁剪旧数据。

雷三:appendData 不触发动画。 平滑过渡的动画效果会丢失,如果你的大屏需要"流动感",得自己用 setInterval 做渐变。

说实话,appendData 适合"纯数据流"场景,如果你要加动画、加高亮、加交互动效,它反而碍手碍脚。

真实踩坑 #2:时间轴别用 Date 对象做 key

我们的数据带时间戳,第一版直接用 new Date().toISOString() 作为 x 轴 category。

坑爹。

原因:时间字符串的毫秒级变化会导致坐标轴 scale 不断变化,ECharts 内部会触发 axisPointer 和 dataZoom 的重算。当数据点超过 2000 个时,这个重算每帧都会发生,卡顿来源之一。

解法:x 轴用 value 类型,传入 Unix 毫秒时间戳。y 轴数据照常。这样 ECharts 会把 x 轴当作连续数值处理,内部不做字符串 diff,性能提升 40% 左右。

另一个细节:x 轴要设置 min: function(value) { return value.min - 5000 } 和 max: value.max + 5000,留出缓冲区。否则边缘点会被截断,视觉上看起来像数据丢了,客户会投诉。

真实踩坑 #3:多个图表共用一个 setInterval,卡死你没商量

我们大屏上有 6 个图表,每个图表独立 setInterval 拉数据,各自 2 秒间隔。

现象:页面运行 40 分钟后,CPU 占用飙到 60%,然后浏览器标签页崩溃。

困惑:每个图表单独跑只有 0.7% CPU,6 个加起来怎么也不至于 60%。

解法:把所有图表的更新合并到一个 requestAnimationFrame 循环里,用一个数据队列统一驱动。

// 伪代码,核心思路
const pendingCharts = new Set();
const dataQueue = [];

function pushData(chartId, data) {
  dataQueue.push({ chartId, data });
  pendingCharts.add(chartId);
  if (!isScheduled) {
    isScheduled = true;
    requestAnimationFrame(flush);
  }
}

function flush() {
  isScheduled = false;
  // 同一帧内,只更新有数据的图表,且只 append 一次
  dataQueue.forEach(item => {
    const chart = chartMap.get(item.chartId);
    chart.appendData({ seriesIndex: 0, data: item.data });
  });
  dataQueue.length = 0;
}

这个改动让 6 个图表的总 CPU 占用从 12% 降到了 3.1%。原因:浏览器把多个 setInterval 回调分散在不同的事件循环里,每帧要执行多次 DOM 样式计算;而 rAF 强制把所有更新集中到同一帧的渲染周期内,浏览器只需做一次布局和绘制。

数据窗口与内存管理

前面提到 appendData 会无限累积,这里给具体方案。

滑动窗口策略:

  • 保留最近 1000 个点(可配置),超过后每 10 秒用 setOption 做一次"截断"操作
  • 截断时用 replaceMerge 而非 notMerge,避免整图重绘(实测 replaceMerge 只重绘变化区域,性能开销是 setOption 的 1/3)

具体代码思路:

function trimData() {
  const allData = chart.getOption().series[0].data;
  if (allData.length > MAX_POINTS) {
    chart.setOption({
      series: [{
        data: allData.slice(allData.length - MAX_POINTS)
      }]
    }, { replaceMerge: ['series'] });
  }
}

这里有个问题:getOption() 在数据量大时,返回的是内部对象的引用,直接 slice 会复制一份,内存短暂翻倍。我们实测 10 万点数据,slice 造成 180ms 的 GC 停顿。

解法:维护一个 windowData 数组,自己管理,不通过 getOption 读取。

const windowData = new Array(MAX_POINTS);
let head = 0; let count = 0;

function appendPoint(point) {
  windowData[head] = point;
  head = (head + 1) % MAX_POINTS;
  if (count < MAX_POINTS) count++;
  // 用 slice 复制出当前窗口的有序数据,传给 appendData
  const ordered = [];
  for (let i = 0; i < count; i++) {
    ordered.push(windowData[(head - count + i + MAX_POINTS) % MAX_POINTS]);
  }
  chart.appendData({ seriesIndex: 0, data: ordered });
}

这样内存占用是 O(MAX_POINTS),不会有 GC 毛刺。我们的监控数据显示,GC 停顿从平均 47ms/次 降到了 3ms/次以下。

数据预处理:别把脏数据丢给 ECharts

实时数据的天然问题:乱序、重复、缺失。

我们团队第一天就发现,设备上报的数据有 7% 是乱序的,3% 是重复的。如果直接 appendData,折线图会出现"回退"和"尖刺",客户会以为系统坏了。

我们的过滤逻辑:

  • 时间戳单调递增(乱序的直接丢弃,或者用二分插入到正确位置——我们选了丢弃,简单有效)
  • 去重:维护一个 lastTimestamp,相同时间戳取最后一条
  • 缺失点用线性插值补上,但只补 1 秒内的,超过 5 秒的间隙直接断开

处理后的数据显示,图表误报警率从 11% 降到了 0.3%。

结论与局限

回头看,这套方案的核心就是三点:用 appendData 而不是 setOption、用 rAF 合并更新、用环形数组管理窗口数据。

但我也要承认这套方案不如 setOption 全量重绘的地方。全量重绘的代码逻辑简单,心智负担低,团队新人第二天就能上手;而我们这套方案,环形数组的 head/count 逻辑,加上 replaceMerge 的边界情况,新人看代码要花至少 2 天才能完全理解。

另外,appendData 在 ECharts 5.5.0 之前有个 bug:当 series 有 visualMap 关联时,新增数据的颜色映射不会自动更新,需要手动触发 chart.dispatchAction({ type: 'updateVisualMap' })。这个我们踩了,浪费了一个下午。

还有,如果你用的是 SVG 渲染器(renderer: 'svg'),appendData 的性能优势会打折扣。V5 版本中 canvas 渲染器的增量更新做了优化(PR #18234),但 SVG 渲染器直到 5.4.0 才算正常。所以我们建议实时场景强制使用 canvas 渲染器。

话说回来,这套方案也不是银弹。如果你的图表不是时间序列,而是实时更新的桑基图或关系图,appendData 就不适用了,你得回到 setOption + replaceMerge,但性能上会有一定损失。

最后留个问题给你们:我们现在的方案在 1800 台设备、50 个并发大屏客户端的压测下,服务器带宽只用了 11%,但前端渲染已经接近 57fps 的极限。如果设备量再翻 10 倍到 1.8 万台,你猜瓶颈会出现在哪?