DuckDBとSQLiteを同じサーバーで使い分ける方法
SQLiteはトランザクション型のアプリ状態、DuckDBはParquetやCSVの分析に適します。1台のVPSで両方を使う理由と、各エンジンの実例を紹介します。
サーバー上で DuckDB と SQLite を比較する: 1 文での回答
SQLite は OLTP エンジン(オンライントランザクション処理)です。データを行として格納し、少数の行を安全かつ高速に読み書きするよう設計されています。DuckDB は OLAP エンジン(オンライン分析処理)です。データを列として格納し、数百万行をスキャンして 1 つの集計結果を返すよう設計されています。どちらも組み込みライブラリであり、通常のファイルを開いて動作します。また、常時監視が必要なサーバープロセスも実行しません。
そのため、「どちらを選ぶべきか」という問いへの正直な答えは、ほとんどの場合「同じ VPS 上で両方を使う」です。アプリケーションの稼働中の状態は SQLite に保持します。レポート作成では、DuckDB で Parquet ファイルと CSV ファイルを読み取ります。両者は同じ処理を行わないため、競合しません。
行ストレージと列ストレージで答えが変わる理由
SQLite は、1 行をページ内の連続した領域として書き込みます。主キーで 1 件の注文を取得する場合、1 つのインデックスページと 1 つのデータページにアクセスするため、読み取りは 2 回です。これは、アプリケーションが毎秒数千回実行する処理そのものです。ユーザーを読み取り、セッションを更新し、注文を挿入します。
DuckDB は各列を個別に書き込み、圧縮します。5000000 行に対して amount_cents を合計すると、amount_cents 列だけを読み取り、ファイル内の他のバイトをすべてスキップし、値のバッチに対してベクトル化コードで合計を計算します。他の列はディスクから一切読み取られません。これが高速化の理由です。
次に、それぞれのエンジンで相手のワークロードを実行します。SQLite で列を合計する場合、1 つのフィールドに到達するためにすべての行を走査し、ページから行全体を取り出す必要があります。そのため、必要以上に多くのデータをディスクから読み取ります。DuckDB で 1 件の注文を挿入する場合、1 つの値を書き込むためにすべての列のストレージにアクセスする必要があります。また、その処理のためにデータベースファイル全体の書き込みロックを取得します。どちらのエンジンにも問題があるわけではありません。それぞれが、本来想定されていない種類の処理に対応しているだけです。
SQLite が適する場面: トランザクション型のアプリケーション状態
書き込みが小さく頻繁で、失われてはならない場合は SQLite を選びます。セッション、注文、キューの行、設定など、Web リクエストによって作成されるあらゆるデータが対象です。
sudo apt update && sudo apt install -y sqlite3
sudo install -d -o "$USER" -g "$USER" /srv/appテーブルの作成と write-ahead logging の有効化を同じ手順で行います。
sqlite3 /srv/app/app.db <<'SQL'
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY,
customer TEXT NOT NULL,
placed_at TEXT NOT NULL,
amount_cents INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS orders_customer ON orders (customer);
INSERT INTO orders (customer, placed_at, amount_cents)
VALUES ('ana', '2026-07-30T09:14:00Z', 4200);
SQL出力の最初の行は wal です。これはモードの切り替え結果を示す PRAGMA であり、サーバーで最も重要な設定です。デフォルトの rollback journal モードでは、書き込み処理がすべての読み取り処理をブロックします。WAL モードでは、1 つの書き込み処理が追記している間も、読み取り処理は直近のコミット済み状態を読み続けます。そのため、時間のかかるレポート処理によって、その後の Web リクエストが停止することはありません。
行が返されたことを確認します。
sqlite3 /srv/app/app.db "SELECT * FROM orders WHERE customer = 'ana';"1|ana|2026-07-30T09:14:00Z|4200 が返ります。データベースの隣に app.db-wal と app.db-shm という 2 つのファイルも作成され、どちらもデータベースに属します。アプリケーションの実行中に app.db だけをコピーすると、不完全なバックアップになります。この問題については後で説明します。
SQLite では、引き続き同時に書き込めるのは 1 つの処理だけです。この制限はキューではなくロックです。そのため、待機時間が長すぎる 2 つ目の書き込み処理は、永遠にブロックせず database is locked で失敗します。アプリケーションが開くすべての接続で、PRAGMA busy_timeout = 5000; により待機時間を延長します。通常の Web ワークロードであれば、5 秒待機させることで、こうしたエラーの大半を解消できます。
DuckDB が有利な場面: すでに手元にあるファイルの分析
質問が「いくつあるか」「どのくらいか」「上位 10 件はどれか」で、入力が複数の CSV または Parquet ファイルの場合は DuckDB を選びます。2026 年 10 月 27 日時点の LTS リリースである version 2.0.0 の command line client をインストールします。
curl https://install.duckdb.org | sh特定のバージョンを指定しない限り、スクリプトは最新の安定版リリースをインストールします。以前のリリースを使い続ける場合は、たとえば curl https://install.duckdb.org | DUCKDB_VERSION=1.5.6 sh を指定します。バイナリは ~/.duckdb/cli/latest/duckdb にインストールされ、PATH に追加する行が表示されます。実行できることを確認します。
~/.duckdb/cli/latest/duckdb :memory: "SELECT version();"クエリ対象となる現実的なファイルを作成します。これにより、zstd で圧縮した注文データ 500 万行が Parquet に書き込まれます。
mkdir -p /srv/data
duckdb :memory: "
COPY (
SELECT i AS id,
'cust_' || (i % 5000) AS customer,
TIMESTAMP '2026-01-01 00:00:00' + INTERVAL (i) MINUTE AS placed_at,
(i * 37) % 20000 AS amount_cents
FROM range(5000000) t(i)
) TO '/srv/data/orders.parquet' (FORMAT parquet, COMPRESSION zstd);"次に、分析用の質問を実行します。shell を開き、タイマーを有効にして、import 手順なしでファイルを直接クエリします。
.timer on
SELECT customer,
count(*) AS orders,
sum(amount_cents)/100.0 AS revenue
FROM '/srv/data/orders.parquet'
GROUP BY customer
ORDER BY revenue DESC
LIMIT 5;公開されている数値をそのまま信頼せず、.timer から自分の結果を読み取ります。結果はディスクとコア数に依存するためです。重要なのは結果の傾向です。CREATE TABLE も INSERT も load 手順もありません。DuckDB は Parquet の footer を読み取り、クエリに必要な column chunk を特定して、それだけを読み取りました。glob の FROM '/srv/data/orders-*.parquet' を使えばディレクトリ全体も同じように扱えます。これにより、毎日の export 1 か月分を 1 つのクエリで処理できます。
ディスク速度が全体の下限になります。column scan は長い sequential read なので、VPS 上の NVMe と古い SATA ストレージの差は、SQLite の小さな random read の場合よりも明確に現れます。
DuckDB から SQLite データベースを読み取る
2 つのエンジンは、DuckDB の sqlite 拡張機能を介して連携します。分析クエリからライブ状態へ書き込めないように、アプリケーションのデータベースを読み取り専用でアタッチします。
INSTALL sqlite;
LOAD sqlite;
ATTACH '/srv/app/app.db' AS app (TYPE sqlite, READ_ONLY);
SELECT customer, count(*) AS orders
FROM app.orders
GROUP BY customer
ORDER BY orders DESC;これはコピーを作成せず、クエリの実行時に SQLite ファイルから行を読み取ります。便利ですが、高速ではありません。ディスク上のデータは行ストレージのままであり、DuckDB がそのデータを走査する必要があるためです。ダッシュボードが 30 秒ごとに再読み込みする用途ではなく、エクスポートに使用してください。
COPY (SELECT * FROM app.orders)
TO '/srv/data/orders-2026-07.parquet' (FORMAT parquet, COMPRESSION zstd);この 1 つの文が、パターン全体です。SQLite が直近のライブ行を管理します。スケジュールされたエクスポートによって、確定した期間のデータを Parquet に変換します。DuckDB は数か月にまたがるすべての質問に回答し、アプリケーションのデータベースは小さく保たれます。その結果、書き込みが高速になります。
エクスポートは手動ではなく、スケジュールに従って実行します。この用途には systemd の service と timer の組み合わせが適しています。1 つの unit で COPY を実行し、1 つの timer で毎晩起動します。
同一 VPS 上で両方を実行する
ここではコンテナもポートも必要ありません。どちらのエンジンもライブラリであるため、インストールに必要なのはパッケージとファイルパスです。スタックの他の部分がすでに同じ VPS 上の Docker Compose で動作している場合は、データベースサービスを追加するのではなく、必要なコンテナにデータディレクトリをマウントしてください。追加するサービス自体が存在しないためです。
この構成を問題なく運用するには、次の 2 つのルールを守ります。
各エンジンに専用のディレクトリを割り当てます。アプリケーションが書き込む SQLite ファイルには /srv/app、分析処理が読み取る Parquet ファイルには /srv/data を使用します。同じディレクトリを共有すると、一方をスナップショットするバックアップジョブが、もう一方の処理と競合する可能性があります。
2 つのプロセスから、1 つの DuckDB データベースファイルを read-write モードで開かないでください。DuckDB ファイルを writing 用に保持できるプロセスは 1 つだけです。2 つ目のプロセスはファイルを開くこと自体に失敗します。すべてのプロセスが access_mode = 'READ_ONLY' を設定していれば、多数のリーダーからの読み取りは問題ありません。SQLite では複数のプロセスが通常どおり 1 つのファイルを共有できるため、SQLite から移行したユーザーには意外に感じられます。分析処理が Parquet ファイルを読み取るだけであれば、この問題は発生しません。永続状態を SQLite に保持する理由が、さらに 1 つ増えます。
バックアップには差分があり、その差が問題になります
実行中の SQLite データベースは3つのファイルで構成されます。書き込み中に cp でこれらをコピーすると、開くことはできても内容が正しくないファイルになります。アプリケーションが書き込みを続けている間に整合性のあるスナップショットを取得する、エンジン独自のバックアップコマンドを使用してください。
sqlite3 /srv/app/app.db ".backup '/srv/backup/app-$(date -u +%Y%m%dT%H%M%SZ).db'"
sqlite3 /srv/backup/app-20260730T091400Z.db "PRAGMA integrity_check;"正常なコピーでは integrity_check が ok を出力します。それ以外の場合は、そのスナップショットを破棄して、もう一度取得してください。
Parquet ファイルは書き込み後に変更されないため、特別な処理は必要ありません。ディレクトリをバックアップしてください。VPS から restic でバックアップする方法を使用して、2つのパスをサーバー外へ送信します。これで、データ層全体を1つのバックアップジョブで2つのディレクトリとして処理できます。
障害のパターンと表示される正確な文字列
database is locked がアプリケーションのドライバー経由でも sqlite3 shell 経由でも表示される場合、別の接続がタイムアウトで許容された時間を超えて書き込みロックを保持していました。データベースの破損ではありません。すべての接続で PRAGMA busy_timeout を設定し、いくつかの短いトランザクションに分けるべきだった長時間のトランザクションを探します。
権限を変更した後に unable to open database file が表示される場合、通常はプロセスがファイルには書き込めても、そのディレクトリには書き込めないことを示します。SQLite はデータベースの隣に app.db-wal と app.db-shm を作成するため、.db ファイルだけでなく、ディレクトリ自体を書き込み可能にする必要があります。
DuckDB の IO Error: Could not set lock on file は、別のプロセスがすでにそのデータベースを開いて書き込み用に保持していることを示します。別の shell を閉じるか、自分の shell を読み取り専用で開きます。
小規模な VPS で DuckDB の Out of Memory Error が表示される場合、通常はクエリが利用できる量を超える作業メモリを必要としたことを示します。DuckDB はデフォルトで中間結果をディスクに退避し、:memory: session も対象になります。そのため、退避を機能させるためにデータベースファイルを開く必要はありません。DuckDB は一時ファイルをデータベースファイルの隣にある .tmp directory に書き込みます。メモリ内 session では、起動した場所を基準にした .tmp directory に書き込みます。そのため、空きディスク容量があり、書き込み可能な場所で起動するか、SET temp_directory = '/var/tmp/duckdb'; で別の場所を指定します。重要なのは上限の設定である SET memory_limit = '2GB'; です。デフォルトはマシンの RAM の 80% です。そのため、他のサービスも実行しているマシンでは、この上限によってアドホックなクエリがアプリケーションを RAM から追い出すことを防ぎます。string_agg() などの一部の集約は退避できません。ソートに基づく集約にも退避できないものがあります。そのため、これらを使用するクエリでは、メモリ不足になる可能性があります。
Parquet をクエリしたときの Binder Error: Referenced column "amount" not found は、ほぼ常にファイルのスキーマが記憶しているものと異なることを示します。DESCRIBE SELECT * FROM '/srv/data/orders.parquet'; を実行し、実際の列名を読み取ります。
実際の選び方
書き込みパターンを確認します。停電後も保持する必要がある小さな書き込みが多数ある場合は、SQLite が適しています。読み取りパターンも確認します。長期間の履歴全体を走査し、集計する場合は DuckDB が適しています。実際のシステムの多くは、両方の条件に該当します。その場合は、一方のエンジンに他方の役割まで無理に担わせず、それぞれが得意とする処理を分担させるのが適切です。
避けるべき移行は、レポートが遅いという理由で、稼働中のアプリケーション状態を DuckDB に移すことです。レポートが遅い原因がストレージレイアウトにあるなら、必要なのは書き込み処理の書き換えではなく、エクスポートです。
FAQ
DuckDB はアプリケーションデータベースとして SQLite を置き換えられますか?
頻繁に書き込むデータベースには適していません。DuckDB はデータベースファイル全体に書き込みロックを取得し、同時に実行できる読み書きプロセスは 1 つだけです。また、単一行の INSERT ではなく、大量の変更を処理するように最適化されています。トランザクション状態は SQLite に保持し、レポートで必要になったときに ATTACH '/srv/app/app.db' AS app (TYPE sqlite, READ_ONLY); を使って DuckDB から読み取ってください。
分析処理では、本当に DuckDB のほうが SQLite より高速ですか?
大きなテーブルのスキャンや集計では高速です。理由はチューニングではなく、ストレージレイアウトにあります。DuckDB はクエリで指定された列だけを読み、値をバッチ単位で処理します。一方、SQLite は 1 つのフィールドに到達するために行全体を走査する必要があります。主キーで 1 行だけを取得する場合は逆になります。SQLite は 2 ページにアクセスしますが、DuckDB はすべての列のストレージにアクセスするためです。
VPS で DuckDB を実行するには、大量の RAM が必要ですか?
いいえ。ただし、使用量の上限と、ディスクへ退避するための空き容量を確保してください。DuckDB は :memory: セッションでも、デフォルトで中間結果をディスクに退避します。これは一時ディレクトリに書き込み権限があり、空き容量がある場合に限られます。そのうえで、VPS で確保できる値を SET memory_limit = '2GB'; に設定してください。デフォルトの RAM の 80% のままにすると、大規模な GROUP BY によって Out of Memory Error が発生したり、他のサービスが RAM から追い出されたりする可能性があります。
SQLite のデータを Parquet に移すにはどうすればよいですか?
DuckDB から SQLite ファイルをアタッチし、COPY (SELECT * FROM app.orders) TO '/srv/data/orders.parquet' (FORMAT parquet, COMPRESSION zstd); を使ってクエリ結果を直接コピーします。先月分の行など、確定した期間を対象にスケジュール実行してください。アプリケーションが引き続き書き込む最新の行は、SQLite に残します。
どちらを、どのようにバックアップすべきですか?
両方を、それぞれ異なる方法でバックアップしてください。SQLite のスナップショットには cp ではなく sqlite3 app.db ".backup '/srv/backup/app.db'" を使用します。実行中のデータベースは -wal であり、-shm ファイルでもあるため、単純なコピーでは内容が不整合になる可能性があります。Parquet ファイルは一度書き込むと変更されないため、ディレクトリをコピーするだけで十分です。