EntityFramework CoreのアップグレードでINクエリがタイムアウトした話

情報システム部では社内システムを .NET + EntityFramework Core + SQLServer という組み合わせで開発しています。

本記事では、社内システム開発中に遭遇した EntityFramework Core が原因のパフォーマンス悪化と、その改善方法を紹介します。


はじめに

冒頭に記載したとおり、情報システム部では .NET 技術を使用して社内システムの開発を行っています。

かなり前に .NET Core 2.x から 3.1 にアップグレードした際に、 EntityFramework Core (以下 EFCore) が原因となってアップグレードに想定以上の時間を要してしまった、という記事を執筆しました。

blog.jbs.co.jp

本記事はこちらの記事の類似内容となり、社内システムを .NET 6.0 から .NET 8.0 に更新した際に、 EFCore 8 での破壊的変更が要因となってパフォーマンス悪化が発生したこと、およびその解決方法の紹介となります。

環境情報

要素 バージョン
.NET 8.0 (SDK 8.0.404)
EFCore 6.0.35 / 8.0.10

何が起こったのか

.NET バージョンを .NET 6.0 から .NET 8.0 へとアップグレードするにあたり、本記事が対象としているシステムでは、クエリのパフォーマンス悪化は2件発生しました。

アプリの主要な画面におけるパフォーマンス悪化

1件目は、アプリの主要な画面においてパフォーマンス悪化が発生したものとなります。この原因は View の中で ROW_NUMBER 関数を使用して全体の並び順を生成していたことでした。

View の中で ROW_NUMBER 関数を使用すると View に対する WHERE 条件が適用される前の全データを対象に連番を付与してしまいます。対象データがメモリに乗り切らずストレージソートとなり大幅なパフォーマンス悪化が発生するという、そもそも実装上の考慮が不十分だった内容でした。

しかしながら、問題の View 自体はこのアップグレード作業では変更しておらず、なぜこのタイミングでパフォーマンス悪化してしまったのか、その原因を突き止めることはできませんでした。

1件目の原因である並び順の生成はコード上でも実行できるような内容でしたので、 View の中から ROW_NUMBER を削除し、コード上でソートして連番を付与することで解消しました。

レスポンスを返すまでにアップグレード前で3~5秒程度、アップグレード後で10秒前後かかっていましたが、修正対応後は1秒未満~数秒と、アップグレード前と比較してもかなりパフォーマンス改善を得られる結果となりました。

特定の検索条件における検索処理のタイムアウトエラー

2件目は、特定の検索条件において検索処理がタイムアウトエラーするというもので、当初は1件目の結果発生していた問題だと考えられていました。

ところが、1件目の対応後も解消せず、別問題であると発覚しました。こちらが本記事の主題です。

本件の問題が発生したクエリの C# コードは以下のような形になっています。今回発生したのが View を対象にしたクエリだったため、本サンプルでも対象を View としています。

カラム物理名 カラムの型
Id UNIQUEIDENTIFIER
Title NVARCHAR(100)
StatusCode VARCHAR(3)
// EFCoreマッピング用のエンティティクラス
class SampleEntity
{
    public Guid Id { get; set; }
    public string Title { get; set; } = default!;
    public string StatusCode { get; set; } = default!;
}

// EFCoreのDbContextクラス
class DatabaseContext : DbContext
{
    public virtual DbSet<SampleEntity> SampleEntities { get; set; } = default!;

    public DatabaseContext(DbContextOptions options)
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<SampleEntity>(entity =>
        {
            entity.ToView("Sample");
            entity.HasNoKey();
        });
    }
}
// 実行するクエリ

// DbContext取得
DatabseContext dbContext = ...;

// 検索条件の設定
var searchCondition = new
{
    Title = "example",
    StatusCodes = new string[] { "A", "B", "C" },
};

// 検索処理
var result = await (
    from e in dbContext.SampleEntities
    where e.Title.Contains(searchCondition.Title) &&
          searchCondition.StatusCodes.Contains(e.StatusCode)
    select e
).ToListAsync();

