RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

【PGlite】WebAssemblyでPostgreSQLを軽量に

はじめに

こんにちは!エンジニア2年目のTKDSです!
今回はPGliteについて調べてみました!
概要・使い方・速度実験・まとめの内容で記事は構成されています。

使ってみた結果として、軽量高速であり色々使いみちがありそうなツールだと感じました。
ぜひ最後まで読んでいただけると幸いです。

PGliteの概要

PGliteは、PostgreSQLをWebAssembly(WASM)にコンパイルした軽量なデータベースエンジンです。
これにより、ブラウザ、Node.js、Bun、DenoなどでPostgreSQLの機能を利用でき、開発者はローカルやサーバーレス環境でデータベース操作を行うことが可能です。
PGliteは、インメモリデータベースやファイルシステム(Node.jsやBun)、IndexedDB(ブラウザ)での永続化をサポートします。

PGliteの特徴

公式サイトによると、
1. Lightweight
2. Extendable
3. Reactive

上記、3つの特徴が挙げられていました。

  • 1:PGliteのWASMバイナリが圧縮状態で3MB程度であること
  • 2:PostgreSQL拡張機能が適用可能であること
  • 3:に関してはテーブルが変更されたときに更新された結果を受け取る機能をサポートしていること

から来ているそうです。
3はCDC(Change Data Capture)の機能に似てますね!
フロントエンドは詳しくないのですが、状態管理などにも使えるのでは?など思い浮かびました。

PGliteを試す

ドキュメントをみつつ環境構築してみましょう。
手軽に試したい場合は、こちらのリンクから試すこともできます。

今回は手元で試してみます。

npm install @electric-sql/pglite

application.jsに下記の内容を書き込みます。

const { PGlite } = require('@electric-sql/pglite');

(async () => {
  try {
    const db = new PGlite();

    await db.exec(`
      CREATE TABLE IF NOT EXISTS todo (
        id SERIAL PRIMARY KEY,
        task TEXT,
        done BOOLEAN DEFAULT false
      );
      INSERT INTO todo (task, done) VALUES ('Install PGlite from NPM', true);
      INSERT INTO todo (task, done) VALUES ('Load PGlite', true);
      INSERT INTO todo (task, done) VALUES ('Create a table', true);
      INSERT INTO todo (task) VALUES ('Update a task');
    `);

    const ret = await db.query(`
      SELECT * from todo WHERE id = 1;
    `);

    console.log("Query Result:", ret.rows);
  } catch (error) {
    console.error("Error executing query:", error);
  }
})();

ドキュメントの内容をそのままコピーしても動かないので、少々修正してあります。
結果は以下のとおりです。

無事に動かすことができました。
雑に時間を測ってみると起動からSQL実行まで2.08秒ほどで完了しています。非常に高速ですね!

ブラウザで使う

コマンドラインで決まったSQLを実行する以外にも、ブラウザでREPLを使って、対話的にPGliteにアクセスできます。
ドキュメントにコードが記載されていますが、2024/8/18時点では、そのまま使用できません。

開発者ツールでみると、Failed to load resource: the server responded with a status of 404 ()とメッセージが表示されています。
ソースからファイルを開くと

Couldn't find the requested file /dist-webcomponent/Repl.js in @electric-sql/pglite.と表示されています。
おそらくドキュメントのコードのリンクが間違っていそうです。
JSDELIVERで調べてみましょう。
調べてみると、下記画像のようにpglite-replがありました!このパスに変更すればいけそうです。
念のため中身をチェックしてから使用しました。

では、以下のコードをファイルに書き、ブラウザで開いてください。

<!doctype html>
<html lang="ja">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>PGlite REPL Example</title>
        <script
            src="https://cdn.jsdelivr.net/npm/@electric-sql/pglite-repl@0.2.1/dist-webcomponent/Repl.js"
            type="module"
        ></script>
    </head>
    <body>
        <h1>PGlite REPL</h1>

        <!-- PGlite REPLコンポーネントがここに表示されます -->
        <pglite-repl id="repl"></pglite-repl>
        <script type="module">
            import { PGlite } from "https://cdn.jsdelivr.net/npm/@electric-sql/pglite/dist/index.js";

            // PGliteインスタンスを作成
            const pg = new PGlite();

            // REPL要素を取得
            const repl = document.getElementById("repl");

            // PGliteインスタンスをREPLに設定
            repl.pg = pg;
        </script>
    </body>
</html>

REPLを開くことができ入力もできました!

PGliteの速度計測

次にPGliteの速度を測ってみます。
SELECT、 INSERT、UPDATE、DELETEについて、それぞれ実験します。
以下のコードで実験します。

各DB操作を行い、1000, 2000, 3000, 4000, 5000, 10000と扱う件数が増えていきます。

