Содержание

Что нового в EF Core 9 и 10

EF Core 9 вышел с .NET 9 (ноябрь 2024), EF Core 10 — с .NET 10 (ноябрь 2025).

EF Core 9:

  • Auto-compiled models (больше не нужно запускать dotnet ef dbcontext optimize вручную)
  • HierarchyId для SQL Server
  • Улучшения LINQ-трансляции (меньше случаев client-side evaluation)
  • ExecuteUpdateAsync / ExecuteDeleteAsync стабильны

EF Core 10:

  • Нативная поддержка LINQ GroupJoin
  • Улучшенный ExecuteUpdate с поддержкой сложных выражений
  • AOT-совместимость без generated models
  • Улучшения JSON columns (вложенные объекты, коллекции)

Главные проблемы производительности

1. Проблема N+1

Классическая ошибка — загрузить список сущностей, потом в цикле загружать связанные данные:

// ПЛОХО — N+1 запросов
var orders = await context.Orders.ToListAsync(); // 1 запрос
foreach (var order in orders)
{
    // каждый раз новый запрос к БД
    Console.WriteLine(order.Customer.Name); // ленивая загрузка
}

// ХОРОШО — 1 запрос с JOIN
var orders = await context.Orders
    .Include(o => o.Customer)
    .ToListAsync();

// ЕЩЁ ЛУЧШЕ — только нужные поля
var orders = await context.Orders
    .Select(o => new { o.Id, o.Amount, CustomerName = o.Customer.Name })
    .ToListAsync();

Чтобы поймать N+1 — логируй запросы:

// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .LogTo(Console.WriteLine, LogLevel.Information)
           .EnableSensitiveDataLogging()); // только для разработки!

2. Загрузка лишних данных

// ПЛОХО — тянет все колонки, все связанные таблицы
var products = await context.Products
    .Include(p => p.Category)
    .Include(p => p.Images)
    .Include(p => p.Reviews)
    .ToListAsync();

// ХОРОШО — только что нужно для конкретного сценария
var products = await context.Products
    .Where(p => p.IsActive)
    .Select(p => new ProductListDto
    {
        Id = p.Id,
        Name = p.Name,
        Price = p.Price,
        CategoryName = p.Category.Name
    })
    .ToListAsync();

AsNoTracking — когда обязателен

EF Core по умолчанию отслеживает (tracks) загруженные сущности. Это нужно для SaveChanges(), но при read-only запросах только тратит память.

// Для read-only запросов — всегда AsNoTracking
var products = await context.Products
    .AsNoTracking()
    .Where(p => p.CategoryId == categoryId)
    .ToListAsync();

// Глобально для read-only репозитория
public class ProductReadRepository(AppDbContext context)
{
    private IQueryable<Product> Query =>
        context.Products.AsNoTracking();

    public async Task<ProductDto?> GetByIdAsync(int id) =>
        await Query
            .Where(p => p.Id == id)
            .Select(p => new ProductDto { ... })
            .FirstOrDefaultAsync();
}

// AsNoTrackingWithIdentityResolution — если нужны связанные
// объекты без дублирования в памяти
var orders = await context.Orders
    .AsNoTrackingWithIdentityResolution()
    .Include(o => o.Items)
    .ThenInclude(i => i.Product)
    .ToListAsync();

Правило: read-only запросы — всегда AsNoTracking(). Write запросы — оставляй трекинг.

Compiled Queries

Каждый LINQ-запрос EF Core компилирует при первом выполнении. Для hot paths это лишняя работа. Compiled query компилируется один раз:

// Объявляем как static readonly — один раз на весь lifetime приложения
public static class Queries
{
    public static readonly Func<AppDbContext, int, Task<Product?>>
        GetProductById = EF.CompileAsyncQuery(
            (AppDbContext ctx, int id) =>
                ctx.Products
                   .AsNoTracking()
                   .FirstOrDefault(p => p.Id == id));

    public static readonly Func<AppDbContext, int, IAsyncEnumerable<OrderListDto>>
        GetUserOrders = EF.CompileAsyncQuery(
            (AppDbContext ctx, int userId) =>
                ctx.Orders
                   .AsNoTracking()
                   .Where(o => o.UserId == userId)
                   .OrderByDescending(o => o.CreatedAt)
                   .Select(o => new OrderListDto
                   {
                       Id = o.Id,
                       Amount = o.Amount,
                       CreatedAt = o.CreatedAt
                   }));
}

// Использование
var product = await Queries.GetProductById(context, productId);

await foreach (var order in Queries.GetUserOrders(context, userId))
{
    // обрабатываем
}

Когда применять: часто вызываемые запросы (список товаров, пользователь по id, корзина). Не нужно для разовых операций.

Auto-Compiled Models (EF Core 9+)

В больших моделях (50+ сущностей) старт EF Core медленный — модель строится из reflection при первом запросе.

EF Core 9 умеет генерировать compiled model автоматически при сборке:

<!-- .csproj -->
<PropertyGroup>
    <EFCoreAutoCompiledModels>true</EFCoreAutoCompiledModels>
