Оптимизация запросов Entity Framework Core в C#

Введение

Entity Framework Core, несмотря на свою мощь и удобство, часто становится источником проблем с производительностью в C# приложениях. Неоптимальные запросы могут приводить к N+1 проблемам, избыточным загрузкам данных и чрезмерному потреблению памяти. Однако с пониманием внутренних механизмов EF Core и правильными техниками оптимизации можно достичь производительности, сравнимой с сырыми SQL-запросами, сохранив при этом все преимущества ORM. Современные версии EF Core предоставляют множество инструментов для диагностики и оптимизации, которые позволяют превратить его из источника проблем в эффективный инструмент работы с данными.


Диагностика проблем производительности

1.1. Логирование и профилирование запросов

1.1.1. Настройка детального логирования

// Program.cs или Startup.cs
services.AddDbContext<ApplicationDbContext>(options =>
{
    options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"));
    
    // Включение детального логирования (только для разработки!)
    options.EnableSensitiveDataLogging();
    options.EnableDetailedErrors();
    
    // Кастомный логгер с фильтрацией
    options.LogTo(Console.WriteLine, 
        new[] { 
            DbLoggerCategory.Database.Command.Name,
            DbLoggerCategory.Query.Name,
            DbLoggerCategory.Performance.Name
        },
        LogLevel.Information,
        DbContextLoggerOptions.DefaultWithLocalTime);
    
    // Логирование медленных запросов
    options.LogTo(message =>
    {
        if (message.Contains("Execution Time") && 
            TryParseExecutionTime(message, out var time) && 
            time > TimeSpan.FromSeconds(1))
        {
            _logger.LogWarning($"Медленный запрос: {message}");
        }
    });
});

// Метод для парсинга времени выполнения
private static bool TryParseExecutionTime(string logMessage, out TimeSpan time)
{
    time = TimeSpan.Zero;
    var match = Regex.Match(logMessage, @"Execution Time: (\d+) ms");
    if (match.Success && int.TryParse(match.Groups[1].Value, out var ms))
    {
        time = TimeSpan.FromMilliseconds(ms);
        return true;
    }
    return false;
}

1.1.2. Использование Application Insights и диагностических инструментов

// Интеграция с Application Insights
services.AddApplicationInsightsTelemetry();

// Кастомный телеметрический процессор для EF Core
public class EfCoreTelemetryProcessor : ITelemetryProcessor
{
    private readonly ITelemetryProcessor _next;
    
    public EfCoreTelemetryProcessor(ITelemetryProcessor next)
    {
        _next = next;
    }
    
    public void Process(ITelemetry telemetry)
    {
        if (telemetry is DependencyTelemetry dependency &&
            dependency.Type == "SQL" &&
            dependency.Duration > TimeSpan.FromSeconds(2))
        {
            // Добавление дополнительной информации для медленных запросов
            dependency.Properties["SlowQuery"] = "true";
            dependency.Properties["ApplicationArea"] = "Database";
        }
        
        _next.Process(telemetry);
    }
}

// Регистрация в DI
services.AddSingleton<ITelemetryProcessor, EfCoreTelemetryProcessor>();

// Использование SQL Server Profiler расширений
public class QueryProfilerMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ConcurrentBag<QueryProfile> _profiles = new();
    
    public QueryProfilerMiddleware(RequestDelegate next)
    {
        _next = next;
    }
    
    public async Task InvokeAsync(HttpContext context, ApplicationDbContext dbContext)
    {
        var stopwatch = Stopwatch.StartNew();
        var initialQueryCount = dbContext.ChangeTracker.DebugView.ShortView
            .Count(c => c.Contains("SELECT"));
        
        await _next(context);
        
        var finalQueryCount = dbContext.ChangeTracker.DebugView.ShortView
            .Count(c => c.Contains("SELECT"));
        
        var queriesExecuted = finalQueryCount - initialQueryCount;
        
        if (queriesExecuted > 10) // Порог для N+1 проблем
        {
            _logger.LogWarning(
                $"Потенциальная N+1 проблема: {queriesExecuted} запросов " +
                $"за {stopwatch.ElapsedMilliseconds}ms в {context.Request.Path}");
        }
    }
}

