数据可视化仪表盘平台架构设计

支撑亿级数据点的实时可视化分析平台 — 从架构设计到性能调优的完整实战

一、项目概述

1.1 项目背景

某大型互联网公司运营着覆盖电商、广告、用户行为等多个业务线的实时数据监控需求。原有监控系统基于传统 BI 工具,存在页面加载慢(首次渲染 >8s)、数据刷新延迟高(轮询间隔 30s)、图表交互卡顿(>100 万数据点时帧率 <10fps)等严重问题。业务方迫切需要一个能够同时展示多维度、大规模数据的实时可视化平台,以支撑运营决策、故障排查和业务分析。

本项目从零开始设计并落地了一套全新的数据可视化仪表盘平台,目标是实现秒级数据刷新、千万级数据点流畅渲染、百毫秒级图表交互响应

1.2 数据规模

📊 核心数据规模

  • 日均数据量:约 50 亿条事件记录,峰值 TPS 达 80 万
  • 查询数据范围:单个仪表盘最多同时查询 20+ 张图表,涉及 50+ 张宽表
  • 单图最大数据点:时序折线图最高展示 500 万个数据点
  • 实时推送:WebSocket 并发连接数峰值 3,000+,推送频率 1s/次
  • 历史回溯:支持 1 年跨度的数据下钻与聚合查询

1.3 展示需求

平台需要支撑以下核心展示场景:

  • 实时大屏:面向运维和运营团队的 4K/8K 大屏展示,要求 60fps 渲染
  • 分析仪表盘:面向数据分析师的多维分析看板,支持自由拖拽布局、图表联动筛选、维度切换
  • 告警看板:实时异常检测与告警展示,要求端到端延迟 <2s
  • 移动端适配:响应式布局,支持手机和平板查看核心指标

二、技术架构设计

2.1 整体架构分层

整个平台采用前后端分离架构,分为四层:数据存储层、数据服务层、网关推送层、前端展示层。

┌─────────────────────────────────────────────────────┐
│                   前端展示层                          │
│  React + TypeScript + ECharts + Ant Design           │
│  ┌──────────┐ ┌──────────┐ ┌──────────────────┐     │
│  │ 图表引擎  │ │ 布局引擎  │ │ 联动筛选引擎     │     │
│  │ (ECharts) │ │ (Grid)   │ │ (Event Bus)     │     │
│  └──────────┘ └──────────┘ └──────────────────┘     │
├─────────────────────────────────────────────────────┤
│                   网关推送层                          │
│  Node.js Gateway + WebSocket Cluster + Redis Pub/Sub │
│  ┌──────────┐ ┌──────────┐ ┌──────────────────┐     │
│  │ WS 推送   │ │ API 网关  │ │ 查询结果缓存     │     │
│  └──────────┘ └──────────┘ └──────────────────┘     │
├─────────────────────────────────────────────────────┤
│                   数据服务层                          │
│  Node.js Query Service + GraphQL Federation          │
│  ┌──────────┐ ┌──────────┐ ┌──────────────────┐     │
│  │ 查询路由  │ │ 聚合计算  │ │ 数据预计算       │     │
│  └──────────┘ └──────────┘ └──────────────────┘     │
├─────────────────────────────────────────────────────┤
│                   数据存储层                          │
│  ClickHouse (时序) + Apache Druid (OLAP) + Redis    │
│  ┌──────────┐ ┌──────────┐ ┌──────────────────┐     │
│  │ClickHouse│ │  Druid   │ │    Redis         │     │
│  │ (原始明细)│ │(多维聚合) │ │  (热数据缓存)    │     │
│  └──────────┘ └──────────┘ └──────────────────┘     │
└─────────────────────────────────────────────────────┘

2.2 前端可视化架构

前端采用微内核 + 插件化的图表架构设计。核心架构包含以下模块:

Chart Engine(图表引擎):基于 ECharts 5.x 封装,统一管理图表实例的生命周期。每个图表实例运行在独立的 Web Worker 中进行数据预处理,主线程只负责渲染,避免了大数据量解析导致的 UI 阻塞。