このクエリを EFCore で翻訳すると以下の SQL が生成されます。なお改行の一部は筆者が入れています。

---------- EFCore 6 ----------

-- [Parameters=[
--   @__searchCondition_Title_0='example' (Size = 4000)],
-- CommandType='Text',
-- CommandTimeout='30']
SELECT [s].[Id], [s].[StatusCode], [s].[Title]
FROM [Sample] AS [s]
WHERE (
      (@__searchCondition_Title_0 LIKE N'')
   OR (CHARINDEX(@__searchCondition_Title_0, [s].[Title]) > 0)
  )
  AND [s].[StatusCode] IN (N'A', N'B', N'C')


---------- EFCore 8 ----------

-- [Parameters=[
--   @__searchCondition_Title_0_contains='%example%' (Size = 4000),
--   @__searchCondition_StatusCodes_1='["A","B","C"]' (Size = 4000)],
-- CommandType='Text',
-- CommandTimeout='30']
SELECT [s].[Id], [s].[StatusCode], [s].[Title]
FROM [Sample] AS [s]
WHERE [s].[Title] LIKE @__searchCondition_Title_0_contains ESCAPE N'\'
  AND [s].[StatusCode] IN (
    SELECT [s0].[value]
    FROM OPENJSON(@__searchCondition_StatusCodes_1) WITH ([value] nvarchar(max) '$') AS [s0]
  )

設定している検索条件の2つ共に対して大きな変化が入っています。

1つ目の検索条件である Title は、 EFCore 6 では CHARINDEX を使って文字列が含まれているかを検索していたのに対して、 EFCore 8 では LIKE 検索に変更されています。

これは、ドキュメントのどこを探しても記載されていませんでしたが、 GitHub に Issue がありました。 EFCore 8 の改善項目の1つだったようです。

github.com

2つ目の検索条件である StatusCode は、 EFCore 6 では IN クエリでパラメーターを埋め込みで作成していたのに対して、 EFCore 8 では OPENJSON を使用してパラメーターは埋め込みでは無くなっています。

こちらは、 EFCore 8 の破壊的変更の1つとして記載されている内容です。

learn.microsoft.com

この2つの変更のうちいずれかが影響を及ぼしていることは想像できます。1つ目の変更は LIKE 検索への変更で、一見、パフォーマンスに影響を及ぼすとは考えづらいため、2つ目の変更が原因の可能性が高いことまでは当たりが付けられます。

結論を書くと、タイムアウトの原因は OPENJSON して得られた項目を nvarchar(max) として扱っていたことでした。

本来この項目は varchar(3) ですので nvarchar(max) として扱うのは過剰です。 EFCore が生成したクエリの nvarchar(max) となっているところを varchar(3) に変更したクエリを手動で実行すると、タイムアウトが発生しないことも確認されました。

それでは何故この項目は nvarchar(max) として扱われてしまったのでしょうか。

その理由は View をマッピングする目的で手動作成したクラスだったため EFCore へのモデル登録時、プロパティに対してカラム長等のメタデータ付与をしていなかったことが原因でした。

EFCore へのモデル登録時にメタデータ登録をすることで、タイムアウトは解消されました。

class DatabaseContext : DbContext
{
    public virtual DbSet<SampleEntity> SampleEntities { get; set; } = default!;

    public DatabaseContext(DbContextOptions options)
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<SampleEntity>(entity =>
        {
            entity.ToView("Sample");
            entity.HasNoKey();

            // メタデータの登録(Fluent API)
            entity.Property(x => x.StatusCode).HasMaxLength(3).IsUnicode(false);
       });
    }
}

おわりに

今回は、社内システムの .NET アップグレード時に発生した EFCore 由来の障害とその対応について紹介しました。

クエリのタイムアウトはユニットテストでは検出されず、またデータ量の問題で開発環境では再現出来ない等、対応が難しいことがあります。本記事が皆様の参考になれば幸いです。

執筆担当者プロフィール
中谷 大造

中谷 大造(日本ビジネスシステムズ株式会社)

情報システム部の中谷です。社内用スクラッチアプリの開発をしています。

担当記事一覧