🧑‍💻

Entity FrameworkのChangeTrackerによるメモリ管理の仕組み

に公開

はじめに

EFにおけるメモリ管理の仕組みを知らないあまりに時間を無駄にしたため、本件についてきちんと理解した内容を備忘録として残します。

開発中、以下のような問題に遭遇しました。

  1. Aエンティティを取得し、そのプロパティを書き換える
  2. SaveChanges()を実行するが、楽観ロックエラーが発生
  3. エラーハンドリングで、Aエンティティの最新データを取得するSelect文を実行
  4. しかし、取得されるのは最新データではなく、手順1で書き換えた結果の情報のまま

先輩に助けていただき「Entity状態をDetachedに変更すること」が解決できました。
なぜそうするべきなのか、他に対処方法はないのかについて、調べた内容を記載します。

対象読者

  • Entity Frameworkを業務で使用している開発者
  • EFのメモリ管理について理解を深めたい方

先に結論

  • なぜそうするべきなのか(「Entity状態をDetachedに変更すること」が解決方法なのに対して)
    SaveChangesが失敗した場合、エンティティはModified状態のまま残るため。
    本来、SaveChanges成功時は追跡から解放されUnchanged状態となる。

  • 他の対処方法について
    ・AsNoTracking()の使用

記事に出てくる用語

  • ChangeTracker
    Entity Frameworkにおいてエンティティの変更を監視・追跡する機能。
    この仕組みにより、EFは「どのエンティティがどのように変更されたか」を効率的に管理し、データベースへの操作を実行する。

  • SaveChanges()
    ChangeTrackerに記録された変更をデータベースに反映する。

Entityの状態管理

ChangeTrackerは、各エンティティに対して以下5つの状態のいずれかを割り当てる。

  • Detached: 追跡されていない状態
  • Unchanged: データベースから取得後、変更が加えられていない状態
  • Added: 新規追加予定の状態
  • Modified: 既存データに変更が加えられた状態
  • Deleted: 削除予定の状態

メソッドによるSQL発行の違い

EFにおけるデータ取得は、使用するメソッドによってSQL発行の有無が異なる。

  • Findメソッドによる取得
var entity = context.Entities.Find(1);
  1. まずChangeTrackerで指定されたプライマリキーのエンティティが既に存在するかを確認
  2. データ取得
    ・エンティティがメモリに存在する場合:SQLを発行せず、メモリ上のエンティティを返却
    ・エンティティがメモリに存在しない場合:SQLを発行してデータベースから取得
  3. 取得したエンティティをUnchanged状態でメモリに格納
  • その他クエリメソッドによる取得
var entity = context.Entities.Where(x => x.Name == "太郎").FirstOrDefault();
  1. 常にSQLを発行してデータベースから取得
  2. 取得結果のプライマリキーがChangeTrackerに既存の場合、データベースの結果を破棄
  3. 既存のメモリ上のオブジェクトを返却

重要な点として、FindメソッドはSQLを発行しない可能性があるのに対し、WhereメソッドなどはSQLを発行しても結果が破棄される。

各状態への遷移タイミング

ChangeTrackerによって管理される各状態について、具体的にいつその状態になるのかをコード例とともに記載。

コード例

Detached

// オブジェクトを作成しただけの状態
var user = new User { Name = "太郎" };
Console.WriteLine(context.Entry(user).State); // Detached

// AsNoTracking()で取得
var users = context.Users.AsNoTracking().ToList();
// これらのエンティティは全てDetached状態

// 明示的にDetachする
var trackedUser = context.Users.Find(1);
context.Entry(trackedUser).State = EntityState.Detached;

// 別のDbContextで取得したエンティティ
User userFromOtherContext;
using (var otherContext = new AppDbContext())
{
    userFromOtherContext = otherContext.Users.Find(1);
}
// 元のcontextから見ると、このuserはDetached状態

Unchanged

// データベースから取得した直後
var user = context.Users.Find(1);
Console.WriteLine(context.Entry(user).State); // Unchanged

// SaveChanges()成功後
user.Name = "変更後"; // Modified状態になる
await context.SaveChangesAsync(); // データベースに反映
Console.WriteLine(context.Entry(user).State); // Unchanged に戻る

