ECharts 大数据量渲染:千万级数据点的降采样与聚合策略

📅 2026/7/16 17:56:55 👁️ 阅读次数 📝 编程学习
ECharts 大数据量渲染:千万级数据点的降采样与聚合策略

ECharts 大数据量渲染:千万级数据点的降采样与聚合策略

1000 万个数据点塞进 ECharts,页面直接卡死?不是 ECharts 不行,是你没有做降采样。

一、问题:浏览器不是数据库,Canvas 也不是无限画布

先搞清楚一个事实:前端图表的瓶颈不在数据量,而在渲染的像素数。你一屏就 1920×1080 像素,折线图横轴满打满算也就一两千个可视点。给你 1000 万个数据点,99.9% 的点都堆叠在同一个像素上,用户根本看不清,还白白消耗了浏览器的渲染和内存。

ECharts 的处理机制是这样的:每个数据点都会被解析成图形元素(线段的连接点、柱子的矩形),然后通过 Canvas 或 SVG 渲染。百万级数据点 = 百万级图形元素 = 浏览器内存飙升 + 渲染线程阻塞 = 页面卡死。

为什么 LTTB 比均匀降采样更能保留视觉特征?均匀降采样(每隔 N 个取一个)相当于给数据加了低通滤波器——高频信号(尖峰、谷底)全部被滤掉了。如果你的时序数据里有一个"CPU 在 14:35 从 30% 跳到 95%"的瞬间尖峰,均匀降采样大概率会跳过这个时间点,图表上完全看不到这个异常。LTTB 的设计目标是"保留对人类视觉最显著的特征"——三角形的面积越大,说明这个点偏离趋势线越远,视觉上越突出。所以 LTTB 天然倾向保留异常点,而这恰恰是数据分析最需要看到的。

二、LTTB:最推荐的时序降采样算法

LTTB(Largest Triangle Three Buckets)是目前在时序数据降采样领域最广泛使用的算法。它的核心思想是:把数据分成若干桶,每桶保留一个能最好代表该桶趋势的点

跟简单的"每隔 N 个取一个"(均匀降采样)比,LTTB 能保留数据的尖峰和谷底——这些恰恰是数据分析最关心的异常点。

import numpy as np def lttb_downsample(x_data, y_data, target_points): """ LTTB(Largest Triangle Three Buckets)降采样算法 原理:把数据分成 target_points 个桶,每桶选一个点。 选点标准:当前点与"前一个已选点"和"下一桶的平均点"形成的三角形面积最大。 三角形面积越大,说明该点在视觉上越突出(偏离趋势线越远)。 参数: - x_data, y_data: 原始数据(x 和 y 分离,适配大多数时序数据) - target_points: 目标降采样后的点数(建议 1000-2000) 返回:降采样后的 (x, y) 数组 """ data_length = len(x_data) # 如果数据量 <= 目标点数,不需要降采样 if data_length <= target_points: return np.array(x_data), np.array(y_data) # 第一步:数据分桶 # 首尾点直接保留(折线图两端必须准确) # 中间的点均匀分配到 target_points - 2 个桶 bucket_size = (data_length - 2) / (target_points - 2) sampled_x = [x_data[0]] sampled_y = [y_data[0]] last_selected_idx = 0 for i in range(target_points - 2): bucket_start = int((i + 1) * bucket_size) + 1 bucket_end = int((i + 2) * bucket_size) + 1 bucket_end = min(bucket_end, data_length - 1) # 第二步:计算下一桶的平均点(用于三角形面积计算) next_bucket_start = bucket_end next_bucket_end = int((i + 3) * bucket_size) + 1 next_bucket_end = min(next_bucket_end, data_length) avg_x_next = np.mean([ x_data[j] for j in range(next_bucket_start, next_bucket_end) ]) avg_y_next = np.mean([ y_data[j] for j in range(next_bucket_start, next_bucket_end) ]) # 第三步:在当前桶里找"最突出"的点 max_area = -1 max_idx = bucket_start for j in range(bucket_start, bucket_end): area = abs( (x_data[last_selected_idx] - avg_x_next) * (y_data[j] - y_data[last_selected_idx]) - (x_data[last_selected_idx] - x_data[j]) * (avg_y_next - y_data[last_selected_idx]) ) * 0.5 if area > max_area: max_area = area max_idx = j sampled_x.append(x_data[max_idx]) sampled_y.append(y_data[max_idx]) last_selected_idx = max_idx sampled_x.append(x_data[-1]) sampled_y.append(y_data[-1]) return np.array(sampled_x), np.array(sampled_y)

