引言
把一张折线图的 data 数组从静态换成「每秒推一个新点」,看起来只是加了个 setInterval。真实系统里,这条路径上的每个环节都会出问题:连接断了谁重连、消息积压了谁丢弃、数据点涨到十万个时坐标轴怎么缩放、页面开了八小时之后内存为什么涨到 2GB。实时图表的难点从来不是「画得快」,而是在数据持续涌入的前提下保持稳定。
约束来自三个方向。数据侧:推送频率可能远高于屏幕刷新率,1 万条/秒的消息不可能逐条渲染。渲染侧:Canvas 与 SVG 在不同元素量下的表现差异巨大,选错后端后优化空间有限。时间侧:实时图表往往要连续运行数天,任何逐帧累积的泄漏最终都会压垮标签页。三者叠加,决定了实时图表必须有一套不同于静态图表的工程方法。
本文按「接入 → 缓冲 → 渲染 → 降采样 → 背压 → 长期运行」的顺序展开,全部结论面向有经验的工程师,涉及 WebSocket/SSE、环形缓冲、LTTB 降采样、Canvas 批量绘制等具体实现。与 大屏渲染性能与优化 的分工是:那篇讲静态大屏的渲染性能,本文聚焦数据流持续涌入时的工程约束。
目录
- 实时图表的场景分类与约束
- 数据接入:WebSocket、SSE 与轮询
- 增量渲染与滑动窗口
- 环形缓冲与数据缓冲策略
- 时序降采样:LTTB 与 MinMax
- Canvas 与 SVG 的性能取舍
- 背压、丢帧与更新合并
- 时间轴与坐标轴的动态处理
- 动画与过渡的取舍
- 长时间运行的内存管理
- 服务端聚合与查询下推
- 实时看板的压测与可观测性
1. 实时图表的场景分类与约束
先分类,不同场景的优化方向完全不同。
| 场景 | 更新频率 | 数据规模 | 核心约束 |
|---|---|---|---|
| 指标大盘 | 5 秒 ~ 1 分钟 | 小(几十点) | 一致性、可回溯 |
| 监控告警 | 1 ~ 5 秒 | 中(数百点) | 延迟、阈值联动 |
| 行情/K 线 | 10 ~ 100 毫秒 | 大(窗口内数千点) | 延迟、抖动 |
| 轨迹追踪 | 1 ~ 10 次/秒 | 极大(持续累积) | 内存、降采样 |
| 日志流分析 | 不定 | 极大 | 吞吐、聚合 |
约束分三层:延迟(从数据产生到屏幕呈现的端到端时间)、吞吐(单位时间能处理的消息量)、稳定性(长时间运行不降级)。三者互相拉扯——降延迟往往要牺牲吞吐(小批量高频更新),提吞吐又要牺牲延迟(攒批)。先确定哪个是硬指标:监控告警看延迟,日志分析看吞吐,长期看板看稳定性。
一个反直觉的结论:多数所谓「实时」需求其实不需要亚秒级。业务方说「要实时」,真正想要的是「打开页面看到的是最新的」,这用 5 秒轮询就能满足。先问清楚数据源的更新频率——如果源数据每分钟才更新一次,前端做 100 毫秒推送是在制造无效负载。
2. 数据接入:WebSocket、SSE 与轮询
三种接入方式的取舍很明确:
| 方式 | 方向 | 协议开销 | 重连 | 适合 |
|---|---|---|---|---|
| 轮询 | 拉 | 每次完整 HTTP | 天然 | 更新慢、简单场景 |
| 长轮询 | 拉(伪推) | 每消息一次 HTTP | 天然 | 兼容性要求高的推送 |
| SSE | 单向推 | 一条长连接 | 浏览器自动重连 | 服务端到客户端的流 |
| WebSocket | 双向 | 一条长连接 + 帧头 | 需自己实现 | 双向、高频、二进制 |
SSE 被严重低估。它基于普通 HTTP,天然穿过代理与防火墙,浏览器内置自动重连(retry 字段控制间隔,Last-Event-ID 支持断点续传),服务端实现比 WebSocket 简单得多。图表场景绝大多数是「服务端推、客户端收」的单向流,SSE 足够。只有当需要客户端上行(订阅切换、参数下推)或需要二进制帧时才上 WebSocket。
// SSE:自动重连 + 事件类型分发
const es = new EventSource('/api/metrics/stream');
es.addEventListener('metric', (e) => {
const point = JSON.parse(e.data);
buffer.push(point);
});
es.onerror = () => { /* 浏览器会自动重连,此处只做状态提示 */ };
WebSocket 则要自己处理心跳与重连退避:
class ReconnectingWS {
constructor(url) { this.url = url; this.attempt = 0; this.connect(); }
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => { this.attempt = 0; this.startHeartbeat(); };
this.ws.onclose = () => {
this.stopHeartbeat();
// 指数退避 + 抖动,避免重连风暴
const delay = Math.min(30000, 2 ** this.attempt++ * 500) * (0.5 + Math.random());
setTimeout(() => this.connect(), delay);
};
this.ws.onmessage = (e) => this.onMessage(e.data);
}
}
重连必须带抖动。上千个客户端同时断线后若按固定间隔重连,会形成同步的重连风暴打垮服务端。指数退避乘以 0.5 到 1 的随机因子是标准做法。
3. 增量渲染与滑动窗口
实时折线图的正确模型不是「每次都重画整条线」,而是滑动窗口 + 增量追加。维护一个固定长度的窗口,新点进来时挤掉最旧的点,只重绘受影响的部分。
const WINDOW = 600; // 窗口内保留 600 个点
const series = new Float64Array(WINDOW);
let head = 0;
function push(value) {
series[head] = value;
head = (head + 1) % WINDOW; // 环形写入,无需搬移数组
}
滑动窗口的三种实现。数组 + shift:最简单,但 Array.prototype.shift 是 O(n),窗口大时每次追加都要搬移整个数组,是隐蔽的性能杀手。环形缓冲:用固定长度数组加写指针,追加是 O(1),代价是读取时要做两次切片拼接。链式滚动:图表库原生支持(ECharts 的 dataZoom 滚动、Chart.js 的 streaming 插件),由库内部管理窗口。
增量渲染的边界在渲染后端。Canvas 可以「清屏 + 全量重绘」,因为绘制 600 个点的开销是毫秒级;SVG 则应只更新变化的那几个 <path> 节点,全量重建 DOM 代价高。真正的优化往往不是「只画变化的部分」,而是让每帧的绘制量恒定——无论历史数据有多少,屏幕上永远只画窗口内的点。
数据与视图解耦是关键设计。缓冲区接收原始点,渲染循环按固定帧率(如 30fps)从缓冲区取最新状态绘制,而不是每来一条消息就画一次。这个「生产者-消费者」的解耦是所有实时图表的通用骨架。
4. 环形缓冲与数据缓冲策略
缓冲区承担三个职责:削峰(消息速率高于渲染速率时暂存)、合并(攒够一批再画)、降采样(超出窗口的点先聚合再丢弃)。
class RingBuffer {
constructor(capacity) {
this.buf = new Float64Array(capacity);
this.cap = capacity;
this.size = 0;
this.head = 0;
}
push(v) {
this.buf[this.head] = v;
this.head = (this.head + 1) % this.cap;
if (this.size < this.cap) this.size++;
}
// 按时间顺序导出,避免两次切片
toArray() {
if (this.size < this.cap) return Array.from(this.buf.subarray(0, this.size));
return [...this.buf.subarray(this.head), ...this.buf.subarray(0, this.head)];
}
}
用 Float64Array 而不是普通数组:TypedArray 内存连续、无装箱开销,且 subarray 是零拷贝视图。十万点的普通数组占用远超 TypedArray,GC 压力也更大。
缓冲策略取决于数据语义。时序数据用环形缓冲,窗口外的直接丢弃(旧数据对实时监控无价值)。累积数据(如轨迹)需要保留全量,此时应定期把历史段落「冻结」为静态层,只对最新段落做高频更新——这就是「冷热分层」。事件流(如日志)通常不需要逐条画,而是按时间桶聚合后画柱状或热力图。
缓冲区要有上限。无界缓冲在数据速率高于消费速率时必然 OOM。设一个硬上限(如 10 万个点),超过时按策略丢弃:丢最旧的(保最新)、丢最密集的(保留极值)、或降采样合并。
5. 时序降采样:LTTB 与 MinMax
当窗口内的点数远多于像素宽度时,必须降采样——屏幕只有 800 像素宽,画 10 万个点既浪费又糊成一团。降采样的目标是在减少点数的同时保留图形的视觉特征。
LTTB(Largest-Triangle-Three-Buckets) 是最常用的保形降采样:把数据分到 N 个桶,每桶选一个点,选点依据是它与前后两个「已选点」构成的三角形面积最大。它保留了极值和整体形态,视觉上几乎与原图一致。
function lttb(data, threshold) {
const n = data.length;
if (threshold >= n || threshold <= 2) return data;
const sampled = [data[0]];
const bucketSize = (n - 2) / (threshold - 2);
let a = 0;
for (let i = 0; i < threshold - 2; i++) {
// 下一个桶的平均点,作为三角形的一个顶点
const nextStart = Math.floor((i + 1) * bucketSize) + 1;
const nextEnd = Math.min(Math.floor((i + 2) * bucketSize) + 1, n);
let avgX = 0, avgY = 0;
for (let j = nextStart; j < nextEnd; j++) { avgX += data[j][0]; avgY += data[j][1]; }
avgX /= (nextEnd - nextStart); avgY /= (nextEnd - nextStart);
const rangeStart = Math.floor(i * bucketSize) + 1;
const rangeEnd = Math.floor((i + 1) * bucketSize) + 1;
let maxArea = -1, maxIdx = rangeStart;
for (let j = rangeStart; j < rangeEnd; j++) {
const area = Math.abs(
(data[a][0] - avgX) * (data[j][1] - data[a][1]) -
(data[a][0] - data[j][0]) * (avgY - data[a][1])
) * 0.5;
if (area > maxArea) { maxArea = area; maxIdx = j; }
}
sampled.push(data[maxIdx]);
a = maxIdx;
}
sampled.push(data[n - 1]);
return sampled;
}
MinMax 降采样是另一种思路:每个桶保留最小值和最大值两个点。它比 LTTB 更快,且保证不丢失尖峰——对监控告警场景(一个瞬时尖峰就是一次故障)比 LTTB 更合适。代价是点数是最优值的两倍,且图形呈锯齿状。
| 算法 | 复杂度 | 保形性 | 保极值 | 适合 |
|---|---|---|---|---|
| 等距抽样 | O(n) | 差 | 否 | 无要求 |
| MinMax | O(n) | 中 | 是 | 监控尖峰 |
| LTTB | O(n) | 好 | 较好 | 通用时序 |
| 分箱平均 | O(n) | 中 | 否 | 平滑趋势 |
降采样应该在渲染前、而非数据入库前做,因为降采样率取决于当前窗口宽度,是视图相关的。ECharts 的 sampling: 'lttb' 就是这个能力的内置实现,详见 ECharts 图表工程实战
。
6. Canvas 与 SVG 的性能取舍
实时图表几乎总是选 Canvas,但理由要说清楚。
SVG 的每个图形是 DOM 节点,浏览器要维护样式、布局与合成。元素数上千后,每帧的属性更新都会触发样式重算,帧率迅速下降。实时图表的点数和线条数量随时间增长,SVG 的复杂度会持续恶化。SVG 的优势——可访问性、CSS 控制、矢量导出——在实时场景里大多用不上。
Canvas 是位图绘制,绘制成本与像素面积相关而非元素数量。十万个点用 fillRect 批量绘制可以在几毫秒内完成。代价是无 DOM、无 CSS、无内置无障碍。
// Canvas 批量绘制:避免逐点切换样式
function draw(points, xScale, yScale) {
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#2f6fed';
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
ctx.rect(xScale(points[i][0]) - 1, yScale(points[i][1]) - 1, 2, 2);
}
ctx.fill(); // 一次 fill 提交所有矩形,比逐点 fillRect 快
}
关键技巧是减少状态切换:fillStyle 的每次赋值都有开销,应在循环外设定一次;用单个 path 累积所有矩形再一次性 fill(),比循环内逐个 fillRect() 快数倍。小圆点用 rect 近似——2 像素的点用矩形画几乎看不出差别,但 arc 的开销是 rect 的数倍。
Canvas 也可以做增量绘制:把静态的历史层画到离屏 canvas,每帧只重绘最新的一段。这是轨迹类图表的标准优化。超过十万点时应考虑 WebGL 或分箱聚合。
7. 背压、丢帧与更新合并
**背压(backpressure)**是实时系统里最容易被忽略的一环:当生产者速率持续高于消费者,缓冲区无限增长直到崩溃。正确的做法是让下游向上游施加压力,或在中间做有损丢弃。
更新合并是最简单的背压形式:不管消息多快,渲染循环按固定帧率跑,每帧只取缓冲区的最新快照。
let dirty = false;
let pending = null;
function onMessage(point) {
pending = point; // 只保留最新,旧的自然被覆盖
dirty = true;
}
function renderLoop() {
if (dirty) {
dirty = false;
applyLatest(pending); // 每帧最多应用一次
}
requestAnimationFrame(renderLoop);
}
rAF 天然限流。requestAnimationFrame 在标签页不可见时会暂停,等于免费的节流;用 setInterval 则会在后台持续空转。渲染必须挂在 rAF 上,而不是在消息回调里直接画。
丢帧策略要显式设计。当一帧内累积了远超处理能力的消息时,有三种选择:丢最旧(保留最新状态,适合监控)、丢中间(保留首尾,适合时序曲线)、降采样合并(把一帧内的多个点聚合成一个,适合计数器)。默认行为不该是「全画」——那只会让帧率崩塌。
要能观测到背压。给缓冲区加水位指标(当前长度/容量),当持续超过 80% 时上报。没有观测的背压控制等于没有控制——问题只会在线上以「页面卡死」的形式暴露。
8. 时间轴与坐标轴的动态处理
时间轴的动态更新有三个陷阱。
其一,X 轴范围每帧都变会导致抖动。若轴范围严格跟着数据走(max = 最新时间),每个新点都会让整条线左移一点,视觉上持续抖动。解决办法是轴范围按固定步长跳变:例如每 5 秒或每 100 个点才更新一次轴上限,中间新点画在轴内。这与「滚动窗口」的语义一致。
// 轴上限按步长跳变,避免每帧抖动
const STEP_MS = 5000;
function axisMax(latestTs) {
return Math.ceil(latestTs / STEP_MS) * STEP_MS;
}
其二,Y 轴自适应会放大噪声。若 Y 轴范围跟着当前窗口的极值走,窗口内数值波动时轴会不断伸缩,曲线的「坡度」随之剧烈变化,产生虚假的剧烈波动感。监控场景推荐固定 Y 轴范围(按历史 99 分位设定),或用带滞回的动态范围(只有超出当前范围 20% 才调整)。
其三,时间标签的重叠。实时窗口滑动时,时间刻度会不断重排,若标签密度设置不当会出现重叠或跳动。应让刻度数随窗口宽度自适应,并用绝对时间格式(HH:mm:ss)而非相对格式。
时区与本地化是另一个坑。服务端推 UTC 时间戳,前端渲染成本地时区,跨时区用户看到的刻度不同。统一在服务端转好时区,或明确约定所有时间戳为 UTC 并在前端统一转换,不要混用。
9. 动画与过渡的取舍
静态图表的过渡动画是加分项,实时图表里往往是减分项。
数据点的入场动画在实时场景中会持续触发,每个新点都做一次缩放或淡入,视觉上变成持续闪烁,且动画队列会累积导致延迟。实时图表的默认应是「无动画」:新点直接出现,旧点直接消失。
但如果需要平滑,用「平移」而非「重建」。滑动窗口的视觉本质是曲线整体左移,正确的动画是让整个绘图区平滑左移一个点的距离,而不是重绘。ECharts 的 dataZoom 滚动配合 animation: false 能实现接近实时的平移效果。
过渡时长要短于更新间隔。若数据每 200 毫秒更新一次,而过渡动画时长 300 毫秒,动画永远播不完就被新数据打断,产生持续的抖动。规则是:动画时长 ≤ 更新间隔的 1/2,或者干脆关掉。
状态变化的动画可以保留(如告警阈值线的颜色闪烁、超限柱子的高亮),因为它们是离散事件而非连续流。区分「数据流更新」与「状态变化」是实时动画设计的关键。
10. 长时间运行的内存管理
实时看板常连续运行数天,任何逐帧累积的泄漏都会压垮标签页。四类常见泄漏。
其一,未清理的定时器与监听。setInterval、requestAnimationFrame、addEventListener、ResizeObserver、WebSocket 连接都必须在组件卸载时清理。SPA 里切换路由不清定时器,会累积几十个渲染循环。
其二,无界数组。把每个点都 push 进一个不设上限的数组,几小时后数组到几百万项。必须用固定容量缓冲或滑动窗口。
其三,闭包捕获大对象。事件回调里引用了整个数据集,导致数据集无法被回收。回调应只捕获必要的小数据。
其四,Canvas 上下文与离屏 canvas 泄漏。频繁创建离屏 canvas 而不复用,会累积大量显存占用。离屏 canvas 应创建一次并复用。
// 清理函数要成对覆盖所有资源
function createChart(container) {
const timers = new Set();
const observers = [];
const sockets = [];
const raf = () => { render(); timers.add(requestAnimationFrame(raf)); };
raf();
const ro = new ResizeObserver(() => resize());
ro.observe(container);
observers.push(ro);
return () => {
timers.forEach((t) => { cancelAnimationFrame(t); clearTimeout(t); });
observers.forEach((o) => o.disconnect());
sockets.forEach((s) => s.close());
chart.dispose();
};
}
用 Chrome DevTools 的 Memory 面板做长时间观察:开一小时的录制,看堆快照是否持续增长。若增长后不回落,就是泄漏;若在 GC 后回落,只是正常的分配波动。**堆快照对比(allocation timeline)**能定位到具体是哪类对象在累积。
11. 服务端聚合与查询下推
前端的降采样与缓冲是兜底,真正的优化在数据侧。
按粒度预聚合。原始数据可能是秒级,但看板只需要分钟级趋势。在写入时就把秒级数据聚合成分钟级(或更高粒度),前端只拉聚合结果。这能把数据量降低一到两个数量级。对时序数据,ClickHouse 的物化视图或聚合表(AggregatingMergeTree)是常见选择。
窗口聚合下推到查询。前端请求「最近 5 分钟、每 10 秒一个点」,这个聚合应在 SQL 侧用 GROUP BY toStartOfInterval(ts, INTERVAL 10 SECOND) 完成,而不是把原始点全拉回来在前端算。
增量拉取而非全量刷新。断线重连后不要重拉整个窗口,而是带上最后收到的时间戳,只拉缺失的区间。这要求服务端支持「从某个游标开始」的查询。
-- 按 10 秒桶聚合,服务端完成降采样
SELECT toStartOfInterval(ts, INTERVAL 10 SECOND) AS bucket,
avg(value) AS v, min(value) AS lo, max(value) AS hi
FROM metrics
WHERE metric = 'cpu' AND ts > now() - INTERVAL 5 MINUTE
GROUP BY bucket ORDER BY bucket;
同时返回 min/max/avg,让前端能画带包络线的趋势图,比只返回 avg 信息量大得多。这套「聚合下推」的思路与批处理数仓完全一致,只是把粒度换成了流式的窗口。
12. 实时看板的压测与可观测性
实时图表的性能问题大多在长时间、高并发下才暴露,必须主动压测。
压测三个维度:消息速率(每秒推 N 条,看帧率与内存)、持续时长(连续跑 24 小时看内存曲线)、并发图表数(一屏 20 张实时图,看是否争抢主线程)。用脚本模拟 WebSocket 服务端,按可配置速率推送,观察 FPS(requestAnimationFrame 间隔)、内存(performance.memory)、以及缓冲区水位。
// 前端帧率采样
let last = performance.now(), frames = 0, fps = 0;
function sample() {
const now = performance.now();
frames++;
if (now - last >= 1000) {
fps = frames; frames = 0; last = now;
report('fps', fps); // 上报到监控
}
requestAnimationFrame(sample);
}
关键指标:端到端延迟(消息时间戳到渲染完成)、帧率、缓冲区水位、重连次数、单帧绘制耗时。这些指标应接入 可观测性平台 ,与后端指标一起看——很多「前端卡」的根因是后端推送速率失控或查询变慢。
降级策略要预设。当帧率持续低于阈值时,自动降级:降低采样率、减少保留点数、关闭动画、暂停非关键图表。降级必须自动且可恢复,等用户手动关图表时体验已经毁了。
回放能力是排障利器。把消息流按时间戳记录下来,支持「回放」到任意时刻——线上问题复现不再依赖「当时正好在看」。这对间歇性的性能抖动尤其有价值。
权衡取舍
| 决策点 | 选项 A | 选项 B | 何时选 A | 何时选 B |
|---|---|---|---|---|
| 接入方式 | SSE | WebSocket | 单向推流、需自动重连 | 双向、二进制、高频 |
| 缓冲 | 环形缓冲 | 普通数组 | 固定窗口、高频 | 数据量小、随机访问 |
| 降采样 | LTTB | MinMax | 通用趋势 | 监控尖峰不能丢 |
| 渲染后端 | Canvas | SVG | 元素多、高频重绘 | 元素少、需无障碍 |
| 轴范围 | 固定/滞回 | 完全自适应 | 监控、避免噪声放大 | 探索性、量级未知 |
| 动画 | 关闭 | 平滑平移 | 高频更新 | 低频更新、需观感 |
| 聚合位置 | 服务端 | 前端 | 数据量大 | 数据量小、需交互 |
常见坑清单
- 消息回调里直接渲染——高频消息导致每帧多次重绘;改为 rAF 循环 + 最新值快照。
- 数组 shift 做滑动窗口——O(n) 搬移拖慢追加;改用环形缓冲或库内置滚动。
- 缓冲区无上限——生产者快于消费者时 OOM;设硬上限并按策略丢弃。
- Y 轴完全自适应——噪声放大成剧烈波动;监控场景固定范围或用滞回。
- 重连不带抖动——上千客户端同步重连形成风暴;指数退避乘随机因子。
- 实时图开过渡动画——动画被打断导致持续抖动;时长 ≤ 更新间隔一半或关闭。
- 逐点 fillRect 并切换样式——状态切换开销大;批量 path + 单次 fill。
- 定时器/监听不清理——SPA 切换路由累积僵尸循环;卸载时成对清理。
- 全量重拉代替增量——重连后拉整个窗口浪费带宽;带游标只拉缺失区间。
- 无指标观测背压——问题以页面卡死形式暴露;上报水位、帧率、延迟。
小结
实时图表是一套「数据流工程」而非「画图技巧」。核心骨架是生产者-消费者解耦:消息进缓冲区,rAF 渲染循环按固定帧率取快照绘制。在此之上,环形缓冲削峰、LTTB/MinMax 降采样保形、背压与丢帧防止积压、服务端聚合降低数据量,共同构成稳定运行的基础。
工程上最容易被忽略的是时间维度:长时间运行的内存管理、轴范围的防抖动设计、动画时长的约束、以及压测与可观测性。这些问题的共同点是「短期看不出、长期必暴露」,必须在设计阶段就留出位置。
下一步可以对照大屏渲染性能与优化的思路补齐静态渲染侧,或看 ECharts 图表工程实战里 sampling、large、progressive 的具体配置;流式数据的后端管道设计与指标采集,则是可观测性平台的核心议题。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。