•19 min read

モダンWebアプリにおけるDuckDB-Wasm:クライアントサイドOLAP、Parquetストリーミング、そして超高速ダッシュボード

モダンWebアプリにおけるDuckDB-Wasm:クライアントサイドOLAP、Parquetストリーミング、そして超高速ダッシュボード

DuckDB-Wasmは、ブラウザ内で直接分析ワークロードを可能にし、多くのOLAPユースケースでサーバー側の計算を不要にします。このガイドでは、DuckDB-Wasmを活用してクライアント側でのデータ処理、リモートParquetストリーミング、および大規模データセットの効率的な可視化を行うことで、バックエンドコストゼロの分析ダッシュボードを構築する方法を詳しく説明します。

Audio Briefing
0:00 / 0:00

アーキテクチャの概要

コアアーキテクチャは、重いSQL実行をWeb Workerにオフロードし、応答性の高いUIスレッドを維持することを中心に展開します。データ入力は主にHTTPレンジリクエストを利用してリモートParquetファイルをストリーミングし、初期ロード時間とメモリフットプリントを最小限に抑えます。可視化にはApache Arrowのゼロコピーメモリバッファを活用し、高価なシリアル化/デシリアル化サイクルをバイパスして直接レンダリングを行います。

主要コンポーネント:

  1. DuckDB-Wasm Web Worker: DuckDBインスタンスを分離し、クエリ実行中のUIスレッドのブロックを防ぎます。データベースの初期化、Parquetファイルの登録、SQLクエリ処理を扱います。
  2. HTTP Range Requests: リモートParquetファイルの必要な部分のみをフェッチし、効率的なストリーミングとネットワークオーバーヘッドの削減を可能にします。
  3. Apache Arrow: DuckDB-Wasmのネイティブ出力形式です。クエリ結果の列指向でメモリ効率の良い表現を提供し、可視化ライブラリで直接利用できます。
  4. Canvasベースのチャート作成: 50万行以上のデータセットの場合、従来のDOMベースのチャートライブラリはパフォーマンスのボトルネックになります。Canvasは直接ピクセル操作が可能で、Arrowデータによる高スループットレンダリングに最適です。
  5. SharedArrayBuffer & Atomics: メインスレッドとWeb Worker間の効率的な通信を促進します。特に、大規模なArrowバッファをコピーせずに転送する場合に有効です。
Advertisement

DuckDB-Wasmのセットアップ

UIの応答性を確保するため、Web Worker内でDuckDB-Wasmを初期化します。メインスレッドはこのWorkerとpostMessageを介して通信します。

Workerの初期化 (duckdb.worker.ts)

// duckdb.worker.ts
import * as duckdb from '@duckdb/duckdb-wasm';
import duckdb_wasm from '@duckdb/duckdb-wasm/dist/duckdb-mvp.wasm';
import duckdb_wasm_next from '@duckdb/duckdb-wasm/dist/duckdb-eh.wasm';

// Define the DuckDB bundle configuration
const DUCKDB_BUNDLES: duckdb.DuckDBBundles = {
  mvp: {
    mainModule: duckdb_wasm,
    mainWorker: new URL('@duckdb/duckdb-wasm/dist/duckdb-browser-mvp.worker.js', import.meta.url).toString(),
  },
  eh: {
    mainModule: duckdb_wasm_next,
    mainWorker: new URL('@duckdb/duckdb-wasm/dist/duckdb-browser-eh.worker.js', import.meta.url).toString(),
  },
};

let db: duckdb.AsyncDuckDB | null = null;
let conn: duckdb.AsyncDuckDBConnection | null = null;

// Initialize DuckDB and establish a connection
async function initializeDuckDB() {
  if (db && conn) return;

  const logger = new duckdb.ConsoleLogger();
  const bundle = await duckdb.selectBundle(DUCKDB_BUNDLES);

  // Instantiate the database
  db = new duckdb.AsyncDuckDB(logger, bundle);
  await db.instantiate(bundle.mainWorker);
  conn = await db.connect();

  console.log('DuckDB-Wasm initialized in worker.');
}

