連載Index(読む順・公開済リンクが最新): S00 C#達人への道(門前の誓い)連載Index
APIは成功っぽいのにDBが変わらない、もしくは更新できたのに触ってない項目まで変わる。
こういうときは原因を想像する前に、更新件数(SaveChanges の戻り値) と 例外 だけ先に見ておくと、当たりが付けやすい。
例外あり/なしを見て、次に更新0件か更新1件以上かを見れば、見る場所が絞れてくる。
※実際は「更新0件なのに成功に見える」パターンがわりと多い。
最初の手がかり
- 更新件数(SaveChanges の戻り値)
-
例外の種類(特に
DbUpdateConcurrencyException/DbUpdateException) - どこから呼ばれたか(同じキーが複数ルートで更新されると追いにくい)
- キー(どのIDの更新かが分かる形にしておく)
迷わない早見表
mermaid や csharp の閉じ忘れがあると、表がコード枠に巻き込まれて崩れやすい。
この表は 行頭インデント無し で置く前提。
| 状況 | 見えた内容 | 直し方の当たり | リンク |
|---|---|---|---|
| DB未反映 | 更新0件 / AsNoTracking | 追跡あり取得へ / 列指定更新へ |
A-1 A-3 |
| DB未反映 | 更新0件 / SelectでDTO | DTO更新の流れを止める / 列指定更新へ |
A-2 A-3 |
| DB未反映 | 更新0件 / 新旧が同じ | 同値更新の可能性 | A-0 |
| 触ってない項目が変わる | 更新1件以上 | Update()の全列更新を避ける / 列指定更新へ | B-1 |
| 時々上書き | 更新1件以上 | 同じキーの複数更新ルートをつぶす | B-2 |
| 上書きを止めたい | 継続発生 | 排他を入れて例外で拾う |
B-3→ C-1 |
| 例外が出る | 例外あり | 排他か制約違反かで分ける |
C-1 C-2 |
最初の切り分け
更新件数と例外で、まずA/B/Cへ切り分ける。A/Bの中は、章の先頭でよくある原因へ当てていく。
色の意味
| 記号 | 意味 | どこを見るか |
|---|---|---|
| 🟨 | 症状 | 起きている現象 |
| 🟦 | 確認 | ログや値として出せる情報 |
| 🟩 | 分け方 | 最初に当たる章(A/B) |
| 🟧 | 原因の候補 | よくあるパターン(A/Bの中で当てる) |
| 🟥 | 例外 | 例外の種類(C章へ) |
サンプルで使うクラス
コード片で出てくる User と UpdateUserDto をここで定義しておく。型の出どころで止まるのを減らす狙い。
using System;
using System.ComponentModel.DataAnnotations;
public class User
{
public int Id { get; set; }
// 例として更新対象にする列
public string DisplayName { get; set; } = "";
// 排他(楽観ロック)に使う列
// [Timestamp] により ConcurrencyToken 扱いになる前提。
// DB側にも RowVersion 列が必要になるので、移行が要るケースがある。
[Timestamp]
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
// 取得はDTOにする運用が多いので、例として定義しておく。
public sealed record UpdateUserDto(int Id, string DisplayName, byte[] RowVersion);
最初の5分:ログを整える
調査が長引くパターンは、だいたいこのどれかになる。
- 更新件数がログに無い(0が出ているのに成功に見える)
- 例外が握り潰されていて、上位が成功扱いになっている
- どこから呼ばれたかが分からず、同じキーの更新が追えない
「更新件数」「例外」「呼び出し元」「キー」が同じ粒度で残る形にすると、探す場所が狭くなりやすい。
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
public static class EfSaveChangesLog
{
/// <summary>
/// SaveChanges の戻り値(更新件数)と例外を、同じ粒度でログへ出す。
///
/// ここで欲しい情報は4つ。
/// - rowsAffected(更新件数): 0 なのか 1以上なのか
/// - 例外: 排他か制約違反か
/// - sourceLabel: どこで SaveChanges が走ったか
/// - key: どのIDの更新か
/// </summary>
public static async Task<int> SaveChangesWithLogAsync(
DbContext db,
ILogger logger,
string sourceLabel,
object key,
CancellationToken ct)
{
try
{
// SaveChangesAsync の戻り値が「更新件数」。
// 0 のままなら「DBへ更新が出ていない」側が濃い。
var rowsAffected = await db.SaveChangesAsync(ct);
// 0件は「成功っぽく見える」になりやすいので、Warningにして目に入れやすくする。
if (rowsAffected == 0)
{
logger.LogWarning(
"rowsAffected=0 source={Source} key={Key}",
sourceLabel, key);
}
else
{
logger.LogInformation(
"rowsAffected={RowsAffected} source={Source} key={Key}",
rowsAffected, sourceLabel, key);
}
return rowsAffected;
}
catch (DbUpdateConcurrencyException ex)
{
// 排他(楽観ロック)で更新できないときに入る。
// ここを握り潰すと「静かな上書き」が続く形になりやすい。
logger.LogWarning(ex, "Concurrency source={Source} key={Key}", sourceLabel, key);
throw;
}
catch (DbUpdateException ex)
{
// 制約違反やFK、ユニーク制約などで入ることが多い。
// InnerException にDB側メッセージが入ることがあるので一緒に出す。
logger.LogWarning(
ex,
"DbUpdateException source={Source} key={Key} inner={Inner}",
sourceLabel, key, ex.InnerException?.Message);
throw;
}
}
}
A章 更新0件(SaveChanges の戻り値)
更新0件は「DBへ更新が出ていない」側が濃厚。まずは原因の候補を4つに分ける。
A-0 新旧が同じ
直し方
- 新旧と更新件数を同じログへ出して、同値更新かを切り分ける
- 同値更新が仕様なら、更新0件は自然な動きになる
// 追跡ありで取得。
// user を DbContext が追いかけるので、プロパティ変更が検知される。
var user = await db.Users.FirstAsync(x => x.Id == id, ct);
// 変更前の値を控える。
// 更新0件のとき「そもそも値が変わってない」がすぐ分かるようにする。
var oldName = user.DisplayName;
// 更新したつもりの値を入れる。
// oldName と newName が同じなら、更新扱いにならず更新0件になりやすい。
user.DisplayName = newName;
// 反映実行。
// 例外が出ずに 0 が返ることがある。
var rowsAffected = await db.SaveChangesAsync(ct);
// 新旧と更新件数を同じログへ出す。
logger.LogInformation(
"Update DisplayName old={Old} new={New} rowsAffected={RowsAffected}",
oldName, newName, rowsAffected);
A-1 AsNoTracking で取っている
直し方
- 更新するなら追跡あり取得へ変える
- 追跡なしのまま進めるなら A-3 の列指定更新へ切り替える
// 追跡なしで取得。
// user を DbContext が追いかけないので、プロパティを変えても更新対象になりにくい。
var user = await db.Users.AsNoTracking().FirstAsync(x => x.Id == id, ct);
// 値を変えても、DbContext が変化を検知しない。
user.DisplayName = newName;
// SaveChanges を呼んでも更新0件になりやすい。
var rowsAffected = await db.SaveChangesAsync(ct);
logger.LogInformation("rowsAffected={RowsAffected}", rowsAffected);
追跡ありへ変えた形。
// 追跡ありで取得。
// 更新する前提なら、まずこれが素直。
var user = await db.Users.FirstAsync(x => x.Id == id, ct);
// 追跡対象なので変更が検知される。
user.DisplayName = newName;
var rowsAffected = await db.SaveChangesAsync(ct);
logger.LogInformation("rowsAffected={RowsAffected}", rowsAffected);
A-2 Select で DTO にしている
直し方
- DTO は追跡されないので、DTOの値を変えて SaveChanges へ進む流れは止める
- A-3 の列指定更新へ切り替える
// DTOとして取得。
// Select の結果はエンティティではないので、DbContext が追いかける対象が作られない。
var dto = await db.Users
.Where(x => x.Id == id)
.Select(x => new UpdateUserDto(x.Id, x.DisplayName, x.RowVersion))
.FirstAsync(ct);
// DTOの値を変える。
// ここで変わるのはメモリ上の dto だけ。
// DbContext が追いかけている Modified のエンティティが無いので更新0件になりやすい。
dto = dto with { DisplayName = newName };
// 追跡対象が無いので、SaveChanges は更新0件になりやすい。
var rowsAffected = await db.SaveChangesAsync(ct);
logger.LogInformation("rowsAffected={RowsAffected}", rowsAffected);
A-3 追跡されていない更新
直し方
- Attach で追跡を始める
- 更新する列だけ
IsModified=trueにする -
Update()の全列更新は避ける
// DTOから最小のエンティティを作る。
// ここでは更新に必要な列だけ用意する。
var user = new User
{
Id = dto.Id,
DisplayName = dto.DisplayName
};
// DbContext に「このエンティティを追いかける」と伝える。
// Attach が無いと更新対象として扱われにくい。
db.Users.Attach(user);
// 更新する列を明示する。
// DisplayName だけを更新対象にして、巻き込みを減らす。
db.Entry(user).Property(x => x.DisplayName).IsModified = true;
// ここで更新1件以上になれば、DBへ更新が出ている。
var rowsAffected = await db.SaveChangesAsync(ct);
logger.LogInformation("rowsAffected={RowsAffected}", rowsAffected);
B章 更新1件以上(SaveChanges の戻り値)
更新1件以上なら更新自体は出ている。
それでも困るのは「触ってない項目が変わる」「時々上書き」「上書きを止めたい」あたり。
B-1 Update() による全列更新
直し方
-
Update()を避けて列指定更新へ切り替える - 触ってない項目まで変わるときはここが多い
// DTOから最小のエンティティを作る。
var user = new User { Id = dto.Id, DisplayName = dto.DisplayName };
// Update() は「全プロパティが更新対象」として扱われやすい。
// ここで用意していない列が初期値のまま送られて、触ってない項目が変わったように見えることがある。
db.Users.Update(user);
var rowsAffected = await db.SaveChangesAsync(ct);
logger.LogInformation("rowsAffected={RowsAffected}", rowsAffected);
列指定更新へ切り替えた形。
// Attach で追跡を始める。
db.Users.Attach(user);
// 変更した列だけ更新対象にする。
db.Entry(user).Property(x => x.DisplayName).IsModified = true;
var rowsAffected = await db.SaveChangesAsync(ct);
logger.LogInformation("rowsAffected={RowsAffected}", rowsAffected);
B-2 同じキーが複数ルートで更新されている
直し方
- SaveChanges のログに
sourceLabelを入れる - 同じキーで
sourceLabelが複数出る流れを減らす
// 呼び出し元ラベルを決めておく。
// Controller / Service / Job など、後で分かれば十分。
var sourceLabel = "Service";
// 反映実行。
var rowsAffected = await db.SaveChangesAsync(ct);
// 更新件数と一緒に「どこから」「どのキーで」を残す。
// 同じキーの更新が複数回出たときに追いやすくなる。
logger.LogInformation(
"rowsAffected={RowsAffected} source={Source} key={Key}",
rowsAffected, sourceLabel, id);
B-3 上書きを止めたい
直し方
- RowVersion 等の排他を入れて、上書きを例外で拾う
- 取得側で RowVersion を持ち回す
- 更新側は C-1 の形で
OriginalValueを入れる
// 取得側で RowVersion を持ち回す。
// 返却や画面保持などで dto.RowVersion を一緒に持つ想定。
var dto = await db.Users
.Where(x => x.Id == id)
.Select(x => new UpdateUserDto(x.Id, x.DisplayName, x.RowVersion))
.FirstAsync(ct);
// 更新時は C-1 で dto.RowVersion を OriginalValue へ入れて一致判定に使う。
C章 例外
例外が出たら種類で分ける。排他か、制約違反かで次の当たりが変わる。
C-1 DbUpdateConcurrencyException
直し方
- RowVersion の元値を
OriginalValueへ入れる - 例外をログへ残す
- API の返し方は 409 / 412 あたりを運用で決めておく
よくある原因は「RowVersion を持っているのに、元値を入れずに更新している」。
// 呼び出し元ラベル。
// ログを見たときに「どこで排他になったか」が追いやすくなる。
var sourceLabel = "Service";
// DTOから最小のエンティティを作る。
var user = new User { Id = dto.Id, DisplayName = dto.DisplayName };
// Attach で追跡を始める。
db.Users.Attach(user);
// 更新する列を明示する。
db.Entry(user).Property(x => x.DisplayName).IsModified = true;
// DTO側が持っている「更新前の RowVersion」を OriginalValue に入れる。
// EF Core は OriginalValue を使って一致判定を行い、ズレていれば排他例外になる。
db.Entry(user).Property(x => x.RowVersion).OriginalValue = dto.RowVersion;
try
{
var rowsAffected = await db.SaveChangesAsync(ct);
logger.LogInformation(
"rowsAffected={RowsAffected} source={Source} key={Key}",
rowsAffected, sourceLabel, dto.Id);
}
catch (DbUpdateConcurrencyException ex)
{
// 排他は「起きた事実」を残したい。
// 握り潰すと、静かな上書きが続く形になりやすい。
logger.LogWarning(ex, "Concurrency source={Source} key={Key}", sourceLabel, dto.Id);
throw;
}
C-2 DbUpdateException
直し方
-
InnerExceptionのメッセージをログへ出す - DB側の制約名と突き合わせて直す
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
// DB側メッセージは InnerException に入ることがある。
// ここを残しておくと「何の制約に当たったか」が追いやすい。
logger.LogWarning(
ex,
"DbUpdateException inner={Inner}",
ex.InnerException?.Message);
throw;
}
まだ当たらないときに見る場所
ここまで当たらないときは、ログや状態の取り方が足りないことが多い。次の項目を順に当たると、原因が絞れることが多い。
- EF Core の SQL ログ(UPDATE が出ているか)
- ChangeTracker の状態(Modified が付いているか)
- トランザクション境界(途中でロールバックしていないか)
- 例外の握り潰し(上位で成功扱いにしていないか)
- 更新の二重実行(同一キーを2回更新していないか)
最後のチェック
- 更新件数がログに残っている
- 例外が握り潰されていない
- 更新0件のとき、追跡なし取得 / DTO化 / 列指定更新のどれかに当たっている
-
更新1件以上のとき、
Update()の全列更新になっていない - 上書きを止めたい場合、RowVersion の持ち回しと排他が入っている
関連リンク
連載Index(読む順・公開済リンクが最新): S00 C#達人への道(門前の誓い)連載Index