Дополнительные разделы о производительности

Создание пулов DbContext

Как DbContext правило, легкий объект: создание и удаление не включает операцию базы данных, и большинство приложений могут сделать это без каких-либо заметных последствий для производительности. Однако каждый экземпляр контекста настраивает различные внутренние службы и объекты, необходимые для выполнения своих обязанностей, и затраты на непрерывное выполнение этого могут быть значительными в сценариях высокой производительности. В таких случаях EF Core может пулировать экземпляры контекста: при удалении контекста EF Core сбрасывает состояние и сохраняет его в внутреннем пуле. При следующем запросе новый экземпляр возвращается вместо настройки нового экземпляра. Пул контекстов позволяет платить затраты на настройку контекста только один раз при запуске программы, а не непрерывно.

Обратите внимание, что пул контекстов является ортогональным для пула подключений к базе данных, который управляется на более низком уровне в драйвере базы данных.

Типичный шаблон в приложении ASP.NET Core с помощью EF Core включает регистрацию пользовательского DbContextтипа в контейнер внедрения зависимостей через AddDbContext. Затем экземпляры этого типа получаются с помощью параметров конструктора в контроллерах или Razor Pages.

Чтобы включить пул контекстов, просто замените следующим AddDbContextPoolобразомAddDbContext:

builder.Services.AddDbContextPool<WeatherForecastContext>(
    o => o.UseSqlServer(builder.Configuration.GetConnectionString("WeatherForecastContext")));

Параметр poolSizeAddDbContextPool задает максимальное количество экземпляров, сохраненных пулом (по умолчанию — 1024). После poolSize превышения новые экземпляры контекста не кэшируются, а EF возвращается к поведению, отличному от пула при создании экземпляров по запросу.

Тесты производительности

Ниже приведены результаты теста для получения одной строки из базы данных SQL Server, работающей локально на одном компьютере, с пулом контекстов и без нее. Как всегда, результаты будут меняться с количеством строк, задержкой на сервере базы данных и другими факторами. Важно отметить, что это тесты производительности пула с одним потоком, в то время как реальный сценарий может иметь разные результаты; тестируйте на платформе перед принятием каких-либо решений. Исходный код доступен здесь, вы можете использовать его в качестве основы для собственных измерений.

Метод NumBlogs Среднее значение Ошибка StdDev 0-го поколения Поколение 1 Поколение 2 Распределено
БезContextPooling 1 701.6 мы 26.62 нас 78.48 мы 11.7188 - - 50.38 КБ
WithContextPooling 1 350.1 мы 6.80 нас 14.64 мы 0.9766 - - 4.63 КБ

Управление состоянием в пулах контекстов

Пул контекстов работает путем повторного использование одного и того же экземпляра контекста в запросах; это означает, что он фактически зарегистрирован в качестве singleton, и один и тот же экземпляр повторно используется в нескольких запросах (или di область). Это означает, что при использовании контекста необходимо учитывать любое состояние, которое может меняться между запросами. Крайне важно, что контекст вызывается только один раз , когда создается контекст OnConfiguring экземпляра, и поэтому нельзя использовать для задания состояния, которое необходимо изменить (например, идентификатор клиента).

Типичный сценарий с состоянием контекста будет мультитенантным приложением ASP.NET Core, где экземпляр контекста имеет идентификатор клиента, который учитывается запросами (дополнительные сведения см. в разделе "Глобальные фильтры запросов"). Так как идентификатор клиента должен измениться с каждым веб-запросом, необходимо выполнить некоторые дополнительные действия, чтобы сделать его все работать с пулом контекстов.

Предположим, что приложение регистрирует службу область dITenant, которая упаковывает идентификатор клиента и любую другую информацию, связанную с клиентом:

// Below is a minimal tenant resolution strategy, which registers a scoped ITenant service in DI.
// In this sample, we simply accept the tenant ID as a request query, which means that a client can impersonate any
// tenant. In a real application, the tenant ID would be set based on secure authentication data.
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenant>(sp =>
{
    var tenantIdString = sp.GetRequiredService<IHttpContextAccessor>().HttpContext.Request.Query["TenantId"];

    return tenantIdString != StringValues.Empty && int.TryParse(tenantIdString, out var tenantId)
        ? new Tenant(tenantId)
        : null;
});

Как описано выше, обратите особое внимание на то, откуда вы получаете идентификатор клиента. Это важный аспект безопасности вашего приложения.