// Handle messages from the main thread
self.onmessage = async (event: MessageEvent) => {
  const { id, type, payload } = event.data;

  try {
    if (type === 'init') {
      await initializeDuckDB();
      self.postMessage({ id, type: 'init_success' });
    } else if (type === 'execute_sql') {
      if (!conn) throw new Error('DuckDB connection not established.');

      const { sql } = payload;
      console.log(`Executing SQL: ${sql}`);

      // Execute query and get results as Arrow
      const result = await conn.query(sql);

      // Transfer Arrow IPC buffer back to main thread
      // The toIPC method serializes the Arrow table into an IPC stream buffer.
      const arrowBuffer = result.toIPC();
      self.postMessage({ id, type: 'execute_sql_success', payload: arrowBuffer }, [arrowBuffer]);
    } else if (type === 'register_parquet_url') {
      if (!db) throw new Error('DuckDB not initialized.');

      const { url, tableName } = payload;
      console.log(`Registering Parquet URL: ${url} as ${tableName}`);

      // Register a remote Parquet file. DuckDB-Wasm handles HTTP range requests internally.
      await db.registerFileURL(tableName, url, duckdb.DuckDBDataProtocol.HTTP, false);
      self.postMessage({ id, type: 'register_parquet_url_success' });
    } else {
      throw new Error(`Unknown message type: ${type}`);
    }
  } catch (error: any) {
    console.error(`Worker error for message ID ${id}:`, error);
    self.postMessage({ id, type: 'error', payload: error.message });
  }
};

メインスレッドインターフェース (duckdb.service.ts)

// duckdb.service.ts
import { Table } from 'apache-arrow';

// Using a dedicated worker for DuckDB operations
const worker = new Worker(new URL('./duckdb.worker.ts', import.meta.url), { type: 'module' });

// Map to store pending requests and resolve them
const pendingRequests = new Map<string, { resolve: Function; reject: Function }>();
let messageIdCounter = 0;

worker.onmessage = (event: MessageEvent) => {
  const { id, type, payload } = event.data;
  const request = pendingRequests.get(id);

  if (request) {
    if (type === 'error') {
      request.reject(new Error(payload));
    } else if (type === 'execute_sql_success') {
      // Reconstruct Arrow Table from the transferred buffer
      const table = Table.from([new Uint8Array(payload)]);
      request.resolve(table);
    } else {
      request.resolve(payload);
    }
    pendingRequests.delete(id);
  } else {
    console.warn(`Received message for unknown request ID: ${id}`);
  }
};

function sendMessageToWorker(type: string, payload?: any): Promise<any> {
  return new Promise((resolve, reject) => {
    const id = `msg_${messageIdCounter++}`;
    pendingRequests.set(id, { resolve, reject });
    worker.postMessage({ id, type, payload });
  });
}

export const duckdbService = {
  init: () => sendMessageToWorker('init'),

  /**
   * Registers a remote Parquet file URL with DuckDB.
   * DuckDB-Wasm will use HTTP range requests to access this file.
   * @param url The URL of the Parquet file.
   * @param tableName The name to register the table as in DuckDB.
   */
  registerParquetUrl: (url: string, tableName: string) =>
    sendMessageToWorker('register_parquet_url', { url, tableName }),

  /**
   * Executes a SQL query and returns the result as an Apache Arrow Table.
   * @param sql The SQL query string.
   * @returns A Promise that resolves to an Apache Arrow Table.
   */
  executeSql: async (sql: string): Promise<Table> =>
    sendMessageToWorker('execute_sql', { sql }),
};

// Initialize DuckDB-Wasm when the service is imported
duckdbService.init().catch(console.error);

リモートParquetファイルのストリーミング

DuckDB-Wasmは、HTTPレンジリクエストを介したリモートParquetファイルの読み取りをネイティブでサポートしています。これは、ファイル全体を事前にダウンロードするのを避けるため、パフォーマンスにとって非常に重要です。

// Example usage in a React component or similar
import React, { useEffect, useState } from 'react';
import { duckdbService } from './duckdb.service';
import { Table } from 'apache-arrow';

const ParquetDataLoader: React.FC = () => {
  const [data, setData] = useState<Table | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const loadData = async () => {
      try {
        // Ensure DuckDB is initialized
        await duckdbService.init();

        const parquetUrl = 'https://example.com/path/to/your/large_dataset.parquet';
        const tableName = 'my_data';

        // Register the remote Parquet file
        await duckdbService.registerParquetUrl(parquetUrl, tableName);

        // Execute a query to fetch some data
        // DuckDB will automatically fetch only the necessary parts of the Parquet file
        const resultTable = await duckdbService.executeSql(`
          SELECT
            category,
            SUM(value) AS total_value,
            COUNT(*) AS record_count
          FROM my_data
          WHERE timestamp > '2023-01-01'
          GROUP BY category
          ORDER BY total_value DESC
          LIMIT 10;
        `);

        setData(resultTable);
      } catch (err: any) {
        setError(err.message);
        console.error('Failed to load Parquet data:', err);
      } finally {
        setLoading(false);
      }
    };

    loadData();
  }, []);

  if (loading) return <div>Loading data...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!data) return <div>No data loaded.</div>;

  return (
    <div>
      <h2>Top Categories by Value</h2>
      <pre>{JSON.stringify(data.toArray().map(row => row.toJSON()), null, 2)}</pre>
      {/* Render chart here using 'data' */}
    </div>
  );
};

