付録 EF Core 4:更新・トランザクション・レプリカ
このページは 第8章:データベースアクセスと ORM (Entity Framework Core) の付録です。保存の応用、トランザクションと同時実行制御、イベントとインターセプター、読み取り専用レプリカを扱います。
本編を先に読んでから、必要な項目をここで参照してください。
第8章のほかの付録
- 付録 EF Core 1:モデル定義(エンティティとリレーションシップ)
- 付録 EF Core 2:モデル定義(キー・採番・SQL Server 固有)
- 付録 EF Core 3:マイグレーションの詳細とクエリ
- 付録 EF Core 5:パフォーマンス
- 付録 EF Core 6:テスト
1. 保存の応用
Section titled “1. 保存の応用”切断されたエンティティのグラフを保存する
Section titled “切断されたエンティティのグラフを保存する”Web API では、親と子をまとめた JSON をクライアントから受け取り、そのグラフごと保存したい場面がよくあります。Add / Attach / Update はグラフを再帰的にたどり、エンティティごとに状態を決めます。
公式の切断されたエンティティの説明では、自動生成キー(int や Guid の主キー)を使う場合、キー値が未設定のエンティティを新規として扱うと説明しています。次は、Id を持つ子 2 件と Id 未設定の子 1 件を含むグラフを Update に渡した確認例です。
var graph = new Blog{ Id = 1, Name = "更新後", Url = "https://example.com", Posts = { new Post { Id = 1, Title = "既存 1" }, new Post { Id = 2, Title = "既存 2" }, new Post { Title = "新規(Id 未設定)" }, },};context.Update(graph);Blog {Id: 1} ModifiedPost {Id: -2147482644} Added FK {BlogId: 1}Post {Id: 1} Modified FK {BlogId: 1}Post {Id: 2} Modified FK {BlogId: 1}Id が未設定だった 1 件だけが Added になり、一時キー値(負の値)が割り当てられています。この値は SaveChanges までの間だけ使われ、保存後にデータベースが採番した実際の値へ置き換わります。Attach を使うと、既存のエンティティは Modified ではなく Unchanged になります(新規の判定は同じです)。
状態の決め方を自分で制御したい場合は ChangeTracker.TrackGraph を使います。グラフ内の各エンティティを追跡する直前にコールバックが呼ばれるため、DTO に持たせたフラグなどで判定できます。
context.ChangeTracker.TrackGraph(graph, node =>{ var entry = node.Entry; var id = (int)entry.Property("Id").CurrentValue!;
entry.State = id switch { 0 => EntityState.Added, _ => EntityState.Modified, };});同じキーのインスタンスが混ざったグラフ
Section titled “同じキーのインスタンスが混ざったグラフ”公式の ID 解決の説明では、同じキーのエンティティを複数のインスタンスとして追跡できないとしています。クライアントから受け取った JSON では、この重複が生じることがあります。「投稿の一覧」を「それぞれの投稿が属するブログ」ごとシリアル化すると、同じブログが何度も現れるためです。
// posts[0].Blog と posts[1].Blog が「同じ Id・別インスタンス」になっているdb.AttachRange(posts);InvalidOperationException: The instance of entity type 'Blog' cannot be trackedbecause another instance with the same key value for {'Id'} is already being tracked.When attaching existing entities, ensure that only one entity instance with a givenkey value is attached.対処は、公式が示す シリアル化の側で参照を保持する設定か、追跡しながら行う ID 解決 (identity resolution) です。上の例外は重複インスタンスを追跡する構成で確認できています。
後者は TrackGraph で書けます。すでに同じキーが追跡されていれば、そのノードを追跡しないという判断をコールバックの中で下します。
db.ChangeTracker.TrackGraph(root, node =>{ var entry = node.Entry; var keyValue = entry.Property("Id").CurrentValue;
var existing = db.ChangeTracker.Entries().FirstOrDefault( e => e.Metadata == entry.Metadata && Equals(e.Property("Id").CurrentValue, keyValue));
if (existing == null) { entry.State = EntityState.Unchanged; }});次は、重複を含むグラフを 2 つ渡した確認例です。この条件では Blog が 1 つに集約され、合計 3 エンティティの追跡を確認できています。
Blog {Id: 1} UnchangedPost {Id: 1} Unchanged FK {BlogId: 1}Post {Id: 2} Unchanged FK {BlogId: 1}チェンジトラッカーの中身を見る
Section titled “チェンジトラッカーの中身を見る”思ったとおりの状態になっているかは、ChangeTracker.DebugView で確認できます。公式ドキュメントの説明どおり、ShortView は追跡中のエンティティ・その状態・キー値だけを、LongView はさらにすべてのプロパティ値とナビゲーションの状態まで表示します。
次は、Blog 1 件と、それに関連する Post 1 件を読み込んで追跡し、Blog.Name を書き換えた状態で、両方を出力する確認例です。Blog だけを読み込んだ場合に、未読の Post まで表示されるわけではありません。
context.ChangeTracker.DetectChanges();Console.WriteLine(context.ChangeTracker.DebugView.ShortView);Blog {Id: 1} ModifiedPost {Id: 1} Unchanged FK {BlogId: 1}Console.WriteLine(context.ChangeTracker.DebugView.LongView);Blog {Id: 1} Modified Id: 1 PK Name: '書き換え後' Modified Originally '元の名前' Posts: [{Id: 1}]Post {Id: 1} Unchanged Id: 1 PK BlogId: 1 FK Title: '投稿1' Blog: {Id: 1}「どのプロパティが変わったのか」を知りたいときは LongView です。ShortView は件数が多いときに全体を俯瞰する用途に向いています。
保存前のキーには一時値が入る
Section titled “保存前のキーには一時値が入る”公式の一時値 (temporary value)では、保存後のデータベース生成キーに代わる値を追跡内部に保持します。次は int 主キーで、DebugView の負の一時値を確認した例です。すべてのキーで同じ動作ではなく、SQL Server の GUID 主キーのようにクライアント側で値を生成する場合もあります。
Blog {Id: -2147482647} AddedPost {Id: -2147482647} Added FK {BlogId: -2147482647}一方、エンティティのプロパティ自体は 0 のままです。一時値は変更追跡の内部にだけ保持され、エンティティには書き戻されません。
db.Add(blog);Console.WriteLine(blog.Id); // 0Console.WriteLine(db.Entry(blog).Property(x => x.Id).IsTemporary); // True
await db.SaveChangesAsync();Console.WriteLine(blog.Id); // 1Console.WriteLine(db.Entry(blog).Property(x => x.Id).IsTemporary); // False子の外部キー (Post.BlogId) にも同じ一時値が伝播しています。保存前の親子関係は、この一時値によって結ばれています。 SaveChanges が親を挿入して本物のキーを受け取ると、EF Core は子の外部キーを実際の値に置き換えてから子を挿入します。
保存のバッチ処理
Section titled “保存のバッチ処理”EF Core は SaveChanges で追跡している変更をバッチにまとめ、データベースとの往復回数を抑えます。ただし、すべての変更が 1 回の送信で完結するわけではありません。公式ドキュメントは SQL Server について「4 文未満ではバッチ処理は概して効率が悪く、40 文前後を超えると利点が薄れるため、既定では 1 回のバッチで最大 42 文までを実行し、残りは別の往復で実行する」と説明しています。
次は、SQL Server 2022 に対して N 件の Add を保存したときの確認例です。このモデルでは、42 件と 43 件の間で保存 SQL の DbCommand が分割されることを確認できています。
| 保存した件数 | 発行された DbCommand |
|---|---|
| 42 | 1 回 |
| 43 | 2 回 |
| 84 | 2 回 |
| 85 | 3 回 |
この上限は MaxBatchSize で変更できます。MinBatchSize でバッチ処理を始める下限も指定できます。
builder.Services.AddDbContext<BloggingContext>(options => options.UseSqlServer(connectionString, sqlOptions => sqlOptions .MinBatchSize(1) .MaxBatchSize(100)));データベーストリガーがあるテーブルの保存
Section titled “データベーストリガーがあるテーブルの保存”EF Core は SaveChanges のとき、SQL Server では T-SQL の OUTPUT 句 を使って生成された値(IDENTITY の主キーなど)を効率よく取得します。ところが INTO のない OUTPUT 句は、その操作に対応するトリガーが有効なテーブルには使えません。OUTPUT ... INTO とは制約が異なります。
公式の保存時の破壊的変更に対応する確認例では、トリガー付きテーブルを未構成のまま保存すると、次の DbUpdateException が確認できています(EF Core 10.0.11、SQL Server 2022)。この例のエラー番号 334 と詳細メッセージは内部例外の SqlException にあります。
DbUpdateException: Could not save changes because the target table has database triggers.Please configure your table accordingly, seehttps://aka.ms/efcore-docs-sqlserver-save-changes-and-output-clause for more information.→ SqlException (Number=334): The target table 'Blogs' of the DML statement cannot have anyenabled triggers if the statement contains an OUTPUT clause without INTO clause.対処方法は 2 つあります。テーブルにトリガーがあることを EF Core に伝えるか、UseSqlOutputClause(false) で、OUTPUT による直接の結果返却を避ける保存方式へ切り替えます。以下の方法 1 または方法 2 のいずれかを使います。OUTPUT ... INTO まで一律に禁止する設定という意味ではありません。
protected override void OnModelCreating(ModelBuilder modelBuilder){ // 方法 1: トリガーの存在を宣言する modelBuilder.Entity<Blog>() .ToTable(tb => tb.HasTrigger("TR_Blogs_Insert"));
// 方法 2: 直接結果を返す OUTPUT を避ける保存方式に切り替える modelBuilder.Entity<Blog>() .ToTable(tb => tb.UseSqlOutputClause(false));}どちらの構成でも、有効な AFTER INSERT トリガーがあるテーブルへの保存を確認できています。以下は、同じ Blog モデルで 2 件を保存したときの SQL の概略です。比較用の既定方式の SQL は、トリガーなしの条件での出力です。生成される SQL の形は、保存する件数やモデルの構成にも依存します。
-- 2 件・トリガーなしの既定方式(観測した SQL の概略)MERGE ... INSERT ([Name]) VALUES (i.[Name])OUTPUT INSERTED.[Id], i._Position;
-- 2 件・トリガーありで方法 1 または方法 2 を構成(同一 DbCommand 内の SQL の概略)INSERT INTO [Blogs] ([Name]) VALUES (@p0);SELECT [Id] ...INSERT INTO [Blogs] ([Name]) VALUES (@p1);SELECT [Id] ...保存をストアドプロシージャに割り当てる
Section titled “保存をストアドプロシージャに割り当てる”既存のデータベースで「テーブルへの直接の書き込みは禁止、更新はすべてストアドプロシージャ経由」という運用が決まっていることがあります。EF Core 7 以降は、INSERT / UPDATE / DELETE の各コマンドをストアドプロシージャに割り当てられます。
割り当ては OnModelCreating で行います。次は SQL Server の例です。
protected override void OnModelCreating(ModelBuilder modelBuilder){ modelBuilder.Entity<Person>() .InsertUsingStoredProcedure( "People_Insert", sp => { sp.HasParameter(p => p.Name); sp.HasResultColumn(p => p.Id); // IDENTITY で採番された値を受け取る }) .UpdateUsingStoredProcedure( "People_Update", sp => { sp.HasOriginalValueParameter(p => p.Id); sp.HasParameter(p => p.Name); sp.HasRowsAffectedResultColumn(); // 影響行数を返してもらう }) .DeleteUsingStoredProcedure( "People_Delete", sp => { sp.HasOriginalValueParameter(p => p.Id); sp.HasRowsAffectedResultColumn(); });}この構成で SaveChangesAsync を呼ぶと、EF Core が発行する SQL(SQL Server)は次のようになります。この条件で、通常の INSERT 文ではなく EXEC が使われることを確認できています。
SET NOCOUNT ON;EXEC [People_Insert] @p0;SET NOCOUNT ON;EXEC [People_Update] @p0, @p1;ストアドプロシージャ側は次のように用意します(SQL Server の例)。
CREATE PROCEDURE [dbo].[People_Insert] @Name [nvarchar](max)ASBEGIN INSERT INTO [People] ([Name]) OUTPUT INSERTED.[Id] VALUES (@Name);END構成を組み立てるうえで、公式ドキュメントが挙げている注意点は次のとおりです。
| 項目 | 内容 |
|---|---|
| 名前の省略 | 第 1 引数の名前は省略できます。省略するとテーブル名に _Insert / _Update / _Delete を付けた名前が使われます(このモデルの確認例では Docs_Update) |
| パラメーターの順序 | ストアドプロシージャの定義と同じ順序で追加します。EF Core は名前付き引数ではなく常に位置引数で呼び出すためです |
| キーの指定 | 更新・削除ではキーに HasOriginalValueParameter を使います。将来のバージョンで可変のキー値がサポートされたときに正しい行が更新されるようにするためです |
| 値の返し方 | 出力パラメーター、HasResultColumn(結果列)、HasRowsAffectedReturnValue(戻り値、影響行数のみ)の 3 とおりがあります |
| 継承 | TPH は 1 組、TPT は抽象型を含むすべての型、TPC は具象型ごとにストアドプロシージャが必要です |
公式のストアドプロシージャの同時実行制御では、HasRowsAffectedResultColumn などで影響行数を返して期待行数と比較し、同時実行トークンも WHERE 条件に含めます。補足として、トークンを含めず WHERE [Id] = @Id と SELECT @@ROWCOUNT を使う確認例でも、別の操作で行を削除した後の更新は 0 行となり、DbUpdateConcurrencyException を確認できています。これは削除競合の確認であり、同時更新のトークンによる検出とは別です。
The database operation was expected to affect 1 row(s), but actually affected 0 row(s);data may have been modified or deleted since entities were loaded.一括更新・一括削除
Section titled “一括更新・一括削除”多数の行を更新・削除する場合、すべてのエンティティを読み込んで変更追跡させるのは非効率です。ExecuteUpdateAsync / ExecuteDeleteAsync は、エンティティを読み込まずに単一の SQL 文を発行します。
この節は付録1の「値の変換・所有型・複合型」に掲載した PostStatus / Post / Comment / Author / Address を使います。本編の同名エンティティとは混ぜず、これらの型を同じ PostStatusSample 名前空間に配置してください。BlogId はこの検証モデルでは対象ブログの識別値として持ち、Blog ナビゲーションは定義しません。
using Microsoft.EntityFrameworkCore;
namespace PostStatusSample;
public class PostStatusContext(DbContextOptions<PostStatusContext> options) : DbContext(options){ public DbSet<Post> Posts => Set<Post>();
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Post>() .Property(p => p.Status).HasConversion<string>().HasMaxLength(20); modelBuilder.Entity<Author>().OwnsOne(a => a.Address); }}以下の context は SQL Server を構成した PostStatusContext、threshold は DateTimeOffset.UtcNow.AddYears(-1)、blogId は対象ブログの ID です。
// 1 年以上前の下書きを一括削除var deleted = await context.Posts .Where(p => p.Status == PostStatus.Draft && p.CreatedAt < threshold) .ExecuteDeleteAsync(cancellationToken);
// 特定ブログの投稿をまとめてアーカイブvar updated = await context.Posts .Where(p => p.BlogId == blogId) .ExecuteUpdateAsync( setters => setters .SetProperty(p => p.Status, PostStatus.Archived) .SetProperty(p => p.ArchivedAt, DateTimeOffset.UtcNow), cancellationToken);EF Core 10 では ExecuteUpdateAsync が通常のラムダ(式ツリーでないもの)を受け付けるようになり、条件分岐を含む構築が書けるようになりました。
await context.Posts .Where(p => p.BlogId == blogId) .ExecuteUpdateAsync(setters => { setters.SetProperty(p => p.Status, PostStatus.Archived);
if (includeTimestamp) { setters.SetProperty(p => p.ArchivedAt, DateTimeOffset.UtcNow); } }, cancellationToken);EF Core 10 では、JSON 列にマッピングされた複合型のプロパティも ExecuteUpdateAsync で更新できる ようになりました。従来の ExecuteUpdateAsync は JSON 列の更新に対応していませんでした。複合型と所有型の違いは付録1の「値の変換・所有型・複合型」で扱います。
この確認例は本編とは別のモデルです。Blog は Id と Details を持ち、Details の複合型は Title(string)と Views(int)を持ちます。付録1の JSON クエリ例で使う Viewers ではなく、この例では Views というプロパティ名を使います。
modelBuilder.Entity<Blog>().ComplexProperty(b => b.Details, bd => bd.ToJson());await context.Blogs.ExecuteUpdateAsync(s => s.SetProperty(b => b.Details.Views, b => b.Details.Views + 1));公式の JSON 列の一括更新に対応する確認例です。SQL Server 2022(JSON は nvarchar(max))では、次の JSON_MODIFY を使う SQL と、{"Title":"T","Views":10} から {"Title":"T","Views":11} への更新を確認できています。
UPDATE [b]SET [b].[Details] = JSON_MODIFY([b].[Details], '$.Views', CAST(JSON_VALUE([b].[Details], '$.Views') AS int) + 1)FROM [Blogs] AS [b]変更追跡の細かい挙動
Section titled “変更追跡の細かい挙動”追跡済みインスタンスがクエリ結果を上書きする
Section titled “追跡済みインスタンスがクエリ結果を上書きする”追跡クエリでは、EF Core は同じキー値のエンティティを 1 つのインスタンスとしてしか追跡しません。これを ID 解決 (identity resolution) と呼びます。すでに追跡中のインスタンスがある場合、新しいインスタンスを作る代わりに既存のものが返されます。
ここに重要な副作用があります。同じ DbContext で同じ行を読み直しても、データベース側の変更は結果に反映されません。
var first = await context.Blogs.FirstAsync(); // "元の名前"
// この間に別のセッションが Blogs を更新したとする
var second = await context.Blogs.FirstAsync(); // "元の名前" のままReferenceEquals(first, second); // true公式の追跡クエリの説明は、既存インスタンスを再利用し値を上書きしないことを、DbContext を作業単位ごとに新しくする理由に挙げています。別接続から更新した行を読む確認例でも、同じ DbContext は古い値、新しい DbContext または ChangeTracker.Clear() 後は更新後の値を返すことを確認できています。
リレーションシップ修正
Section titled “リレーションシップ修正”EF Core は、読み込み時や変更検出時に外部キーの値とナビゲーションプロパティの整合を取ります。これを リレーションシップ修正 (relationship fixup) と呼びます。追跡中のエンティティ同士では、別々のクエリで読み込んだ場合にも働きます。
そのため、Include を書いていなくてもナビゲーションが埋まることがあります。
var blog = await context.Blogs.FirstAsync();Console.WriteLine(blog.Posts.Count); // 0
await context.Posts.ToListAsync(); // 別のクエリで Post を読むConsole.WriteLine(blog.Posts.Count); // 2 に増えている公式のリレーションシップ修正は双方向に働きます。このモデルでも、変更検出後に外部キーとナビゲーションの相互の追従を確認できています。
2. トランザクションと同時実行制御
Section titled “2. トランザクションと同時実行制御”自動トランザクションの作成を制御する
Section titled “自動トランザクションの作成を制御する”SaveChangesAsync は、自分でトランザクションを開始していない場合、必要なときだけトランザクションを作ります。この判断は Database.AutoTransactionBehavior で変更できます。
context.Database.AutoTransactionBehavior = AutoTransactionBehavior.Always;| 値 | 公式ドキュメントの説明 |
|---|---|
WhenNeeded(既定) | 必要なときだけ作る。単一の SQL 文はデータベース側で暗黙にトランザクションとして実行されるため、EF Core は明示的なトランザクションを作らない |
Always | ユーザーのトランザクションがなければ常に作る。往復が増えて性能が落ちる可能性がある |
Never | 自動では決して作らない |
次は、SQL Server 2022、MaxBatchSize(1)、保存 1 行または 3 行の条件で、TransactionStarted イベントから開始回数を確認した結果です。
| 設定 | 保存行数(MaxBatchSize(1)) | トランザクション開始 |
|---|---|---|
WhenNeeded(既定) | 1 | 0 回 |
WhenNeeded(既定) | 3 | 1 回 |
Always | 1 | 1 回 |
Always | 3 | 1 回 |
Never | 1 | 0 回 |
Never | 3 | 0 回 |
公式の AutoTransactionBehavior APIでは、WhenNeeded は必要な場合に作成する設定です。DbCommand 数だけの判定とはしません。EF Core 10.0.11 / SQL Server 2022 の 4 行更新の確認例では、1 コマンドでもトランザクション開始は 1 回です。上の表とは MaxBatchSize の条件が異なります。
セーブポイント
Section titled “セーブポイント”SaveChangesAsync は、すでにトランザクションが開始されている場合、自動的に セーブポイント を作成します。保存中にエラーが起きると、そのセーブポイントまでロールバックされ、トランザクション全体は維持されます。
セーブポイントは手動でも作成できます。
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken);
context.Blogs.Add(new Blog { Name = "A", Url = "https://a.example.com" });await context.SaveChangesAsync(cancellationToken);
await transaction.CreateSavepointAsync("BeforeOptionalWork", cancellationToken);
try{ await ImportOptionalDataAsync(context, cancellationToken); await context.SaveChangesAsync(cancellationToken);}catch{ // 任意処理だけを取り消し、それまでの変更は残す await transaction.RollbackToSavepointAsync("BeforeOptionalWork", cancellationToken);}
await transaction.CommitAsync(cancellationToken);楽観的同時実行制御
Section titled “楽観的同時実行制御”複数のユーザーが同じ行を同時に更新する状況では、後から保存した内容が先の変更を上書きしてしまう「ロストアップデート」が起こります。EF Core では 同時実行トークン を使った楽観的同時実行制御で検出します。
SQL Server では rowversion 列を使うのが一般的です。
using System.ComponentModel.DataAnnotations;
public class Blog{ public int Id { get; set; } public required string Name { get; set; } public required string Url { get; set; }
[Timestamp] public byte[]? Version { get; set; }}Fluent API では次のように書きます。
builder.Property(b => b.Version).IsRowVersion();これにより、UPDATE 文の WHERE 句に読み込み時の Version が含まれ、更新対象の行が 0 行だった場合に DbUpdateConcurrencyException が発生します。実際に SQL Server 2022 に対して発行された SQL は次のとおりです。
UPDATE [Blogs] SET [Name] = @p0OUTPUT INSERTED.[Version]WHERE [Id] = @p1 AND [Version] = @p2;OUTPUT INSERTED.[Version] によって、更新後に採番された新しい Version がその場でアプリケーション側に返され、追跡中のエンティティに反映されます。そのため、連続して更新しても再読み込みは不要です。
例外の処理例です。ここでは「クライアント側の変更を採用して保存し直す」戦略 (Client Wins) を示します。
public async Task<bool> UpdateBlogAsync(int id, string newName, CancellationToken cancellationToken){ var blog = await context.Blogs.FirstAsync(b => b.Id == id, cancellationToken); blog.Name = newName;
try { await context.SaveChangesAsync(cancellationToken); return true; } catch (DbUpdateConcurrencyException ex) { foreach (var entry in ex.Entries) { var databaseValues = await entry.GetDatabaseValuesAsync(cancellationToken);
if (databaseValues is null) { // 他のユーザーによって削除されていた return false; }
// 「元の値」だけをデータベースの現在値に差し替える。 // 変更後の値 (CurrentValues) はクライアントのものが残るため、 // 次の SaveChanges でクライアントの変更が採用される (Client Wins) entry.OriginalValues.SetValues(databaseValues); }
await context.SaveChangesAsync(cancellationToken); return true; }}競合解決の方針は 3 つに整理できます。
| 戦略 | 内容 |
|---|---|
| Client Wins | クライアント側の値で上書きする。entry.OriginalValues.SetValues(databaseValues) の後にそのまま保存 |
| Store Wins | データベース側の値を採用し、クライアントの変更を破棄する。entry.Reload() |
| ユーザーに提示 | 現在値と変更値を画面に表示し、ユーザーに選択させる |
Store Wins にしたい場合は、OriginalValues.SetValues の代わりに ReloadAsync を呼びます。エンティティの現在値がデータベースの値で置き換わるため、クライアントの変更は失われます。
foreach (var entry in ex.Entries){ // 現在値ごとデータベースから読み直す (Store Wins) await entry.ReloadAsync(cancellationToken);}rowversion が使えないプロバイダーでは、アプリケーション側で管理するプロパティを同時実行トークンにできます。次の例では、更新日時とは別に、Blog クラスへ Guid 型の ConcurrencyToken プロパティを追加します。
// Blog クラス内public Guid ConcurrencyToken { get; set; }エンティティの構成で、このプロパティを同時実行トークンに指定します。
builder.Property(b => b.ConcurrencyToken).IsConcurrencyToken();分離レベルによる同時実行制御
Section titled “分離レベルによる同時実行制御”同時実行制御の手段は同時実行トークンだけではありません。公式ドキュメントは、トランザクションの分離レベルを上げる方法も紹介しています。同時実行トークンが不要になり、トランザクション内で常に同じデータが見えるという利点があります。
分離レベルは BeginTransactionAsync に渡します。
using var transaction = await context.Database .BeginTransactionAsync(IsolationLevel.RepeatableRead, cancellationToken);
var account = await context.Accounts.FirstAsync(a => a.Id == id, cancellationToken);account.Balance += amount;
await context.SaveChangesAsync(cancellationToken);await transaction.CommitAsync(cancellationToken);公式ドキュメントによると、データベースの実装によって次の 2 通りに分かれます。
| 動作 | 該当する分離レベル | 分類 |
|---|---|---|
| 読み取った行に共有ロックを取り、外部の更新をブロックする | SQL Server の RepeatableRead(Serializable も同様) | 悲観的ロック |
| ロックは取らず、自分が更新する時点でシリアル化エラーにする | SQL Server の Snapshot、PostgreSQL の repeatable read | 楽観的ロック |
以下は、SQL Server 2022 でこの違いを確認した条件付きの結果です。
RepeatableRead— トランザクション内で 1 行を読んだ状態を保持した場合、別接続から同じ行へのUPDATEのブロックと、コマンドタイムアウト(Number=-2)を確認できています。Snapshot— 同じ条件で別接続からのUPDATEは 0.2 秒で成功し、その後の自分の更新・保存はエラーです。以下は、この確認例の例外チェーン内のSqlExceptionです。
SqlException Number=3960: Snapshot isolation transaction aborted due to update conflict.You cannot use snapshot isolation to access table 'dbo.Accounts' directly or indirectlyin database 'v26' to update, delete, or insert the row that has been modified or deletedby another transaction. Retry the transaction or change the isolation level for theupdate/delete statement.接続の回復性とトランザクションの併用
Section titled “接続の回復性とトランザクションの併用”クラウド上のデータベースでは、一時的な接続エラー(トランジェントエラー)が避けられません。SQL Server プロバイダーの EnableRetryOnFailure を有効にすると、EF Core が自動的に再試行します。
builder.Services.AddDbContext<BloggingContext>(options => options.UseSqlServer(connectionString, sqlOptions => sqlOptions.EnableRetryOnFailure( maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(30), errorNumbersToAdd: null)));この場合は、公式の例と同様に、実行戦略を取得するコンテキストと、試行ごとに作るコンテキストを分けます。トランザクションをロールバックしても、変更トラッカーの Added などの状態まで初期化されるわけではありません。
次は、試行間で追跡状態を持ち越さないための例です。options は、前述の UseSqlServer(..., s => s.EnableRetryOnFailure()) を設定した DbContextOptions<BloggingContext> とし、BloggingContext はこの型を受け取るコンストラクターを持つものとします。Statistics には BlogCount を保持する集計行が 1 行だけある前提です。
static async Task AddBlogAndUpdateStatisticsAsync( DbContextOptions<BloggingContext> options, CancellationToken cancellationToken){ await using var strategyContext = new BloggingContext(options); var strategy = strategyContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () => { await using var context = new BloggingContext(options); await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken);
context.Blogs.Add(new Blog { Name = "A", Url = "https://a.example.com" }); await context.SaveChangesAsync(cancellationToken);
await context.Database.ExecuteSqlAsync( $"UPDATE [Statistics] SET [BlogCount] = [BlogCount] + 1", cancellationToken);
await transaction.CommitAsync(cancellationToken); });}EF Core 10.0.11 の SqlServerRetryingExecutionStrategy を SQLite の実トランザクションと組み合わせた障害注入の確認例では、同じコンテキストを再利用すると、保存途中の TimeoutException 後にデータベースの行はロールバックされても Added が残り、再試行後は Blog が 2 行、統計値が 1 になることを確認できています。上のように試行ごとに作り直すと、コミット前に失敗してロールバックされる条件では 1 行・統計値 1 に戻せています。これは追跡状態と再実行の制御を調べる確認例であり、SQL Server の接続障害を再現したものではありません。
接続を自前で扱う場合の開閉と所有権
Section titled “接続を自前で扱う場合の開閉と所有権”公式の SetConnectionString APIは、接続が開いていると変更できない場合があると説明しています。EF Core 10.0.11 / SQL Server 2022 の確認例では、開いた接続の ApplicationName 変更は InvalidOperationException、閉じた後の同じ変更と SQL 実行は成功です。
また、外部で作った接続を SetDbConnection(connection, contextOwnsConnection: false) で渡す場合、接続の所有者と破棄責任は呼び出し側に残ります。この条件の確認例でも、アプリ側で開いた SqlConnection は DbContext.DisposeAsync() 後も開いており、SELECT 1 の成功を確認できています。呼び出し側の using / await using で寿命を管理してください。
contextOwnsConnection: true では、公式 API の契約どおり EF Core に所有権を引き渡します。所有権だけを切り替えた確認例では、DbContext.DisposeAsync() 後の接続は Closed で、そのままの SELECT 1 は失敗です。どちらの設定でも、破棄済みの DbContext 自体でクエリを実行することはできません。 外部接続の状態とコンテキストの寿命は別です。
デッドロックへの対処
Section titled “デッドロックへの対処”複数のトランザクションが互いの保持するロックを待ち合う状態を デッドロック (deadlock) と呼びます。SQL Server はデッドロックを検出すると、片方を強制的に中止して デッドロックの犠牲者 (deadlock victim) に選び、もう片方を進めます。
sequenceDiagram
accTitle: ロックの取得順序が逆転したデッドロックの例
accDescr: トランザクション1が行Aを、トランザクション2が行Bをロックする。次に互いが保持する行を更新しようとして待ち合い、SQL Server が片方を犠牲者として中止する。
participant T1 as トランザクション 1
participant R1 as 行 A
participant R2 as 行 B
participant T2 as トランザクション 2
T1->>R1: UPDATE 行 A(ロック取得)
T2->>R2: UPDATE 行 B(ロック取得)
T1->>R2: UPDATE 行 B(T2 のロック待ち)
T2->>R1: UPDATE 行 A(T1 のロック待ち)
Note over T1,T2: 相互に待ち合い → デッドロック
Note over T2: SQL Server が犠牲者に選び中止(エラー 1205)
公式のデッドロックガイドでは、犠牲者側にエラー番号 1205 が返ると説明しています。次は、SQL Server 2022 で行 A → B と行 B → A の順に更新するトランザクションを重ねた確認例のメッセージです。
SqlException Number=1205Transaction (Process ID 65) was deadlocked on lock resources with another processand has been chosen as the deadlock victim. Rerun the transaction.デッドロックは、犠牲者となったトランザクション全体の再実行で復旧できる場合があります。SQL Server プロバイダーの実装は 1205 を一時的エラーとして扱うため、EnableRetryOnFailure と CreateExecutionStrategy() を組み合わせて再試行できます。EF Core 10.0.11 / SQL Server 2022 の確認例でも、1205、犠牲者側の 2 回目の操作、両方のコミット成功を確認できています。競合が続く場合などの成功を保証するものではありません。
var strategy = context.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>{ await using var transaction = await context.Database.BeginTransactionAsync();
// デッドロックの犠牲者になっても、このブロック全体が再実行される await context.Database.ExecuteSqlAsync($"UPDATE Blogs SET Rating = Rating + 1 WHERE Id = {firstId}"); await context.Database.ExecuteSqlAsync($"UPDATE Blogs SET Rating = Rating + 1 WHERE Id = {secondId}");
await transaction.CommitAsync();});ただし再試行は最後の手段です。設計でデッドロックそのものを減らすほうが確実です。
| 対策 | 内容 |
|---|---|
| 更新順序を揃える | すべてのトランザクションで、同じ種類のリソースを同じ順序(例: 常に主キーの昇順)で更新する |
| トランザクションを短くする | ロックを保持する時間を最小化する。トランザクション内で外部 API 呼び出しやユーザー入力待ちをしない |
| 読み取りと書き込みのロック競合を減らす | ワークロードに合う場合は READ_COMMITTED_SNAPSHOT を検討する。AsNoTracking は変更追跡の設定であり、データベースの共有ロックを抑止する設定ではない |
| 必要な行だけロックする | 広い範囲を UPDATE せず、主キーで対象を絞る |
3. イベントとインターセプター
Section titled “3. イベントとインターセプター”変更追跡イベントで状態の変化を捕まえる
Section titled “変更追跡イベントで状態の変化を捕まえる”インターセプターより手軽に「エンティティが追跡されたとき」「状態が変わったとき」に処理を挟みたい場合は、DbContext が公開している .NET イベントを使います。EF Core が発行するイベントは次の 5 つです。
| イベント | 発行されるタイミング |
|---|---|
DbContext.SavingChanges | SaveChanges / SaveChangesAsync の開始時 |
DbContext.SavedChanges | SaveChanges / SaveChangesAsync の成功時 |
DbContext.SaveChangesFailed | SaveChanges / SaveChangesAsync の失敗時 |
ChangeTracker.Tracked | エンティティがコンテキストに追跡されたとき |
ChangeTracker.StateChanged | 追跡済みエンティティの状態が変わったとき |
イベントは DbContext インスタンスごとに登録します。プロセス内のすべての DbContext で同じ情報を取りたい場合は診断リスナーを使ってください。
public class BlogsContext : DbContext{ public BlogsContext() { ChangeTracker.Tracked += (s, e) => Console.WriteLine($"Tracked: {e.Entry.Entity.GetType().Name} 状態={e.Entry.State} クエリ由来={e.FromQuery}"); ChangeTracker.StateChanged += (s, e) => Console.WriteLine($"StateChanged: {e.Entry.Entity.GetType().Name} {e.OldState} -> {e.NewState}"); }}EntityTrackedEventArgs.FromQuery は、公式 API リファレンスによると「エンティティがデータベースクエリの一部として追跡されている場合は true」を返します。次の確認例では、Add による追跡は False、クエリ結果の追跡は True です。
-- 新規追加 --Tracked: Blog 状態=Added クエリ由来=FalseTracked: Post 状態=Added クエリ由来=FalseStateChanged: Blog Added -> Unchanged ← SaveChanges の成功後に発行されるStateChanged: Post Added -> Unchanged
-- 読み込みと変更 --Tracked: Blog 状態=Unchanged クエリ由来=TrueTracked: Post 状態=Unchanged クエリ由来=TrueStateChanged: Post Unchanged -> DeletedStateChanged: Blog Unchanged -> ModifiedTracked: Post 状態=Added クエリ由来=False ← 追加した子は StateChanged ではなく TrackedStateChanged: Blog Modified -> UnchangedStateChanged: Post Deleted -> Detached ← 削除されたエンティティは追跡から外れる保存そのものを捕まえる 3 つのイベントは次のように使います。
context.SavingChanges += (s, e) => Console.WriteLine($"保存開始: AcceptAllChangesOnSuccess = {e.AcceptAllChangesOnSuccess}");context.SavedChanges += (s, e) => Console.WriteLine($"保存成功: 保存されたエンティティ数 = {e.EntitiesSavedCount}");context.SaveChangesFailed += (s, e) => Console.WriteLine($"保存失敗: {e.Exception.GetType().Name}");AcceptAllChangesOnSuccess は公式 API リファレンスによると「SaveChanges または SaveChangesAsync に渡された値」、EntitiesSavedCount は「保存されたエンティティの数」です。この確認例では、成功時は SavingChanges → SavedChanges、失敗時は SavingChanges → SaveChangesFailed の順で、後者に SavedChanges の通知はありません。
診断リスナーでプロセス全体のイベントを観測する
Section titled “診断リスナーでプロセス全体のイベントを観測する”前節のイベントは DbContext インスタンスごとの登録です。プロセス内で発生するすべての EF Core イベントを観測したい場合は、DiagnosticListener を使います。公式ドキュメントによると、これは .NET 全体で共通の仕組みで、稼働中のアプリケーションから診断情報を取得するためのものです。
購読は 2 段階です。まず DiagnosticListener そのものの観測者を作り、EF Core のリスナー(名前は Microsoft.EntityFrameworkCore、DbLoggerCategory.Name から取得できます)を見つけたら、そのリスナーを購読します。
次は、公式の診断イベントの観測例に対応する 2 段階の観測者です。KeyValueObserver がイベント名を記録し、CompleteDiagnosticObserver が各リスナーの購読を保持して、破棄時に解除します。どちらの OnError も、通知された例外を Console.Error に渡します。DiagnosticSample 名前空間の独立したファイルとして配置します。
この実装は、診断対象の操作を逐次実行する有限の検証用です。記録先の List<string> と購読一覧を並行アクセスから保護しておらず、複数のスレッドでイベントが発生する本番アプリケーションへそのまま組み込む例ではありません。
using System;using System.Collections.Generic;using System.Diagnostics;using Microsoft.EntityFrameworkCore;
namespace DiagnosticSample;
public class CompleteDiagnosticObserver(List<string> events) : IObserver<DiagnosticListener>, IDisposable{ private readonly List<IDisposable> subscriptions = [];
public void OnCompleted() { } public void OnError(Exception error) => Console.Error.WriteLine(error);
public void OnNext(DiagnosticListener value) { if (value.Name == DbLoggerCategory.Name) subscriptions.Add(value.Subscribe(new KeyValueObserver(events))); }
public void Dispose() { foreach (var s in subscriptions) s.Dispose(); }}
public class KeyValueObserver(List<string> events) : IObserver<KeyValuePair<string, object?>>{ public void OnCompleted() { } public void OnError(Exception error) => Console.Error.WriteLine(error);
public void OnNext(KeyValuePair<string, object?> value) => events.Add(value.Key);}呼び出し側では、AllListeners の購読も保持します。次の context は、Blogs を公開する呼び出し側の BloggingContext です。接続先のテーブルは準備済みとし、このインスタンスではまだ EF Core の操作を実行していない状態で購読を開始します。using の範囲で Count() を 1 回実行し、成功時も例外時も、AllListeners、各 EF Core リスナーの順に購読を解除します。イベント名の表示は解除後に行います。受信するイベント名や件数は検証条件に依存するため、次の実測記録を固定の期待値にはしません。
using System;using System.Collections.Generic;using System.Diagnostics;using System.Linq;using DiagnosticSample;
var events = new List<string>();using (var observer = new CompleteDiagnosticObserver(events))using (DiagnosticListener.AllListeners.Subscribe(observer)){ _ = context.Blogs.Count();}
foreach (var name in events.Distinct()) Console.WriteLine(name);掲載した購読保持・解除と OnError 実装は、EF Core 10.0.11 と SQLite で動作を確認できています。OnError はインターフェイスからの直接呼び出しによる標準エラー出力の確認で、EF Core がエラー通知を発行した事例ではありません。
以下は、それとは別の既存の観測記録です。Count() 1 回の実行で受信した 19 種類のイベント名のうち、一部を示します。
Microsoft.EntityFrameworkCore.Infrastructure.ContextInitializedMicrosoft.EntityFrameworkCore.Query.QueryCompilationStartingMicrosoft.EntityFrameworkCore.Query.QueryExecutionPlannedMicrosoft.EntityFrameworkCore.Database.Connection.ConnectionCreatingMicrosoft.EntityFrameworkCore.Database.Connection.ConnectionCreatedMicrosoft.EntityFrameworkCore.Database.Connection.ConnectionOpeningインターセプターによる横断的な処理
Section titled “インターセプターによる横断的な処理”作成日時の自動設定、監査ログ、クエリへのヒント付与のような すべての操作に共通する処理 は、個々のリポジトリやサービスに書くと漏れが生じます。EF Core は インターセプター (Interceptor) を提供しており、低レベルの操作に割り込んで処理を追加したり、操作そのものを抑制・変更したりできます。
公式ドキュメントが挙げるインターセプターは次のとおりです。
| インターフェイス | 割り込める操作 | シングルトン |
|---|---|---|
IDbCommandInterceptor | コマンドの生成・実行・失敗、DbDataReader の破棄 | いいえ |
IDbConnectionInterceptor | 接続の生成・開閉、接続の失敗 | いいえ |
IDbTransactionInterceptor | トランザクションの生成・使用・コミット・ロールバック、セーブポイント、失敗 | いいえ |
ISaveChangesInterceptor | SavingChanges / SavedChanges、SaveChangesFailed、楽観的同時実行の処理 | いいえ |
IMaterializationInterceptor | クエリ結果からのエンティティの生成・初期化・確定 | はい |
IQueryExpressionInterceptor | クエリがコンパイルされる前の LINQ 式ツリーの変更 | はい |
IIdentityResolutionInterceptor | エンティティ追跡時の ID 競合の解決 | はい |
登録は DbContextOptionsBuilder.AddInterceptors で行います。OnConfiguring は AddDbContext を使う場合でも呼ばれるため、DbContext の構築方法によらず設定を適用できる場所として公式が推奨しています。
次は ISaveChangesInterceptor を使って、作成日時と更新日時を自動で設定する例です(SaveChangesInterceptor は空実装を持つ基底クラスで、必要なメソッドだけをオーバーライドできます)。
公式の監査例に従い、同期の SaveChanges と非同期の SaveChangesAsync のどちらでも処理するため、SavingChanges と SavingChangesAsync の両方を実装します。日時を設定する共通処理は SetAuditTimestamps にまとめます。
この例は、AuditSample 名前空間にまとめた独立した監査用モデルです。CreatedAt と UpdatedAt を持つ Blog を、この例の BloggingContext に登録します。本編の同名型へメンバーを追加する差分ではありません。呼び出し側で DbContextOptionsBuilder<AuditSample.BloggingContext> にプロバイダーと接続値を設定し、その Options をコンストラクターへ渡します。インターセプターの登録先は、このコンテキストの OnConfiguring です(公式の登録方法)。
using System;using System.Threading;using System.Threading.Tasks;using Microsoft.EntityFrameworkCore;using Microsoft.EntityFrameworkCore.Diagnostics;
namespace AuditSample;
public class Blog{ public int Id { get; set; } public required string Name { get; set; } public DateTime CreatedAt { get; set; } public DateTime? UpdatedAt { get; set; }}
public class AuditInterceptor : SaveChangesInterceptor{ public override InterceptionResult<int> SavingChanges( DbContextEventData eventData, InterceptionResult<int> result) { SetAuditTimestamps(eventData.Context); return base.SavingChanges(eventData, result); }
public override ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default) { SetAuditTimestamps(eventData.Context); return base.SavingChangesAsync(eventData, result, cancellationToken); }
private static void SetAuditTimestamps(DbContext? context) { if (context is not null) { var now = DateTime.UtcNow; foreach (var entry in context.ChangeTracker.Entries<Blog>()) { if (entry.State == EntityState.Added) { entry.Entity.CreatedAt = now; } else if (entry.State == EntityState.Modified) { entry.Entity.UpdatedAt = now; } } } }}
public class BloggingContext(DbContextOptions<BloggingContext> options) : DbContext(options){ public DbSet<Blog> Blogs => Set<Blog>();
// インターセプターは多くの場合ステートレスなので、 // 1 つのインスタンスをすべての DbContext で共有できます private static readonly AuditInterceptor Audit = new();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.AddInterceptors(Audit);}掲載した AuditSample は、EF Core 10.0.11 と SQLite で、同期・非同期の両方について追加・更新時の日時設定と再読み取りを確認できています。以下の日時は、別の SQL Server 2022 での観測記録です。この条件では、追加時は CreatedAt だけが設定され(UpdatedAt は null)、その後の更新で UpdatedAt だけが変わることを確認できています。
after insert: CreatedAt=2026-09-01T08:23:51.0828970 UpdatedAt=nullafter update: CreatedAt=2026-09-01T08:23:51.0828970 UpdatedAt=2026-09-01T08:23:51.7206710読み込み時に処理を挟む(IMaterializationInterceptor)
Section titled “読み込み時に処理を挟む(IMaterializationInterceptor)”ISaveChangesInterceptor が「書き込み」に割り込むのに対し、IMaterializationInterceptor は クエリ結果からエンティティが組み立てられる過程 に割り込みます。データベースの列にマッピングしていないプロパティを、読み込み時に初期化するといった用途に使えます。
この例も本編と別の StampSample 名前空間に配置します。LoadedAt は検証用 Blog のプロパティで、StampContext.OnModelCreating の Ignore により列へのマッピングから外します。呼び出し側で DbContextOptionsBuilder<StampContext> にプロバイダーと接続値を設定して Options を渡してください。OnConfiguring で登録する LoadStampInterceptor は、Singleton インターセプターの指針に従って同じインスタンスを再利用します。
using System;using Microsoft.EntityFrameworkCore;using Microsoft.EntityFrameworkCore.Diagnostics;
namespace StampSample;
public class Blog{ public int Id { get; set; } public required string Name { get; set; } public DateTime LoadedAt { get; set; }}
public sealed class LoadStampInterceptor : IMaterializationInterceptor{ public object InitializedInstance(MaterializationInterceptionData data, object instance) { if (instance is Blog blog) { blog.LoadedAt = DateTime.UtcNow; }
return instance; }}
public class StampContext(DbContextOptions<StampContext> options) : DbContext(options){ private static readonly LoadStampInterceptor Stamp = new();
public DbSet<Blog> Blogs => Set<Blog>();
protected override void OnConfiguring(DbContextOptionsBuilder options) => options.AddInterceptors(Stamp);
protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity<Blog>().Ignore(b => b.LoadedAt);}このインターフェイスには次の 4 つのメソッドがあり、公式ドキュメントはそれぞれの呼び出しタイミングを次のように定義しています。
| メソッド | 呼び出しタイミング |
|---|---|
CreatingInstance | エンティティのインスタンスを生成する直前(コンストラクターの呼び出し前) |
CreatedInstance | インスタンスの生成直後(コンストラクターで設定されなかったプロパティ値が設定される前) |
InitializingInstance | プロパティ値を設定する直前(コンストラクターが設定した値はすでに入っている) |
InitializedInstance | プロパティ値の設定が完了した直後 |
掲載した StampSample は、EF Core 10.0.11 と SQLite で、LoadedAt の非マップと読み取り時の値設定を確認できています。以下の SQL Server 2022 の出力は、4 コールバックを記録する別実装の観測です。掲載した LoadStampInterceptor 自体が、この順序ログを出力するわけではありません。
この確認例では SQL Server 2022 から未追跡の 2 件を読み込み、新規インスタンス 1 件ごとの呼び出し順序と、Ignore でマッピングから外した LoadedAt への値設定を確認できています。
CreatingInstanceCreatedInstanceInitializingInstanceInitializedInstance: BlogCreatingInstanceCreatedInstanceInitializingInstanceInitializedInstance: Blog b1 LoadedAt=2026-09-01T08:42:58.3176110Z b2 LoadedAt=2026-09-01T08:42:58.3226340Z4. 読み取り専用レプリカ (Read-Only Replica) の扱い
Section titled “4. 読み取り専用レプリカ (Read-Only Replica) の扱い”参照系のトラフィックが書き込みを大きく上回るアプリケーションでは、読み取りを レプリカ に逃がすことでプライマリの負荷を下げられます。
読み取りスケールアウトの仕組み
Section titled “読み取りスケールアウトの仕組み”Azure SQL Database の 読み取りスケールアウト (Read Scale-Out) では、高可用性のために維持されているレプリカを読み取り専用ワークロードに開放できます。接続文字列に ApplicationIntent=ReadOnly を指定すると、読み取り専用レプリカにルーティングされます。
flowchart LR
accTitle: 書き込み用と読み取り用の接続経路
accDescr: アプリケーションは BloggingContext でプライマリへ書き込み、BloggingReadContext で読み取り専用レプリカへ問い合わせる。プライマリのデータはレプリカへ複製される。この図は複製の同期方式を指定するものではない。
APP["ASP.NET Core アプリケーション"]
subgraph W["書き込み系"]
WC["BloggingContext<br>ApplicationIntent=ReadWrite"]
end
subgraph R["読み取り系"]
RC["BloggingReadContext<br>ApplicationIntent=ReadOnly"]
end
PRI[("プライマリ")]
REP[("読み取り専用レプリカ")]
APP --> WC --> PRI
APP --> RC --> REP
PRI -. "データの複製" .-> REP
読み取り側のレプリカに接続するには、接続文字列に ApplicationIntent=ReadOnly を指定します。
Server=tcp:myserver.database.windows.net,1433;Database=Blogging;Authentication=Active Directory Default;ApplicationIntent=ReadOnly;読み取りスケールアウトが利用できるサービスレベルには制限があります。
| サービスレベル | 読み取りスケールアウト |
|---|---|
| Premium / Business Critical | 利用可能。新規データベースでは既定で有効 |
| Hyperscale | セカンダリレプリカを 1 つ以上追加すると利用可能。新規データベースでは既定で有効だが、セカンダリレプリカが 0 の構成では自動的に無効になる |
| Basic / Standard / General Purpose | 利用不可(代替として geo レプリカを検討する) |
接続先がレプリカであることは、次のクエリで確認できます。
var updateability = await context.Database .SqlQuery<string>( $"SELECT CAST(DATABASEPROPERTYEX(DB_NAME(), 'Updateability') AS nvarchar(128)) AS [Value]") .FirstAsync(cancellationToken);
// 読み取り専用レプリカに接続していれば "READ_ONLY" が返る次は Azure SQL Database の Business Critical(2 vCore)に SqlClient で接続し、ApplicationIntent の値だけを変えた確認例です。ExecuteScalarAsync による DATABASEPROPERTYEX の結果で、上の EF Core コード自体の Azure 上での実行記録ではありません。
ApplicationIntent | Updateability |
|---|---|
| 指定なし | READ_WRITE |
ReadWrite | READ_WRITE |
ReadOnly | READ_ONLY |
データ整合性と遅延の制約
Section titled “データ整合性と遅延の制約”ここでは Azure SQL Database の読み取りスケールアウトに戻って説明します。読み取り側へのデータ反映は即時とは限らず、レプリカのデータはプライマリより遅れることがあります。
| ユースケース | 接続先 |
|---|---|
| 更新処理と、その直後の再読み込み | プライマリ |
| 同時実行トークンの検証を伴う読み取り | プライマリ |
| 一覧・検索・ダッシュボード・レポート | レプリカ |
| 分析・集計バッチ | レプリカ |
また、公式の読み取りスケールアウトの説明では、読み取り専用レプリカ上のトランザクションは、セッションの分離レベル設定やクエリヒントに関係なくスナップショット分離レベルで実行され、書き込みはできません。次は Business Critical のレプリカへ ApplicationIntent=ReadOnly で接続し、SqlClient の ExecuteNonQueryAsync でテーブル作成を試みた確認例です。SaveChangesAsync の例外ではありません。
Microsoft.Data.SqlClient.SqlException: Failed to update database "BloggingBC"because the database is read-only.EF Core 側での読み書き分離
Section titled “EF Core 側での読み書き分離”最も分かりやすいのは、読み取り用の DbContext 型を別に定義する 方法です。型が分かれていれば、どちらに接続しているかをコード上で区別しやすくなります。ただし、型を分けるだけで書き込みが禁止されるわけではありません。
この節では、本編の「エンティティクラスの定義」にある Blog / Post / Contributor を使います。Blog.Posts と Post.BlogId / Post.Blog もその定義に含まれます。直前の AuditSample.Blog とは別のモデルです。
using Microsoft.EntityFrameworkCore;using BloggingApi.Models;
namespace BloggingApi.Data;
// 書き込みを含む通常のコンテキスト(プライマリに接続)public class BloggingContext(DbContextOptions<BloggingContext> options) : DbContext(options){ public DbSet<Blog> Blogs => Set<Blog>(); public DbSet<Post> Posts => Set<Post>();
protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.ApplyConfigurationsFromAssembly(typeof(BloggingContext).Assembly);}
// 読み取り専用コンテキスト(レプリカに接続)public class BloggingReadContext(DbContextOptions<BloggingReadContext> options) : DbContext(options){ public DbSet<Blog> Blogs => Set<Blog>(); public DbSet<Post> Posts => Set<Post>();
protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.ApplyConfigurationsFromAssembly(typeof(BloggingContext).Assembly);}読み取り専用 DbContext の設計
Section titled “読み取り専用 DbContext の設計”読み取り用コンテキストでは、追跡を既定で無効化し、SaveChanges / SaveChangesAsync の各オーバーロードをガードして、通常の保存経路での誤用を防ぎます。引数なしの経路だけでなく、acceptAllChangesOnSuccess を受け取る経路も対象にします。
public class BloggingReadContext(DbContextOptions<BloggingReadContext> options) : DbContext(options){ public DbSet<Blog> Blogs => Set<Blog>(); public DbSet<Post> Posts => Set<Post>();
protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.ApplyConfigurationsFromAssembly(typeof(BloggingContext).Assembly);
public override int SaveChanges() => throw new InvalidOperationException("読み取り専用コンテキストでは保存できません。");
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => throw new InvalidOperationException("読み取り専用コンテキストでは保存できません。");
public override int SaveChanges(bool acceptAllChangesOnSuccess) => throw new InvalidOperationException("読み取り専用コンテキストでは保存できません。");
public override Task<int> SaveChangesAsync( bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) => throw new InvalidOperationException("読み取り専用コンテキストでは保存できません。");}DI への登録では、それぞれ別の接続文字列を割り当てます。
var writeConnection = builder.Configuration.GetConnectionString("BloggingPrimary") ?? throw new InvalidOperationException("接続文字列 'BloggingPrimary' が見つかりません。");var readConnection = builder.Configuration.GetConnectionString("BloggingReplica") ?? writeConnection; // レプリカ未構成の環境ではプライマリにフォールバックする
builder.Services.AddDbContext<BloggingContext>(options => options.UseSqlServer(writeConnection, sql => sql.EnableRetryOnFailure()));
builder.Services.AddDbContext<BloggingReadContext>(options => options.UseSqlServer(readConnection, sql => sql.EnableRetryOnFailure()) .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));対応する appsettings.json は次のようになります。
{ "ConnectionStrings": { "BloggingPrimary": "Server=tcp:myserver.database.windows.net,1433;Database=Blogging;Authentication=Active Directory Default;", "BloggingReplica": "Server=tcp:myserver.database.windows.net,1433;Database=Blogging;Authentication=Active Directory Default;ApplicationIntent=ReadOnly;" }}利用側では、目的に応じてコンテキストを注入します。一覧はレプリカから取得し、作成直後の確認にも使う 1 件取得はプライマリから取得します。CreatedAtAction の公式説明に従い、POST の Location ヘッダーには、この 1 件取得の URI を返します。
using BloggingApi.Data;using BloggingApi.Models;using Microsoft.AspNetCore.Mvc;using Microsoft.EntityFrameworkCore;
[ApiController][Route("api/blogs")]public class BlogsController( BloggingContext writeContext, BloggingReadContext readContext) : ControllerBase{ // 一覧はレプリカから読む [HttpGet] public async Task<ActionResult<IEnumerable<BlogSummary>>> GetBlogs( CancellationToken cancellationToken) => await readContext.Blogs .Select(b => new BlogSummary(b.Id, b.Name, b.Posts.Count)) .ToListAsync(cancellationToken);
// 作成直後でも取得できるように、1 件取得はプライマリから読む [HttpGet("{id:int}")] public async Task<ActionResult<Blog>> GetBlog( int id, CancellationToken cancellationToken) { var blog = await writeContext.Blogs.AsNoTracking() .SingleOrDefaultAsync(b => b.Id == id, cancellationToken);
if (blog is null) return NotFound();
return blog; }
// 書き込みと、その直後の応答はプライマリを使う [HttpPost] public async Task<ActionResult<Blog>> CreateBlog( CreateBlogRequest request, CancellationToken cancellationToken) { var blog = new Blog { Name = request.Name, Url = request.Url }; writeContext.Blogs.Add(blog); await writeContext.SaveChangesAsync(cancellationToken);
return CreatedAtAction(nameof(GetBlog), new { id = blog.Id }, blog); }}
public record CreateBlogRequest(string Name, string Url);public record BlogSummary(int Id, string Name, int PostCount);どのクエリをレプリカに流すか
Section titled “どのクエリをレプリカに流すか”読み書き分離は「導入すればすべてが速くなる」仕組みではありません。次の点を踏まえて対象を選びます。
- 書き込み直後の読み取りは対象外にする — POST / PUT のレスポンスとして返す内容は、プライマリから取得する
- ユーザーが自分の操作結果を確認する画面は慎重に — 「保存しました」の直後に一覧へ戻るような導線では、古いデータが見えると不具合として認識される
- 集計・レポート・全文検索のような重いクエリを優先的に移す — プライマリの負荷低減効果が最も大きい
- レプリカ障害時のフォールバックを用意する — レプリカに接続できない場合にプライマリへ切り替えるか、機能を縮退させるかをあらかじめ決めておく
5. 参考ドキュメント
Section titled “5. 参考ドキュメント”- ASP.NET Core を使用して Web API を作成する | Microsoft Learn
- 切断されたエンティティ | Microsoft Learn
- チェンジトラッカーのデバッグ | Microsoft Learn
- 変更の検出と通知 | Microsoft Learn
- ID 解決 | Microsoft Learn
- 追跡クエリと非追跡クエリ | Microsoft Learn
- 外部キーとナビゲーションの変更 | Microsoft Learn
- その他の変更追跡機能 | Microsoft Learn
- データの保存 | Microsoft Learn
- マイグレーションの管理 | Microsoft Learn
- EF Core 7.0 の新機能 | Microsoft Learn
- EF Core 10.0 の新機能 | Microsoft Learn
- トランザクションの使用 | Microsoft Learn
- 同時実行の競合の処理 | Microsoft Learn
- インターセプター | Microsoft Learn
- IMaterializationInterceptor インターフェイス | Microsoft Learn
- EF Core の .NET イベント | Microsoft Learn
- EF Core での診断リスナーの使用 | Microsoft Learn
- DiagnosticListener.Subscribe メソッド | Microsoft Learn
- using ステートメント | Microsoft Learn
- エンティティのプロパティ | Microsoft Learn
- 接続の回復性 | Microsoft Learn
- SetConnectionString メソッド | Microsoft Learn
- SetDbConnection メソッド | Microsoft Learn
- 読み取りスケール可用性グループの構成 | Microsoft Learn
- 可用性グループの読み取り専用ルーティングの構成 | Microsoft Learn
- EnableRetryOnFailure メソッド | Microsoft Learn
- 効率的な更新 | Microsoft Learn
- レプリカからのクエリ読み取り | Microsoft Learn
- アクティブ geo レプリケーション | Microsoft Learn
- ワークロードをセカンダリ可用性グループレプリカにオフロードする | Microsoft Learn
- SQL Server / Azure SQL データベース プロバイダー | Microsoft Learn