После того как у нас есть служба область, ITenant зарегистрируйте фабрику контекстов пула в качестве службы Singleton, как обычно:

builder.Services.AddPooledDbContextFactory<WeatherForecastContext>(
    o => o.UseSqlServer(builder.Configuration.GetConnectionString("WeatherForecastContext")));

Затем напишите настраиваемую фабрику контекста, которая получает контекст с пулом из зарегистрированной фабрики Singleton и внедряет идентификатор клиента в экземпляры контекста, которые он передает:

public class WeatherForecastScopedFactory : IDbContextFactory<WeatherForecastContext>
{
    private const int DefaultTenantId = -1;

    private readonly IDbContextFactory<WeatherForecastContext> _pooledFactory;
    private readonly int _tenantId;

    public WeatherForecastScopedFactory(
        IDbContextFactory<WeatherForecastContext> pooledFactory,
        ITenant tenant)
    {
        _pooledFactory = pooledFactory;
        _tenantId = tenant?.TenantId ?? DefaultTenantId;
    }

    public WeatherForecastContext CreateDbContext()
    {
        var context = _pooledFactory.CreateDbContext();
        context.TenantId = _tenantId;
        return context;
    }
}

После создания настраиваемой фабрики контекстов зарегистрируйте его в качестве службы с областью действия:

builder.Services.AddScoped<WeatherForecastScopedFactory>();

Наконец, упорядочение контекста для внедрения из нашей фабрики scoped:

builder.Services.AddScoped(
    sp => sp.GetRequiredService<WeatherForecastScopedFactory>().CreateDbContext());

В этом случае контроллеры автоматически внедряются с помощью экземпляра контекста, имеющего правильный идентификатор клиента, не имея ничего об этом.

Полный исходный код для этого примера доступен здесь.

Примечание.

Хотя EF Core заботится о сбросе внутреннего состояния для DbContext и связанных служб, обычно он не сбрасывает состояние в базовом драйвере базы данных, который находится за пределами EF. Например, если вы вручную открываете и используете или управляете состоянием DbConnection ADO.NET, необходимо восстановить это состояние перед возвращением экземпляра контекста в пул, например путем закрытия соединения. Сбой этого может привести к утечке состояния между несвязанными запросами.

Скомпилированные запросы

Когда EF получает дерево запросов LINQ для выполнения, он должен сначала скомпилировать это дерево, например создать SQL из него. Так как эта задача является тяжелым процессом, EF кэширует запросы по фигуре дерева запросов, поэтому запросы с той же структурой повторно используют внутренние кэшированные выходные данные компиляции. Это кэширование гарантирует, что выполнение одного запроса LINQ несколько раз очень быстро, даже если значения параметров отличаются.

Однако EF по-прежнему должен выполнять определенные задачи, прежде чем он сможет использовать внутренний кэш запросов. Например, дерево выражений запроса должно быть рекурсивно по сравнению с деревьями выражений кэшированных запросов, чтобы найти правильный кэшированный запрос. Затраты на эту начальную обработку незначительны в большинстве приложений EF, особенно при сравнении с другими затратами, связанными с выполнением запросов (сетевые операции ввода-вывода, фактические операции обработки запросов и операций ввода-вывода на диске в базе данных...). Однако в некоторых сценариях высокой производительности ее может потребоваться устранить.

EF поддерживает скомпилированные запросы, которые позволяют явно компилировать запрос LINQ в делегат .NET. После получения этого делегата его можно вызвать непосредственно для выполнения запроса, не предоставляя дерево выражений LINQ. Этот метод проходит поиск кэша и предоставляет наиболее оптимизированный способ выполнения запроса в EF Core. Ниже приведены некоторые результаты тестирования, сравнивающие скомпилированные и нескомпилированные производительность запросов; тестируйте на платформе перед принятием каких-либо решений. Исходный код доступен здесь, вы можете использовать его в качестве основы для собственных измерений.

Метод NumBlogs Среднее значение Ошибка StdDev 0-го поколения Распределено
WithCompiledQuery 1 564.2 нас 6.75 нас 5.99 мы 1.9531 9 КБ
БезCompiledQuery 1 671.6 мы 12.72 нас 16.54 мы 2.9297 13 КБ
WithCompiledQuery 10 645.3 мы 10.00 нас 9.35 нас 2.9297 13 КБ
БезCompiledQuery 10 709.8 нас 25.20 нас 73.10 мы 3.9063 18 КБ

