C# DataReader vs DataAdapter — メモリ消費と性能の使い分け(業務SE 判断軸)

みなさんこんにちは!ヒロポンです!!

今回は ADO.NET 業務SE現場でガチで踏みやすいやつ!!の話。

「DataAdapter で 10万件取ろうとしたら画面が固まって、タスクマネージャ見たらメモリが800MB 食ってた」「DataReader で書いたコードでConnection を Close 忘れて接続枯渇」「DataReader 中に別の SQL 投げたら MARS エラー で詰まった」みたいなADO.NET 読み取りクラスの事故って、業務SEなら誰しも一回はやらかしますよね??

俺も2社目くらいの流通系SIer時代に、過去ログ画面を DataAdapter で書いていて、3年分のデータを取った瞬間に画面が3秒固まってメモリ800MBになる事件をやらかしました。夕方の運用報告で「画面が落ちる」って報告で気づいて半日デバッガで追ってハマったやつ。原因は完全に全件 Fill で、DataReader の1行ストリームに書き換えただけで12MB 程度に収まった。

この記事では VS2019 / .NET Framework 4.7.2 / C# 7.3 / SQL Server 2016 環境で、ADO.NET の 2大読み取りクラス DataReaderDataAdapterメモリ消費・性能・編集可否の違い5シナリオ別の使い分けハマりポイント を、コード6本でまとめます。後ろの「現場メモ」で、業務系チームでルール化した時の話も書いてる。

3行で結論:

  • 画面表示・編集・DataGridView バインドDataAdapter + DataTable(1万件未満なら無難)
  • 大量データ読み取り・集計・CSV / 帳票出力DataReader(10万件超でメモリ12MB 程度)
  • 10万件超を DataAdapter.Fill するのは禁忌(メモリ800MB / 画面固まりの典型ケース)
目次

定石1: DataReader の最小コード — ストリーム読み取り

DataReader は「接続を維持したまま、1行ずつ前方に読み進める」シンプルなクラスっす:

// ✅ 定石1: SqlDataReader でストリーム読み取り
using (var conn = new SqlConnection(connStr))
using (var cmd = new SqlCommand("SELECT id, name, amount FROM order_log WHERE status = @s", conn))
{
    conn.Open();
    cmd.Parameters.AddWithValue("@s", "active");

    using (var reader = cmd.ExecuteReader())
    {
        while (reader.Read())
        {
            int id = reader.GetInt32(0);
            string name = reader.IsDBNull(1) ? null : reader.GetString(1);
            decimal amount = reader.IsDBNull(2) ? 0m : reader.GetDecimal(2);

            // 1行ずつ処理(CSV 書き出し・集計・別 API 連携など)
            ProcessOneRow(id, name, amount);
        }
    }
}
// ↑ ブロック終了で reader → cmd → conn の順に Dispose() される

ポイント:

  1. using 入れ子で Connection / Command / Reader を解放(前回の例外処理記事で紹介したパターン)
  2. reader.Read() の戻り値で行末判定(false で抜ける)
  3. GetXxx(int ordinal) で型付き取得(カラム番号は 0 始まり)
  4. NULL 許容列は IsDBNull(ordinal) で先にチェックSqlNullValueException 回避)

DataReader は内部にデータを溜め込まないので、100万件読んでもメモリは1行分しか使わないのが最大の利点。CSV エクスポートや帳票出力のような「ストリーム書き出し」用途で本命のクラスっす。

定石2: DataAdapter の最小コード — DataTable バルクロード

DataAdapter は「全行を一気に DataTable に読み込んで、Connection を閉じる」パターン:

// ✅ 定石2: SqlDataAdapter で DataTable にバルクロード
DataTable dt = new DataTable();

using (var conn = new SqlConnection(connStr))
using (var adapter = new SqlDataAdapter("SELECT id, name, amount FROM order_log WHERE status = @s", conn))
{
    adapter.SelectCommand.Parameters.AddWithValue("@s", "active");
    adapter.Fill(dt);   // 全行を DataTable にロード、内部で Open/Close される
}

// この時点で Connection は閉じている、後段で DataTable を画面に流す
dataGridView1.DataSource = dt;

// 行ごとの編集・追加が可能
foreach (DataRow row in dt.Rows)
{
    if ((decimal)row["amount"] > 1_000_000m)
    {
        row["status"] = "high";   // メモリ上で編集
    }
}