// Атрибут для профилирования конкретных методов
[AttributeUsage(AttributeTargets.Method)]
public class ProfileQueriesAttribute : ActionFilterAttribute
{
    public override async Task OnActionExecutionAsync(
        ActionExecutingContext context, 
        ActionExecutionDelegate next)
    {
        var dbContext = context.HttpContext
            .RequestServices.GetService<ApplicationDbContext>();
        
        var oldQueryCount = dbContext.GetCurrentQueryCount();
        
        var result = await next();
        
        var newQueryCount = dbContext.GetCurrentQueryCount();
        var queryCount = newQueryCount - oldQueryCount;
        
        if (queryCount > 5)
        {
            context.HttpContext.Items["QueryCount"] = queryCount;
        }
    }
}

Оптимизация запросов LINQ

2.1. Избегание N+1 проблемы

2.1.1. Неправильный подход (N+1 проблема)

// ПЛОХО: N+1 проблема
public async Task<List<OrderDto>> GetOrdersSlowAsync(int customerId)
{
    var orders = await _context.Orders
        .Where(o => o.CustomerId == customerId)
        .ToListAsync(); // 1 запрос для получения заказов
    
    var result = new List<OrderDto>();
    
    foreach (var order in orders)
    {
        // Для каждого заказа отдельный запрос к OrderItems
        // N запросов - это и есть N+1 проблема!
        var items = await _context.OrderItems
            .Where(oi => oi.OrderId == order.Id)
            .ToListAsync();
        
        result.Add(new OrderDto
        {
            OrderId = order.Id,
            Items = items.Select(i => new OrderItemDto
            {
                ProductName = i.Product.Name, // Еще один запрос!
                Quantity = i.Quantity
            }).ToList()
        });
    }
    
    return result;
}
// Общее количество запросов: 1 + N + N (где N - количество заказов)

2.1.2. Правильные подходы

// ХОРОШО: Использование Include и ThenInclude
public async Task<List<OrderDto>> GetOrdersOptimizedAsync(int customerId)
{
    var orders = await _context.Orders
        .Where(o => o.CustomerId == customerId)
        .Include(o => o.OrderItems) // Жадная загрузка
            .ThenInclude(oi => oi.Product) // Загрузка связанных данных
        .Include(o => o.Customer) // Загрузка покупателя
        .AsNoTracking() // Отключение отслеживания для read-only
        .ToListAsync(); // Всего 1 запрос!
    
    return orders.Select(o => new OrderDto
    {
        OrderId = o.Id,
        CustomerName = o.Customer.Name,
        Items = o.OrderItems.Select(i => new OrderItemDto
        {
            ProductName = i.Product.Name, // Уже загружено
            Quantity = i.Quantity
        }).ToList()
    }).ToList();
}

// ЕЩЕ ЛУЧШЕ: Проекция (Select) вместо Include
public async Task<List<OrderDto>> GetOrdersProjectionAsync(int customerId)
{
    return await _context.Orders
        .Where(o => o.CustomerId == customerId)
        .Select(o => new OrderDto
        {
            OrderId = o.Id,
            CustomerName = o.Customer.Name,
            Items = o.OrderItems.Select(oi => new OrderItemDto
            {
                ProductName = oi.Product.Name,
                Quantity = oi.Quantity,
                Price = oi.Price
            }).ToList(),
            Total = o.OrderItems.Sum(oi => oi.Quantity * oi.Price)
        })
        .AsNoTracking()
        .ToListAsync(); // Всего 1 оптимизированный запрос
    
    // Преимущества проекции:
    // 1. Только необходимые поля
    // 2. Нет лишних JOIN для отслеживания изменений
    // 3. Возможность агрегации на стороне БД
}

2.2. Разделение запросов (Split Queries)

2.2.1. Проблема Cartesian Explosion

// Проблема: Cartesian Explosion при множественных Include
var orders = await _context.Orders
    .Include(o => o.OrderItems) // Допустим, 10 товаров в заказе
    .Include(o => o.ShippingAddress) // 1 адрес
    .Include(o => o.Payments) // 3 платежа
    .Take(100)
    .ToListAsync();

// Проблема: SQL запрос создает декартово произведение
// 100 заказов * 10 товаров * 1 адрес * 3 платежа = 3000 строк
// Большинство данных дублируются!

2.2.2. Решение: Split Queries

// Настройка Split Queries глобально
services.AddDbContext<ApplicationDbContext>(options =>
{
    options.UseSqlServer(connectionString, sqlOptions =>
    {
        sqlOptions.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
    });
});

