サーバーで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 年 7 月時点のバージョンは 1.5.5 です。
curl https://install.duckdb.org | shこのスクリプトはバイナリを ~/.duckdb/cli/latest/duckdb にインストールし、PATH に追加するための行を表示します。実行できることを確認します。
~/.duckdb/cli/latest/duckdb :memory: "SELECT version();"クエリ対象となる現実的なファイルを作成します。次のコマンドは、zstd で圧縮した Parquet ファイルに注文データ 5000000 行を書き込みます。
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);"次に、分析上の質問を実行します。シェルを開き、タイマーを有効にして、インポート処理なしでファイルを直接クエリします。
.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 から自分で数値を確認してください。結果はディスクと CPU コア数に依存するためです。重要なのは値の傾向です。CREATE TABLE も INSERT もロード処理もありません。DuckDB は Parquet のフッターを読み取り、クエリに必要なカラムチャンクを特定して、それらだけを読み取りました。glob の FROM '/srv/data/orders-*.parquet' を使えばディレクトリ全体も同じように扱えます。そのため、1 か月分の毎日のエクスポートを 1 つのクエリで処理できます。
この処理全体の下限を決めるのはディスク速度です。カラムスキャンは長いシーケンシャル読み取りになるため、VPS における NVMe と従来型 SATA ストレージの差は、SQLite の小さなランダム読み取りの場合よりもここで明確に現れます。
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つのディレクトリとして処理できます。
障害パターンと実際に表示される文字列
SQLite の Error: database is locked は、別の接続がタイムアウトで許可された時間を超えて書き込みロックを保持していたことを示します。データ破損ではありません。すべての接続で PRAGMA busy_timeout を設定し、本来はいくつかの短いトランザクションに分けるべきだった長時間のトランザクションを探します。
権限変更後の Error: unable to open database file は、通常、プロセスがファイルには書き込めるものの、そのディレクトリには書き込めないことを示します。SQLite はデータベースの隣に app.db-wal と app.db-shm を作成するため、.db ファイルだけでなく、ディレクトリ自体も書き込み可能でなければなりません。
DuckDB の IO Error: Could not set lock on file は、別のプロセスがすでにそのデータベースを開いて書き込み中であることを示します。別のシェルを閉じるか、自分の接続を読み取り専用で開きます。
小規模な VPS で DuckDB の Out of Memory Error が表示される場合、クエリが利用可能な作業メモリを超えるメモリを必要としたことを示します。DuckDB は可能な場合にディスクへスピルするため、:memory: ではなくディスク上のデータベースファイルを開いて、スピル先を用意します。さらに SET memory_limit = '2GB'; でメモリ使用量に上限を設定します。他のサービスも実行しているホストでは、この上限により、アドホックなクエリがアプリケーションを RAM 不足に陥らせるのを防げます。
Parquet をクエリしたときの Binder Error: Referenced column "amount" not found は、ほぼ常に、ファイルのスキーマが記憶しているものと異なることを示します。DESCRIBE SELECT * FROM '/srv/data/orders.parquet'; を実行し、実際のカラム名を確認します。
実際の選び方
書き込みパターンを確認します。停電後も保持する必要がある小さな書き込みが多数ある場合は、SQLite が適しています。読み取りパターンも確認します。長期間の履歴全体を走査し、集計する場合は DuckDB が適しています。実際のシステムの多くは、両方の条件に該当します。その場合は、一方のエンジンに他方の役割まで無理に担わせず、それぞれが得意とする処理を分担させるのが適切です。
避けるべき移行は、レポートが遅いという理由で、稼働中のアプリケーション状態を DuckDB に移すことです。レポートが遅い原因がストレージレイアウトにあるなら、必要なのは書き込み処理の書き換えではなく、エクスポートです。
FAQ
DuckDB でアプリケーションのデータベースとして SQLite を置き換えられますか?
頻繁に書き込むデータベースには適しません。DuckDB はデータベースファイル全体に書き込みロックを取得し、読み書き可能なプロセスを一度に 1 つだけ許可します。また、単一行の挿入よりも一括変更向けに調整されています。トランザクション状態は 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 が必要ですか?
必要ありません。ただし、メモリ上限とディスクを用意してください。:memory: ではなくデータベースファイルを開くと、DuckDB は中間結果をディスクに退避できます。そのうえで、VPS で確保できる値を SET memory_limit = '2GB'; に設定します。上限を設定しないと、大きな 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 に残します。
どちらを、どのようにバックアップすべきですか?
用途を分けて、両方をバックアップします。実行中のデータベースは -wal でもあり、-shm ファイルでもあるため、cp ではなく sqlite3 app.db ".backup '/srv/backup/app.db'" で SQLite のスナップショットを取得してください。単純なコピーでは内容が不整合になる可能性があります。Parquet ファイルは一度書き込むと変更されないため、ディレクトリをコピーするだけで十分です。