1. はじめに
PostgreSQLには、データ登録方法として通常のINSERT
文を使う方法と、大量データを書き込む専用のCOPY
コマンドを使う方法があります。INSERT
文では1件ずつ、または複数行のレコードを挿入できますが、COPY
を用いた一括登録の方が圧倒的に高速です (COPY | Npgsql Documentation )。
特に数百万件規模のデータを扱う場合、その差は顕著で、後述するように場合によっては10倍以上の速度差が出ます。実際、PostgreSQL 16ではCOPY
のパフォーマンスがさらに向上し、従来より300%以上高速化されています。
本記事では、PostgreSQLデータベースに対し、.NETのデータプロバイダであるNpgsqlを用いて、C#からBeginBinaryImport
メソッド(内部的にPostgreSQLのCOPY
を使用)によって大量の売上データを高速に登録する方法を解説します。従来のINSERT
を使った方法と比較しながら、その高性能ぶりと実装上の注意点を説明していきます。
2. サンプルテーブルの作成
まず、検証用に売上データを格納するテーブルを用意しましょう。ここではシンプルに日付と金額を持つsales
テーブルを例とします。以下のSQLでテーブルを作成します。
CREATE TABLE sales (
id SERIAL PRIMARY KEY,
sale_date DATE NOT NULL,
amount INTEGER NOT NULL
);
このテーブルは、自動採番の主キーid
と、売上日付を表すsale_date
(DATE型)、売上金額を表すamount
(INTEGER型)を持ちます。
3. 従来のINSERTの実装とパフォーマンス
まずは通常のINSERT
文を用いた実装を考えてみます。例えばC# + Npgsqlで1件ずつINSERTする場合、以下のようなコードになるでしょう。
using var conn = new NpgsqlConnection(connString);
conn.Open();
// 100万件のデータを挿入(例として同じ日付・金額を繰り返し挿入)
using var transaction = conn.BeginTransaction();
using var cmd = new NpgsqlCommand("INSERT INTO sales (sale_date, amount) VALUES (:date, :amt)", conn);
cmd.Parameters.Add(new NpgsqlParameter("date", NpgsqlTypes.NpgsqlDbType.Date));
cmd.Parameters.Add(new NpgsqlParameter("amt", NpgsqlTypes.NpgsqlDbType.Integer));
for (int i = 1; i <= 1000000; i++)
{
cmd.Parameters["date"].Value = new DateTime(2025, 1, 1); // 仮の売上日
cmd.Parameters["amt"].Value = 100; // 仮の金額
cmd.ExecuteNonQuery();
}
transaction.Commit();
上記ではプリペアドステートメントを使いパラメータを都度設定していますが、それでも100万回のExecuteNonQuery()
呼び出しが発生します。INSERT
文を100万回発行するのは非常に重く、ネットワーク越しであれば往復の遅延も積み重なります。また各SQLのパースやログ記録、トランザクション管理のオーバーヘッドも蓄積します。少しでも高速化するために、例えば一度のSQLで複数レコードを挿入できる「マルチバリューINSERT」(INSERT ... VALUES (...), (...), ...
)を使ったり、トランザクションをまとめたりする手があります。しかしそれでも大量データではCOPY
には及びません。
実際に上記のようなアプローチで100万件のデータをINSERTした場合、挿入完了までに数十秒程度はかかるでしょう。例えばあるローカル環境で試したところ、10万件のデータをINSERTするのに最も効率的な方法(マルチバリューINSERTを活用し単一トランザクションで投入)でも約10秒程度要しました。単純計算で100万件では100秒近くかかることになります。このように従来のINSERTでは、大量データ挿入に時間を要してしまうのです。
4. BeginBinaryImport
を用いた高速バルクインサート
次に、Npgsqlが提供する高速なバルクインサート機能であるBeginBinaryImport
を使った方法を紹介します。BeginBinaryImport
はPostgreSQLのCOPY ... FROM STDIN BINARY
(バイナリ転送によるコピー)を開始するメソッドで、戻り値としてNpgsqlBinaryImporter
オブジェクトを返します。このオブジェクトに対して書き込み操作を行うことで、大量のデータを一括してテーブルに取り込むことができます。
基本的な使い方の手順は以下のとおりです。
- COPYコマンドの準備: 挿入先テーブルとカラムを指定した
COPY
文を用意します。例えばsales
テーブルのsale_date
とamount
にデータをコピーする場合、"COPY sales (sale_date, amount) FROM STDIN (FORMAT BINARY)"
という文字列になります。 - バイナリインポート開始:
NpgsqlConnection.BeginBinaryImport(copy文)
を呼び出し、NpgsqlBinaryImporter
(以下、インポータ)を取得します。 - 行データの書き込み: インポータの
StartRow()
メソッドで新規行の書き込みを開始し、続けて各列の値をWrite(値, 型)
メソッドで書き込みます。テーブルのカラム数と同じ回数のWrite
を毎行ごとに呼ぶ必要があります (C#でNpgsqlを使ってPostgreSQLへ大量のバイナリデータを高速に処理する-なべひろBlog)。例えば100万件分ループしてデータを流し込みます。 - 完了処理: 全てのデータを書き終えたら、
Complete()
メソッドを呼び出してコピー処理を完了します。これにより一括挿入がコミットされます。
上記をコードにすると次のようになります。
using var conn = new NpgsqlConnection(connString);
conn.Open();
// COPYコマンドの実行開始(sale_dateとamount列にコピー)
using var writer = conn.BeginBinaryImport("COPY sales (sale_date, amount) FROM STDIN (FORMAT BINARY)");
for (int i = 1; i <= 1000000; i++)
{
// 1行分の書き込み開始
writer.StartRow();
// 各列の値を書き込み(型も指定)
writer.Write(new DateTime(2025, 1, 1), NpgsqlTypes.NpgsqlDbType.Date);
writer.Write(100, NpgsqlTypes.NpgsqlDbType.Integer);
}
// データ書き込み完了
writer.Complete();
ポイントとして、StartRow()
を呼ぶごとに、先ほど指定したsale_date, amount
の2列ぶん、必ず2回のWrite
を実行しています。列数が合わないと次のStartRow()
呼び出し時にエラーが発生しますので注意してください 。また、Write
メソッドでは第2引数にPostgreSQLのデータ型を指定しています。実はこの第2引数は省略可能ですが、明示的に型を指定したほうが安全です。型を指定することで、Npgsqlが内部で適切なバイナリ形式にエンコードしてくれます(この理由については後述します)。最後にComplete()
を呼ぶとコピーが確定し、トランザクション的にはこのタイミングで100万件が一度にコミットされます。
上記コードでは、たとえ100万件であってもサーバーとのやりとりはCOPY
コマンド1度きり(ストリーム上でデータを連続送信)です。Npgsqlがネットワークストリーム経由でバイナリ形式のデータを送り込むため、繰り返しSQLを発行する場合と比べ圧倒的に効率的です。
5. パフォーマンス比較
では、従来方式(INSERT)とBeginBinaryImport
方式でどれほど速度差が出るか、実際のパフォーマンスを比較してみましょう。上記のようなテストを行った場合の一例として、ローカル環境で100万件のダミーデータを挿入した際の所要時間をまとめます。
- 通常のINSERT(マルチバリューINSERT最適化あり): 約100秒程度(※100万件換算)
- バイナリCOPY(BeginBinaryImport): 約3〜4秒程度
さらに大規模なケースでは差はもっと開きます。ある検証では、1,000万行のデータ挿入でシングルINSERTを繰り返した場合は約9,000秒(2時間半以上)かかったのに対し、COPYではわずか20秒で完了しています 。このように、データ量が大きくなるほどCOPY
方式の優位性が際立ちます。
なぜこれほど速いかというと、COPY
では以下のようなオーバーヘッドが大幅に削減されるためです 。
- トランザクション処理: 100万行を個別にINSERTすれば100万回のコミットが必要ですが、COPYでは1度のコミットで済みます。
- ネットワーク往復: INSERTの場合サーバーとの通信往復が大量に発生しますが、COPYではストリームを介した一括送信で遅延が最小限です。
- ログ書き込み: PostgreSQLのWALログへの書き込みも、INSERTでは行ごとに記録されるのに対し、COPYではまとめて処理されます 。
- パースとプランニング: INSERTではSQLの解析・実行計画策定が何度も行われますが、COPYは専用プロトコルでテーブルに直接書き込むため一度の準備で済みます。
以上より、BeginBinaryImport
を使った方法は大量データ投入において非常に高いパフォーマンスを発揮します。実運用でも、データマイグレーションやバッチ処理で何百万件ものレコードを扱う場合には、単純なINSERT繰り返しではなくCOPYベースの処理に切り替えることで大幅な時間短縮が期待できます。
6. エラーハンドリング
大量データのコピー処理では、エラー時の扱いにも注意が必要です。BeginBinaryImport
で開始したコピー操作は、Complete()
を呼ぶまでデータベースに対して実際の挿入が確定しません 。裏を返せば、コピー途中で問題が発生した場合はComplete()
を呼ばずに処理を中断すれば、これまで書き込んだデータはすべて破棄されロールバックされます。
したがって、基本的なエラーハンドリングのソースは次のとおりです。
try
{
// ... データを書き込み ...
writer.Complete(); // 正常時は完了を確定
}
catch (Exception ex)
{
// エラー発生時:Completeせずにcatchへ来る
// 必要ならログ出力などを行う(この時点ではデータは挿入されていない)
Console.Error.WriteLine("エラー発生: " + ex.Message);
// 明示的にCancel()を呼ぶ必要はない(CompleteしなければDispose時に自動キャンセル)
// トランザクションを明示的に張っているなら transaction.Rollback() を検討
throw;
}
上記のように、Complete()
の呼出し前に例外が投げられた場合、usingブロックを抜けるときにインポート操作はキャンセルされ、部分的に挿入されたレコードは存在しない状態になります。Complete()
を呼んだ時点で初めてコピー処理がコミットされる設計のため、例外時にはそのまま何もせず処理を抜ければデータベースへの影響はありません(未完了のコピーはサーバー側でもロールバックされます)。これは、例外発生時に誤って部分コミットされてしまうことを防ぐためのNpgsqlの設計上の仕様です。
なお、エラーの種別によっては例外がスローされるタイミングが異なる可能性があります。ネットワークエラーや接続障害などの場合は書き込み途中でWrite()
メソッドから例外が出るかもしれません。一方で、テーブル定義と異なるデータ型を書き込んでしまった場合などは、Complete()
を呼んだ際にPostgreSQL側でエラー検出され、PostgresException
がスローされます 。例えば、列数の不一致や型の不整合があればComplete()
時にエラーとなりコピー処理はロールバックされます。このような例外もcatchで捕捉して適切に対処する必要があります。
総じて、BeginBinaryImport
利用時には「Complete()
が呼ばれるまでデータは確定しない」ことを念頭に置き、エラー時にはComplete()
を実行しない(=自動キャンセル)ようにすることで、登録の途中で問題が起きても中途半端なデータが残る心配はありません。
7. Date型カラムの扱い
最後に、実装上ハマりやすいポイントとしてPostgreSQLのDATE型カラムにDateTime値を書き込む際の注意について説明します。先ほどのコード例では、sale_date
列に対し.Write(new DateTime(2025,1,1), NpgsqlDbType.Date)
と、NpgsqlDbType.Date
を明示的に指定していました。この理由は、.NETのDateTime
型がPostgreSQL側でデフォルトだとタイムスタンプ(timestamp型、8バイト)として解釈されてしまうためです。sales
テーブルのsale_date
列はDATE型(日付のみ、4バイト)なので、そのままではバイナリ形式が不一致となりエラーが発生します。
解決策は簡単で、Write
メソッドの第2引数にNpgsqlDbType.Date
を指定することです。これによりNpgsqlは該当の値をDATE型用の4バイト形式(内部的には日付オフセット値)でエンコードし、PostgreSQLも正しくDATE型のデータとして受け取ります。
一般にNpgsqlでバイナリコピーを行う際は、値を書き込むときに対応するPostgreSQLの型を明示しておくと安心です。Npgsql公式ドキュメントでも、Write()
のオーバーロードでNpgsqlDbType
を指定して明確に型を指定することが強く推奨されています 。とくに日付・時刻や特殊なデータ型では、.NET側の型との対応関係に注意し、適切なNpgsqlDbTypeを指定しましょう。例えば、本記事のようにDATE型にはNpgsqlDbType.Date
、タイムスタンプ(timestamp without time zone型)にはNpgsqlDbType.Timestamp
、タイムスタンプTZ(timestamp with time zone型)にはNpgsqlDbType.TimestampTz
という具合です。型を指定することで内部のバイナリ変換が正しく行われ、フォーマットエラーを未然に防ぐことができます。
まとめ
C#+PostgreSQL+NpgsqlのBeginBinaryImport
を使用し、大量データを高速にインサートする方法を解説しました。大量データを扱う際にはCOPY
の活用が非常に有効であり、C#アプリケーションからでもNpgsqlを通じてその恩恵を享受できます。ぜひ自身の環境でも試してみて、その高速性を実感してみてください。
0 件のコメント:
コメントを投稿