// Или для конкретного запроса
public async Task<List<Order>> GetOrdersWithSplitQueryAsync()
{
    return await _context.Orders
        .Include(o => o.OrderItems)
        .Include(o => o.ShippingAddress)
        .Include(o => o.Payments)
        .AsSplitQuery() // Явное указание разделения запросов
        .Take(100)
        .ToListAsync();
    
    // Теперь выполняется 4 запроса вместо 1:
    // 1. Основные данные заказов
    // 2. Товары заказов
    // 3. Адреса доставки
    // 4. Платежи
    // Общее количество строк: 100 + 1000 + 100 + 300 = 1500 (в 2 раза меньше!)
}

// Настройка максимального количества запросов при разделении
public async Task<List<Order>> GetOrdersWithControlledSplitAsync()
{
    return await _context.Orders
        .Include(o => o.OrderItems)
        .Include(o => o.ShippingAddress)
        .Include(o => o.Payments)
        .AsSplitQuery()
        .WithBufferSize(50) // Ограничение буферизации
        .Take(100)
        .ToListAsync();
}

Индексы и их использование

3.1. Создание оптимальных индексов

3.1.1. Конфигурация индексов через Fluent API

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>(entity =>
    {
        // Простой индекс
        entity.HasIndex(e => e.CustomerId);
        
        // Составной индекс
        entity.HasIndex(e => new { e.CustomerId, e.OrderDate })
              .IsDescending(false, true); // OrderDate по убыванию
        
        // Уникальный индекс
        entity.HasIndex(e => e.OrderNumber)
              .IsUnique();
        
        // Индекс с включенными колонками (INCLUDE)
        entity.HasIndex(e => e.OrderDate)
              .IncludeProperties(e => e.TotalAmount, e => e.Status)
              .HasFilter("[Status] = 'Completed'"); // Фильтрованный индекс
        
        // Полнотекстовый индекс (требует дополнительной настройки)
        entity.HasIndex(e => e.Notes)
              .HasMethod("FULLTEXT"); // Для SQL Server
    });
    
    modelBuilder.Entity<Product>(entity =>
    {
        // Индекс для часто используемых запросов
        entity.HasIndex(e => new { e.CategoryId, e.Price, e.StockQuantity })
              .HasName("IX_Products_Category_Price_Stock")
              .IsDescending(false, true, false);
        
        // Индекс для поиска по префиксу
        entity.HasIndex(e => e.Name)
              .HasOperators(new[] { "varchar_pattern_ops" }); // Для PostgreSQL
    });
}

3.2. Мониторинг использования индексов

public class IndexUsageMonitor
{
    private readonly ApplicationDbContext _context;
    
    public async Task<List<IndexUsageInfo>> GetIndexUsageAsync()
    {
        var connection = _context.Database.GetDbConnection();
        await connection.OpenAsync();
        
        using var command = connection.CreateCommand();
        command.CommandText = @"
            SELECT 
                OBJECT_NAME(s.object_id) AS TableName,
                i.name AS IndexName,
                i.type_desc AS IndexType,
                s.user_seeks,
                s.user_scans,
                s.user_lookups,
                s.user_updates,
                s.last_user_seek,
                s.last_user_scan,
                s.last_user_lookup
            FROM sys.dm_db_index_usage_stats s
            INNER JOIN sys.indexes i ON s.object_id = i.object_id 
                AND s.index_id = i.index_id
            WHERE OBJECT_NAME(s.object_id) IN ('Orders', 'Products', 'Customers')
            ORDER BY (s.user_seeks + s.user_scans + s.user_lookups) DESC";
        
        using var reader = await command.ExecuteReaderAsync();
        var results = new List<IndexUsageInfo>();
        
        while (await reader.ReadAsync())
        {
            results.Add(new IndexUsageInfo
            {
                TableName = reader.GetString(0),
                IndexName = reader.GetString(1),
                Seeks = reader.GetInt64(3),
                Scans = reader.GetInt64(4),
                Lookups = reader.GetInt64(5),
                Updates = reader.GetInt64(6)
            });
        }
        
        await connection.CloseAsync();
        return results;
    }
    
