C#+PostgreSQLで大量のデータを超高速に登録する方法

2025年3月13日木曜日

C# PostgreSQL

t f B! P L

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オブジェクトを返します。このオブジェクトに対して書き込み操作を行うことで、大量のデータを一括してテーブルに取り込むことができます。

基本的な使い方の手順は以下のとおりです。

  1. COPYコマンドの準備: 挿入先テーブルとカラムを指定したCOPY文を用意します。例えばsalesテーブルのsale_dateamountにデータをコピーする場合、"COPY sales (sale_date, amount) FROM STDIN (FORMAT BINARY)"という文字列になります。
  2. バイナリインポート開始: NpgsqlConnection.BeginBinaryImport(copy文)を呼び出し、NpgsqlBinaryImporter(以下、インポータ)を取得します。
  3. 行データの書き込み: インポータのStartRow()メソッドで新規行の書き込みを開始し、続けて各列の値をWrite(値, 型)メソッドで書き込みます。テーブルのカラム数と同じ回数のWriteを毎行ごとに呼ぶ必要があります (C#でNpgsqlを使ってPostgreSQLへ大量のバイナリデータを高速に処理する-なべひろBlog)。例えば100万件分ループしてデータを流し込みます。
  4. 完了処理: 全てのデータを書き終えたら、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を通じてその恩恵を享受できます。ぜひ自身の環境でも試してみて、その高速性を実感してみてください。

スポンサーリンク
スポンサーリンク

このブログを検索

Profile

自分の写真
Webアプリエンジニア。 日々新しい技術を追い求めてブログでアウトプットしています。
プロフィール画像は、猫村ゆゆこ様に書いてもらいました。

仕事募集もしていたり、していなかったり。

QooQ