</PropertyGroup>

Или вручную через CLI:

dotnet ef dbcontext optimize --output-dir CompiledModels --namespace MyApp.CompiledModels
// Program.cs — указываем использовать compiled model
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .UseModel(MyAppCompiledModel.Instance)); // сгенерированный класс

Ускоряет первый запуск на 30–70% для больших моделей.

JSON Columns (EF Core 7+, улучшения в 9/10)

Хранить документообразные данные в реляционной БД без отдельной таблицы:

// Модели
public class Order
{
    public int Id { get; set; }
    public decimal Amount { get; set; }
    public ShippingInfo Shipping { get; set; } = null!; // JSON колонка
    public List<OrderItem> Items { get; set; } = []; // JSON колонка
}

public class ShippingInfo
{
    public string Address { get; set; } = string.Empty;
    public string City { get; set; } = string.Empty;
    public string PostalCode { get; set; } = string.Empty;
}

// Конфигурация в OnModelCreating
modelBuilder.Entity<Order>()
    .OwnsOne(o => o.Shipping, builder => builder.ToJson())
    .OwnsMany(o => o.Items, builder => builder.ToJson());
// Запросы по полям JSON работают через LINQ — EF генерирует SQL JSON_VALUE
var moscowOrders = await context.Orders
    .AsNoTracking()
    .Where(o => o.Shipping.City == "Moscow")
    .ToListAsync();
// SQL: WHERE JSON_VALUE(Shipping, '$.City') = 'Moscow'

Когда использовать JSON columns: адреса доставки, настройки пользователя, метаданные — данные которые не нужно джойнить по отдельным полям. Не используй для данных которые нужно фильтровать/джойнить часто.

Bulk операции без загрузки в память

EF Core 7+ поддерживает прямые UPDATE/DELETE без загрузки сущностей:

// ПЛОХО — загружает все сущности в память
var expiredTokens = await context.RefreshTokens
    .Where(t => t.ExpiresAt < DateTime.UtcNow)
    .ToListAsync();
context.RefreshTokens.RemoveRange(expiredTokens);
await context.SaveChangesAsync();

// ХОРОШО — один DELETE запрос без загрузки
await context.RefreshTokens
    .Where(t => t.ExpiresAt < DateTime.UtcNow)
    .ExecuteDeleteAsync();

// Массовый UPDATE без загрузки
await context.Products
    .Where(p => p.CategoryId == oldCategoryId)
    .ExecuteUpdateAsync(s =>
        s.SetProperty(p => p.CategoryId, newCategoryId)
         .SetProperty(p => p.UpdatedAt, DateTime.UtcNow));

Генерирует один SQL-запрос. Идеально для фоновых задач очистки, массовых обновлений.

Пагинация: cursor vs OFFSET

На стороне базы для этого критичны правильные индексы — разбор с EXPLAIN есть в статье про оптимизацию MySQL.

// ПЛОХО — OFFSET пагинация, медленно на больших таблицах
public async Task<List<Product>> GetPageAsync(int page, int pageSize)
{
    return await context.Products
        .OrderBy(p => p.Id)
        .Skip((page - 1) * pageSize) // OFFSET становится медленным при больших значениях
        .Take(pageSize)
        .AsNoTracking()
        .ToListAsync();
}

// ХОРОШО — cursor пагинация, всегда быстро
public async Task<List<Product>> GetNextPageAsync(int? afterId, int pageSize)
{
    var query = context.Products.AsNoTracking().OrderBy(p => p.Id);

    if (afterId.HasValue)
        query = (IOrderedQueryable<Product>)query.Where(p => p.Id > afterId.Value);

    return await query.Take(pageSize).ToListAsync();
}

Cursor пагинация всегда O(log n) через индекс. OFFSET пагинация становится O(n) — на миллионной записи скан всех предыдущих строк.

Полезный паттерн: Specification

Вместо захламления репозиториев методами — спецификации:

// Спецификация
public class ActiveProductsSpec : Specification<Product>
{
    public ActiveProductsSpec(int? categoryId = null)
    {
        AddCriteria(p => p.IsActive);

        if (categoryId.HasValue)
            AddCriteria(p => p.CategoryId == categoryId);

        AddInclude(p => p.Category);
        ApplyOrderBy(p => p.Name);
        ApplyAsNoTracking();
    }
}

// Репозиторий стал тонким
public async Task<List<T>> ListAsync(Specification<T> spec) =>
    await ApplySpec(context.Set<T>(), spec).ToListAsync();

// Использование
var products = await repo.ListAsync(new ActiveProductsSpec(categoryId: 5));

💡 Самый быстрый запрос — тот который не попадает в БД. Внедри кеширование (IMemoryCache / Redis) поверх репозиториев для часто читаемых и редко меняемых данных. Найти N+1 и недостающие AsNoTracking по всему проекту помогает Claude Code со скиллом /efcore-optimize. Фичи EF Core 9/10 доступны после миграции на .NET 10.

Поделиться: