2015年4月14日

Entity Framework でアトミックインクリメント & 一括更新

Entity Framework (EF) 標準の更新処理では、アトミックインクリメント(デクリメントも)や一括更新(BULK UPDATE/DELETE)ができない。
一括処理については、条件に当てはまるものを select 後に update ないし delete は可能だが、件数が多い場合はかなり効率の悪い処理になる。(select → update/delete × 件数)
そこだけ文字列 SQL 使えば解決・・・なんだけど、せっかく EF 使ってるので文字列 SQL は避けたい、という人は多いと思う。

そういう要望に応えるライブラリがいくつかある。(非 MS 製) ※すべて NuGet からインストール可能

ここでは、一番よく使われているであろう EntityFramework.Extended を触ってみる。
※ EntityFramework.Utilities は EF 6.0 以降がメインだし、ZZZ は有料なので

[環境]
  • Visual Studio 2010 SP1
  • .NET 4.5.2 (TargetFramework は 4.0)
  • Entity Framework 5.0
  • EntityFramework.Extended 5.0.0.73

EntityFramework.Extended サンプルコード

[コード]

// テーブル定義
public partial class CountTable
{
    public string Name { get; set; }
    public int Value { get; set; }
    public System.DateTime Updated { get; set; }
}

static class EExfTest
{
    // アトミックインクリメント
    public static void UpdateIncrement()
    {
        using( var context = new TestEntities() ){
            context.CountTables.Update(
                item => item.Name == "aaa",
                item => new CountTable {
                    Value = item.Value + 1,
                    Updated = EntityFunctions.AddDays(item.Updated, 1).Value
                }
            );
        }
        // Update は IQueryable に対する拡張メソッド
        // 第 1 引数は where 条件、第 2 引数は set の中身 (オブジェクト初期化子で指定する)
        // 両引数は式木として解釈される
        // where 条件を指定しないオーバーロードもあり
        //
        // 数値のインクリメントだけでなく、EntityFunctions を使って日付のインクリメントも可能
    }

    // 一括変更
    public static void BulkUpdate()
    {
        using( var context = new TestEntities() ){
            context.CountTables.Update(
                item => new CountTable {
                    Value = 0
                }
            );
        }
    }

    // 一括削除
    public static void BulkDelete()
    {
        using( var context = new TestEntities() ){
            context.CountTables
                .Where(item => item.Value < 1)
                .Delete();
        }
        // Delete は IQueryable に対する拡張メソッド
        // 第 1 引数(where 条件)を指定するオーバーロードもあり
    }
}
[実行される SQL]

-- UpdateIncrement
UPDATE [dbo].[CountTable] SET 
[Value] = [Value] + 1 , 
[Updated] = DATEADD (day, 1, [Updated])  
FROM [dbo].[CountTable] AS j0 INNER JOIN (
SELECT 
1 AS [C1], 
[Extent1].[Name] AS [Name]
FROM [dbo].[CountTable] AS [Extent1]
WHERE 'aaa' = [Extent1].[Name]
) AS j1 ON (j0.[Name] = j1.[Name])

