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

目次(10 項目)
DuckDB-Wasmは、ブラウザ内で直接分析ワークロードを可能にし、多くのOLAPユースケースでサーバー側の計算を不要にします。このガイドでは、DuckDB-Wasmを活用してクライアント側でのデータ処理、リモートParquetストリーミング、および大規模データセットの効率的な可視化を行うことで、バックエンドコストゼロの分析ダッシュボードを構築する方法を詳しく説明します。
アーキテクチャの概要
コアアーキテクチャは、重いSQL実行をWeb Workerにオフロードし、応答性の高いUIスレッドを維持することを中心に展開します。データ入力は主にHTTPレンジリクエストを利用してリモートParquetファイルをストリーミングし、初期ロード時間とメモリフットプリントを最小限に抑えます。可視化にはApache Arrowのゼロコピーメモリバッファを活用し、高価なシリアル化/デシリアル化サイクルをバイパスして直接レンダリングを行います。
主要コンポーネント:
- DuckDB-Wasm Web Worker: DuckDBインスタンスを分離し、クエリ実行中のUIスレッドのブロックを防ぎます。データベースの初期化、Parquetファイルの登録、SQLクエリ処理を扱います。
- HTTP Range Requests: リモートParquetファイルの必要な部分のみをフェッチし、効率的なストリーミングとネットワークオーバーヘッドの削減を可能にします。
- Apache Arrow: DuckDB-Wasmのネイティブ出力形式です。クエリ結果の列指向でメモリ効率の良い表現を提供し、可視化ライブラリで直接利用できます。
- Canvasベースのチャート作成: 50万行以上のデータセットの場合、従来のDOMベースのチャートライブラリはパフォーマンスのボトルネックになります。Canvasは直接ピクセル操作が可能で、Arrowデータによる高スループットレンダリングに最適です。
- SharedArrayBuffer & Atomics: メインスレッドとWeb Worker間の効率的な通信を促進します。特に、大規模なArrowバッファをコピーせずに転送する場合に有効です。
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オブジェクトに変換するのを避けるため、大規模データセットにおける主要なパフォーマンスボトルネックを解消します。
アーキテクチャのトレードオフ
| 機能 | クライアントサイド DuckDB-Wasm | サーバーサイド OLAP (例: ClickHouse) |
|---|---|---|
| コスト | バックエンド計算ゼロ | サーバーホスティング、メンテナンス、スケーリング |
| レイテンシ | ローカル実行、データのみネットワーク | すべてのクエリでネットワークラウンドトリップ |
| スケーラビリティ | クライアントリソース(CPU、RAM)に限定 | 水平スケーラブル |
| データサイズ | ギガバイト(ストリーミングあり) | テラバイトからペタバイト |
| セキュリティ | データはクライアントサイドに残る | データはサーバーに送信される |
| 複雑さ | フロントエンド中心、Web Worker管理 | フルスタック、インフラ管理 |
| オフライン | ローカルデータで可能 | 永続的な接続が必要 |
| 初期ロード | Wasmバンドルダウンロード(数MB) | 最小限、UIアセットのみ |
本番環境での注意点とトラブルシューティング
- ブラウザのメモリ制限: 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内でメモリ制限を設定します。これにより、クエリが制限を超えた場合に正常に失敗するようになり、クラッシュを防ぐことができます。sqlPRAGMA memory_limit='2GB'; -- Example: limit to 2GB
- クエリの最適化: 効率的なSQLを記述します。
- Web Worker通信のオーバーヘッド: メインスレッドとWorker間で大規模なArrowバッファを転送する。
- 症状: UIのガタつき、データ転送中の高いCPU使用率。
- 修正:
- 転送可能なオブジェクト:
ArrayBuffer(Arrow IPCバッファなど)には常にpostMessage(data, [transferableObjects])を使用します。これにより所有権が移動し、コピーが回避されます。提供されているduckdb.service.tsはすでにこれを行っています。 - 転送の最小化: 視覚化に必要な最終的な集計データのみを転送し、生の途中結果は転送しません。
- 転送可能なオブジェクト:
- クロスオリジンリソース共有 (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-RangeContent-RangeのAccess-Control-Expose-Headersは、レンジリクエストが正しく機能するために重要です。
- 症状:
- 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ファイルをダウンロードできることを確認します。
- パスの確認:
- 症状:
- SQLクエリのパフォーマンス: 大規模データセットでのクエリの遅延。
- 症状:
executeSql呼び出しの待機時間が長い。 - 修正:
- 列指向アクセス: DuckDBは列指向です。幅の広いテーブルから少数の列のみを選択するクエリは高速になります。
- 述語プッシュダウン: フィルター(
WHERE句)はParquetリーダーにプッシュダウンされ、処理されるデータが削減されます。 - 集計: クエリの早い段階で集計を実行します。
EXPLAIN:EXPLAINを使用してクエリプランを理解し、ボトルネックを特定します。sqlEXPLAIN SELECT ... FROM my_data WHERE ...;
- 症状:
- 日付/時刻の処理: JavaScriptの
DateオブジェクトとDuckDBの内部型との間の不整合。- 症状: 日付/時刻データのフィルタリングまたは表示が正しくない。
- 修正:
- ISO 8601: 信頼性の高い解析のために、日付/時刻文字列をISO 8601形式(例:
'2023-10-27T10:00:00Z')でDuckDBに渡します。 - Arrow
Date/Timestamp型: Arrow Tableを受け取る際、特定のArrow日付/時刻型(例:TimestampMillisecond、DateDay)に注意してください。表示のために必要に応じてJavaScriptのDateオブジェクトに変換します。
- ISO 8601: 信頼性の高い解析のために、日付/時刻文字列をISO 8601形式(例:
よくある質問
- DuckDB-Wasmはデータをローカルに永続化できますか?
はい。DuckDB-Wasmはさまざまなストレージバックエンドをサポートしています。ブラウザのIndexedDBにデータを保存するために
duckdb.DuckDBDataProtocol.BROWSER_FSを使用でき、セッション間で永続的なストレージを可能にします。これには、WorkerでFileSystemAPIを設定する必要があります。typescript// 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', }, }, }); - DuckDB-Wasmは同時クエリをどのように処理しますか?
単一のDuckDB-Wasmインスタンス(したがって単一の
AsyncDuckDBConnection)は、クエリを順次処理します。真の同時実行性のためには、それぞれ独自のDuckDBインスタンスを持つ複数のWeb Workerが必要になります。ただし、これによりメモリ消費量が増加します。ほとんどのダッシュボードシナリオでは、クエリは通常短命であるため、単一のWorkerで十分です。 - DuckDB-Wasmが処理できるデータサイズの最大値はどれくらいですか? クライアントのメモリによって制限されますが、効率的なParquetストリーミングとクエリの最適化により、DuckDB-Wasmはデータセット全体をRAMにロードすることなく、ギガバイト範囲(例: 1〜10 GB)のデータセットを効果的にクエリできます。重要なのは、必要なデータチャンクのみをフェッチして処理することです。
- 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は列指向性、圧縮、述語プッシュダウン機能により、分析ワークロードには一般的に推奨され、大規模データセットでははるかに高いパフォーマンスを発揮します。 - DuckDB-WasmはクライアントサイドのJavaScriptデータライブラリ(例: Data-Forge、Lodash)と比較してどうですか? DuckDB-Wasmは、大規模データセットでの分析SQLワークロードに対して優れたパフォーマンスを提供します。C++で記述され、WebAssemblyにコンパイルされているため、ほぼネイティブの実行速度を提供します。列指向処理とベクトル化された実行を活用しており、JavaScriptライブラリでは通常これに匹敵しません。小規模データセットでの単純なデータ操作にはJSライブラリで十分かもしれませんが、数十万または数百万行にわたる複雑な集計、結合、フィルター処理には、DuckDB-Wasmは何桁も高速です。
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

Next.jsにおけるDuckDB-Wasm: 1000万行に対する超高速クライアントサイド分析
Next.jsでDuckDB-Wasmを使用し、本番環境レベルのアーキテクチャとコード例で1000万行に対する超高速クライアントサイド分析を実現する包括的なガイド。
Read more
2026年のClickHouseとDuckDB:インメモリ組み込みOLAP対分散型ベクトル化ウェアハウス
2026年におけるClickHouseとDuckDBを、インメモリ組み込みOLAPと分散型ベクトル化ウェアハウスの観点から、本番環境レベルのアーキテクチャとコード例を交えて網羅的に解説するガイドです。
Read moreClickHouse vs DuckDB: アーキテクチャの深掘りと本番環境ベンチマーク
2つの主要なカラムナ分析データベースエンジンを比較します。DuckDBの組み込みベクトル化とClickHouseの分散リアルタイムOLAPの使い分けを解説します。
Read more