深夜2時のOut-of-Memory悪夢
深夜に鳴り響くPagerDutyのアラート。ステージングサーバー上のトランザクションログ集計ボットが、LinuxのOOM Killerによって強制終了されていました。ターミナルを確認すると、原因はおなじみのパターンでした。PythonスクリプトがPandasを使って8.5GB(約3500万行)のCSVファイルを、メモリが4GBしかないVPSにそのまま読み込もうとしていたのです。
以前なら、データをSQLiteに投入してからSQLを実行するのが一般的な応急処置でした。しかし、数千万件のレコードに対する集約処理(GROUP BY、AVG、COUNT DISTINCT)では、行指向(row-oriented)ストレージであるSQLiteは非常に低速です。一方、Pandasを使えば即座にメモリ不足でクラッシュしてしまいます。そこで最適な救世主となるのがDuckDBです。SQLiteのように軽量な組み込み型データベースでありながら、圧倒的な分析処理能力(OLAP)を備えています。
DuckDBはSQLiteと何が違うのか?
DuckDBは「分析版SQLite」と呼ばれることもよくあります。サービスデーモンのインストールは不要で、ユーザー名やパスワードの設定もいらず、Pythonプロセス内で直接動作します。
DuckDBの真の強みは、アーキテクチャ上の3つの要素にあります:
- 列指向ストレージ(Columnar Storage): 全行をスキャンするSQLiteとは異なり、DuckDBは必要なカラムのみを読み込みます。例えば3000万行に対して
SUM(revenue)を計算する場合、エンジンはディスクからrevenueカラムのみを読み取り、その他のカラムはスキップするため、I/Oを80〜90%削減できます。 - ベクトル化実行エンジン(Vectorized Execution Engine): データはCPUのL1/L2キャッシュに収まるベクトル単位(約2048個の値)で処理され、最新チップのSIMD命令セットを最大限に活用します。
- 外部メモリ処理(Out-of-Core Processing / Streaming to Disk): データセットが物理メモリより大きい場合でも、プログラムをクラッシュさせる代わりに、DuckDBが自動的にデータを分割して一時バッファをディスクに書き出します。
PythonでDuckDBを実践する
1. 環境構築
DuckDBは単一のバイナリとしてパッケージ化されており、外部のC++ライブラリに依存しません:
pip install duckdb pandas pyarrow
2. ディスク上のCSV / Parquetファイルを直接クエリ
あらかじめデータベースにデータをINSERTする必要はありません。DuckDBはディスクから直接ファイルを読み込んでスキャンできます:
import duckdb
# 一時的なインメモリ接続を初期化
con = duckdb.connect(database=':memory:')
# 8.5GBのCSVファイルをメモリに全展開せずに直接スキャン
query_csv = """
SELECT
status_code,
COUNT(*) AS total_requests,
ROUND(AVG(response_time_ms), 2) AS avg_latency
FROM 'server_logs.csv'
GROUP BY status_code
HAVING total_requests > 1000
ORDER BY total_requests DESC;
"""
# わずか2〜3秒で結果をDataFrameとして取得
df_result = con.execute(query_csv).fetchdf()
print(df_result)
DuckDBはデータ型を自動判別し、利用可能なすべてのCPUコアを活用して並列処理を行います。
3. Pandas DataFrameとのZero-Copy連携
メモリ上に既存のDataFrameがある場合、DuckDBはデータをコピーするオーバーヘッドなしに、Apache Arrowのメモリポインタを介してその変数を直接クエリできます。
import pandas as pd
import duckdb
# 注文データのDataFrameを模擬作成
orders_df = pd.DataFrame({
'order_id': range(1, 6),
'customer_id': ['C101', 'C102', 'C101', 'C103', 'C102'],
'amount': [250.0, 120.5, 310.0, 89.9, 450.0]
})
# DuckDBは現在のスコープ内のorders_df変数を自動認識
query = """
SELECT
customer_id,
SUM(amount) AS total_spent,
COUNT(order_id) AS total_orders
FROM orders_df
GROUP BY customer_id
ORDER BY total_spent DESC;
"""
summary = duckdb.sql(query).df()
print(summary)
4. データの永続化(Persistent Storage)
分析結果をディスクに保存して他のプロセスで再利用するには、':memory:'を具体的なファイルパスに変更するだけです:
import duckdb
con = duckdb.connect('analytics.duckdb')
# パターンに一致する複数のParquetファイルを1つのテーブルに統合
con.execute("""
CREATE TABLE IF NOT EXISTS daily_metrics AS
SELECT * FROM 'logs/metrics_2026_*.parquet';
"""
)
total_rows = con.execute("SELECT COUNT(*) FROM daily_metrics;").fetchone()[0]
print(f"{total_rows:,} 件のレコードを正常に読み込みました。")
con.close()
5. 低スペックサーバーで大規模データを処理する際のメモリ制御
リソースが限られたサーバーでは、メモリ上限やスレッド数を明示的に制限することをおすすめします:
import duckdb
con = duckdb.connect('warehouse.duckdb')
# 最大メモリを2GBに制限し、4つのCPUスレッドを使用
con.execute("SET max_memory = '2GB';")
con.execute("SET threads = 4;")
# 集計結果をZSTD圧縮のParquetファイルへ直接エクスポート
con.execute("""
COPY (
SELECT
date_trunc('day', timestamp) AS report_date,
user_id,
COUNT(event_id) AS purchase_count,
SUM(total_amount) AS revenue
FROM 'raw_events_*.csv'
WHERE event_type = 'PURCHASE'
GROUP BY 1, 2
) TO 'daily_purchase_summary.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);
"""
)
print("処理が完了し、2GBのメモリ制限内でParquetファイルを正常に出力しました!")
DuckDB、SQLite、PostgreSQLの選び分け
それぞれのツールは異なるユースケースに適しています:
- SQLiteを選ぶべき場合: モバイルアプリ、ローカルデスクトップアプリ、設定の保存、シンプルなCRUDシステム(OLTP)。SQLiteは個別の行単位の読み書きに最適化されています。
- PostgreSQL / MySQLを選ぶべき場合: ネットワーク経由で多数のクライアントが同時接続するWebシステム、複雑なユーザー権限管理、厳密なACIDトランザクションが必要な場合。
- DuckDBを選ぶべき場合: 統計分析の実行、大規模ログファイル(CSV、Parquet、JSON)の処理、PythonでのローカルETLパイプライン構築、または複雑なSparkクラスタを構築せずにダッシュボード用のクエリエンジンを用意したい場合。
まとめ
PandasをDuckDBに置き換えたことで、ログ処理パイプラインはメモリ不足エラーから解放され、同じ格安VPS上で処理時間を15分からわずか8秒へと劇的に短縮できました。ローカル環境で数ギガバイト規模のデータファイル処理に苦労しているなら、DuckDBは今すぐ試す価値のあるツールです。