Layout Engine(布局引擎):支持自由拖拽的 Grid 布局系统,采用 CSS Grid + ResizeObserver 实现,支持响应式断点适配。布局配置持久化到后端,多端共享。

Interaction Engine(交互引擎):基于自定义事件总线(EventEmitter)实现图表联动。当用户在一张图表上进行筛选(时间范围、维度选择、数据框选)时,通过事件总线广播到所有订阅图表,触发级联刷新。

2.3 数据流设计

数据流分为三条路径,分别满足不同时效性要求:

🔄 三条数据流路径

  • 实时流(<1s):数据采集 → Kafka → Flink 实时聚合 → Redis → WebSocket 推送 → 前端增量渲染
  • 近线流(1-5min):数据写入 ClickHouse → Node.js 查询服务 → API 轮询/SSE → 前端全量刷新
  • 离线流(定时):Druid 预计算 Cube → 物化视图 → CDN 缓存 → 前端加载

2.4 实时推送架构

WebSocket 推送层采用 Node.js Cluster 集群模式,配合 Redis Pub/Sub 实现跨进程消息广播。每个 WebSocket 连接绑定到特定的仪表盘实例,服务端只推送与当前仪表盘相关的增量数据变更。

💡 推送优化策略:采用增量 JSON Patch(RFC 6902)格式推送变更,而非全量数据。客户端收到 Patch 后本地合并,避免了大数据集的全量替换。平均每条推送消息从 50KB 压缩到 2-5KB,降低了 90% 的传输量。

三、核心技术挑战与解决方案

挑战一:超大规模数据点的高性能渲染

问题描述:当单个折线图需要展示超过 100 万数据点时,ECharts 的 SVG 渲染模式会创建等量的 DOM 节点,导致页面严重卡顿甚至崩溃。实测在 200 万数据点时,首次渲染时间超过 30 秒,交互帧率低于 5fps。

解决方案:

  1. Canvas 渲染 + 数据抽样:强制所有大数据量图表使用 Canvas 渲染器(ECharts renderer: 'canvas')。同时实现了基于 LTTB(Largest-Triangle-Three-Buckets)算法的客户端数据降采样,在视觉保真度损失 <1% 的情况下,将 200 万数据点降至 2 万点,渲染时间从 30s 降至 200ms。
  2. Web Worker 数据预处理:将数据解析、降采样、格式转换等 CPU 密集型操作移入 Web Worker,主线程零阻塞。Worker 内置线程池(navigator.hardwareConcurrency),支持并行处理多张图表的数据准备。
  3. 增量渲染(Progressive Rendering):利用 ECharts 的 progressive 配置,将大数据集分块渲染。首屏快速展示前 1000 个点,后续数据在空闲帧中逐步补充,用户感知的"可用时间"从 30s 降至 800ms。