-- BulkUpdate
-- ※実際は sp_executesql で実行
UPDATE [dbo].[CountTable] SET 
[Value] = 0
FROM [dbo].[CountTable] AS j0 INNER JOIN (
SELECT 
1 AS [C1], 
[Extent1].[Name] AS [Name]
FROM [dbo].[CountTable] AS [Extent1]
) AS j1 ON (j0.[Name] = j1.[Name]

-- BulkDelete
DELETE [dbo].[CountTable]
FROM [dbo].[CountTable] AS j0 INNER JOIN (
SELECT 
[Extent1].[Value] AS [Value], 
[Extent1].[Name] AS [Name]
FROM [dbo].[CountTable] AS [Extent1]
WHERE [Extent1].[Value] < 1
) AS j1 ON (j0.[Name] = j1.[Name])

EntityFramework.Extended のトランザクション

EF.Extended の Update と Delete は、ExecuteSqlCommand と同じく即時 SQL が実行される。
つまり、DbContext.SaveChanges と無関係のトランザクションになる。

EF 標準の更新処理と EF.Extended による更新処理を混ぜる場合は、自分でトランザクションを管理する必要がある。

[トランザクション使用例]

using( var context = new TestEntities() )
using( var tx = context.BeginTransaction() ){
    // 更新処理

    context.SaveChanges();
    tx.Commit();
}

// BeginTransaction も EF.Extended にある拡張メソッド
// EF 6.0 からは context.Database.BeginTransaction() が可能になったので、Obsolete に

EntityFramework.Extended の対応 DB

現在(2015/04/13)の EF.Extended のソースを見る限り、SQL Server にしか対応してない。
※ /Source/EntityFramework.Extended/Batch 配下に SqlServerBatchRunner.cs しかないため

ただし、EF の接続オブジェクト ObjectContext.Connection を利用するため、他の DB も一応は使用可能。
(その DB が扱えない SQL が構築されると、DB 側でエラーになる。)

2015年4月4日

Entity Framework のパフォーマンス #2 更新処理

Entity Framework (EF) の更新処理 (insert, update, delete) は、AutoDetectChangesEnabled に false を設定するだけでパフォーマンスを向上できることがある。

[AutoDetectChangesEnabled 設定方法]

using( var context = new SampleEntities() ){
    context.Configuration.AutoDetectChangesEnabled = false;

    // 更新処理
}

パフォーマンスが向上するケース

[環境]
  • Windows 7 64bit
  • Visual Studio 2010 SP1
  • .NET 4.5.2 (TargetFramework は 4.0)
  • Entity Framework 5.0 + Database First + DbContext

[測定コード]
※テーブル定義、経過時間測定メソッドはこの記事と同じ

// 測定処理
static class EfTest
{
    // KeyTable1 の追加処理(insert)の時間
    public static void Key1Insert(bool autoDetect, int count)
    {
        using( var context = new TestEntities() ){
            context.Configuration.AutoDetectChangesEnabled = autoDetect;

            // 既存データを削除
            context.Database.ExecuteSqlCommand("delete from KeyTable1");
            // 追加データを作成
            var date = DateTime.Now;
            var data =
                Enumerable.Range(0, count)
                .Select(i => new KeyTable1 {
                    Key = i.ToString(),
                    Name = "No." + i,
                    Register = date
                }).ToArray();

            Measurement.ConsoleElapsedTime(() => {
                foreach( var d in data ) context.KeyTable1.Add(d);
                context.SaveChanges();
            });
        }
    }

    // KeyTable1 の削除処理(delete)の時間
    public static void Key1Delete(bool autoDetect, int count)
    {
        using( var context = new TestEntities() ){
            context.Configuration.AutoDetectChangesEnabled = autoDetect;

            // 既存データを削除
            context.Database.ExecuteSqlCommand("delete from KeyTable1");
            // 削除データを追加
            for( var i = 0; i < count; i++ ){
                context.Database.ExecuteSqlCommand(
                    "insert into KeyTable1 values({0}, '_', getdate())", i);
            }
            // 削除データを取得
            var data = context.KeyTable1.ToArray();

            Measurement.ConsoleElapsedTime(() => {
                foreach( var d in data ) context.KeyTable1.Remove(d);
                context.SaveChanges();
            });
        }
    }
}

ケース1. 複数件追加


bool autoDetect; // true or false
DbContextLayer.Key1Insert(autoDetect, 1); // ウォームアップ用
DbContextLayer.Key1Insert(autoDetect, 100);
DbContextLayer.Key1Insert(autoDetect, 1000);
AutoDetectChangesEnabled = true
[Key1Insert] 00:00:00.1478355
[Key1Insert] 00:00:00.1596723
[Key1Insert] 00:00:01.9620471
AutoDetectChangesEnabled = false
[Key1Insert] 00:00:00.1454600
[Key1Insert] 00:00:00.1597164
[Key1Insert] 00:00:01.5293208
1 回目はウォームアップ用なので無視。
100 件では同じくらいだが、1000 件になると差が出る。
また、「AutoDetectChangesEnabled = true」の場合、件数の増加と実行時間が非線形。

ケース2. 複数件削除


bool autoDetect; // true or false
DbContextLayer.Key1Delete(autoDetect, 1); // ウォームアップ用
DbContextLayer.Key1Delete(autoDetect, 100);
DbContextLayer.Key1Delete(autoDetect, 1000);
AutoDetectChangesEnabled = true
[Key1Delete] 00:00:00.0689035
[Key1Delete] 00:00:00.1432008
[Key1Delete] 00:00:02.3928906
AutoDetectChangesEnabled = false
[Key1Delete] 00:00:00.0662479
[Key1Delete] 00:00:00.1323060
[Key1Delete] 00:00:01.3451472
ケース1. と似たような結果。
違いは、「AutoDetectChangesEnabled = true」の場合の、ケース2. の方が、件数増加による実行時間の増加が大きいこと。

パフォーマンスが向上する理由


くわしくは上記参照だけど、長いのでまとめる。
DbSet<TEntity>.AddDbSet<TEntity>.Remove は、DetectChanges() というコストの高いメソッドが呼ばれているため。
「AutoDetectChangesEnabled = false」とすると、これを呼ばないようにできる。
ケース1. とケース2. でパフォーマンスが向上しているのは、DetectChanges() の呼び出し回数が減っているため。

DetectChanges() の処理を簡単に言うと、DB から取得したデータと、現在のデータを比べて、変更があればデータのステータス EntityState を更新している。
しかし、DbSet<TEntity>.Add や DbSet<TEntity>.Remove は、対象データのステータスを EntityState.Added や EntityState.Deleted に更新するので、DetectChanges() の実行は不要。
(不要なのに呼ばれる実装になっている理由は不明・・・)

AutoDetectChangesEnabled = false の注意点

上記の通り、追加処理 (insert) と削除処理 (delete) は問題ないのだが、変更処理 (update) の場合、通常処理のままだと問題が出てくる。

[更新できない変更処理]

using( var context = new SampleEntities() ){
    context.Configuration.AutoDetectChangesEnabled = false;

    var data = context.KeyTable1.First(); // KeyTable1 は 1 件以上
    data.Name = "First";
    context.SaveChanges();
}
このコードでは DB に対する更新処理は実行されない。
理由は、context.SaveChanges() で DetectChanges() が呼ばれないようになっているため、EF がデータの更新を検知できず、update 文が実行されないため。

[更新できない変更処理の修正例]

using( var context = new SampleEntities() ){
    context.Configuration.AutoDetectChangesEnabled = false;

    var data = context.KeyTable1.First(); // KeyTable1 は 1 件以上
    data.Name = "First";

    // DetectChanges の手動呼び出し
    context.ChangeTracker.DetectChanges();

    // または、ステータスの手動変更
    context.Entry(data).State = EntityState.Modified;

    // いずれかだけでよい

    context.SaveChanges();
}

ただ、SaveChanges を 1 回しか使わない場合(=DetectChanges の呼び出し回数が変わらない)は「AutoDetectChangesEnabled = false」としても効果はほぼないので、デフォルト(true)のままがベストだと思われる。

まとめ

結局、AutoDetectChangesEnabled の設定をどうすべきかは、ケースバイケースになる。

基本的な方針としては、1 つのトランザクションで複数件の追加・削除がある場合のみ、false を設定。
そのトランザクションに変更が含まれる場合、 SaveChanges 前に DetectChanges を呼び出すか、 EntityState を変更。

常に「AutoDetectChangesEnabled = false」でもいいかもしれないが、上のURL にある注意点に、複雑なことをやると SaveChanges 前に DetectChanges を呼び出すだけでは対応できないケースがあるとか・・・
EF の更新処理は「AutoDetectChangesEnabled = true」がデフォルトとして実装されているので、基本的に変更しない方がいいらしい。

また、EF6.0 から複数件の追加・削除を考慮した DbSet<TEntity>.AddRangeDbSet<TEntity>.RemoveRange が導入されているので、常に「AutoDetectChangesEnabled = true」のままで十分かも。

2015年4月3日

Entity Framework のパフォーマンス #1 LINQ to Entities クエリ

Entity Framework (EF) の LINQ to Entities クエリの 1 回目(コールドクエリ)は、かなり遅い。
1 回目なので DB アクセスも当然遅いのだが、それだけでなく、プログラム側でコストの高い初期化処理を行っているため、遅くなっている。

どんな処理を行っているかは下記参照。

パフォーマンス

いくつかの簡単な LINQ to Entities クエリの SQL 構築時間と実行時間を測定してみる。
[環境]
  • Windows 7 64bit
  • Visual Studio 2010 SP1
  • .NET 4.5.2 (TargetFramework は 4.0)
  • Entity Framework 5.0 + Database First + DbContext
    ※ EF5.0 の理由は、VS2010 で DbContext Generator が提供されている最終バージョンのため
    ※ ObjectContext はほぼ使わないと思うのでナシ
    (DbContext は ObjectContext のラッパーなので性能は ObjectContext の方が少し上のはず)

[測定コード]

// テーブル定義
public class KeyTable1
{
    public string Key { get; set; }
    public string Name { get; set; }
    public DateTime Register { get; set; }
}
public class KeyTable2
{
    public string Key { get; set; }
    public string Name { get; set; }
    public DateTime Register { get; set; }
}
public class KeyTable3
{
    public string Key { get; set; }
    public string Name { get; set; }
    public DateTime Register { get; set; }
}

// 測定処理
static class EfTest
{
    // KeyTable1 の select の SQL 構築時間
    public static void Key1Select()
    {
        using( var context = new TestEntities() ){
            var query = from item in context.KeyTable1 select item;
            Measurement.ConsoleElapsedTime(() => query.ToString());
/* 構築される SQL :
SELECT
[Extent1].[Key] AS [Key],
[Extent1].[Name] AS [Name],
[Extent1].[Register] AS [Register]
FROM [dbo].[KeyTable1] AS [Extent1] */
        }
    }

    // KeyTable2 の select の SQL 構築時間
    public static void Key2Select()
    {
        using( var context = new TestEntities() ){
            var query =from item in context.KeyTable2 select item;
            Measurement.ConsoleElapsedTime(() => query.ToString());
        }
    }

    // KeyTable3 の select の SQL 構築時間
    public static void Key3Select()
    {
        using( var context = new TestEntities() ){
            var query = from item in context.KeyTable3 select item;
            Measurement.ConsoleElapsedTime(() => query.ToString());
        }
    }

    // KeyTable1 の select-where の SQL 構築時間
    public static void Key1SelectWhere()
    {
        using( var context = new TestEntities() ){
            var query =
                from item in context.KeyTable1
                where item.Key == "1"
                select item;
            Measurement.ConsoleElapsedTime(() => query.ToString());
        }
    }

    // KeyTable1 の select-where-in (自動コンパイル対象外) の SQL 構築時間
    public static void Key1SelectWhereIn()
    {
        var keys = new[] { "1" };
        using( var context = new TestEntities() ){
            var query =
                from item in context.KeyTable1
                where keys.Contains(item.Key)
                select item;
            Measurement.ConsoleElapsedTime(() => query.ToString());
        }
    }

    //  KeyTable1 の select の SQL 実行時間
    public static void ExecuteKey1Select()
    {
        using( var context = new TestEntities() ){
            var query = from item in context.KeyTable1 select item;
            Measurement.ConsoleElapsedTime(() => query.AsEnumerable().GetEnumerator());
        }
    }
}

// 経過時間測定ユーティリティ
static class Measurement
{
    public static void ConsoleElapsedTime(Action act)
    {
        var caller = new StackFrame(1);
        var watch = Stopwatch.StartNew();
        act();
        watch.Stop();
        Console.WriteLine("[{0}] {1}", caller.GetMethod().Name, watch.Elapsed);
    }
}

実験1. 同じテーブルに対する select

Key1Select × 5
[Key1Select] 00:00:00.1411941
[Key1Select] 00:00:00.0002129
[Key1Select] 00:00:00.0001244
[Key1Select] 00:00:00.0001124
[Key1Select] 00:00:00.0001150
1 回目はとにかく遅い。
2 回目は色々なキャッシュが効いて速くなる。(この結果だと 663 倍)
3 回目も少し速くなり、以降横ばい。

実験2. 複数のテーブルそれぞれに対する select

Key1Select × 3 → Key2Select × 3 → Key3Select × 3
[Key1Select] 00:00:00.1421443
[Key1Select] 00:00:00.0002142
[Key1Select] 00:00:00.0001231
[Key2Select] 00:00:00.0072696
[Key2Select] 00:00:00.0001616
[Key2Select] 00:00:00.0001167
[Key3Select] 00:00:00.0070160
[Key3Select] 00:00:00.0001599
[Key3Select] 00:00:00.0001321
1 回目ほどではないが、各テーブルの 1 回目もそれなりに遅い。
2 回目以降はキャッシュが効いている。

実験3. select 後の select-where

Key1Select → Key1SelectWhere × 3
[Key1Select] 00:00:00.1420541
[Key1SelectWhere] 00:00:00.0013068
[Key1SelectWhere] 00:00:00.0001667
[Key1SelectWhere] 00:00:00.0001364
Key2Select → Key1SelectWhere × 3
[Key2Select] 00:00:00.1429743
[Key1SelectWhere] 00:00:00.0074316
[Key1SelectWhere] 00:00:00.0002014
[Key1SelectWhere] 00:00:00.0001372
QueryKey1Where の 1 回目について、前者(先に同じテーブルの SQL を構築していた場合)の方が速い。
つまり、異なるクエリでも、式木が部分的に一致していればキャッシュが効くようになっている。

実験4. 自動コンパイルされないクエリ

Key1Select → Key1SelectWhereIn × 3
[Key1Select] 00:00:00.1426134
[Key1SelectWhereIn] 00:00:00.0021291
[Key1SelectWhereIn] 00:00:00.0010327
[Key1SelectWhereIn] 00:00:00.0010109
2 回目以降は早くなっているが、自動コンパイルクエリの対象外であるため、実験3. の 2 回目より遅い。

※自動コンパイルクエリについて
EF 5.0 + .NET 4.5 (TargetFramework は 4.0 でも可) の環境から、LINQ to Entities クエリは自動でコンパイルされてキャッシュされるようになっている。(以前は CompiledQuery を手動で実装する必要があった)
しかし、IN 句を構築するための Enumerable.Contains を式木に含めると自動コンパイルの対象外となる。
理由は、LINQ to Entities クエリから SQL が確定できないため。(下記参照)

実験5. SQL 構築後の SQL 実行

ExecuteKey1Select × 3
[ExecuteKey1Select] 00:00:00.1694211
[ExecuteKey1Select] 00:00:00.0020680
[ExecuteKey1Select] 00:00:00.0016365
Key1Select → ExecuteKey1Select × 3
[Key1Select] 00:00:00.1425704
[ExecuteKey1Select] 00:00:00.0296910
[ExecuteKey1Select] 00:00:00.0019183
[ExecuteKey1Select] 00:00:00.0016942
SQL 構築を事前に行っておくと、その分、SQL 実行も速くなっている。
また、1 回目のSQL 構築時間は 1回目の SQL 実行時間と比べてもかなり遅い。
(SQL 実行時間は環境依存なのであまり比較に意味はないかもしれないが・・・)

まとめ

上記から明らかなように、LINQ to Entities クエリの 1 回目は遅い。

対策としては、動作時間の長いプログラム(Web アプリやサービス)だと、起動時に全テーブルの SQL 構築処理を実行して事前キャッシュしておく、などが可能だが、1 回の実行で終わるようなプログラムの場合はどうしようもない。
そういうものに EF は使うべきではない、ということなんだろう・・・
今のところ、SQL 構築のオーバーヘッドは大きすぎると思われるので、パフォーマンスが求められる場合も EF は向いていない。

EF 5.0 以降を使用している場合、.NET 4.5 をインストールしておくことは割と重要。
複雑なクエリほど SQL 構築に時間がかかるため、自動コンパイルクエリが有効だと、2 回目以降のパフォーマンスが向上する。
CompiledQuery でクエリのキャッシュを手動管理することも可能だが、DbContext では使えないことに注意。(CompiledQuery は ObjectContext のみ対応)

クエリの初期化処理に「ビュー生成」があり、これは事前に作成しておくこと可能だが、上記の測定ではあまり効果が無かったため、省略している。
Entity Framework Power Tools を使えば、プリコンパイルビューが簡単に生成可能。