    public async Task<List<string>> GetMissingIndexesAsync()
    {
        var connection = _context.Database.GetDbConnection();
        await connection.OpenAsync();
        
        using var command = connection.CreateCommand();
        command.CommandText = @"
            SELECT 
                statement AS TableName,
                equality_columns,
                inequality_columns,
                included_columns,
                avg_user_impact
            FROM sys.dm_db_missing_index_details mid
            INNER JOIN sys.dm_db_missing_index_groups mig ON mid.index_handle = mig.index_handle
            INNER JOIN sys.dm_db_missing_index_group_stats migs ON mig.index_group_handle = migs.group_handle
            WHERE avg_user_impact > 50 -- Индекс улучшит производительность более чем на 50%
            ORDER BY avg_user_impact DESC";
        
        using var reader = await command.ExecuteReaderAsync();
        var suggestions = new List<string>();
        
        while (await reader.ReadAsync())
        {
            var suggestion = $"CREATE INDEX IX_{reader.GetString(0).Replace("[", "").Replace("]", "")}_Missing " +
                            $"ON {reader.GetString(0)} " +
                            $"({reader.GetString(1) ?? ""} " +
                            $"{(string.IsNullOrEmpty(reader.GetString(2)) ? "" : ", " + reader.GetString(2))}) " +
                            $"{(string.IsNullOrEmpty(reader.GetString(3)) ? "" : "INCLUDE (" + reader.GetString(3) + ")")} " +
                            $"-- Предполагаемое улучшение: {reader.GetDecimal(4)}%";
            
            suggestions.Add(suggestion);
        }
        
        await connection.CloseAsync();
        return suggestions;
    }
}

Кэширование и временные данные

4.1. Кэширование результатов запросов

4.1.1. First-level кэширование в EF Core

public class CachedProductService
{
    private readonly ApplicationDbContext _context;
    private readonly IMemoryCache _memoryCache;
    private readonly IDistributedCache _distributedCache;
    private static readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();
    
    public async Task<Product> GetProductCachedAsync(int productId)
    {
        var cacheKey = $"product_{productId}";
        
        // Попытка получить из кэша
        if (_memoryCache.TryGetValue(cacheKey, out Product cachedProduct))
        {
            return cachedProduct;
        }
        
        // Использование распределенной блокировки для предотвращения Cache Stampede
        var lockKey = $"lock_{cacheKey}";
        var slimLock = _locks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1));
        
        await slimLock.WaitAsync();
        
        try
        {
            // Двойная проверка после получения блокировки
            if (_memoryCache.TryGetValue(cacheKey, out cachedProduct))
            {
                return cachedProduct;
            }
            
            // Получение из базы с AsNoTracking
            var product = await _context.Products
                .AsNoTracking()
                .Include(p => p.Category)
                .FirstOrDefaultAsync(p => p.Id == productId);
            
            if (product != null)
            {
                // Кэширование с политикой срока действия
                var cacheOptions = new MemoryCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
                    SlidingExpiration = TimeSpan.FromMinutes(2),
                    Size = 1, // Размер в кэше
                    Priority = CacheItemPriority.High
                };
                
                // Регистрация обратного вызова при удалении из кэша
                cacheOptions.RegisterPostEvictionCallback((key, value, reason, state) =>
                {
                    _logger.LogInformation($"Продукт {key} удален из кэша по причине: {reason}");
                });
                
                _memoryCache.Set(cacheKey, product, cacheOptions);
                
                // Также сохраняем в распределенном кэше
                var serialized = JsonSerializer.Serialize(product);
                await _distributedCache.SetStringAsync(
                    $"dist_{cacheKey}",
                    serialized,
                    new DistributedCacheEntryOptions
                    {
                        AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
                    });
            }
            
            return product;
        }
        finally
        {
            slimLock.Release();
            _locks.TryRemove(lockKey, out _);
        }
    }
    
    // Инвалидация кэша при изменении данных
    public async Task UpdateProductAsync(int productId, ProductUpdateDto update)
    {
        using var transaction = await _context.Database.BeginTransactionAsync();
        
        try
        {
            // Обновление в базе
            var product = await _context.Products.FindAsync(productId);
            if (product != null)
            {
                product.Name = update.Name;
                product.Price = update.Price;
                await _context.SaveChangesAsync();
                
                // Инвалидация кэша
                var cacheKey = $"product_{productId}";
                _memoryCache.Remove(cacheKey);
                await _distributedCache.RemoveAsync($"dist_{cacheKey}");
                
                // Инвалидация связанных кэшей
                await InvalidateRelatedCachesAsync(product.CategoryId);
            }
            
            await transaction.CommitAsync();
        }
        catch
        {
            await transaction.RollbackAsync();
            throw;
        }
    }
}

