はじめに
ASP.NET Core でパフォーマンスの向上を目的に、データベースのクエリを並列(パラレル)で実行したいという相談を受けることがあります。
今回は、EntityFramework Core 5.0 から追加された IDbContextFactory を使って並列でクエリを実行する方法について見ていきます。
誤ったコード
次のコードでは★部分で2つのクエリが並列に実行され、結果が変数 tasks に格納されることを期待しています。
[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
private readonly BlogDbContext _blogDbContext;
public ValuesController(BlogDbContext blogDbContext)
{
_blogDbContext = blogDbContext;
}
[HttpGet]
public async Task<IEnumerable<Blog>> GetBlogs()
{
var task1 = _blogDbContext.Blogs.Where(b => b.BlogName.Contains("hoge1")).ToListAsync();
var task2 = _blogDbContext.Blogs.Where(b => b.BlogName.Contains("hoge2")).ToListAsync();
// ★ここでtask1とtask2のクエリが並列で実行されることを期待している
var tasks = await Task.WhenAll(task1, task2);
return tasks.SelectMany(_ => _).OrderBy(_ => _.BlogId).ToList();
}
}
このコードを実行すると★の部分で実行時エラーが発生します。
System.InvalidOperationException: A second operation was started on this context instance before a previous operation completed. This is usually caused by different threads concurrently using the same instance of DbContext. For more information on how to avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913.
at Microsoft.EntityFrameworkCore.Infrastructure.Internal.ConcurrencyDetector.EnterCriticalSection()
at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.AsyncEnumerator.MoveNextAsync()
at Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToListAsync[TSource](IQueryable`1 source, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToListAsync[TSource](IQueryable`1 source, CancellationToken cancellationToken)
at WebParallelQuerySample.Controllers.ValuesController.GetBlogs() in C:\Users\杉山洋一\source\repos\WebParallelQuerySample\WebParallelQuerySample\Controllers\ValuesController.cs:line 22
at lambda_method5(Closure , Object )
// 以下略
これは、EntityFramework が1つのDataContextで並列の操作をサポートしていないことが原因で、次のドキュメントで言及されています。
Entity Framework Core では、同じ DbContext インスタンス上での複数の並列操作の実行がサポートされていません。 これには、非同期クエリの並列実行と、複数のスレッドからの明示的な同時使用の両方が含まれます。 そのため、常に非同期呼び出しを直ちに await するか、並列実行される操作に対して個別の DbContext インスタンスを使用します。
これを回避する方法としては次の2つがあります。
- DataContextのスコープを Transient として登録する
- 並列実行する処理では、DataContextを分ける
ただ、DataContext のスコープを Transient にすると既存のコードに影響が出そうなのとパフォーマンスの面で心配なので、基本的には並列処理を行う場合は DataContext を分けるという回避方法になると思います。
IDbContextFactory と AddDbContextFactory
EntityFramework Core 5.0 では、DataContext のスコープを調整しやすくするために IDbContextFactory インターフェイスが追加されています。
利用する場合は、プログラムの初期化時に AddDbContext
ではなく AddDbContextFactory
メソッドでデータベースの初期化を行い、
builder.Services.AddDbContextFactory<BlogDbContext>(options =>
{
options.UseMySql("server=localhost;user=root;password=mysql;database=mydb",
new MySqlServerVersion("8.0"));
});
//builder.Services.AddDbContext<BlogDbContext>(options =>
//{
// options.UseMySql("server=localhost;user=root;password=mysql;database=mydb",
// new MySqlServerVersion("8.0"));
//});
IDbContextFactory 経由で並列に実行したい操作分 DataContext を作ることで対応します。
ここで作った DataContext は自動的には破棄されないので、using で Dispose することを忘れないようにしましょう。
[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
private readonly IDbContextFactory<BlogDbContext> _blogContextFactory;
public ValuesController(IDbContextFactory<BlogDbContext> blogContextFactory)
{
_blogContextFactory = blogContextFactory;
}
[HttpGet]
public async Task<IEnumerable<Blog>> GetBlogs()
{
await using var dbContext1 = await _blogContextFactory.CreateDbContextAsync();
var task1 = dbContext1.Blogs.Where(b => b.BlogName.Contains("hoge1")).ToListAsync();
await using var dbContext2 = await _blogContextFactory.CreateDbContextAsync();
var task2 = dbContext2.Blogs.Where(b => b.BlogName.Contains("hoge2")).ToListAsync();
var tasks = await Task.WhenAll(task1, task2);
return tasks.SelectMany(_ => _).OrderBy(_ => _.BlogId).ToList();
}
}
更新に使う場合は注意が必要
Entity Framework 6 には MSDTC のサポートがあったので、複数のコネクションは分散トランザクションとして処理され、どちらかで失敗した場合は1つのトランザクションとしてロールバックすることが可能でした。
Entity Framework Core 6 ではまだ DTC のサポートがないため、複数のコネクションで正しくトランザクションをロールバックするにはサーガパターンでの巻き戻しを検討する必要があります(Entity Framework 7 では Windows 限定ですが DTC のサポートが計画されているようです)。
本当に並列クエリが必要?
バッチ処理などであれば、扱うデータ量であったり、クエリの複雑さから並列でクエリを実行する必要があるのはわかります。
ただ、Webアプリの場合はどうでしょう?もちろんパフォーマンスの向上のためにごく一部の機能では、Webアプリであっても並列でクエリを必要とするケースはあると思います。しかし、コネクションを自前で管理する必要があったり、トランザクションが複雑になったりすることを考えると並列クエリを検討するのは一番最後にどうしようもなくなってからでも良い気がします。
おわりに
ということで、IDbContextFactoryの使い方でした。