// LTTB 降采样算法核心实现
function downsampleLTTB(
  data: number[][],
  threshold: number
): number[][] {
  const sampled: number[][] = [data[0]]; // 始终保留首点
  const bucketSize = (data.length - 2) / (threshold - 2);

  let a = 0; // 上一个选中点
  for (let i = 1; i < threshold - 1; i++) {
    const avgRangeStart = Math.floor((i) * bucketSize) + 1;
    const avgRangeEnd = Math.floor((i + 1) * bucketSize) + 1;
    const avgRangeLength = avgRangeEnd - avgRangeStart;

    // 计算 bucket 平均点
    const avgX = avgRangeStart + avgRangeLength / 2;
    const avgY = data.slice(avgRangeStart, avgRangeEnd)
      .reduce((sum, d) => sum + d[1], 0) / avgRangeLength;

    // 选择与前一个选中点形成最大三角形面积的点
    const rangeOffs = Math.floor((i - 1) * bucketSize) + 1;
    const rangeTo = Math.floor(i * bucketSize) + 1;

    let maxArea = -1;
    let maxIdx = rangeOffs;
    for (let j = rangeOffs; j < rangeTo; 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[data.length - 1]); // 始终保留末点
  return sampled;
}

挑战二:高并发实时数据推送的低延迟保障

问题描述:在 3,000+ WebSocket 并发连接、每秒推送一次的场景下,服务端出现消息堆积,P99 推送延迟从 200ms 劣化到 3s 以上,且部分客户端出现断连后无法及时恢复的问题。

解决方案:

  1. 消息分级 + 优先队列:将推送消息分为三级:告警(P0,立即推送)、核心指标(P1,合并窗口 500ms)、辅助信息(P2,合并窗口 2s)。使用 Node.js 的 async.priorityQueue 实现,确保高优先级消息不被低优先级消息阻塞。
  2. 增量推送 + 本地状态合并:服务端维护每个连接的客户端状态快照,推送时计算增量(JSON Patch),客户端本地合并后更新图表。对于时间序列数据,采用追加模式(append-only),服务端只推送新增的时间窗口数据。
  3. 连接分级恢复:断线重连时,客户端携带上次接收的消息序列号(seqId),服务端从该 seqId 开始回放缺失消息。对于超时过长(>30s)的断连,降级为全量刷新而非逐条回放,避免内存积压。
// WebSocket 消息优先级调度
class PushScheduler {
  private queues: Map<Priority, AsyncPriorityQueue<PushMessage>>;
  private seqCounter = 0;

  async push(
    connId: string,
    message: PushMessage,
    priority: Priority
  ): Promise<void> {
    // 消息去重 & 压缩
    const deduped = this.deduplicate(connId, message);
    if (!deduped) return;

    const enriched: PushMessage = {
      ...deduped,
      seqId: ++this.seqCounter,
      timestamp: Date.now()
    };

    const queue = this.queues.get(priority)!;
    queue.push(enriched);
  }

  // P0 告警立即发送,P1/P2 按 batch 窗口合并
  private scheduleFlush(): void {
    // P0: 立即消费
    setInterval(() => this.flushQueue(Priority.CRITICAL), 0);
    // P1: 500ms 窗口
    setInterval(() => this.flushQueue(Priority.HIGH), 500);
    // P2: 2s 窗口
    setInterval(() => this.flushQueue(Priority.NORMAL), 2000);
  }
}

挑战三:多图表联动的性能与一致性

问题描述:一个仪表盘包含 15-20 张图表时,用户在任意图表上的筛选操作都会触发其他图表的级联查询。如果采用串行请求,最后一张图表的渲染延迟会累加到 5-10s;如果采用并行请求,则会对后端造成瞬时高并发压力(20 个并发查询),可能触发 ClickHouse 查询队列拥塞。

解决方案:

  1. 智能并行 + 依赖分析:在联动手动配置阶段,运维人员定义图表间的筛选依赖关系。系统构建 DAG(有向无环图),自动识别可并行的查询组。无依赖关系的图表并行请求,有依赖关系的串行执行。首屏渲染从串行 8s 优化到并行 1.5s。
  2. 请求合并 + 批量查询:检测到多张图表使用相同数据源但不同聚合维度时,将多个查询合并为一个宽查询(SELECT 维度1, 维度2, ... GROUP BY 维度1, 维度2),一次请求获取所有图表所需数据,减少网络 RTT 和后端查询次数。
  3. 乐观 UI + 骨架屏过渡:筛选操作触发后,前端立即展示加载骨架屏(Skeleton),避免白屏闪烁。同时实现"乐观更新"策略:对于维度切换(不涉及数据量变化)的操作,直接在前端过滤已有数据实现即时响应,后台异步请求最新数据后静默替换。

挑战四:查询性能优化 — 秒级响应亿级数据

问题描述:分析师在进行跨月数据下钻时,ClickHouse 单表查询涉及数十亿行数据,裸查询耗时 15-45s,远超用户体验可接受范围(<3s)。

解决方案:

  1. 预计算 Cube + 物化视图:根据高频查询模式,在 ClickHouse 中创建多层级物化视图。按 1min / 5min / 1h / 1d 四个粒度预聚合,查询时自动路由到匹配粒度的视图,避免实时全表扫描。
  2. 查询结果多级缓存:采用三级缓存策略:L1 内存缓存(HotKey,TTL 30s)、L2 Redis 缓存(TTL 5min,缓存聚合结果)、L3 CDN 边缘缓存(静态报表数据,TTL 1h)。缓存命中率峰值达 87%,大幅降低 ClickHouse 查询压力。
  3. 查询自适应降级:当 ClickHouse 负载过高(队列 >100)时,自动将查询降级到 Druid 预聚合层,牺牲少量实时性换取可用性。前端展示降级提示标签,避免用户误判数据时效。

四、关键技术实现

4.1 ECharts 深度性能配置

针对大规模数据场景,我们对 ECharts 进行了多项深度配置优化:

/**
 * 大数据量 ECharts 配置生成器
 * 根据数据量动态选择最优渲染策略
 */
export function createOptimizedChartOption(
  rawData: ChartData[],
  chartType: ChartType
): EChartsOption {
  const dataPointCount = rawData.length;
  const useProgressive = dataPointCount > 50_000;
  const useLargeMode = dataPointCount > 200_000;
  const useSampling = dataPointCount > 500_000;

  // 根据数据量选择降采样策略
  const displayData = useSampling
    ? downsampleLTTB(rawData, 50_000)
    : rawData;

  return {
    animation: false, // 大数据量关闭动画
    renderer: 'canvas', // 强制 Canvas 渲染
    progressive: useProgressive ? 2000 : 0,
    progressiveThreshold: useProgressive ? 50_000 : Infinity,
    large: useLargeMode, // 开启大数据模式
    largeThreshold: 200_000,
    tooltip: {
      trigger: 'axis',
      // 使用函数格式化避免大数据量 Tooltip 计算
      formatter: throttle((params: any) => {
        return formatTooltip(params);
      }, 100)
    },
    xAxis: {
      type: 'category',
      // 大数据量时隐藏部分刻度标签
      axisLabel: {
        interval: dataPointCount > 10_000 ? 'auto' : 0,
        rotate: dataPointCount > 5_000 ? 45 : 0
      }
    },
    series: [{
      type: chartType,
      data: displayData,
      showSymbol: dataPointCount < 1_000, // 大数据量隐藏散点
      sampling: useLargeMode ? 'lttb' : undefined,
      itemStyle: {
        // 简化渲染:关闭抗锯齿
        borderWidth: dataPointCount > 100_000 ? 0 : 1
      },
      emphasis: {
        // 大数据量禁用 hover 高亮
        disabled: useLargeMode
      }
    }],
    // 开启 GPU 加速
    useCoarsePointer: true,
    pointerCapture: true
  };
}
💡 ECharts 内存管理要点:图表实例销毁时必须调用 chart.dispose() 释放 Canvas 和 WebGL 上下文。我们封装了 useECharts Hook,在组件 useEffect cleanup 中自动 dispose,并使用 WeakMap 跟踪实例引用,防止内存泄漏。

4.2 WebSocket 实时推送实现

基于 ws 库实现高性能 WebSocket 服务,配合 Redis Pub/Sub 实现集群间消息广播:

/**
 * WebSocket 推送服务核心实现
 * 支持:增量推送、消息去重、断线重连、背压控制
 */
class DashboardPushServer {
  private wss: WebSocket.Server;
  private connections: Map<string, ConnectionState>;
  private redisSub: Redis;
  private seqId = 0;

  async start(port: number): Promise<void> {
    this.wss = new WebSocket.Server({
      port,
      perMessageDeflate: true, // 启用 permessage-deflate 压缩
      maxPayload: 1024 * 1024  // 限制单条消息 1MB
    });

    this.wss.on('connection', (ws, req) => {
      this.handleConnection(ws, req);
    });

    // 订阅 Redis 频道,接收其他实例广播的消息
    this.redisSub.subscribe('dashboard:push', (channel, msg) => {
      this.broadcastLocally(msg);
    });
  }

  private handleConnection(ws: WebSocket, req: Request): void {
    const connId = generateConnId();
    const state: ConnectionState = {
      id: connId,
      ws,
      dashboards: new Set(),
      lastSeqId: 0,
      buffer: [] // 发送缓冲区
    };
    this.connections.set(connId, state);

    ws.on('message', (data) => {
      const msg = JSON.parse(data.toString());

      switch (msg.type) {
        case 'subscribe':
          state.dashboards.add(msg.dashboardId);
          // 订阅时拉取增量:携带 lastSeqId
          this.sendMissingUpdates(connId, msg.dashboardId, msg.lastSeqId);
          break;
        case 'ack':
          // 客户端确认收到,移除缓冲区
          this.ackMessage(connId, msg.seqId);
          break;
      }
    });

    // 背压控制:检测发送缓冲区
    this.monitorBackpressure(connId);
  }

  private monitorBackpressure(connId: string): void {
    setInterval(() => {
      const state = this.connections.get(connId);
      if (!state) return;

      const buffered = state.ws.bufferedAmount;
      if (buffered > 256 * 1024) { // 超过 256KB 缓冲
        // 跳过低优先级消息,防止雪崩
        this.skipLowPriorityMessages(connId);
      }
    }, 1000);
  }
}

4.3 Canvas 渲染优化与 OffscreenCanvas

对于自定义可视化组件(热力图、关系图谱等不适用 ECharts 的场景),我们基于原生 Canvas API 实现了渲染引擎,并利用 OffscreenCanvas 将渲染工作迁移到 Worker 线程:

/**
 * OffscreenCanvas 渲染管线
 * 将 Canvas 渲染完全移出主线程
 */
class OffscreenRenderer {
  private worker: Worker;
  private canvas: HTMLCanvasElement;

  constructor(canvas: HTMLCanvasElement) {
    this.canvas = canvas;
    this.worker = new Worker(
      new URL('./render-worker.ts', import.meta.url),
      { type: 'module' }
    );

    // 将 Canvas 控制权转移给 Worker
    const offscreen = canvas.transferControlToOffscreen();
    this.worker.postMessage(
      { type: 'init', canvas: offscreen },
      [offscreen] // Transferable
    );

    this.worker.onmessage = (e) => {
      if (e.data.type === 'renderComplete') {
        // 渲染完成回调(如在渲染后更新 DOM overlay)
        this.onRenderComplete?.(e.data.meta);
      }
    };
  }

  // 主线程只负责发送数据和配置,不参与渲染
  updateData(data: RenderData): void {
    this.worker.postMessage({
      type: 'update',
      data: data.buffer, // 使用 ArrayBuffer 避免结构化克隆开销
      viewport: {
        width: this.canvas.clientWidth * devicePixelRatio,
        height: this.canvas.clientHeight * devicePixelRatio,
        dpr: devicePixelRatio
      }
    }, [data.buffer]); // Transferable 零拷贝
  }

  onRenderComplete?: (meta: RenderMeta) => void;
}

// Worker 线程内的渲染逻辑
// render-worker.ts
self.onmessage = (e) => {
  if (e.data.type === 'init') {
    const ctx = (e.data.canvas as OffscreenCanvas)
      .getContext('2d')!;
    startRenderLoop(ctx);
  }

  if (e.data.type === 'update') {
    pendingData = e.data.data;
    pendingViewport = e.data.viewport;
  }
};

function startRenderLoop(ctx: OffscreenCanvasRenderingContext2D) {
  const render = () => {
    if (pendingData) {
      ctx.clearRect(0, 0, pendingViewport.width, pendingViewport.height);
      drawHeatmap(ctx, pendingData, pendingViewport);
      pendingData = null;
      self.postMessage({ type: 'renderComplete' });
    }
    requestAnimationFrame(render);
  };
  render();
}

4.4 虚拟滚动在数据表格中的应用

仪表盘中的数据明细表格需要支持 10 万+ 行的流畅滚动,我们基于 @tanstack/react-virtual 实现了高度优化的虚拟滚动方案:

/**
 * 大数据量虚拟表格组件
 * 支持:动态行高、固定列、滚动锚定、键盘导航
 */
import { useVirtualizer } from '@tanstack/react-virtual';

interface VirtualTableProps<T> {
  data: T[];
  columns: ColumnDef<T>[];
  estimateRowHeight: (index: number) => number;
  overscan?: number; // 预渲染行数
}

function VirtualTable<T>({
  data,
  columns,
  estimateRowHeight,
  overscan = 20
}: VirtualTableProps<T>) {
  const parentRef = useRef<HTMLDivElement>(null);

  // 动态行高的虚拟化器
  const virtualizer = useVirtualizer({
    count: data.length,
    getScrollElement: () => parentRef.current,
    estimateSize: estimateRowHeight,
    overscan,
    // 测量后缓存实际行高,避免重测
    measureElement: (el) => el
  });

  // 预计算可见范围内的行数据(避免渲染时重复计算)
  const visibleRange = virtualizer.getVirtualItems();
  const virtualItems = useMemo(
    () => visibleRange.map((item) => ({
      ...item,
      data: data[item.index]
    })),
    [visibleRange, data]
  );

  return (
    <div ref={parentRef} className="virtual-table-container">
      <div
        style={{
          height: virtualizer.getTotalSize(),
          width: '100%',
          position: 'relative'
        }}
      >
        {virtualItems.map((virtualRow) => (
          <div
            key={virtualRow.key}
            data-index={virtualRow.index}
            ref={virtualizer.measureElement}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              transform: `translateY(${virtualRow.start}px)`,
              width: '100%'
            }}
          >
            <TableRow
              data={virtualRow.data}
              columns={columns}
              rowIndex={virtualRow.index}
            />
          </div>
        ))}
      </div>
    </div>
  );
}

