コンテンツにスキップ

付録 EF Core 4:更新・トランザクション・レプリカ

このページは 第8章:データベースアクセスと ORM (Entity Framework Core) の付録です。保存の応用、トランザクションと同時実行制御、イベントとインターセプター、読み取り専用レプリカを扱います。

本編を先に読んでから、必要な項目をここで参照してください。

第8章のほかの付録


  1. 保存の応用
  2. トランザクションと同時実行制御
  3. イベントとインターセプター
  4. 読み取り専用レプリカ (Read-Only Replica) の扱い
  5. 参考ドキュメント

切断されたエンティティのグラフを保存する

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} Modified
Post {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 tracked
because 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 given
key 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} Unchanged
Post {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} Modified
Post {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} Added
Post {Id: -2147482647} Added FK {BlogId: -2147482647}

一方、エンティティのプロパティ自体は 0 のままです。一時値は変更追跡の内部にだけ保持され、エンティティには書き戻されません。

db.Add(blog);
Console.WriteLine(blog.Id); // 0
Console.WriteLine(db.Entry(blog).Property(x => x.Id).IsTemporary); // True
await db.SaveChangesAsync();
Console.WriteLine(blog.Id); // 1
Console.WriteLine(db.Entry(blog).Property(x => x.Id).IsTemporary); // False

子の外部キー (Post.BlogId) にも同じ一時値が伝播しています。保存前の親子関係は、この一時値によって結ばれています。 SaveChanges が親を挿入して本物のキーを受け取ると、EF Core は子の外部キーを実際の値に置き換えてから子を挿入します。

EF Core は SaveChanges で追跡している変更をバッチにまとめ、データベースとの往復回数を抑えます。ただし、すべての変更が 1 回の送信で完結するわけではありません。公式ドキュメントは SQL Server について「4 文未満ではバッチ処理は概して効率が悪く、40 文前後を超えると利点が薄れるため、既定では 1 回のバッチで最大 42 文までを実行し、残りは別の往復で実行する」と説明しています。

次は、SQL Server 2022 に対して N 件の Add を保存したときの確認例です。このモデルでは、42 件と 43 件の間で保存 SQL の DbCommand が分割されることを確認できています。

保存した件数発行された DbCommand
421 回
432 回
842 回
853 回

この上限は 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, see
https://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 any
enabled 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)
AS
BEGIN
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.

多数の行を更新・削除する場合、すべてのエンティティを読み込んで変更追跡させるのは非効率です。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 “追跡済みインスタンスがクエリ結果を上書きする”

追跡クエリでは、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() 後は更新後の値を返すことを確認できています。

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(既定)10 回
WhenNeeded(既定)31 回
Always11 回
Always31 回
Never10 回
Never30 回

公式の AutoTransactionBehavior APIでは、WhenNeeded は必要な場合に作成する設定です。DbCommand 数だけの判定とはしません。EF Core 10.0.11 / SQL Server 2022 の 4 行更新の確認例では、1 コマンドでもトランザクション開始は 1 回です。上の表とは MaxBatchSize の条件が異なります。

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);

複数のユーザーが同じ行を同時に更新する状況では、後から保存した内容が先の変更を上書きしてしまう「ロストアップデート」が起こります。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] = @p0
OUTPUT 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 indirectly
in database 'v26' to update, delete, or insert the row that has been modified or deleted
by another transaction. Retry the transaction or change the isolation level for the
update/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 自体でクエリを実行することはできません。 外部接続の状態とコンテキストの寿命は別です。

複数のトランザクションが互いの保持するロックを待ち合う状態を デッドロック (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=1205
Transaction (Process ID 65) was deadlocked on lock resources with another process
and 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.SavingChangesSaveChanges / SaveChangesAsync の開始時
DbContext.SavedChangesSaveChanges / SaveChangesAsync の成功時
DbContext.SaveChangesFailedSaveChanges / 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 クエリ由来=False
Tracked: Post 状態=Added クエリ由来=False
StateChanged: Blog Added -> Unchanged ← SaveChanges の成功後に発行される
StateChanged: Post Added -> Unchanged
-- 読み込みと変更 --
Tracked: Blog 状態=Unchanged クエリ由来=True
Tracked: Post 状態=Unchanged クエリ由来=True
StateChanged: Post Unchanged -> Deleted
StateChanged: Blog Unchanged -> Modified
Tracked: Post 状態=Added クエリ由来=False ← 追加した子は StateChanged ではなく Tracked
StateChanged: Blog Modified -> Unchanged
StateChanged: 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.ContextInitialized
Microsoft.EntityFrameworkCore.Query.QueryCompilationStarting
Microsoft.EntityFrameworkCore.Query.QueryExecutionPlanned
Microsoft.EntityFrameworkCore.Database.Connection.ConnectionCreating
Microsoft.EntityFrameworkCore.Database.Connection.ConnectionCreated
Microsoft.EntityFrameworkCore.Database.Connection.ConnectionOpening

インターセプターによる横断的な処理

Section titled “インターセプターによる横断的な処理”

作成日時の自動設定、監査ログ、クエリへのヒント付与のような すべての操作に共通する処理 は、個々のリポジトリやサービスに書くと漏れが生じます。EF Core は インターセプター (Interceptor) を提供しており、低レベルの操作に割り込んで処理を追加したり、操作そのものを抑制・変更したりできます。

公式ドキュメントが挙げるインターセプターは次のとおりです。

インターフェイス割り込める操作シングルトン
IDbCommandInterceptorコマンドの生成・実行・失敗、DbDataReader の破棄いいえ
IDbConnectionInterceptor接続の生成・開閉、接続の失敗いいえ
IDbTransactionInterceptorトランザクションの生成・使用・コミット・ロールバック、セーブポイント、失敗いいえ
ISaveChangesInterceptorSavingChanges / 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=null
after 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 への値設定を確認できています。

CreatingInstance
CreatedInstance
InitializingInstance
InitializedInstance: Blog
CreatingInstance
CreatedInstance
InitializingInstance
InitializedInstance: Blog
b1 LoadedAt=2026-09-01T08:42:58.3176110Z
b2 LoadedAt=2026-09-01T08:42:58.3226340Z

4. 読み取り専用レプリカ (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 上での実行記録ではありません。

ApplicationIntentUpdateability
指定なしREAD_WRITE
ReadWriteREAD_WRITE
ReadOnlyREAD_ONLY

ここでは 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.

最も分かりやすいのは、読み取り用の 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);
}

読み取り用コンテキストでは、追跡を既定で無効化し、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 のレスポンスとして返す内容は、プライマリから取得する
  • ユーザーが自分の操作結果を確認する画面は慎重に — 「保存しました」の直後に一覧へ戻るような導線では、古いデータが見えると不具合として認識される
  • 集計・レポート・全文検索のような重いクエリを優先的に移す — プライマリの負荷低減効果が最も大きい
  • レプリカ障害時のフォールバックを用意する — レプリカに接続できない場合にプライマリへ切り替えるか、機能を縮退させるかをあらかじめ決めておく