// 編集を DB に反映するなら別途 SqlCommandBuilder + adapter.Update(dt)

ポイント:

  1. adapter.Fill(dt) で接続を自動 Open/Close(using 内で完結)
  2. DataTable は編集可能row["col"] = newValue で書き換え)
  3. DataGridView.DataSource = dt でバインド(行追加・削除も自動反映)
  4. adapter.Update(dt) で DB に逆反映(INSERT / UPDATE / DELETE 自動生成)

業務系の画面で「マスタ一覧を表示・編集・保存」する流れは、この DataAdapter + DataGridView パターンが王道っす。

定石3: メモリ消費の実測比較 — Stopwatch + GC.GetTotalMemory

DataReaderDataAdapter の体感差を実測すると、桁違いの違いが見えます:

// ✅ 定石3: メモリ・実行時間の実測比較
const string sql = "SELECT id, name, memo, amount, created_at FROM big_log";

// --- DataAdapter 版 ---
GC.Collect(); GC.WaitForPendingFinalizers();
long memBefore = GC.GetTotalMemory(true);
var sw1 = Stopwatch.StartNew();

DataTable dt = new DataTable();
using (var conn = new SqlConnection(connStr))
using (var adapter = new SqlDataAdapter(sql, conn))
{
    adapter.Fill(dt);   // 10万件をメモリに全部ロード
}
sw1.Stop();
long memAfter = GC.GetTotalMemory(false);
Console.WriteLine($"DataAdapter: {sw1.ElapsedMilliseconds}ms / {(memAfter - memBefore) / 1024 / 1024}MB");

// --- DataReader 版 ---
GC.Collect(); GC.WaitForPendingFinalizers();
memBefore = GC.GetTotalMemory(true);
var sw2 = Stopwatch.StartNew();

int rowCount = 0;
using (var conn = new SqlConnection(connStr))
using (var cmd = new SqlCommand(sql, conn))
{
    conn.Open();
    using (var reader = cmd.ExecuteReader())
    {
        while (reader.Read())
        {
            rowCount++;
            // CSV に1行ずつ書き出すイメージ(メモリには溜めない)
        }
    }
}
sw2.Stop();
memAfter = GC.GetTotalMemory(false);
Console.WriteLine($"DataReader : {sw2.ElapsedMilliseconds}ms / {(memAfter - memBefore) / 1024 / 1024}MB");

俺の手元(VS2019 / .NET Framework 4.7.2 / SQL Server 2016 / 10万件 × 5列)で計測すると、こんな感じの体感差になります:

クラス 10万件ロード時間 メモリ消費
DataAdapter.Fill 約 1,800ms 約 800MB
DataReader ストリーム 約 1,200ms 約 12MB

メモリ消費が桁違い(約65倍)なのが見える。DataAdapter の DataTable は全行をメモリに保持する+ DataRowVersion(Original / Current / Proposed)の3バージョン分のオーバーヘッドがあるので、件数が増えると一気に膨らむ。10万件超のロードは DataAdapter を避ける、というのが業務SEの判断軸っす。

定石4: NULL 値ハンドリングの違い

DataReaderDataAdapter で NULL 値の扱いが微妙に違うのが業務SEで地味に詰まるポイント:

// ❌ NG: DataReader.GetInt32() に NULL が来ると例外
int id = reader.GetInt32(0);   // NULL 行で SqlNullValueException

// ✅ OK: DataReader は IsDBNull で先にチェック
int? id = reader.IsDBNull(0) ? (int?)null : reader.GetInt32(0);

// ✅ OK: DataAdapter / DataTable は Field<int?> で受ける
int? id = dt.Rows[0].Field<int?>("id");   // NULL は null で返る
// ❌ NG: Field<int> だと NULL 行で例外(StrongTypingException または InvalidCastException)
int id2 = dt.Rows[0].Field<int>("id");

DataReader は明示チェック必須DataAdapterField<T> は Nullable で受ければ自動 null 化。同じ NULL 値でも書き味が違うので、両方の書き方を頭に入れておくのが業務系で詰まらないコツっす。

DataReader の NULL チェックを省きたい場合は、ヘルパー関数を1個用意しておくとコードレビューが楽になります:

// ✅ 定石4-b: DataReader 用 NULL 安全ヘルパー
public static class ReaderEx
{
    public static int? GetIntOrNull(this SqlDataReader r, int i)
        => r.IsDBNull(i) ? (int?)null : r.GetInt32(i);