Чтобы использовать скомпилированные запросы, сначала скомпилируйте запрос EF.CompileAsyncQuery следующим образом (используйте EF.CompileQuery для синхронных запросов):

private static readonly Func<BloggingContext, int, IAsyncEnumerable<Blog>> _compiledQuery
    = EF.CompileAsyncQuery(
        (BloggingContext context, int length) => context.Blogs.Where(b => b.Url.StartsWith("http://") && b.Url.Length == length));

В этом примере кода мы предоставляем EF лямбда-прием DbContext экземпляра и произвольный параметр, передаваемый в запрос. Теперь этот делегат можно вызвать каждый раз, когда вы хотите выполнить запрос:

await foreach (var blog in _compiledQuery(context, 8))
{
    // Do something with the results
}

Обратите внимание, что делегат является потокобезопасной и может вызываться одновременно в разных экземплярах контекста.

Ограничения

  • Скомпилированные запросы могут использоваться только для одной модели EF Core. Различные экземпляры контекста одного типа иногда можно настроить для использования различных моделей; Выполнение скомпилированных запросов в этом сценарии не поддерживается.
  • При использовании параметров в скомпилированных запросах используйте простые скалярные параметры. Более сложные выражения параметров , такие как доступ к члену или методу в экземплярах, не поддерживаются.

Кэширование запросов и параметризация

Когда EF получает дерево запросов LINQ для выполнения, он должен сначала скомпилировать это дерево, например создать SQL из него. Так как эта задача является тяжелым процессом, EF кэширует запросы по фигуре дерева запросов, поэтому запросы с той же структурой повторно используют внутренние кэшированные выходные данные компиляции. Это кэширование гарантирует, что выполнение одного запроса LINQ несколько раз очень быстро, даже если значения параметров отличаются.

Рассмотрим следующие два запроса:

var post1 = context.Posts.FirstOrDefault(p => p.Title == "post1");
var post2 = context.Posts.FirstOrDefault(p => p.Title == "post2");

Так как деревья выражений содержат разные константы, дерево выражений отличается, и каждый из этих запросов будет скомпилирован отдельно EF Core. Кроме того, каждый запрос создает немного другую команду SQL:

SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] = N'blog1'

SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] = N'blog2'

Так как SQL отличается, сервер базы данных, скорее всего, также потребуется создать план запроса для обоих запросов, а не повторно использовать один и тот же план.

Небольшое изменение запросов может значительно изменить ситуацию:

var postTitle = "post1";
var post1 = context.Posts.FirstOrDefault(p => p.Title == postTitle);
postTitle = "post2";
var post2 = context.Posts.FirstOrDefault(p => p.Title == postTitle);

Так как имя блога теперь параметризовано, оба запроса имеют одну и ту же форму дерева, и EF необходимо скомпилировать только один раз. Созданный SQL также параметризован, что позволяет базе данных повторно использовать тот же план запроса:

SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] = @__blogName_0

Обратите внимание, что не требуется параметризировать каждый запрос: это идеально хорошо подходит для того, чтобы иметь некоторые запросы с константами, и, действительно, базы данных (и EF) иногда могут выполнять определенную оптимизацию вокруг констант, которые не могут быть возможными, когда запрос параметризован. См. раздел о динамически созданных запросах , например, где правильная параметризация имеет решающее значение.

Примечание.

Счетчики событий EF Core сообщают о частоте попаданий кэша запросов. В обычном приложении этот счетчик достигает 100 % вскоре после запуска программы, после выполнения большинства запросов по крайней мере один раз. Если этот счетчик остается стабильным ниже 100 %, это означает, что ваше приложение может делать что-то, что побеждает кэш запросов - это рекомендуется изучить это.

Примечание.

Как база данных управляет планами запросов кэша, зависит от базы данных. Например, SQL Server неявно поддерживает кэш плана запросов LRU, в то время как PostgreSQL не поддерживает (но подготовленные инструкции могут создать очень похожий конечный эффект). Дополнительные сведения см. в документации по базе данных.

Динамически созданные запросы

В некоторых ситуациях необходимо динамически создавать запросы LINQ, а не указывать их прямо в исходном коде. Это может произойти, например, на веб-сайте, который получает произвольные сведения о запросе от клиента, с открытыми операторами запросов (сортировка, фильтрация, разбиение по страницам...). В принципе, если это правильно, динамически созданные запросы могут быть столь же эффективными, как обычные (хотя невозможно использовать скомпилированную оптимизацию запросов с динамическими запросами). Однако на практике они часто являются источником проблем с производительностью, так как легко случайно создавать деревья выражений с фигурами, которые отличаются каждый раз.

