DuckDBでNode.jsのデータ分析を最適化:軽量なin-process OLAPソリューション

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Node.jsで大規模データを処理する際の3つのアプローチ

約500万レコード(約1.2 GB)のエクスポートファイルから、カテゴリごとの総売上と平均注文額を算出する必要があるとします。サーバーをクラッシュさせずにNode.jsでこれを処理するには、どのように実装すべきでしょうか?

バックエンド開発の実務において、通常は次の3つの選択肢が考えられます。

  • ピュアJavaScriptによる処理(Streams / Chunks):CSVを1行ずつ読み込むかJSONをパースし、mapやreduceで計算します。外部依存を追加する必要がない反面、CPU負荷が極めて高くなり、ストリーム処理のコードも複雑化しがちです。
  • 専用データベースへのオフロード(PostgreSQL、ClickHouse):Node.jsはネットワーク経由でSQLクエリを送信し、結果を受け取る仲介役としてのみ機能します。安定性は高いもののインフラコストが増加し、追加のデータベースクラスタの運用が必要になるほか、クエリごとに20ms〜50ms程度のネットワークレイテンシが発生します。
  • DuckDBをNode.jsに直接組み込む(In-process):DuckDBはC++バインディングを通じて同一プロセス内(in-process)で直接動作します。使い慣れたSQL構文を用いて、ディスク上のCSVやParquetファイルをダイレクトに読み取ることができます。ネットワーク経由の通信も、別個のデータベースサーバーの構築も不要です。

各ソリューションのメリット・デメリット詳細比較

各アプローチには、処理速度、メモリ消費量、運用コストの間でそれぞれトレードオフが存在します。

1. ピュアJavaScriptによる処理

  • メリット:5万行未満のデータセットであれば素早く実装可能。サードパーティの依存関係も発生しません。
  • デメリット:JavaScriptはシングルスレッドで動作します。数百万個のオブジェクト配列を計算処理しようとすると、V8エンジンのヒープメモリ上限(約1.4 GB)に達してOut of Memory(OOM)エラーを引き起こしやすくなります。Worker Threadsに分離しない限りEvent Loopが完全にブロックされ、他のすべてのAPI応答が停止してしまいます。

2. リレーショナルデータベース(PostgreSQL、MySQL)の利用

  • メリット:馴染み深く、厳格なACIDトランザクション、高度なセキュリティ、詳細なアクセス制御がサポートされています。
  • デメリット:従来のRDBMSは行指向(row-oriented)ストレージを採用しています。SUMやAVGのために数百万レコードをスキャンするクエリを実行すると、関係のない列までディスクから読み込む必要があります。標準的なスペックのサーバー(2 vCPU、4GB RAM)では、500万行に対するGROUP BYクエリに15〜30秒ほどかかる場合があります。

3. ローカルへのDuckDB組み込み

  • メリット:
    • カラム型ストレージ(Columnar):SELECT句で指定した必要な列のみを読み取ります。ディスクI/Oを最大80%削減可能です。
    • ベクトル化実行(Vectorized Execution):1行ずつ順次処理するのではなく、CPUレジスタ上でチャンク単位のバッチとしてデータを処理します。
    • ゼロネットワークレイテンシ(Zero Network Latency):共有メモリを介してDuckDBとNode.js間でデータが直接受け渡されます。
    • ファイルの直接読み取り:中間テーブルを作成することなく、Parquet、CSV、Arrowファイルに対して直接SQLクエリを実行できます。
  • デメリット:高並行の書き込み処理(concurrent writes)には向いていません。何千ものリクエストが同時に同一の.duckdbファイルに書き込もうとすると、ロックの競合が発生します。

どのツールを選択すべきか?

ワークロードの特性に応じて最適なツールを選択しましょう。

  • ピュアJavaScript:ファイルサイズが10MB未満で、複雑な文字列処理ロジックが必要かつ実行頻度が低い場合に適しています。
  • PostgreSQL / MySQL:カート管理、注文確定、ユーザー認証、残高更新など、一般的なOLTP業務に最適です。
  • DuckDB:定期レポート生成、システムログ集計、社内分析ダッシュボード、定期データエクスポートなど、ローカルでのOLAP処理に最適です。

