大量 INSERT を速くする — multi-row VALUES / COPY / LOAD DATA
大量データを高速に投入したいとき。単行 INSERT のループが遅い理由と対処。
問題 — 単一 INSERT ループが極端に遅い
10万件のデータを for ループで INSERT INTO ... VALUES (...) 1行ずつ流す実装は、分単位〜時間単位で詰まります。
理由は主に3つ:
- ネットワーク往復が1回/行
- オートコミットで1行ごとに fsync(ディスク同期)
- 毎回のインデックス更新 / WAL 書き込み / トリガ発火
これをまとめて減らすのが高速化の基本戦略です。
解法1: multi-row VALUES
最もポータブルな高速化。1つの INSERT 文に多数行をまとめる:
ネットワーク往復が一気に減り、パース・プラン作成も1回で済みます。1バッチあたり 500〜1000 行が目安(大きすぎると max_allowed_packet などに引っかかる)。PostgreSQL / MySQL / SQLite / SQL Server すべてで使えます。
INSERT INTO users (name, email) VALUES
('Alice', 'a@example.com'),
('Bob', 'b@example.com'),
('Carol', 'c@example.com'),
... -- 500〜1000 行
('Zed', 'z@example.com');解法2: PostgreSQL の COPY / MySQL の LOAD DATA INFILE
RDBMS 固有のバルクロード専用コマンド。INSERT よりもはるかに速い(PostgreSQL の COPY は INSERT の10倍以上速いことも)。
- PostgreSQL:
COPY table FROM STDIN(psql の\copyや各ドライバの COPY API) - MySQL:
LOAD DATA INFILE(サーバーファイルシステム読み込み、またはLOAD DATA LOCAL INFILEでクライアント側) - SQL Server:
BULK INSERTやbcpユーティリティ
初期データ投入、大量 ETL、復旧での流し込み、などではまずこれを検討。
-- PostgreSQL
COPY users (name, email)
FROM '/tmp/users.csv' WITH (FORMAT csv, HEADER true);
-- psql では \copy でクライアント側ファイルを読ませられる
\copy users (name, email) FROM 'users.csv' CSV HEADER
-- MySQL
LOAD DATA LOCAL INFILE 'users.csv'
INTO TABLE users
FIELDS TERMINATED BY ',' ENCLOSED BY '"'
LINES TERMINATED BY '\n'
IGNORE 1 ROWS
(name, email);解法3: トランザクションでまとめる
どうしても 1行ずつ INSERT したい場合でも、明示的なトランザクションで包むだけで大幅改善。オートコミットによる fsync を1回にできるため:
ただしトランザクションが長すぎるとロック・WAL・UNDOが膨らむので、適度なバッチ(例: 1万件ごとに COMMIT)で刻むのが実務的。
BEGIN;
INSERT INTO users (name, email) VALUES ('Alice', 'a@example.com');
INSERT INTO users (name, email) VALUES ('Bob', 'b@example.com');
-- ... 1万件
COMMIT;解法4: インデックス・制約を一時オフにする
初期データ投入のような他からアクセスがないタイミングでは:
- 対象テーブルの二次インデックスを DROP
- 外部キー・トリガを無効化
- バルクロード
- 後からインデックス再作成、制約再有効化、
ANALYZE(統計更新)
行ごとにインデックスを更新するよりも、データ投入後にまとめて構築する方がはるかに速い。PostgreSQL なら CREATE INDEX CONCURRENTLY ではなく通常の CREATE INDEX(ロックしてよいので)で一気に構築する。
注意点
本番稼働中のテーブルでインデックスを落とさないこと。外部キーやユニーク制約を切った状態で投入すると、途中で失敗したら整合性が壊れたデータが残る。メンテウィンドウや空テーブル時限定の戦術です。
LOAD DATA LOCAL INFILE は MySQL サーバー/クライアント双方の設定が必要(セキュリティ上デフォルト無効になっていることが多い)。動かない時はまず local_infile 設定を確認。
バッチサイズは大きければよい訳ではなく、MySQL の max_allowed_packet、PostgreSQL の WAL バッファ、ドライバのメモリ消費を見ながら 500〜数千行の現実的な値を選ぶこと。