import { PGlite } from "@electric-sql/pglite";
import { performance } from "perf_hooks";

const measureDbStartup = () => {
  const dbStartTime = performance.now(); // DB起動時間の測定開始
  const db = new PGlite(); // データベースインスタンスを初期化
  const dbEndTime = performance.now(); // DB起動時間の測定終了
  const dbStartupTime = dbEndTime - dbStartTime;
  console.log(`DB startup time: ${dbStartupTime.toFixed(3)} ms`);
  return db;
};

const initDb = async (db, numRows) => {
  await db.exec(`
        CREATE TABLE IF NOT EXISTS test_table (
            id SERIAL PRIMARY KEY,
            name TEXT,
            value INTEGER
        );
    `);

  const values = [];
  for (let i = 0; i < numRows; i++) {
    values.push(`('name_${i}', ${i})`);
  }
  await db.exec(
    `INSERT INTO test_table(name, value) VALUES ${values.join(", ")}`,
  );
};

const measureOperationTime = async (operation, db, numRows) => {
  let totalTime = 0;

  for (let trial = 1; trial <= 3; trial++) {
    const startTime = performance.now();

    await operation(db, numRows);

    const endTime = performance.now();
    const duration = endTime - startTime;
    totalTime += duration;

    console.log(
      `Trial ${trial}, ${operation.name.toUpperCase()} ${numRows} rows: ${duration.toFixed(3)} ms`,
    );
  }

  const averageTime = totalTime / 3;
  console.log(
    `Average ${operation.name.toUpperCase()} time for ${numRows} rows: ${averageTime.toFixed(3)} ms`,
  );
  console.log(`Time per row: ${(averageTime / numRows).toFixed(6)} ms/row`);
};

const selectOperation = async (db, numRows) => {
  await db.query(`SELECT * FROM test_table LIMIT ${numRows}`);
};

const insertOperation = async (db, numRows) => {
  const values = [];
  for (let i = numRows; i < numRows * 2; i++) {
    values.push(`('name_${i}', ${i})`);
  }
  await db.exec(
    `INSERT INTO test_table(name, value) VALUES ${values.join(", ")}`,
  );
};

const updateOperation = async (db, numRows) => {
  await db.exec(
    `UPDATE test_table SET value = value + 1 WHERE id <= ${numRows}`,
  );
};

const deleteOperation = async (db, numRows) => {
  await db.exec(`DELETE FROM test_table WHERE id <= ${numRows}`);
};

// 実行
const main = async () => {
  const operationCounts = [1000, 2000, 3000, 4000, 5000, 10000];
  const operations = [
    selectOperation,
    insertOperation,
    updateOperation,
    deleteOperation,
  ];

  for (const count of operationCounts) {
    for (const operation of operations) {
      console.log(
        `\nRunning ${operation.name.toUpperCase()} test with ${count} rows:`,
      );
      const db = measureDbStartup(); // DB起動時間の測定とインスタンス作成
      await initDb(db, count); // DB初期化とデータ挿入
      await measureOperationTime(operation, db, count); // 操作のパフォーマンス測定
    }
  }
};

main()
  .then(() => console.log("All tests completed"))
  .catch((err) => console.error("Error:", err));

結果を以下にまとめます。

行数 平均起動時間 (ms) SELECT (ms) INSERT (ms) UPDATE (ms) DELETE (ms) SELECT (ms/row) INSERT (ms/row) UPDATE (ms/row) DELETE (ms/row)
1000 0.141 2.539 3.651 4.893 0.476 0.002539 0.003651 0.004893 0.000476
2000 0.051 2.801 7.267 9.216 0.701 0.001401 0.003633 0.004608 0.000350
3000 0.071 3.867 10.362 13.177 0.835 0.001289 0.003454 0.004392 0.000278
4000 0.035 5.588 14.742 17.931 1.776 0.001397 0.003685 0.004483 0.000444
5000 0.047 7.007 19.127 23.373 2.102 0.001401 0.003825 0.004675 0.000420
10000 0.036 10.971 37.880 43.966 3.535 0.001097 0.003788 0.004397 0.000354

起動時間は概ね1msかからず、高速であることがわかります。
データ操作も高速です。
1万件でも一番遅くて、UPDATEの43msのため、テスト用途などであれば十分実用に耐えるのではないかと感じました。

まとめ

今回はPGliteを動かすまでの方法と試行錯誤について書きました。
まだ開発中ということもあり、色々不正確なことや情報がなく大変でした。
PGlite自体は非常に高速でREPLも用意されており、ツールとして非常に魅力的でした!
今回は非常に簡単な使い方だけだったので、今後は起動状態を維持し、クライアントライブラリから接続して使ってみたり、テストでのDBとして使ってみたいと考えています。
ここまで読んでいただきありがとうございました!

Copyright © RAKUS Co., Ltd. All rights reserved.