    public static string GetStringOrNull(this SqlDataReader r, int i)
        => r.IsDBNull(i) ? null : r.GetString(i);

    public static decimal GetDecimalOrZero(this SqlDataReader r, int i)
        => r.IsDBNull(i) ? 0m : r.GetDecimal(i);
}

// 使う側
while (reader.Read())
{
    int? id = reader.GetIntOrNull(0);
    string name = reader.GetStringOrNull(1);
    decimal amount = reader.GetDecimalOrZero(2);
}

業務系チームに置いておくと、DataReader 周りのコードがいい感じに揃って読みやすくなる。csharp-sqlserver-dbnull-5idioms で書いた DBNull ハンドリングの延長で、実装テンプレ化しておく形っす。

定石5: 5シナリオ別の使い分け表

業務SE現場で迷う5シナリオと、それぞれの推奨クラスを表にまとめると、こんな感じになります:

シナリオ 推奨 理由
一覧画面・編集 UI(1万件未満) DataAdapter + DataTable DataGridView バインド・行編集が楽
大量データ表示(10万件超) 仮想化 + DataReader ページング メモリ節約
集計・count/sum(読み捨て) DataReader DataTable 不要
CSV エクスポート・帳票出力 DataReader ストリーム書き出しでメモリ最小
マスタ更新(INSERT/UPDATE/DELETE) DataAdapter.Update + SqlCommandBuilder 自動 SQL 生成・トランザクション込み

業務系で一番踏みやすいのが「一覧画面で件数が増えた時」。最初は1000件で快適だったのが、運用1年で5万件・10万件になって突然画面が固まる。ん?このまま運用続けても大丈夫やん??って思ってると、ある日突然メモリ800MB事故で詰まる。件数が増える可能性がある画面は最初からページング設計、というのを業務系チーム規約に入れておくと事故を予防できる。

ハマりポイント — 実体験ベースの本番事故3点

1. DataAdapter で10万件 800MB 事故(半日デバッガで追ってハマった)

過去ログ画面を DataAdapter.Fill で書いて、3年分のデータを取った瞬間に画面が3秒固まってメモリ800MB事件。夕方の運用報告で「画面が落ちる」って報告で気づいて半日デバッガで追ってハマった。原因は完全に全件 Fill で、DataReader のストリーム読み取り+ページングに書き換えてメモリ12MB 程度に収まった。それ以来、業務系チームで「件数オーダーが見えない画面は DataReader 寄せ + ページング」をルール化しました。

2. DataReader 中に別 SQL で MARS エラー(30分溶かした)

DataReader を while で回している最中に、別の SqlCommand.ExecuteScalar() を投げてしまって、InvalidOperationException: There is already an open DataReader で詰まった事件。30分溶かした末に、接続文字列に MultipleActiveResultSets=True を追加するか、別の Connection を開くかの2択と分かって、業務系では別 Connection 開くパターンに揃えた。MARS は性能トレードオフがあるので、本番では避けるのが無難。

3. Connection.Close() 忘れで接続枯渇(数日プロファイラで追った)

DataReaderusing で囲わずに書いて、例外発生時に Close() が呼ばれずに接続が枯渇する事件。数日プロファイラで追ってようやく気付いた。SQL Server の sp_who2 で接続数が増え続けていることが分かって、using 入れ子に書き換えて解決。ADO.NET の Connection / Command / Reader は全部 using で囲うを業務系チーム規約に入れた。

著者の現場メモ — 業務系チームでの ADO.NET 規約

流通系SIer時代に、過去コードを grep -rn "DataAdapter\|DataReader" . でひっかけたら、150箇所近く 出てきたんですよね。書き方がバラバラで、DataAdapter.Fill で件数オーダー無視で全件取ってる箇所、DataReaderusing で囲ってない箇所、MARS 設定なしで複数 Reader を並行開してる箇所、全部入り。

んで、後輩と一緒に 3行ルール にまとめた:

  1. 画面表示・編集 = DataAdapter / 大量読み取り・帳票 = DataReader(1万件超は DataReader 寄せ)
  2. ADO.NET の Connection / Command / Reader は全部 using で囲う(例外時の接続枯渇を予防)
  3. DataReader 中の別 SQL 発行は禁止(必要なら別 Connection を開く・MARS は最終手段)

このルール化で、メモリ800MB / 接続枯渇 / MARS エラー の3大事故が消えた。用途で2クラスを使い分けるだけで保守工数も事故率も両方下がるので、業務系チームには結構おすすめのルールっす。

C# 7.3 + .NET Framework 4.7.2 + SQL Server 2016 のレガシー業務系って、DataReaderDataAdapter も10年以上 API が変わってないのに、書き方が現場ごとにバラバラなコードベースが本当に多い。書き方のアップデートだけで保守工数が下がる実例だと思ってます。

まとめ

状況 推奨パターン
一覧画面・編集 UI(1万件未満) DataAdapter + DataTable
大量データ読み取り(10万件超) DataReader ストリーム
集計・count/sum DataReader(DataTable 不要)
CSV / 帳票出力 DataReader ストリーム書き出し
マスタ更新(CRUD) DataAdapter.Update + SqlCommandBuilder
NULL 値ハンドリング DataReader.IsDBNull / DataRow.Field<T?>
Connection 管理 using 入れ子で例外時も解放
複数 Reader の並行 別 Connection(MARS は最終手段)

ADO.NET の使い分けは、「件数オーダー」と「編集の有無」 で整理できます。1万件未満で編集 UI なら DataAdapter、10万件超や集計・帳票なら DataReader、迷ったら件数オーダーを先に確認するのが業務SEの現実解。用途で2クラスを使い分けるだけで、メモリ800MB の事故はだいぶ減ります。

よくある質問

Q1. DataReaderDataAdapter、新規開発ならどっちを使うべき?

A. 用途で分けるのが正解です。画面表示・編集・DataGridView バインド・行ごとの編集 UI なら DataAdapter + DataTable。大量データ読み取り・集計・CSV エクスポート・帳票出力なら DataReader。「迷ったら DataAdapter」は1万件未満なら無難ですが、10万件超のロードでメモリ消費が桁違いになるので、件数オーダーで判断するのが業務SEの現実解です。

Q2. 10万件の DataAdapter で 800MB 食うって本当?

A. 本当です。私の業務SE時代の体感数字で、SQL Server 2016 から 10万件 × 20列のテーブルを DataAdapter.Fill で取ると、DataTable に約 800MB 確保される現場がありました。同じデータを DataReader で1行ずつストリームすると 12MB 程度で済む。原因は DataTable が全行をメモリに保持する+ DataRowVersion(Original/Current/Proposed)の3バージョン分のオーバーヘッドがあるため。大量データは DataReader 寄せが業務SEの鉄則です。

Q3. DataReader 中に別の SQL を投げたいんですが、どうすれば?

A. MARS(Multiple Active Result Sets)を有効にするか、Connection を別に開くかの2択です。接続文字列に MultipleActiveResultSets=True を追加すると、同じ Connection で DataReader を保持したまま別 SQL を投げられます。ただし MARS は性能トレードオフがあるので、業務系の本番では Connection を別途用意するパターンの方が安定します。

Q4. DataReaderGetInt32(0) で NULL が来たらどうなりますか?

A. SqlNullValueException(または環境によっては InvalidCastException)が飛びます。NULL を扱うカラムは if (reader.IsDBNull(0)) { ... } で先にチェックしてから GetInt32(0) を呼んでください。int? で受けたい場合は reader.IsDBNull(0) ? (int?)null : reader.GetInt32(0) のパターンで書くのが業務SE定番です。

Q5. DataAdapter.Update で UPDATE 文を自動生成してくれますか?

A. SqlCommandBuilder を使えば自動生成されます。var builder = new SqlCommandBuilder(adapter); を Fill の前後で呼ぶと、adapter.Update(dt) 時に SELECT 文から逆引きで INSERT/UPDATE/DELETE を生成してくれる。ただし JOIN や複雑な SELECT には対応しないので、複雑なクエリは UPDATE 文を手書きで adapter.UpdateCommand にセットするのが確実です。

ここまでで DataReaderDataAdapter の使い分け・性能・ハマりポイントは押さえた。ADO.NET の隣接トピックも貼っておきます。

関連記事

以上!

同じ罠でハマってる業務SE仲間いたら、どんどんシェア待ってるぜ!!

この記事が気に入ったら
いいねしてね!

どんどんシェア待ってるぜ!!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次