export default ParquetDataLoader;

Apache ArrowとCanvasで50万行以上のデータを可視化する

大規模なArrow TableをDOM要素で直接レンダリングするのは非効率です。Canvasはパフォーマンスの高い代替手段を提供します。ここでは、Arrowの有用性を示す散布図の概念的なアプローチを概説します。

// components/ArrowScatterPlot.tsx
import React, { useRef, useEffect } from 'react';
import { Table } from 'apache-arrow';

interface ArrowScatterPlotProps {
  data: Table;
  xColumn: string;
  yColumn: string;
  width?: number;
  height?: number;
  pointColor?: string;
  pointRadius?: number;
}

const ArrowScatterPlot: React.FC<ArrowScatterPlotProps> = ({
  data,
  xColumn,
  yColumn,
  width = 800,
  height = 400,
  pointColor = 'rgba(75, 192, 192, 0.5)',
  pointRadius = 2,
}) => {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    if (!data || !canvasRef.current) return;

    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    ctx.clearRect(0, 0, width, height); // Clear previous drawing

    const xData = data.getChild(xColumn);
    const yData = data.getChild(yColumn);

    if (!xData || !yData || xData.length !== yData.length) {
      console.warn('Invalid x or y column data for scatter plot.');
      return;
    }

    // Determine data bounds for scaling
    const minX = Math.min(...(xData.toArray() as number[]));
    const maxX = Math.max(...(xData.toArray() as number[]));
    const minY = Math.min(...(yData.toArray() as number[]));
    const maxY = Math.max(...(yData.toArray() as number[]));

    // Simple linear scaling functions
    const scaleX = (value: number) => {
      if (maxX === minX) return width / 2; // Avoid division by zero
      return ((value - minX) / (maxX - minX)) * width;
    };
    const scaleY = (value: number) => {
      if (maxY === minY) return height / 2; // Avoid division by zero
      // Invert Y-axis for canvas coordinates (0,0 is top-left)
      return height - (((value - minY) / (maxY - minY)) * height);
    };

    ctx.fillStyle = pointColor;
    ctx.beginPath();

    // Iterate directly over Arrow vectors for performance
    // Using get() is slower than iterating over underlying TypedArrays if possible,
    // but for demonstration, get() is simpler. For extreme performance,
    // access xData.data.values directly if it's a primitive type.
    for (let i = 0; i < data.numRows; i++) {
      const x = xData.get(i) as number;
      const y = yData.get(i) as number;

      if (x !== null && y !== null && !isNaN(x) && !isNaN(y)) {
        const screenX = scaleX(x);
        const screenY = scaleY(y);

        ctx.moveTo(screenX, screenY); // Move before arc to avoid connecting dots
        ctx.arc(screenX, screenY, pointRadius, 0, Math.PI * 2);
      }
    }
    ctx.fill();

    // Optional: Draw axes and labels
    ctx.strokeStyle = '#ccc';
    ctx.lineWidth = 1;
    ctx.font = '10px Arial';
    ctx.fillStyle = '#333';

    // X-axis
    ctx.beginPath();
    ctx.moveTo(0, height);
    ctx.lineTo(width, height);
    ctx.stroke();
    ctx.fillText(minX.toFixed(2), 0, height - 5);
    ctx.fillText(maxX.toFixed(2), width - 30, height - 5);

    // Y-axis
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(0, height);
    ctx.stroke();
    ctx.fillText(maxY.toFixed(2), 5, 15);
    ctx.fillText(minY.toFixed(2), 5, height - 5);

  }, [data, xColumn, yColumn, width, height, pointColor, pointRadius]);

  return <canvas ref={canvasRef} width={width} height={height} style={{ border: '1px solid #eee' }} />;
};

export default ArrowScatterPlot;

