vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

【Entity Framework Core】 N + 1 とかなんとか 2

はじめに

続きです。

今回は処理速度を雑に計測してみるところから。

あんまり速くない

前回の Include を使った処理INNER JOIN で取ってきた値を C# でまとめた処理、そして SELECT 文がたくさん発行される処理を計測してみることにしました。

HomeController.cs

~省略~
        [Route("/")]
        [Route("/Home")]
        [Produces("application/json")]
        public async Task> Index()
        {
            var watch = new System.Diagnostics.Stopwatch();
            watch.Start();

            // ------ ここの部分を差し替え ------
            List< Store> results = await _context.Stores
                .Include(s => s.Authors)
                .ThenInclude(a => a.Books)
                .ToListAsync();

            foreach (var result in results)
            {
                foreach (var author in result.Authors)
                {
                    author.Books = author.Books.Where(b => b.Available).ToList();
                }
            }
            // ------ ここまで ------

            watch.Stop();
            Console.WriteLine("time " + watch.ElapsedMilliseconds);

            return results;
        }
    }
}

計測方法はもう少しちゃんと考えた方が良いかもしれませんが、今回はこれで試すことにしました。

また、SELECT 文がたくさん投げられちゃうバージョンは、前回別のクラスに格納していたため他と合わせることにしました。

結果

Include

  • 3615 ms
  • 4659 ms
  • 2876 ms

N+1

  • 3156 ms
  • 2680 ms
  • 3975 ms

index

  • 3931 ms
  • 2805 ms
  • 2604 ms

… 3 回ずつしか計測していないこともあり、ばらつきがあることを考慮すると、あまりどれも変わらないような。。。

INNER JOIN を使ったコードも結局のところ、 SELECT 文を投げなくなった分 C# のコードで繰り返し処理をしているわけで、そもそも Include を使った時に速くなりうるのは DB アクセスとの速度差によるものなのでしょう。

※今回のように DB 、 アプリの両方がローカルサーバー上で動いている状態より、別々のサーバーで動いているような状態だとわかりやすいのかもしれません。

もう一回 Include を使ってみる

結局速度に大差がないのであれば、せめてシンプルに書きたいところ。

ということで、 Include をもう少し見てみることにしました。

いつ値はセットされるか

前回少しだけ触れましたが、 Include というのは DB に頻繁にアクセスされることが予想される場合に、あらかじめデータを読み込んでおき、発行される SELECT 文の数を減らすための機能と理解しています。

ということは、大量にレコードがあるテーブルを不用意に Include しちゃうとかえって遅くなったり。。。?

というところも気になるのですが、まずはどのタイミングで Store クラスの Authors や Author クラスの Store に値がセットされるのかを調べてみます。

やり方は単純で、それぞれの Setter でコンソールに出力するよう手を加えるだけです。

Store.css

~省略~
private List< Author> _authers;

[NotMapped]
public List< Author> Authors
{
    get { return _authers;}
    set
    {
        Console.WriteLine("Set Authors");
        _authers = value;
    }
}
~省略~

Author.cs

~省略~
private Store _store;
        
[NotMapped]
[IgnoreDataMember]
public Store Store {
    get { return _store;}
    set
    {
        Console.WriteLine("Set Store");
        _store = value;
    }
    
}
~省略~

先ほどの Include と、ついでに Include せず個別に実行するとどうなるのか見てみることにしました。

HomeController.cs

~省略~
Console.WriteLine("Prepare");

List< Store> stores = await _context.Stores.ToList();
List< Author> authors = await _context.Authors.ToList();
List< Book> books = await _context.Books.ToList();

Console.WriteLine("Start Including");

List< Store> results = _context.Stores
        .Include(s => s.Authors)
        .ThenInclude(a => a.Books)
        .ToList();

Console.WriteLine("end");
~省略~

順番がわかりすいよう同期的に実行しています。

出力された内容はこんな感じ。

Prepare

[Store に対する SELECT]
[Author に対する SELECT]

Set Store
Set Authors
Set Store
Set Store
Set Store
Set Authors
Set Store
Set Store

[Book に対する SELECT]

Start Including

[Include の SQL]

Set Store
Set Store
Set Store
Set Authors
Set Store
Set Store
Set Store

end