4.2. Compiled Queries для часто выполняемых запросов

public static class CompiledQueries
{
    // Скомпилированный запрос для получения продукта по ID
    public static readonly Func<ApplicationDbContext, int, Task<Product>> 
        GetProductById = EF.CompileAsyncQuery(
            (ApplicationDbContext context, int productId) =>
                context.Products
                    .AsNoTracking()
                    .Include(p => p.Category)
                    .FirstOrDefault(p => p.Id == productId));
    
    // Скомпилированный запрос для поиска продуктов по категории
    public static readonly Func<ApplicationDbContext, string, int, int, Task<List<Product>>>
        GetProductsByCategory = EF.CompileAsyncQuery(
            (ApplicationDbContext context, string category, int skip, int take) =>
                context.Products
                    .AsNoTracking()
                    .Where(p => p.Category.Name == category)
                    .OrderBy(p => p.Name)
                    .Skip(skip)
                    .Take(take)
                    .ToList());
    
    // Скомпилированный запрос для агрегации
    public static readonly Func<ApplicationDbContext, int, Task<CategoryStats>>
        GetCategoryStats = EF.CompileAsyncQuery(
            (ApplicationDbContext context, int categoryId) =>
                context.Products
                    .Where(p => p.CategoryId == categoryId)
                    .GroupBy(p => p.CategoryId)
                    .Select(g => new CategoryStats
                    {
                        CategoryId = g.Key,
                        ProductCount = g.Count(),
                        AveragePrice = g.Average(p => p.Price),
                        TotalStock = g.Sum(p => p.StockQuantity)
                    })
                    .FirstOrDefault());
}

// Использование скомпилированных запросов
public class ProductRepository
{
    private readonly ApplicationDbContext _context;
    
    public async Task<Product> GetProductFastAsync(int productId)
    {
        // Использование скомпилированного запроса
        // Преимущества:
        // 1. Нет накладных расходов на компиляцию LINQ
        // 2. Кэширование плана выполнения
        // 3. Меньше аллокаций памяти
        return await CompiledQueries.GetProductById(_context, productId);
    }
    
    public async Task<List<Product>> GetProductsByCategoryFastAsync(
        string category, int page, int pageSize)
    {
        var skip = (page - 1) * pageSize;
        return await CompiledQueries.GetProductsByCategory(
            _context, category, skip, pageSize);
    }
}

Продвинутые техники оптимизации

5.1. Bulk Operations и массовые операции

5.1.1. Использование ExecuteUpdate и ExecuteDelete

public class BulkOperationsService
{
    private readonly ApplicationDbContext _context;
    
    // Массовое обновление без загрузки в память
    public async Task<int> UpdateProductPricesAsync(string category, decimal increasePercent)
    {
        // Старый подход (медленный):
        // var products = await _context.Products
        //     .Where(p => p.Category.Name == category)
        //     .ToListAsync();
        // 
        // foreach (var product in products)
        // {
        //     product.Price *= (1 + increasePercent / 100);
        // }
        // 
        // return await _context.SaveChangesAsync();
        
        // Новый подход в EF Core 7+:
        return await _context.Products
            .Where(p => p.Category.Name == category)
            .ExecuteUpdateAsync(setters => setters
                .SetProperty(p => p.Price, 
                    p => p.Price * (1 + increasePercent / 100))
                .SetProperty(p => p.UpdatedAt, DateTime.UtcNow));
        
        // Генерируется один SQL запрос:
        // UPDATE Products 
        // SET Price = Price * 1.1, UpdatedAt = GETUTCDATE()
        // WHERE CategoryId IN (SELECT Id FROM Categories WHERE Name = @category)
    }
    
    // Массовое удаление
    public async Task<int> DeleteOldOrdersAsync(DateTime cutoffDate)
    {
        return await _context.Orders
            .Where(o => o.OrderDate < cutoffDate && o.Status == OrderStatus.Completed)
            .ExecuteDeleteAsync();
        
        // Генерируется:
        // DELETE FROM Orders 
        // WHERE OrderDate < @cutoffDate AND Status = 'Completed'
    }
    
