Введение
Entity Framework Core, несмотря на свою мощь и удобство, часто становится источником проблем с производительностью в C# приложениях. Неоптимальные запросы могут приводить к N+1 проблемам, избыточным загрузкам данных и чрезмерному потреблению памяти. Однако с пониманием внутренних механизмов EF Core и правильными техниками оптимизации можно достичь производительности, сравнимой с сырыми SQL-запросами, сохранив при этом все преимущества ORM. Современные версии EF Core предоставляют множество инструментов для диагностики и оптимизации, которые позволяют превратить его из источника проблем в эффективный инструмент работы с данными.
Диагностика проблем производительности
1.1. Логирование и профилирование запросов
1.1.1. Настройка детального логирования
// Program.cs или Startup.cs services.AddDbContext (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 (); // Использование SQL Server Profiler расширений public class QueryProfilerMiddleware { private readonly RequestDelegate _next; private readonly ConcurrentBag _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 (); 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 > GetOrdersSlowAsync(int customerId) { var orders = await _context.Orders .Where(o => o.CustomerId == customerId) .ToListAsync(); // 1 запрос для получения заказов var result = new List (); 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 > 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 > 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 (options => { options.UseSqlServer(connectionString, sqlOptions => { sqlOptions.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); }); }); // Или для конкретного запроса public async Task > 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 > 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 (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 (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 > 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 (); 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 > 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 (); 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 _locks = new(); public async Task 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 > GetProductById = EF.CompileAsyncQuery( (ApplicationDbContext context, int productId) => context.Products .AsNoTracking() .Include(p => p.Category) .FirstOrDefault(p => p.Id == productId)); // Скомпилированный запрос для поиска продуктов по категории public static readonly Func >> 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 > 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 GetProductFastAsync(int productId) { // Использование скомпилированного запроса // Преимущества: // 1. Нет накладных расходов на компиляцию LINQ // 2. Кэширование плана выполнения // 3. Меньше аллокаций памяти return await CompiledQueries.GetProductById(_context, productId); } public async Task > 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 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 DeleteOldOrdersAsync(DateTime cutoffDate) { return await _context.Orders .Where(o => o.OrderDate BulkInsertProductsAsync(List 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 > 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 TagOrdersQuery(IQueryable 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 > 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 Apply(IQueryable query); } public class FullTextSearchStrategy : IQueryStrategy { private readonly string _searchTerm; public FullTextSearchStrategy(string searchTerm) { _searchTerm = searchTerm; } public IQueryable Apply(IQueryable 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 Apply(IQueryable 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 _logger; public QueryOptimizerInterceptor(ILogger logger) { _logger = logger; } public override InterceptionResult ReaderExecuting( DbCommand command, CommandEventData eventData, InterceptionResult 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 > ReaderExecutingAsync( DbCommand command, CommandEventData eventData, InterceptionResult 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 (options => { options.UseSqlServer(connectionString); options.AddInterceptors(new QueryOptimizerInterceptor(logger)); });
Заключение
Оптимизация запросов Entity Framework Core — это комплексный процесс, требующий понимания как внутренних механизмов ORM, так и особенностей работы базы данных.
Оптимизированный EF Core может быть не менее производительным, чем сырые SQL-запросы, при этом сохраняя все преимущества типобезопасности и поддерживаемости кода. Ключ к успеху — в понимании того, как ваши LINQ-запросы преобразуются в SQL, и в использовании правильных инструментов для каждого сценария.