/**
 * 关键优化:滚动事件节流 + will-change 提示
 * 使用 IntersectionObserver 替代 scroll 事件监听
 */
// CSS 层面优化
// .virtual-table-container {
//   will-change: transform;
//   contain: strict;
//   content-visibility: auto;
// }

五、性能指标与成果

5.1 核心性能指标对比

📈 优化前后对比(均值,P99 括号内)

指标 优化前 优化后 提升幅度
首屏渲染时间(20 图表仪表盘) 8.2s (15s) 1.5s (2.8s) ↓ 82%
百万级折线图交互帧率 8fps (3fps) 55fps (45fps) ↑ 587%
实时数据推送端到端延迟 3.5s (8s) 0.8s (1.5s) ↓ 77%
图表联动全量刷新时间 10s (18s) 2s (3.5s) ↓ 80%
单图表最大数据点 50 万 500 万 ↑ 10x
WebSocket 并发连接数 500 3,500 ↑ 7x
ClickHouse 查询 P99 12s 2.5s ↓ 79%
缓存命中率(综合) 35% 87% ↑ 152%

5.2 关键技术参数

  • 前端包体积:主包 180KB gzip,ECharts 按需加载 250KB gzip,首屏 JS 总计 430KB gzip
  • 内存占用:单仪表盘页面稳定运行时内存 <150MB,含 20 张图表 + WebSocket 连接
  • Lighthouse 性能评分:Performance 92,FCP 1.1s,LCP 1.8s,CLS 0.02
  • 服务端资源:Node.js 推送集群 4 实例(4C8G),单实例支撑 900+ WS 连接,CPU 均值 35%