    // Массовая вставка с использованием Table-Valued Parameters
    public async Task<int> BulkInsertProductsAsync(List<Product> products)
    {
        if (!products.Any()) return 0;
        
        // Использование сторонней библиотеки для bulk insert
        // Например: EFCore.BulkExtensions
        
        await _context.BulkInsertAsync(products, new BulkConfig
        {
            BatchSize = 1000,
            UseTempDB = true,
            SetOutputIdentity = true,
            PreserveInsertOrder = true
        });
        
        return products.Count;
    }
}

5.2. Query Tags и комментарии SQL

public class QueryTaggingService
{
    private readonly ApplicationDbContext _context;
    
    public async Task<List<Order>> GetOrdersWithTagsAsync(int customerId)
    {
        return await _context.Orders
            .Where(o => o.CustomerId == customerId)
            .TagWith("Получение заказов клиента") // Комментарий в SQL
            .TagWith($"CustomerId: {customerId}") // Параметры в комментарии
            .TagWithCallSite() // Добавление информации о месте вызова
            .AsNoTracking()
            .ToListAsync();
        
        // Сгенерированный SQL будет содержать:
        // -- Получение заказов клиента
        // -- CustomerId: 123
        // -- File: OrderService.cs, Line: 42
        // SELECT ... FROM Orders WHERE CustomerId = @p0
    }
    
    // Динамические теги на основе контекста
    public IQueryable<Order> TagOrdersQuery(IQueryable<Order> query, HttpContext httpContext)
    {
        var userId = httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        var requestPath = httpContext.Request.Path;
        
        return query
            .TagWith($"User: {userId}")
            .TagWith($"Endpoint: {requestPath}")
            .TagWith($"Timestamp: {DateTime.UtcNow:O}");
    }
}

5.3. Оптимизация производительности через стратегию запросов

public class QueryStrategyService
{
    private readonly ApplicationDbContext _context;
    private readonly IQueryStrategyFactory _strategyFactory;
    
    public async Task<List<Product>> GetProductsOptimizedAsync(ProductQuery query)
    {
        // Выбор стратегии на основе параметров запроса
        IQueryStrategy strategy;
        
        if (query.UseFullTextSearch && !string.IsNullOrEmpty(query.SearchTerm))
        {
            strategy = _strategyFactory.CreateFullTextStrategy(query.SearchTerm);
        }
        else if (query.CategoryId.HasValue && query.MinPrice.HasValue)
        {
            strategy = _strategyFactory.CreateFilterStrategy(
                query.CategoryId.Value, query.MinPrice.Value);
        }
        else
        {
            strategy = _strategyFactory.CreateDefaultStrategy();
        }
        
        // Применение стратегии
        var queryable = strategy.Apply(_context.Products);
        
        // Дополнительная оптимизация
        if (!query.NeedTracking)
        {
            queryable = queryable.AsNoTracking();
        }
        
        if (query.UseSplitQuery)
        {
            queryable = queryable.AsSplitQuery();
        }
        
        return await queryable.ToListAsync();
    }
}

// Паттерн Стратегия для запросов
public interface IQueryStrategy
{
    IQueryable<Product> Apply(IQueryable<Product> query);
}

public class FullTextSearchStrategy : IQueryStrategy
{
    private readonly string _searchTerm;
    
    public FullTextSearchStrategy(string searchTerm)
    {
        _searchTerm = searchTerm;
    }
    
    public IQueryable<Product> Apply(IQueryable<Product> query)
    {
        return query
            .Where(p => EF.Functions.Contains(p.Name, _searchTerm) ||
                       EF.Functions.Contains(p.Description, _searchTerm))
            .OrderByDescending(p => EF.Functions.Contains(p.Name, _searchTerm) ? 1 : 0)
            .ThenBy(p => p.Name);
    }
}

public class FilterStrategy : IQueryStrategy
{
    private readonly int _categoryId;
    private readonly decimal _minPrice;
    
    public FilterStrategy(int categoryId, decimal minPrice)
    {
        _categoryId = categoryId;
        _minPrice = minPrice;
    }
    
    public IQueryable<Product> Apply(IQueryable<Product> query)
    {
        return query
            .Where(p => p.CategoryId == _categoryId && p.Price >= _minPrice)
            .OrderBy(p => p.Price)
            .ThenBy(p => p.Name);
    }
}

Мониторинг и настройка производительности

6.1. Использование Performance Counters

public class EfCorePerformanceMonitor
{
    private readonly PerformanceCounter _queryCounter;
    private readonly PerformanceCounter _connectionCounter;
    private readonly PerformanceCounter _cacheCounter;
    