このArrowScatterPlotコンポーネントはApache Arrow Tableを直接受け取ります。その後、指定された列(xColumn、yColumn)にArrow Vectorとしてアクセスし、それらを反復処理してCanvas上に点を描画します。このアプローチは、データセット全体をJavaScriptオブジェクトに変換するのを避けるため、大規模データセットにおける主要なパフォーマンスボトルネックを解消します。

Advertisement

アーキテクチャのトレードオフ

機能クライアントサイド DuckDB-Wasmサーバーサイド OLAP (例: ClickHouse)
コストバックエンド計算ゼロサーバーホスティング、メンテナンス、スケーリング
レイテンシローカル実行、データのみネットワークすべてのクエリでネットワークラウンドトリップ
スケーラビリティクライアントリソース(CPU、RAM)に限定水平スケーラブル
データサイズギガバイト(ストリーミングあり)テラバイトからペタバイト
セキュリティデータはクライアントサイドに残るデータはサーバーに送信される
複雑さフロントエンド中心、Web Worker管理フルスタック、インフラ管理
オフラインローカルデータで可能永続的な接続が必要
初期ロードWasmバンドルダウンロード(数MB)最小限、UIアセットのみ

本番環境での注意点とトラブルシューティング

  1. ブラウザのメモリ制限: DuckDB-Wasmは、特に大規模な中間クエリ結果やParquetファイル全体をキャッシュする場合に、かなりのメモリを消費する可能性があります。
    • 症状: ブラウザタブのクラッシュ、コンソールでの「Out of Memory」エラー。
    • 修正:
      • クエリの最適化: 効率的なSQLを記述します。LIMIT句、GROUP BY集計、FILTER述語を早期に使用します。
      • ストリーミング: レンジリクエストを有効にするために、ParquetファイルがregisterFileURLを介してDuckDBDataProtocol.HTTPで登録されていることを確認します。大規模なファイルではregisterFileBufferを避けます。
      • ガベージコレクション: DuckDB-Wasmは独自のメモリを管理します。不要になった接続(conn.close())とデータベースインスタンス(db.terminate())を明示的に閉じますが、永続的なダッシュボードの場合、これはあまり一般的ではありません。
      • PRAGMA memory_limit: DuckDB-Wasm内でメモリ制限を設定します。
        PRAGMA memory_limit='2GB'; -- Example: limit to 2GB
        
        これにより、クエリが制限を超えた場合に正常に失敗するようになり、クラッシュを防ぐことができます。
  2. Web Worker通信のオーバーヘッド: メインスレッドとWorker間で大規模なArrowバッファを転送する。
    • 症状: UIのガタつき、データ転送中の高いCPU使用率。
    • 修正:
      • 転送可能なオブジェクト: ArrayBuffer(Arrow IPCバッファなど)には常にpostMessage(data, [transferableObjects])を使用します。これにより所有権が移動し、コピーが回避されます。提供されているduckdb.service.tsはすでにこれを行っています。
      • 転送の最小化: 視覚化に必要な最終的な集計データのみを転送し、生の途中結果は転送しません。
  3. クロスオリジンリソース共有 (CORS): リモートParquetファイルへのアクセス。
    • 症状: registerFileURLが呼び出されたときにコンソールでFailed to fetch、CORS policyエラーが発生する。
    • 修正: Parquetファイルをホストするサーバーは、適切なCORSヘッダー、特にRangeリクエストを許可するヘッダーを含める必要があります。
      Access-Control-Allow-Origin: *
      Access-Control-Allow-Methods: GET, HEAD
      Access-Control-Allow-Headers: Range
      Access-Control-Expose-Headers: Accept-Ranges, Content-Encoding, Content-Length, Content-Range
      
      Content-RangeのAccess-Control-Expose-Headersは、レンジリクエストが正しく機能するために重要です。
  4. Wasmバンドルのロード失敗: ネットワークの問題、またはWasmファイルのパスが間違っている。
    • 症状: .wasmまたは.worker.jsファイルに関連するFailed to load module、TypeError: Failed to fetch。
    • 修正:
      • パスの確認: duckdb.worker.tsのDUCKDB_BUNDLESが、Workerの実行コンテキストに対してWasmおよびWorker JSファイルへの正しいパスを指していることを確認します。
      • バンドラーの設定: Webpack/Viteを使用している場合、file-loaderなどが.wasmファイルを処理するように設定されており、Workerスクリプトが正しくバンドルされていることを確認します。Viteはnew URL('...', import.meta.url)でこれをうまく処理します。
      • ネットワークチェック: ブラウザが実際にWasmファイルをダウンロードできることを確認します。
  5. SQLクエリのパフォーマンス: 大規模データセットでのクエリの遅延。
    • 症状: executeSql呼び出しの待機時間が長い。
    • 修正:
      • 列指向アクセス: DuckDBは列指向です。幅の広いテーブルから少数の列のみを選択するクエリは高速になります。
      • 述語プッシュダウン: フィルター(WHERE句)はParquetリーダーにプッシュダウンされ、処理されるデータが削減されます。
      • 集計: クエリの早い段階で集計を実行します。
      • EXPLAIN: EXPLAINを使用してクエリプランを理解し、ボトルネックを特定します。
        EXPLAIN SELECT ... FROM my_data WHERE ...;
        
  6. 日付/時刻の処理: JavaScriptのDateオブジェクトとDuckDBの内部型との間の不整合。
    • 症状: 日付/時刻データのフィルタリングまたは表示が正しくない。
    • 修正:
      • ISO 8601: 信頼性の高い解析のために、日付/時刻文字列をISO 8601形式(例: '2023-10-27T10:00:00Z')でDuckDBに渡します。
      • Arrow Date / Timestamp型: Arrow Tableを受け取る際、特定のArrow日付/時刻型(例: TimestampMillisecond、DateDay)に注意してください。表示のために必要に応じてJavaScriptのDateオブジェクトに変換します。