В следующем примере используются три метода для создания лямбда-выражения запроса Where :

  1. API выражений с константой: динамическое построение выражения с помощью API выражений с помощью постоянного узла. Это часто возникает ошибка при динамическом создании деревьев выражений и приводит к повторной компиляции запроса при каждом вызове с другим константным значением (это также обычно вызывает загрязнение кэша планов на сервере базы данных).
  2. API выражений с параметром: улучшенная версия, которая заменяет константу параметром. Это гарантирует, что запрос компилируется только один раз, независимо от предоставленного значения, и создается тот же (параметризованный) SQL.
  3. Простой параметр: версия, которая не использует API выражений, для сравнения, которая создает то же дерево, что и метод выше, но гораздо проще. Во многих случаях можно динамически создавать дерево выражений, не прибегая к API выражений, что легко получить неправильно.

Мы добавим оператор к запросу, только если заданный Where параметр не имеет значения NULL. Обратите внимание, что это не хороший вариант использования для динамического создания запроса, но мы используем его для простоты:

[Benchmark]
public int ExpressionApiWithConstant()
{
    var url = "blog" + Interlocked.Increment(ref _blogNumber);
    using var context = new BloggingContext();

    IQueryable<Blog> query = context.Blogs;

    if (_addWhereClause)
    {
        var blogParam = Expression.Parameter(typeof(Blog), "b");
        var whereLambda = Expression.Lambda<Func<Blog, bool>>(
            Expression.Equal(
                Expression.MakeMemberAccess(
                    blogParam,
                    typeof(Blog).GetMember(nameof(Blog.Url)).Single()),
                Expression.Constant(url)),
            blogParam);

        query = query.Where(whereLambda);
    }

    return query.Count();
}

Тестирование этих двух методов дает следующие результаты:

Метод Среднее значение Ошибка StdDev 2-го поколения Поколение 1 Распределено
ExpressionApiWithConstant 1665.8 нас 56.99 мы 163.5 нас 15.6250 - 109.92 КБ
ExpressionApiWithParameter 757.1 мы 35.14 мы 103.6 нас 12.6953 0.9766 54.95 КБ
SimpleWithParameter 760.3 мы 37.99 мы 112.0 нас 12.6953 - 55.03 КБ

Даже если разница в вложенных миллисекундах кажется небольшой, помните, что постоянная версия постоянно загрязняет кэш и приводит к повторной компиляции других запросов, замедляя их, а также с общим негативным воздействием на общую производительность. Настоятельно рекомендуется избежать перекомпиляции постоянных запросов.

Примечание.

Не создавайте запросы с помощью API дерева выражений, если вам не нужно. Помимо сложности API, при их использовании очень легко непреднамеренно вызвать значительные проблемы с производительностью.

Скомпилированные модели

Скомпилированные модели могут улучшить время запуска EF Core для приложений с большими моделями. Большая модель обычно означает сотни тысяч типов сущностей и связей. Время запуска — это время выполнения первой операции при DbContext первом использовании этого DbContext типа в приложении. Обратите внимание, что только создание экземпляра DbContext не приводит к инициализации модели EF. Стандартные первые операции, которые приводят к инициализации модели, включают вызов DbContext.Add или выполнение первого запроса.

Скомпилированные модели создаются с помощью программы командной строки dotnet ef. Прежде чем продолжить, убедитесь, что у вас установлена последняя версия программы.

Для создания скомпилированной модели используется новая команда dbcontext optimize. Например:

dotnet ef dbcontext optimize

Параметры --output-dir и --namespace можно использовать для указания каталога и пространства имен, в которых будет создаваться скомпилированная модель. Например:

PS C:\dotnet\efdocs\samples\core\Miscellaneous\CompiledModels> dotnet ef dbcontext optimize --output-dir MyCompiledModels --namespace MyCompiledModels
Build started...
Build succeeded.
Successfully generated a compiled model, to use it call 'options.UseModel(MyCompiledModels.BlogsContextModel.Instance)'. Run this command again when the model is modified.
PS C:\dotnet\efdocs\samples\core\Miscellaneous\CompiledModels>
  • Дополнительные сведения см. по адресу dotnet ef dbcontext optimize.
  • Если вы более комфортно работаете в Visual Studio, вы также можете использовать Optimize-DbContext