    public EfCorePerformanceMonitor()
    {
        // Счетчики для мониторинга EF Core
        if (PerformanceCounterCategory.Exists(".NET Data Provider for SqlServer"))
        {
            _queryCounter = new PerformanceCounter(
                ".NET Data Provider for SqlServer",
                "NumberOfActiveConnectionPools",
                "ApplicationDbContext");
            
            _connectionCounter = new PerformanceCounter(
                ".NET Data Provider for SqlServer",
                "NumberOfPooledConnections",
                "ApplicationDbContext");
        }
        
        // Кастомные счетчики
        SetupCustomCounters();
    }
    
    private void SetupCustomCounters()
    {
        if (!PerformanceCounterCategory.Exists("EFCore"))
        {
            var counters = new CounterCreationDataCollection
            {
                new CounterCreationData(
                    "QueriesPerSecond",
                    "Количество запросов в секунду",
                    PerformanceCounterType.RateOfCountsPerSecond32),
                
                new CounterCreationData(
                    "SlowQueries",
                    "Медленные запросы ( > 1 сек)",
                    PerformanceCounterType.NumberOfItems32),
                
                new CounterCreationData(
                    "CacheHitRatio",
                    "Процент попаданий в кэш",
                    PerformanceCounterType.RawFraction)
            };
            
            PerformanceCounterCategory.Create(
                "EFCore",
                "Entity Framework Core Metrics",
                PerformanceCounterCategoryType.MultiInstance,
                counters);
        }
    }
    
    public void RecordQuery(TimeSpan duration, bool fromCache)
    {
        if (_queryCounter != null)
        {
            _queryCounter.Increment();
            
            if (duration > TimeSpan.FromSeconds(1))
            {
                // Запись медленного запроса
                using var slowQueryCounter = new PerformanceCounter(
                    "EFCore", "SlowQueries", "ApplicationDbContext", false);
                slowQueryCounter.Increment();
            }
        }
    }
}

6.2. Автоматическая оптимизация запросов

public class QueryOptimizerInterceptor : DbCommandInterceptor
{
    private readonly ILogger<QueryOptimizerInterceptor> _logger;
    
    public QueryOptimizerInterceptor(ILogger<QueryOptimizerInterceptor> logger)
    {
        _logger = logger;
    }
    
    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result)
    {
        // Анализ SQL запроса
        if (command.CommandText.Contains("SELECT") && 
            !command.CommandText.Contains("WHERE"))
        {
            _logger.LogWarning($"Запрос без WHERE: {command.CommandText}");
        }
        
        // Проверка на SELECT *
        if (command.CommandText.Contains("SELECT *"))
        {
            _logger.LogWarning($"Используется SELECT *: {command.CommandText}");
        }
        
        // Проверка на отсутствие ORDER BY при пагинации
        if (command.CommandText.Contains("OFFSET") && 
            !command.CommandText.Contains("ORDER BY"))
        {
            _logger.LogError($"Пагинация без ORDER BY: {command.CommandText}");
        }
        
        return base.ReaderExecuting(command, eventData, result);
    }
    
    public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result,
        CancellationToken cancellationToken = default)
    {
        // Асинхронная оптимизация
        OptimizeQuery(command);
        return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
    }
    
    private void OptimizeQuery(DbCommand command)
    {
        // Простые оптимизации SQL
        if (command.CommandText.Contains("NOLOCK"))
        {
            // Проверка необходимости NOLOCK
            if (!IsLongRunningQuery(command))
            {
                // Убираем NOLOCK для коротких запросов
                command.CommandText = command.CommandText
                    .Replace("WITH (NOLOCK)", "")
                    .Replace("NOLOCK", "");
            }
        }
    }
    
    private bool IsLongRunningQuery(DbCommand command)
    {
        // Эвристика для определения длительных запросов
        return command.CommandText.Contains("JOIN") &&
               command.CommandText.Split(' ').Length > 50;
    }
}

// Регистрация интерцептора
services.AddDbContext<ApplicationDbContext>(options =>
{
    options.UseSqlServer(connectionString);
    options.AddInterceptors(new QueryOptimizerInterceptor(logger));
});

Заключение

Оптимизация запросов Entity Framework Core — это комплексный процесс, требующий понимания как внутренних механизмов ORM, так и особенностей работы базы данных.

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