よくある質問

  1. DuckDB-Wasmはデータをローカルに永続化できますか? はい。DuckDB-Wasmはさまざまなストレージバックエンドをサポートしています。ブラウザのIndexedDBにデータを保存するためにduckdb.DuckDBDataProtocol.BROWSER_FSを使用でき、セッション間で永続的なストレージを可能にします。これには、WorkerでFileSystem APIを設定する必要があります。
    // In worker initialization
    await db.instantiate(bundle.mainWorker, {
      query: {
        'default_connection': {
          'user_agent': navigator.userAgent,
          'allow_unsigned_http_requests': 'true', // For HTTP range requests
        },
        'default_database': {
          'path': 'my_persistent_db.duckdb', // Name for IndexedDB storage
          'type': 'browser_fs',
        },
      },
    });
    
  2. DuckDB-Wasmは同時クエリをどのように処理しますか? 単一のDuckDB-Wasmインスタンス(したがって単一のAsyncDuckDBConnection)は、クエリを順次処理します。真の同時実行性のためには、それぞれ独自のDuckDBインスタンスを持つ複数のWeb Workerが必要になります。ただし、これによりメモリ消費量が増加します。ほとんどのダッシュボードシナリオでは、クエリは通常短命であるため、単一のWorkerで十分です。
  3. DuckDB-Wasmが処理できるデータサイズの最大値はどれくらいですか? クライアントのメモリによって制限されますが、効率的なParquetストリーミングとクエリの最適化により、DuckDB-Wasmはデータセット全体をRAMにロードすることなく、ギガバイト範囲(例: 1〜10 GB)のデータセットを効果的にクエリできます。重要なのは、必要なデータチャンクのみをフェッチして処理することです。
  4. DuckDB-WasmをCSVやJSONなどの他のデータ形式で使用できますか? はい。DuckDB-WasmはCSVおよびJSONファイルの読み取りをサポートしています。リモートファイルの場合、registerFileURLをDuckDBDataProtocol.HTTPと適切なファイルタイプ(例: CREATE TABLE my_csv AS SELECT * FROM read_csv_auto('https://example.com/data.csv');)で使用できます。ただし、Parquetは列指向性、圧縮、述語プッシュダウン機能により、分析ワークロードには一般的に推奨され、大規模データセットでははるかに高いパフォーマンスを発揮します。
  5. DuckDB-WasmはクライアントサイドのJavaScriptデータライブラリ(例: Data-Forge、Lodash)と比較してどうですか? DuckDB-Wasmは、大規模データセットでの分析SQLワークロードに対して優れたパフォーマンスを提供します。C++で記述され、WebAssemblyにコンパイルされているため、ほぼネイティブの実行速度を提供します。列指向処理とベクトル化された実行を活用しており、JavaScriptライブラリでは通常これに匹敵しません。小規模データセットでの単純なデータ操作にはJSライブラリで十分かもしれませんが、数十万または数百万行にわたる複雑な集計、結合、フィルター処理には、DuckDB-Wasmは何桁も高速です。
Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement