みなさんこんにちは!ヒロポンです!!
今回は 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大読み取りクラス DataReader と DataAdapter の メモリ消費・性能・編集可否の違い、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() される
ポイント:
using入れ子で Connection / Command / Reader を解放(前回の例外処理記事で紹介したパターン)reader.Read()の戻り値で行末判定(false で抜ける)GetXxx(int ordinal)で型付き取得(カラム番号は 0 始まり)- 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)
ポイント:
adapter.Fill(dt)で接続を自動 Open/Close(using 内で完結)DataTableは編集可能(row["col"] = newValueで書き換え)DataGridView.DataSource = dtでバインド(行追加・削除も自動反映)adapter.Update(dt)で DB に逆反映(INSERT / UPDATE / DELETE 自動生成)
業務系の画面で「マスタ一覧を表示・編集・保存」する流れは、この DataAdapter + DataGridView パターンが王道っす。
定石3: メモリ消費の実測比較 — Stopwatch + GC.GetTotalMemory
DataReader と DataAdapter の体感差を実測すると、桁違いの違いが見えます:
// ✅ 定石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 値ハンドリングの違い
DataReader と DataAdapter で 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 は明示チェック必須、DataAdapter の Field<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() 忘れで接続枯渇(数日プロファイラで追った)
DataReader を using で囲わずに書いて、例外発生時に Close() が呼ばれずに接続が枯渇する事件。数日プロファイラで追ってようやく気付いた。SQL Server の sp_who2 で接続数が増え続けていることが分かって、using 入れ子に書き換えて解決。ADO.NET の Connection / Command / Reader は全部 using で囲うを業務系チーム規約に入れた。
著者の現場メモ — 業務系チームでの ADO.NET 規約
流通系SIer時代に、過去コードを grep -rn "DataAdapter\|DataReader" . でひっかけたら、150箇所近く 出てきたんですよね。書き方がバラバラで、DataAdapter.Fill で件数オーダー無視で全件取ってる箇所、DataReader を using で囲ってない箇所、MARS 設定なしで複数 Reader を並行開してる箇所、全部入り。
んで、後輩と一緒に 3行ルール にまとめた:
- 画面表示・編集 = DataAdapter / 大量読み取り・帳票 = DataReader(1万件超は DataReader 寄せ)
- ADO.NET の Connection / Command / Reader は全部
usingで囲う(例外時の接続枯渇を予防) DataReader中の別 SQL 発行は禁止(必要なら別 Connection を開く・MARS は最終手段)
このルール化で、メモリ800MB / 接続枯渇 / MARS エラー の3大事故が消えた。用途で2クラスを使い分けるだけで保守工数も事故率も両方下がるので、業務系チームには結構おすすめのルールっす。
C# 7.3 + .NET Framework 4.7.2 + SQL Server 2016 のレガシー業務系って、DataReader も DataAdapter も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. DataReader と DataAdapter、新規開発ならどっちを使うべき?
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. DataReader の GetInt32(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 にセットするのが確実です。
ここまでで DataReader と DataAdapter の使い分け・性能・ハマりポイントは押さえた。ADO.NET の隣接トピックも貼っておきます。
関連記事
- C# DataAdapter.Update() で DBNull 例外が出た時の最短対処 —
DataAdapter.Updateで DBNull が絡む時の境界線対処に効く - C# DataTable を LINQ でフィルタ・GroupBy・分割する3パターン —
DataAdapterで取った DataTable を LINQ で整形する時に効く - SQL Server の DBNull を C# で安全にハンドリングする5つのイディオム —
DataReader.IsDBNull/DataRow.Field<T?>の体系的な書き分けに効く
以上!
同じ罠でハマってる業務SE仲間いたら、どんどんシェア待ってるぜ!!


コメント