LTTB 算法的复杂度是 O(N),100 万数据点降到 1000 个,Python 里几秒就能跑完。前端的渲染负载从 100 万降低到 1000,性能提升是数量级的。

三、服务端降采样 vs 前端降采样

数据到底在哪做降采样?有两种方案:

方案 A:服务端降采样(推荐)

from flask import Flask, request, jsonify app = Flask(__name__) @app.route('/api/timeseries') def get_timeseries(): """ 服务端降采样 API 前端传入视口宽度(像素),后端自动计算合适的目标点数 原则:保留点数 ≤ 视口宽度的 2 倍就够了 """ start_time = request.args.get('start') end_time = request.args.get('end') viewport_width = int(request.args.get('width', 1200)) target_points = min(viewport_width * 2, 2000) x_sampled, y_sampled = lttb_downsample( raw_data['timestamp'], raw_data['value'], target_points ) return jsonify({ 'data': [{'x': float(x), 'y': float(y)} for x, y in zip(x_sampled, y_sampled)], 'original_count': len(raw_data), 'sampled_count': len(x_sampled) })

方案 B:前端降采样(适合静态数据)

const option = { xAxis: { type: 'time' }, yAxis: { type: 'value' }, series: [{ type: 'line', sampling: 'lttb', // ECharts 内置的 LTTB 降采样 large: true, largeThreshold: 2000, data: rawData }], dataZoom: [{ type: 'slider', start: 0, end: 100 }] };

四、Hexbin:散点图的大数据量方案

散点图的降采样特别难,因为单纯的"去点"会丢失分布信息。10 万个散点降成 2000 个点,可能一个密集区域在降采样后只剩几个点,看起来像数据稀疏。

这时候该用 Hexbin(六边形聚合):把画布分割成六边形网格,统计每个六边形内的点数,用颜色深浅表示密度。

import numpy as np import pandas as pd def hexbin_aggregate(x, y, grid_size=50): """ 六边形聚合:把散点数据转成密度热力图 """ x_norm = (x - x.min()) / (x.max() - x.min()) if x.max() > x.min() else np.zeros_like(x) y_norm = (y - y.min()) / (y.max() - y.min()) if y.max() > y.min() else np.zeros_like(y) hex_x = (x_norm * grid_size).astype(int) hex_y = (y_norm * grid_size * 2 / np.sqrt(3)).astype(int) df = pd.DataFrame({'hx': hex_x, 'hy': hex_y}) density = df.groupby(['hx', 'hy']).size().reset_index(name='count') return density

🚨 踩坑提醒

  1. ECharts 的sampling: 'lttb'large: true不能解决所有问题。ECharts 的内置降采样只作用于渲染层——原始 1000 万个数据点仍然存在 JavaScript 的内存里。如果数据是通过 API 全量返回的,这 1000 万个 JSON 对象在浏览器里占的内存就是天花板。服务端降采样的意义不在于"算法更好",而在于"根本不让 1000 万个点进浏览器"。

  2. viewport_width * 2的 2 倍系数只适合 1x 显示屏。Retina 屏(2x)应该设成viewport_width * devicePixelRatio * 2,否则在高分屏上折线会模糊。但也不要设成 4 倍——4K 屏幕 3840×4×2=30720 个点,虽然渲染不卡,但 JS 内存和序列化传输都吃不住。实际上 2000 个点对任何屏幕都够了。

  3. Hexbin 的grid_size不能拍脑袋。50×50 的网格在 800×600 的画布上没问题,但放到 4K 投影仪上每个六边形宽 80 像素,观感像马赛克。正确做法是根据前端传来的画布像素数动态计算grid_size = canvas_width / 20(每个六边形约 20 像素宽)。

五、总结

ECharts 加载千万级数据卡死的本质,是浏览器在渲染它不需要渲染的东西。优化策略总结:

图表类型推荐策略效果
时序折线图LTTB 降采样保留尖峰谷底,视觉无损
柱状图/聚合图时间窗口预聚合后端算好汇总值
散点图Hexbin 聚合用密度代替散点,不丢分布信息
大数据监控看板服务端降采样 + 数据分层后端根据缩放级别返回不同粒度

核心原则:传给前端的点数 = 需要的像素数,不是原始数据行数。你 1920 像素宽的屏幕,给 500 个点就够了,500 万点是浪费。

另外提一句:如果数据大到连后端降采样都慢(几十亿行),加一层 OLAP 引擎(ClickHouse/Doris)做预聚合,把聚合逻辑从应用层推到数据库层。


本文由朱大喜原创,数据可视化的坑还有很多,欢迎关注后续更新~