Выходные данные выполнения этой команды содержат фрагмент кода для копирования и вставки в DbContext конфигурацию, чтобы EF Core использовала скомпилированную модель. Например:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .UseModel(MyCompiledModels.BlogsContextModel.Instance)
        .UseSqlite(@"Data Source=test.db");

Начальная загрузка скомпилированной модели

Обычно нет необходимости проверять созданный код начальной загрузки. Однако иногда может быть полезно настроить модель или ее загрузку. Код начальной загрузки выглядит примерно так:

[DbContext(typeof(BlogsContext))]
partial class BlogsContextModel : RuntimeModel
{
    private static BlogsContextModel _instance;
    public static IModel Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new BlogsContextModel();
                _instance.Initialize();
                _instance.Customize();
            }

            return _instance;
        }
    }

    partial void Initialize();

    partial void Customize();
}

Это разделяемый класс с разделяемыми методами, которые можно реализовать для настройки модели по мере необходимости.

Кроме того, можно создать несколько скомпилированных моделей для DbContext типов, которые могут использовать разные модели в зависимости от определенной конфигурации среды выполнения. Их следует поместить в разные папки и пространства имен, как показано выше. Сведения о среде выполнения, такие как строка подключения, можно проверить, а необходимая модель возвращается по мере необходимости. Например:

public static class RuntimeModelCache
{
    private static readonly ConcurrentDictionary<string, IModel> _runtimeModels
        = new();

    public static IModel GetOrCreateModel(string connectionString)
        => _runtimeModels.GetOrAdd(
            connectionString, cs =>
            {
                if (cs.Contains("X"))
                {
                    return BlogsContextModel1.Instance;
                }

                if (cs.Contains("Y"))
                {
                    return BlogsContextModel2.Instance;
                }

                throw new InvalidOperationException("No appropriate compiled model found.");
            });
}

Ограничения

У скомпилированных моделей есть некоторые ограничения:

В связи с этими ограничениями следует использовать только скомпилированные модели, если запуск EF Core выполняется слишком медленно. Компиляция небольших моделей, как правило, не стоит того.

Если поддержка какой-либо из этих функций имеет решающее значение для вашего успеха, проголосуйте за соответствующие проблемы, указанные выше.

Сокращение затрат на время выполнения

Как и в любом уровне, EF Core добавляет немного затрат на время выполнения по сравнению с программированием непосредственно на API базы данных нижнего уровня. Эта нагрузка на среду выполнения вряд ли влияет на большинство реальных приложений значительным образом; Другие разделы в этом руководстве по производительности, такие как эффективность запросов, использование индексов и минимизация циклов, являются гораздо более важными. Кроме того, даже для высокооптимизированных приложений задержка сети и операций ввода-вывода базы данных обычно доминируют в любой момент времени, потраченного внутри EF Core. Однако для высокопроизводительных приложений с низкой задержкой, где каждый бит perf важен, можно использовать следующие рекомендации, чтобы сократить затраты EF Core до минимума:

  • Включение пула DbContext; наши тесты показывают, что эта функция может иметь решающее влияние на высокопроизводительные приложения с низкой задержкой.
    • Убедитесь, что maxPoolSize соответствует вашему сценарию использования; если оно слишком низко, DbContext экземпляры будут постоянно создаваться и удаляться, ухудшая производительность. Установка слишком высокого уровня может потребоваться без необходимости использовать память, так как неиспользуемые DbContext экземпляры сохраняются в пуле.
    • Для дополнительного крошечного увеличения perf рекомендуется использовать PooledDbContextFactory вместо прямого внедрения экземпляров контекста DI. Управление пулом DbContext влечет за собой небольшую нагрузку.
  • Используйте предварительно скомпилированные запросы для горячих запросов.
    • Чем сложнее запрос LINQ - тем больше операторов, которые он содержит, и чем больше результирующее дерево выражений, тем больше результатов можно ожидать от использования скомпилированных запросов.
  • Попробуйте отключить проверка безопасности потоков, задав EnableThreadSafetyChecks значение false в конфигурации контекста.
    • Использование одного DbContext экземпляра одновременно из разных потоков не поддерживается. EF Core имеет функцию безопасности, которая обнаруживает эту ошибку программирования во многих случаях (но не все), и немедленно вызывает информативное исключение. Однако эта функция безопасности добавляет некоторые затраты на среду выполнения.
    • ПРЕДУПРЕЖДЕНИЕ. Отключайте только проверка безопасности потоков после тщательного тестирования, что приложение не содержит таких ошибок параллелизма.