// Attach()でエンティティをアタッチ
var existingUser = new User { Id = 1, Name = "太郎" };
context.Users.Attach(existingUser);
Console.WriteLine(context.Entry(existingUser).State); // Unchanged

// 明示的にUnchanged状態に設定
context.Entry(user).State = EntityState.Unchanged;

Added

// Add()メソッドで追加
var newUser = new User { Name = "花子" };
context.Users.Add(newUser);
Console.WriteLine(context.Entry(newUser).State); // Added

// AddRange()で複数追加
var newUsers = new List<User>
{
    new User { Name = "次郎" },
    new User { Name = "三郎" }
};
context.Users.AddRange(newUsers);
// 全てAdded状態

// 明示的にAdded状態に設定
var user = new User { Name = "四郎" };
context.Entry(user).State = EntityState.Added;

// SaveChanges後は Unchanged になる
await context.SaveChangesAsync();
Console.WriteLine(context.Entry(newUser).State); // Unchanged (IDも自動設定される)

Modified

// 追跡中エンティティのプロパティを変更
var user = context.Users.Find(1);
user.Name = "変更後の名前"; // この瞬間にModified状態になる
Console.WriteLine(context.Entry(user).State); // Modified

// 複数プロパティの変更
user.Email = "new@example.com";
user.UpdatedAt = DateTime.Now;
// 状態は引き続きModified

// Update()メソッドで明示的に更新対象に設定
var userFromOtherSource = new User {
    Id = 1,
    Name = "更新",
    Email = "updated@test.com" };
context.Users.Update(userFromOtherSource);
Console.WriteLine(context.Entry(userFromOtherSource).State); // Modified

// 明示的にModified状態に設定
context.Entry(user).State = EntityState.Modified;

// どのプロパティが変更されたかも確認可能
foreach (var property in context.Entry(user).Properties)
{
    if (property.IsModified)
    {
        Console.WriteLine($"{property.Metadata.Name}: {property.OriginalValue}{property.CurrentValue}");
    }
}

Deleted

// Remove()メソッドで削除マーク
var user = context.Users.Find(1);
context.Users.Remove(user);
Console.WriteLine(context.Entry(user).State); // Deleted

// RemoveRange()で複数削除
var usersToDelete = context.Users.Where(u => !u.IsActive).ToList();
context.Users.RemoveRange(usersToDelete);
// 全てDeleted状態

// 明示的にDeleted状態に設定
context.Entry(user).State = EntityState.Deleted;

// SaveChanges後はChangeTrackerから除去される
await context.SaveChangesAsync();
// この時点で、userはもうChangeTrackerに存在しない

また、以下には基本的な状態遷移の例を示す。
新規作成: Detached → Added → Unchanged(SaveChanges成功時)
取得・更新: Unchanged → Modified → Unchanged(SaveChanges成功時)
削除: Unchanged → Deleted → ChangeTrackerから除去(SaveChanges成功時)

対処方法

  1. AsNoTracking()の使用
    →ChangeTrackerによる追跡がなく、常に別のオブジェクトを作成する
  2. EntityState.Detachedの使用
    →ChangeTrackerから追跡を削除する
  3. Reload()の使用
    →状態をUnchangedにする

これにより、SaveChangesが失敗した場合でも、意図しない既存エンティティの取得を防ぐ事が可能。

AsNoTrackingの活用

読み取り専用のクエリでは、AsNoTracking()の使用が推奨される。
これにより、ChangeTrackerによる追跡を回避し、メモリ使用量の削減とパフォーマンスの向上が見込める。
なお、以下の理由から、すべてのクエリでAsNoTrackingを使用することは推奨されていない。

  • 遅延読み込み(Lazy Loading)が無効になる: 関連エンティティの自動読み込みが機能しない
  • 関連エンティティとの整合性が保てない: 複数のエンティティ間の関連性が維持されない
  • 自動的な重複排除が効かない: 同じデータに対して複数のインスタンスが作成される可能性
  • 変更検知機能が使えない: EFの自動的な変更追跡機能が利用できない

AsNoTrackingを使用して取得したエンティティで更新をかけたい場合は、UpdateメソッドやAttachメソッドでChangeTrackerに追跡状態を登録してSaveChangeしないといけない。(変更追跡機能がないため)

Discussion