5.3 业务成果

平台上线后服务于公司 12 个业务线,日均活跃用户 2,800+,仪表盘创建量 1,500+。运营团队故障发现平均时间从 15 分钟缩短到 3 分钟以内,数据分析师自助分析效率提升 4 倍,整体节省人力成本约 200 万元/年。

六、架构演进经验

6.1 从单体到微内核的演进

项目初期采用"一个大组件包含所有图表"的单体方式开发,随着图表类型增多(折线图、柱状图、饼图、散点图、热力图、桑基图、地图等 15+ 种),代码膨胀到 2 万行以上,维护难度急剧上升。

第二阶段我们将图表引擎重构为微内核 + 插件架构:

  • ChartKernel(微内核):负责图表实例管理、生命周期控制、事件总线、数据分发
  • ChartPlugin(插件):每种图表类型独立为一个插件,通过注册机制挂载到内核,支持运行时动态加载
  • Interceptor(拦截器):在数据流和渲染流的关键节点插入拦截器,实现数据转换、渲染增强、日志记录等横切关注点

重构后单图表组件代码量从平均 1,500 行降至 300 行,新增图表类型的开发周期从 3 天缩短到 0.5 天。

6.2 从轮询到推送再到混合模式

数据获取方式经历了三轮演进:

  1. V1 轮询模式:前端每 30s 发起 HTTP 请求拉取全量数据。简单但延迟高、服务器压力大。
  2. V2 纯推送模式:全量切换到 WebSocket 实时推送。延迟降低但带来新问题:弱网环境下 WS 断连频繁、服务端需要维护大量长连接状态。
  3. V3 混合模式(最终方案):根据数据时效性需求分级:核心指标走 WS 推送(<1s 延迟)、分析类数据走 SSE(Server-Sent Events,单向推送,无需双向通信开销)、静态报表走 HTTP + 缓存。通过自适应降级机制,在 WS 不可用时自动回退到 SSE 或轮询。