実際のEコマースシステムを例にとると、注文データはPostgreSQLに保存し、夜間にワーカースクリプトでParquet形式のスナップショットファイルを抽出します。管理者がダッシュボードを開いて12か月間のトレンドグラフを閲覧する際、Node.js APIはDuckDBを呼び出してこの2GBのParquetファイルを直接読み取ります。これにより、プライマリデータベースで42秒かかっていた応答時間がわずか350msまで短縮され、顧客のチェックアウト処理への負荷影響も完全に回避できます。

実践:Node.jsアプリケーションへのDuckDBの統合

以下のステップに従って、ライブラリのセットアップと実際のファイルに対するデータクエリを実行してみましょう。

ステップ1:ライブラリのインストール

プロジェクトディレクトリを作成し、duckdbパッケージをインストールします。

mkdir node-duckdb-demo
cd node-duckdb-demo
npm init -y
npm install duckdb express

ステップ2:DuckDB接続の初期化

DuckDBはインメモリ(:memory:)で動作させることも、ローカルファイルに永続化することも可能です。analytics.jsファイルを作成します。

const duckdb = require('duckdb');

// インメモリデータベースの初期化
const db = new duckdb.Database(':memory:');

// async/awaitで使用できるようにdb.allをPromise化
const query = (sql) => {
  return new Promise((resolve, reject) => {
    db.all(sql, (err, rows) => {
      if (err) return reject(err);
      resolve(rows);
    });
  });
};

async function main() {
  const result = await query('SELECT 42 AS answer');
  console.log('接続結果:', result);
}

main();

ステップ3:CSVファイルに対する直接クエリの実行

売上履歴をシミュレートしたサンプルファイルorders.csvを作成します。

order_id,customer_id,category,amount,created_at
1,101,Electronics,250.50,2024-01-01
2,102,Books,15.20,2024-01-01
3,101,Electronics,120.00,2024-01-02
4,103,Clothing,89.90,2024-01-02

インポート処理を実行することなく、ファイルパスに対して直接SQLを記述できます。

async function analyzeSales() {
  const sql = `
    SELECT 
      category,
      COUNT(*) AS total_orders,
      ROUND(SUM(amount), 2) AS total_revenue,
      ROUND(AVG(amount), 2) AS average_order_value
    FROM 'orders.csv'
    GROUP BY category
    ORDER BY total_revenue DESC;
  `;

  console.time('Query CSV');
  const summary = await query(sql);
  console.timeEnd('Query CSV');

  console.table(summary);
}

analyzeSales();

ステップ4:Parquet形式への圧縮と読み込み

CSVファイルは容量が大きくなりやすく、読み込むたびに文字列のパースが必要です。100万行を超えるデータセットを扱う場合は、カラム単位での高速読み取りと圧縮効率に優れたParquet形式への変換をお勧めします。

async function convertAndAnalyze() {
  // 1. CSVをSnappy圧縮のParquetに変換
  await query(`
    COPY (SELECT * FROM 'orders.csv') 
    TO 'orders.parquet' (FORMAT PARQUET, COMPRESSION SNAPPY);
  `);
  console.log('orders.parquet の作成に成功しました。');

  // 2. Parquetファイルに対して直接クエリを実行
  const parquetSql = `
    SELECT 
      category,
      ROUND(SUM(amount), 2) AS revenue 
    FROM 'orders.parquet' 
    WHERE amount > 50
    GROUP BY category;
  `;
  const result = await query(parquetSql);
  console.table(result);
}

convertAndAnalyze();

ステップ5:ExpressによるレポートAPIの作成

DuckDBのクエリをAPIルートに組み込み、フロントエンドにデータを返却します。

const express = require('express');
const app = express();

app.get('/api/reports/monthly', async (req, res) => {
  try {
    const data = await query(`
      SELECT 
        strftime('%Y-%m', CAST(created_at AS DATE)) AS month,
        ROUND(SUM(amount), 2) AS monthly_revenue
      FROM 'orders.parquet'
      GROUP BY month
      ORDER BY month ASC
    `);
    res.json({ success: true, count: data.length, data });
  } catch (error) {
    res.status(500).json({ success: false, message: error.message });
  }
});

app.listen(3000, () => {
  console.log('分析APIがポート3000で起動しました');
});

DuckDBは、Node.jsにおけるローカルデータ分析の課題を見事に解決します。大掛かりなOLAPクラスタを構築する必要がなく、サーバーコストを最小限に抑えながら、社内レポートや集計処理で極めて高速なレスポンスを実現できます。

Share: