0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

E08 【現場救急Tips】SaveChanges しても反映されない:反映件数で切り分ける EF Core Web編

0
Last updated at Posted at 2026-02-16

連載Index(読む順・公開済リンクが最新): S00 C#達人への道(門前の誓い)連載Index

APIは成功っぽいのにDBが変わらない、もしくは更新できたのに触ってない項目まで変わる。
こういうときは原因を想像する前に、更新件数(SaveChanges の戻り値)例外 だけ先に見ておくと、当たりが付けやすい。
例外あり/なしを見て、次に更新0件か更新1件以上かを見れば、見る場所が絞れてくる。

※実際は「更新0件なのに成功に見える」パターンがわりと多い。


最初の手がかり

  • 更新件数(SaveChanges の戻り値)
  • 例外の種類(特に DbUpdateConcurrencyException / DbUpdateException
  • どこから呼ばれたか(同じキーが複数ルートで更新されると追いにくい)
  • キー(どのIDの更新かが分かる形にしておく)

迷わない早見表

mermaidcsharp の閉じ忘れがあると、表がコード枠に巻き込まれて崩れやすい。
この表は 行頭インデント無し で置く前提。

状況 見えた内容 直し方の当たり リンク
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章へ)

サンプルで使うクラス

コード片で出てくる UserUpdateUserDto をここで定義しておく。型の出どころで止まるのを減らす狙い。

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?