【C#】EF Coreで読み取り専用データの取得を高速化する方法

目次

概要

Entity Framework Core (EF Core) でデータを表示するだけの(更新を行わない)場合、AsNoTracking メソッドを使用することでパフォーマンスを劇的に向上させることができます。 これにより、DbContextが行う「変更追跡(Change Tracking)」のスナップショット作成処理がスキップされ、メモリ消費量の削減とクエリ実行速度の向上が見込めます。

仕様(入出力)

  • 入力: 取得したい記事の最大件数。
  • 出力: 取得した記事データのタイトルと著者名をコンソール表示。
  • 前提: .NET 6.0以上、Microsoft.EntityFrameworkCore.InMemory(動作確認用)。

基本の使い方

クエリのメソッドチェーンの中に .AsNoTracking() を追加するだけです。通常は ToListAsync などを呼ぶ直前に記述します。

// 変更追跡を行わない(高速・省メモリ)
var products = await context.Products
    .AsNoTracking() // ★ここに追加
    .Where(p => p.Price > 1000)
    .ToListAsync();

コード全文

ここでは「新着記事リストを表示する(編集機能はない)」というシナリオでの実装例です。 大量のデータを取得するバッチ処理や、Web APIのGETリクエスト処理などで特に有効です。

外部ライブラリとして Microsoft.EntityFrameworkCore.InMemory が必要です。

dotnet add package Microsoft.EntityFrameworkCore.InMemory
using System;
using System.Threading.Tasks;
using System.Linq;
using Microsoft.EntityFrameworkCore;

public class Program
{
    public static async Task Main()
    {
        var options = new DbContextOptionsBuilder<BlogContext>()
            .UseInMemoryDatabase("BlogDb_NoTracking")
            .Options;

        // --- 1. 初期データの準備 ---
        using (var context = new BlogContext(options))
        {
            if (!await context.Posts.AnyAsync())
            {
                var author = new User { Name = "TechUser" };
                context.Users.Add(author);

                // 大量のデータを想定
                for (int i = 1; i <= 1000; i++)
                {
                    context.Posts.Add(new Post
                    {
                        Title = $"C# Tips Vol.{i}",
                        Content = "Content...",
                        SentTime = DateTime.Now.AddHours(-i),
                        User = author
                    });
                }
                await context.SaveChangesAsync();
            }
        }

        // --- 2. 読み取り専用クエリの実行 ---
        using (var context = new BlogContext(options))
        {
            var worker = new BlogReader(context);
            await worker.ShowRecentPostsAsync(10);
        }
    }
}

// 業務ロジッククラス
public class BlogReader
{
    private readonly BlogContext _context;

    public BlogReader(BlogContext context)
    {
        _context = context;
    }

    public async Task ShowRecentPostsAsync(int count)
    {
        Console.WriteLine($"--- 最新 {count} 件の記事を取得します ---");

        // AsNoTrackingを使用
        // これにより、EF Coreは取得したエンティティのコピーを追跡マネージャーに保存しません
        var posts = await _context.Posts
            .Include(p => p.User)        // 関連データも含める
            .OrderByDescending(p => p.SentTime)
            .Take(count)
            .AsNoTracking()              // ★パフォーマンス向上の鍵
            .ToArrayAsync();

        foreach (var post in posts)
        {
            // Nullチェック (Includeしていてもデータ不整合でnullの可能性があるため安全策)
            var authorName = post.User?.Name ?? "Unknown";
            Console.WriteLine($"[{post.SentTime:MM/dd HH:mm}] {authorName}: {post.Title}");
        }
        
        // 注意: ここで post.Title = "変更"; としても、SaveChangesAsyncでDBは更新されません
    }
}

// エンティティ定義
public class User
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
}

public class Post
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public DateTime SentTime { get; set; }
    
    public int UserId { get; set; }
    public User? User { get; set; }
}

// DbContext定義
public class BlogContext : DbContext
{
    public BlogContext(DbContextOptions<BlogContext> options)
        : base(options) { }

    public DbSet<Post> Posts => Set<Post>();
    public DbSet<User> Users => Set<User>();
}

カスタムポイント

  • デフォルト設定の変更: 個別のクエリではなく、そのDbContext全体で追跡を無効にしたい場合は、DbContext の設定で QueryTrackingBehavior.NoTracking を指定できます。C#optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
  • ID解決付きNoTracking: 結合によって同じエンティティ(例:同じ User)が何度も結果に含まれる場合、通常の AsNoTracking だと別々のインスタンスとして生成されメモリを浪費します。EF Core 5.0以降では AsNoTrackingWithIdentityResolution() を使うことで、同一キーのインスタンスを共有化しメモリ効率を高められます。

注意点

  1. 更新不可: AsNoTracking で取得したエンティティのプロパティを変更して SaveChangesAsync を呼んでも、データベースには反映されません。更新が必要な場合は使用しないでください。
  2. 遅延読み込み(Lazy Loading)無効: 追跡されていないエンティティは、ナビゲーションプロパティの遅延読み込み機能が働きません。必要なデータは必ず Include で明示的にロードする必要があります。
  3. Findメソッド: DbSet.Find(id) は追跡されているデータをキャッシュから返す機能がありますが、AsNoTracking は常にデータベースへのクエリを発行します。用途に応じて使い分けてください。

応用

Selectによる射影(さらなる高速化)

エンティティ全体を取得する必要がない場合、Select を使って必要な項目だけの匿名クラス(またはDTO)を取得するのが最速です。これは自動的に NoTracking として扱われます。

// エンティティ化せず、必要なカラムだけ取得(最軽量)
var dtos = await _context.Posts
    .OrderByDescending(p => p.SentTime)
    .Take(10)
    .Select(p => new 
    { 
        p.Title, 
        AuthorName = p.User.Name // Include不要で結合データにアクセス可
    })
    .ToListAsync();

まとめ

さらに最適化したい場合は、Select を使って取得する列を絞り込んでください。

データの表示、レポート出力、Web APIの参照系エンドポイントなど、保存を伴わない処理では常に AsNoTracking を付ける癖をつけてください。

メモリ使用量とCPU負荷の両方を削減でき、高負荷時のスケーラビリティが向上します。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

私が勉強したこと、実践したこと、してることを書いているブログです。
主に資産運用について書いていたのですが、
最近はプログラミングに興味があるので、今はそればっかりです。

目次