💡 经验总结:不要盲目追求"全实时"。根据业务场景选择合适的数据时效性,分级推送远比全量推送更稳定、更节省资源。实时性是成本,不是免费的。

6.3 从 ClickHouse 单引擎到混合存储

最初所有查询都打到 ClickHouse,随着数据量增长和查询模式复杂化,单一引擎无法同时满足实时明细查询和复杂多维聚合的需求。

最终演进为混合存储架构:

  • ClickHouse:承担明细数据存储和实时查询(近 7 天),利用 MergeTree 引擎的高写入吞吐
  • Apache Druid:承担历史数据的预聚合 OLAP 查询(7 天 - 1 年),利用其原生时间序列优化和预加载 segment 缓存
  • Redis Cluster:承担热数据缓存和实时聚合中间结果

查询路由层根据时间范围、聚合维度、数据源自动选择最优引擎,对上层应用透明。

6.4 架构师视角的关键经验

🎯 可复用的架构经验

  • 性能优化从测量开始:不要凭直觉优化。我们使用 Chrome DevTools Performance Panel、Lighthouse、自建性能埋点(上报渲染时间、帧率、内存)作为优化决策依据。量化指标 > 主观感受。
  • 分层解耦是大规模系统的生命线:前端图表引擎与数据层、推送层解耦后,每个层可以独立演进。新增数据源(如接入新的 OLAP 引擎)只需在数据服务层适配,前端无感知。
  • 优雅降级 > 完美但不稳定:在弱网、高负载、数据异常等边界场景下,"展示略有延迟但可用"远好于"追求极致但崩溃"。我们为每个关键路径都设计了降级方案。
  • 渐进式架构:不追求一步到位的完美架构。V1 快速验证 → V2 解决核心痛点 → V3 系统性优化。每个版本都是可交付的,不是中间态。
  • 可视化性能是前端工程化的试金石:数据可视化项目会同时考验网络层(数据获取)、计算层(数据处理)、渲染层(Canvas/DOM)、内存层(实例管理)的综合能力,是对前端架构能力的全面检验。

技术栈

React 18 TypeScript 5.x ECharts 5.x Ant Design 5.x Node.js 20 LTS WebSocket (ws) ClickHouse Apache Druid Redis 7 Cluster Kafka Flink GraphQL Federation OffscreenCanvas Web Worker TanStack Virtual

相关案例