う~ん、よくわからない順番。。。

Store.Authors が Include では一回呼ばれていないのも気になりますね。

まぁそれはそれとして、 ToList で実体化するときに値がセットされることはわかりました。

また、最初に全レコードを読み込んだ後も、 _context.Stores などでアクセスするたびに値がセットされているのもわかります。

…これあんまり呼ばない方が良いのでは。。。?

コンストラクターで全レコード読み込んでみる(失敗)

_context.Stores などによる DB アクセスはあまり頻繁に行わない方が良さそう、ということであれば、最初に全部読み込んでしまえば...?

と思ったのでやってみました。

HomeController.cs

~省略~
private readonly EfCoreNpgsqlSampleContext _context;

private List< Store> _stores;

public HomeController(EfCoreNpgsqlSampleContext context)
{
    _context = context;
    
    _stores = _context.Stores
        .Include(s => s.Authors)
        .ThenInclude(a => a.Books)
        .ToList();
}
[Route("/")]
[Route("/Home")]
[Produces("application/json")]
public async Task> Index()
{ 
    List< Store> results = _stores
        /* Where とか Select とか */
        .ToList();

~省略~

ぱっと見は良さげな感じです。

見た感じは。

問題点 1

大きく 2 つ問題があるのですが、まず 1 つ目。

_stores に対して Where でフィルタリングを掛けてしまうと、 Include していたはずの Authors が空になってしまいます。

おそらく値がセットされるタイミングのためと考えられますが、手動で Author のリストをセットしても正しくセットされませんでした。

そのため、今回のような使い方をしようとすると、常に同じ条件で(つまり全レコード)返す必要がでてきます。

問題点 2

値が追加されるなど、 DB が更新されても反映されません。

まぁそれはそうですよね。というところでもありますが。

更新時にも必ず _stores の内容を変更した上で DB を変更する、ということにすればある程度までは対応できるかもしれませんが、直接、または別アプリで DB を更新された場合はどうすることもできません。

ということで、前もって読み込んでおく方法はとらない方が良さそうです。

怒りの手動 Include

最初に前もって読み込んでおく方法は使えず、 Include した値をフィルタリングするためにはいったん全部読み込む必要があって。。。

だんだん悲しくなってきた気がしますが気のせいですよね。

さて、 Include の役割というのは、最初に必要なデータを一括で読み込んでしまい、個別に SELECT 文を発行しなくて良いようにする、ということ。

であれば、Include を使わず直接値を入れてしまっても良さそうです。

EfCoreNpgsqlSampleContext.cs

~省略~
    public class EfCoreNpgsqlSampleContext: DbContext
    {
        public EfCoreNpgsqlSampleContext(DbContextOptions options)
            : base(options)
        {
            
        }
        public DbSet< Store> Stores { get; set; }
        public DbSet< Author> Authors { get; set; }
        public DbSet< Book> Books { get; set; }
    }
}
  • HasMany などを取り除いて、デフォルトとほぼ同じ状態に戻しました。

HomeController.cs

~省略~
List< Store> stores = await _context.Stores.ToListAsync();

List< Author> authors = await (from author in _context.Authors
        join store in stores on author.StoreId equals store.Id
        select author)
    .ToListAsync();

List< Book> books = await (from book in _context.Books
        join author in authors on book.AuthorId equals author.Id
        where book.Available
        select book)
    .ToListAsync();

foreach (Author author in authors)
{
    author.Books = books.Where(b => b.AuthorId == author.Id).ToList();
}

foreach (Store store in stores)
{
    store.Authors = authors.Where(a => a.StoreId == store.Id).ToList();
}
~省略~
  • 今回の条件では Stores や Authors はフィルタリングしていないため Join は不要ですね。

これでフィルタリングした値を持たせることができる!…は良いのですが、やっぱり目から汗が。

おわりに

結論としては、

  • 階層構造を持ったデータを取得する際、特に検索結果が多い場合、大量の SELECT 文が発行されてしまい、パフォーマンス問題の原因となる場合がある
  • Include などを使ってあらかじめ DB から値を取得しておくことでそれを抑えることができる
  • とはいえ最初に読み込んでそのまま保持、とするのは難しそう

といったところでしょうか。

次回に続く。。。かも。

参照