Каждый .NET разработчик рано или поздно сталкивается с ситуацией, когда привычные методы работы с базами данных превращаются в источник бессонных ночей. Я сам неоднократно попадал в такие ситуации, особенно когда системы начинали обрабатывать реально большие объемы данных. Стандартные подходы, которым нас учат на курсах и в туториалах, часто создают иллюзию простоты - пиши себе CRUD-операции через Entity Framework, используй асинхронные методы, и всё будет летать. Но реальность жестока: как только нагрузка растет, эти подходы трещат по швам.
Симптомы узких мест в стандартном подходе
N+1 проблема, пожалуй, самый известный бич ORM-фреймворков. Казалось бы мелочь - подгрузка связанных сущностей, но она способна превратить быстрый запрос в бесконечно долгую операцию. Однажды я работал над проектом, где простой список заказов с информацией о клиентах генерировал более 1000 запросов к базе при загрузке всего 100 записей! И это только верхушка айсберга.
Гораздо менее очевидный симптом - деградация производительности при масштабировании. Код, который отлично работал на тестовой выборке в 1000 записей, внезапно начинает пожирать всю доступную память при 100000 записей. Причина кроется в неправильном управлении контекстом и неоптимальных запросах, которые генерирует ORM.
C# | 1
2
3
4
5
6
7
8
9
| // Этот безобидный код может стать причиной падения сервера
using (var context = new DataContext())
{
var customers = context.Customers
.Include(c => c.Orders)
.Include(c => c.Payments)
.ToList();
// Дальнейшая обработка...
} |
|
Продвинутые книги по C# Начал с книги Фленова "Библия С#", последнее издание. Теперь нужно углублять знания. Какую... Продвинутые курсы Microsoft Access Добрый день, подскажите где можно найти Продвинутые курсы Microsoft Access. Я основы знаю, хотелось... Есть ли продвинутые элементы управления в access 2010 Доброе утро, уважаемые форумчане!
Позвольте вопрос от начинающего...
У меня Лицензионный MS... Стандартные приемы работы с БД. Что использовать здравствуйте.я пишу программу где нужно создать базу данных.чтобы она редактировалась несколькими...
Асинхронные камни преткновения
Асинхронное программирование в контексте работы с базами данных - это отдельный мир граблей. Часто встречаю код, где разработчики бездумно навешивают async/await на все методы доступа к данным, полагая, что это магическим образом ускорит работу.
Суровая правда в том, что асинхронность - это не о скорости выполнения, а о масштабируемости. Неправильное использование async/await с БД может привести к утечкам соединений, взаимоблокировкам и даже к полной остановке пула потоков. Я не раз видел, как приложения зависали из-за неправильно организованной асинхронной цепочки запросов.
Когда контекст становится ловушкой
Entity Framework - мощный инструмент, но при неправильном использовании он превращается в источник утечек памяти. Особенно коварны долгоживущие контексты, которые отслеживают изменения сущностей.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
| // Этот антипаттерн встречается чаще, чем хотелось бы
public class LongLivingService
{
private readonly DataContext _context = new DataContext();
public async Task ProcessData()
{
// Работа с контекстом
var data = await _context.SomeEntities.ToListAsync();
// ...
}
// Метод Dispose никогда не вызывается!
} |
|
С каждым запросом такой сервис накапливает в памяти отслеживаемые сущности, пока приложение не рухнет с OutOfMemoryException.
Блокировки в многопоточном мире
Самая коварная проблема - неожиданные блокировки транзакций в многопоточных приложениях. Ситуация, когда несколько потоков пытаются изменить одни и те же данные, может привести к deadlock, особенно если смешиваются синхронные и асинхронные операции с БД.
Я помню проект, где каждый день в определенное время сервис зависал. Причиной оказалась банальная конкуренция между обработкой запросов пользователей и фоновой задачей, которая обновляла те же самые записи. Ни логи, ни профилировщики не помогли - пришлось разбираться с блокировками на уровне SQL Server.
Архитектурные паттерны для эффективной работы с данными
Столкнувшись с ограничениями стандартных подходов, я начал экспериментировать с различными архитектурными паттернами. Не все из них одинаково полезны, но некоторые действительно спасают в сложных ситуациях.
Repository и Unit of Work
Паттерн Repository долгое время считался золотым стандартом для работы с данными в .NET-приложениях. Он позволяет абстрагировать логику доступа к данным от бизнес-логики. Однако классическая реализация этого паттерна часто становится источником проблем в современных высоконагруженных системах.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // Классическая реализация Repository
public class CustomerRepository : ICustomerRepository
{
private readonly DataContext _context;
public CustomerRepository(DataContext context)
{
_context = context;
}
public Customer GetById(int id)
{
return _context.Customers.Find(id);
}
// Другие методы...
} |
|
Проблема такого подхода - он не учитывает специфику конкретных запросов. Один и тот же метод GetById может использоваться как для получения данных для отображения, так и для сложных бизнес-операций, но требования к загружаемым данным в этих случаях совершенно разные. Я пришел к выводу, что эффективнее использовать специализированные репозитории или запросы для конкретных сценариев использования:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| // Более специализированный подход
public class CustomerQueries
{
private readonly DataContext _context;
public CustomerQueries(DataContext context)
{
_context = context;
}
public CustomerDisplayDto GetForDisplay(int id)
{
return _context.Customers
.Where(c => c.Id == id)
.Select(c => new CustomerDisplayDto
{
Id = c.Id,
Name = c.Name,
Email = c.Email
})
.FirstOrDefault();
}
public CustomerEditDto GetForEdit(int id)
{
return _context.Customers
.Include(c => c.Address)
.Where(c => c.Id == id)
.Select(c => new CustomerEditDto
{
// Маппинг полей
})
.FirstOrDefault();
}
} |
|
Что касается Unit of Work, этот паттерн уже встроен в Entity Framework Core через DbContext. Создание дополнительной абстракции часто только усложняет код без реальных преимуществ. Я предпочитаю использовать DbContext напрямую, но с правильным управлением его жизненным циклом.
Секреты Connection Pooling
Одна из самых недооцененных, но критически важных тем - управление соединениями с базой данных. Многие разработчики даже не подозревают, что ADO.NET автоматически создает пул соединений, а неправильное использование этого механизма может привести к исчерпанию доступных соединений. Типичная ошибка - создание и закрытие соединений в цикле:
C# | 1
2
3
4
5
6
7
8
9
| // Антипаттерн: исчерпание пула соединений
foreach (var item in items)
{
using (var connection = new SqlConnection(connectionString))
{
connection.Open();
// Операции с БД...
} // Соединение возвращается в пул
} |
|
При большом количестве итераций этот код может исчерпать пул соединений, особенно если операции выполняются асинхронно. Правильный подход - использовать одно соединение для всех операций в цикле:
C# | 1
2
3
4
5
6
7
8
9
| // Правильный подход
using (var connection = new SqlConnection(connectionString))
{
connection.Open();
foreach (var item in items)
{
// Операции с БД с использованием одного соединения
}
} // Соединение возвращается в пул только один раз |
|
В случае с Entity Framework ситуация еще сложнее. EF Core управляет соединениями автоматически, открывая их только на время выполнения запроса. Но если вы используете AsNoTracking() или проецируете результаты в DTO, соединение закрывается сразу после выполнения запроса. Это позволяет эффективно использовать пул соединений.
Когда ORM становится узким местом
ORM-фреймворки значительно упрощают разработку, но иногда становятся узким местом производительности. Я выделил несколько сценариев, когда стоит отказаться от Entity Framework в пользу более низкоуровневых инструментов:
1. Массовые операции с данными (импорт/экспорт).
2. Сложные отчеты с агрегацией данных.
3. Запросы с множеством JOIN-ов и условий.
4. Операции, требующие специфичных для СУБД оптимизаций.
В таких случаях я часто обращаюсь к Dapper - легковесной ORM, которая дает больше контроля над SQL-запросами при сохранении удобства маппинга результатов на объекты:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| using (var connection = new SqlConnection(connectionString))
{
connection.Open();
var results = await connection.QueryAsync<CustomerReport>(
@"SELECT c.Id, c.Name, COUNT(o.Id) AS OrderCount, SUM(o.Total) AS TotalSpent
FROM Customers c
LEFT JOIN Orders o ON c.Id = o.CustomerId
WHERE c.CreatedDate > @since
GROUP BY c.Id, c.Name
HAVING COUNT(o.Id) > 0
ORDER BY SUM(o.Total) DESC",
new { since = DateTime.Now.AddMonths(-6) });
return results.ToList();
} |
|
Такой запрос выполняется в разы быстрее, чем эквивалентный LINQ-запрос через Entity Framework, особенно на больших объемах данных.
Lazy Loading против Eager Loading: миф о производительности
Один из самых спорных вопросов при работе с ORM - выбор между ленивой (Lazy) и жадной (Eager) загрузкой данных. Многие разработчики считают, что ленивая загрузка - это всегда плохо из-за N+1 проблемы. Однако реальность сложнее. Когда я работал над приложением для анализа логистических цепочек, мы столкнулись с интересным кейсом. При отображении списка поставок с полной детализацией (с вложенностью до 5 уровней) жадная загрузка через Include создавала запросы, которые SQL Server просто не мог оптимизировать - планы выполнения становились монстрами с десятками операций соединения.
C# | 1
2
3
4
5
6
7
8
| // Этот монстр-запрос выполнялся более 30 секунд
var shipments = context.Shipments
.Include(s => s.Route)
.Include(s => s.Items).ThenInclude(i => i.Product)
.Include(s => s.Items).ThenInclude(i => i.PackageType)
.Include(s => s.Carrier).ThenInclude(c => c.ContactInfo)
.Include(s => s.Tracking).ThenInclude(t => t.CheckPoints)
.ToList(); |
|
Решение пришло неожиданно: мы разделили загрузку на несколько отдельных запросов - основные данные и связанные коллекции. Это увеличило количество запросов, но каждый был простым и быстрым:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // Загружаем базовые данные
var shipments = context.Shipments
.Include(s => s.Route)
.Include(s => s.Carrier)
.ToList();
// Затем догружаем связанные коллекции для уже полученных объектов
var shipmentIds = shipments.Select(s => s.Id).ToList();
var items = context.ShipmentItems
.Include(i => i.Product)
.Include(i => i.PackageType)
.Where(i => shipmentIds.Contains(i.ShipmentId))
.ToList();
// Группируем и связываем вручную
foreach (var shipment in shipments)
{
shipment.Items = items.Where(i => i.ShipmentId == shipment.Id).ToList();
}
// Аналогично для других связанных данных |
|
Суммарное время выполнения сократилось с 30+ секунд до менее чем 2 секунды! Иногда контролируемое "ручное" N+1 эффективнее, чем один гигантский запрос.
Паттерн Command для сложных операций
Для сложных операций с данными я часто использую паттерн Command, который отлично сочетается с CQRS (Command Query Responsibility Segregation). Этот подход позволяет инкапсулировать всю логику работы с данными в отдельные классы, что упрощает тестирование и поддержку:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
| public class CreateOrderCommand
{
public Customer Customer { get; set; }
public List<OrderItem> Items { get; set; }
public ShippingInfo ShippingInfo { get; set; }
}
public class CreateOrderHandler
{
private readonly DataContext _context;
public CreateOrderHandler(DataContext context)
{
_context = context;
}
public async Task<Order> HandleAsync(CreateOrderCommand command)
{
// Транзакционная логика создания заказа
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var order = new Order
{
CustomerId = command.Customer.Id,
OrderDate = DateTime.UtcNow,
Status = OrderStatus.Created,
// Другие свойства
};
_context.Orders.Add(order);
await _context.SaveChangesAsync();
// Добавление позиций заказа
foreach (var item in command.Items)
{
var orderItem = new OrderItem
{
OrderId = order.Id,
ProductId = item.ProductId,
Quantity = item.Quantity,
UnitPrice = item.UnitPrice
};
_context.OrderItems.Add(orderItem);
}
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return order;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
} |
|
Такой подход особенно полезен, когда операция затрагивает несколько таблиц или требует сложной валидации и бизнес-логики.
Секционирование данных на уровне приложения
Когда объем данных растет до десятков и сотен миллионов записей, даже хорошо оптимизированные запросы начинают тормозить. В таких случаях я применяю секционирование данных прямо на уровне приложения. В одном из проектов мы обрабатывали данные о транзакциях за несколько лет. Вместо хранения всего в одной таблице, мы разделили данные по годам и месяцам, используя отдельные таблицы. На уровне приложения мы создали фасад, который направлял запросы к нужной таблице в зависимости от параметров:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| public async Task<List<Transaction>> GetTransactionsAsync(DateTime from, DateTime to)
{
var result = new List<Transaction>();
// Определяем, какие секции нам нужны
var sections = GetRequiredSections(from, to);
foreach (var section in sections)
{
// Динамически формируем имя таблицы
var tableName = $"Transactions_{section.Year}_{section.Month:D2}";
// Используем Dapper для прямого SQL-запроса
var query = $"SELECT * FROM {tableName} WHERE TransactionDate BETWEEN @From AND @To";
using (var connection = new SqlConnection(_connectionString))
{
var transactions = await connection.QueryAsync<Transaction>(
query,
new { From = from, To = to }
);
result.AddRange(transactions);
}
}
return result;
} |
|
Такой подход не только улучшает производительность запросов, но и упрощает архивирование старых данных.
Продвинутые техники оптимизации запросов
Когда стандартные методы работы с базами данных исчерпали свой потенциал, настало время для более серьезной артиллерии. За годы работы с высоконагруженными системами я собрал арсенал техник, которые помогают выжать максимум производительности из взаимодействия C# с базами данных. Делюсь самыми эффективными.
Компилированные запросы в Entity Framework Core
Entity Framework под капотом трансформирует LINQ-выражения в SQL. Эта трансформация - довольно ресурсоемкий процесс, особенно для сложных запросов. Но мало кто знает, что можно скомпилировать часто используемые запросы один раз и потом просто переиспользовать их.
C# | 1
2
3
4
5
6
7
8
9
10
11
| // Объявляем компилированный запрос как статическое поле
private static readonly Func<ApplicationDbContext, int, Task<Customer>> GetCustomerById =
EF.CompileAsyncQuery((ApplicationDbContext context, int id) =>
context.Customers.FirstOrDefault(c => c.Id == id));
// Используем
public async Task<Customer> GetCustomer(int id)
{
using var context = new ApplicationDbContext();
return await GetCustomerById(context, id);
} |
|
Прирост производительности может достигать 30-40% для запросов, которые выполняются тысячи раз. Однажды я применил эту технику в сервисе обработки платежей, и время отклика сократилось с 200мс до 120мс - ощутимая разница при большом количестве транзакций. Но есть и подводные камни. Компилированные запросы не поддерживают локальные переменные из замыкания - все параметры должны быть явно переданы. Кроме того, они фиксируют структуру запроса, так что для разных вариаций придется создавать отдельные компилированные версии.
Мастерство пакетных операций
Одна из самых затратных операций - множественные вставки или обновления данных. Наивный подход с вызовом SaveChanges() после каждой операции может превратить быстрый процесс в многочасовую пытку.
C# | 1
2
3
4
5
6
| // Антипаттерн при массовых операциях
foreach (var item in items)
{
context.Items.Add(item);
await context.SaveChangesAsync(); // Каждый раз новая транзакция!
} |
|
Гораздо эффективнее группировать операции:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // Правильный подход
foreach (var item in items)
{
context.Items.Add(item);
// Сохраняем изменения пакетами по 100 элементов
if (items.IndexOf(item) % 100 == 99)
{
await context.SaveChangesAsync();
}
}
// Не забываем сохранить оставшиеся изменения
if (items.Count % 100 != 0)
{
await context.SaveChangesAsync();
} |
|
Для действительно больших объемов данных я предпочитаю использовать SqlBulkCopy. Это ниже уровень абстракции, но на порядки быстрее:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| public async Task BulkInsertCustomersAsync(List<Customer> customers)
{
// Преобразуем список объектов в DataTable
var dataTable = new DataTable();
dataTable.Columns.Add("Id", typeof(int));
dataTable.Columns.Add("Name", typeof(string));
dataTable.Columns.Add("Email", typeof(string));
// Другие колонки...
foreach (var customer in customers)
{
dataTable.Rows.Add(customer.Id, customer.Name, customer.Email /*, ...*/);
}
// Используем SqlBulkCopy для массовой вставки
using (var connection = new SqlConnection(_connectionString))
{
await connection.OpenAsync();
using (var bulkCopy = new SqlBulkCopy(connection))
{
bulkCopy.DestinationTableName = "Customers";
bulkCopy.BatchSize = 1000;
bulkCopy.ColumnMappings.Add("Id", "Id");
bulkCopy.ColumnMappings.Add("Name", "Name");
bulkCopy.ColumnMappings.Add("Email", "Email");
// Другие маппинги...
await bulkCopy.WriteToServerAsync(dataTable);
}
}
} |
|
На реальных данных разница между обычной вставкой через EF и SqlBulkCopy может быть колоссальной. В одном из проектов я сократил время импорта 500 000 записей с 40 минут до 12 секунд!
Table-Valued Parameters: когда нужно передать таблицу
Часто возникает задача передать в хранимую процедуру набор значений - например, список идентификаторов для фильтрации. Многие до сих пор используют строки с разделителями и парсинг на стороне SQL, но это медленно и подвержено ошибкам. Современный подход - использовать табличные параметры (TVP). Создаем пользовательский тип таблицы в SQL:
SQL | 1
2
3
4
5
6
7
8
9
10
| CREATE TYPE dbo.IdList AS TABLE (Id INT);
CREATE PROCEDURE GetProductsByIds
@Ids dbo.IdList READONLY
AS
BEGIN
SELECT p.*
FROM Products p
INNER JOIN @Ids ids ON p.Id = ids.Id;
END |
|
И используем его из C#:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| public async Task<List<Product>> GetProductsByIdsAsync(List<int> ids)
{
var idTable = new DataTable();
idTable.Columns.Add("Id", typeof(int));
foreach (var id in ids)
{
idTable.Rows.Add(id);
}
using (var connection = new SqlConnection(_connectionString))
{
await connection.OpenAsync();
using (var command = new SqlCommand("GetProductsByIds", connection))
{
command.CommandType = CommandType.StoredProcedure;
var parameter = command.Parameters.AddWithValue("@Ids", idTable);
parameter.SqlDbType = SqlDbType.Structured;
parameter.TypeName = "dbo.IdList";
using (var reader = await command.ExecuteReaderAsync())
{
var products = new List<Product>();
while (await reader.ReadAsync())
{
products.Add(new Product
{
Id = reader.GetInt32(reader.GetOrdinal("Id")),
Name = reader.GetString(reader.GetOrdinal("Name")),
// Другие поля...
});
}
return products;
}
}
}
} |
|
А если вы используете Dapper, то код становится еще компактнее:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| public async Task<List<Product>> GetProductsByIdsAsync(List<int> ids)
{
var idTable = new DataTable();
idTable.Columns.Add("Id", typeof(int));
foreach (var id in ids)
{
idTable.Rows.Add(id);
}
using (var connection = new SqlConnection(_connectionString))
{
await connection.OpenAsync();
var products = await connection.QueryAsync<Product>(
"GetProductsByIds",
new { Ids = idTable.AsTableValuedParameter("dbo.IdList") },
commandType: CommandType.StoredProcedure
);
return products.ToList();
}
} |
|
Я использовал этот подход для реализации фильтрации в административной панели интернет-магазина. Раньше фильтрация по 1000+ товарам с множеством условий занимала 5-7 секунд, а после оптимизации с использованием TVP - менее секунды.
Важно отметить, что для ленивых разработчиков Entity Framework Core 7.0+ упростил работу с пакетными операциями через метод ExecuteUpdateAsync, который позволяет выполнять массовые обновления без загрузки сущностей в память:
C# | 1
2
3
4
5
| await context.Customers
.Where(c => c.LastOrderDate < DateTime.UtcNow.AddYears(-1))
.ExecuteUpdateAsync(s => s
.SetProperty(c => c.Status, c => "Inactive")
.SetProperty(c => c.UpdatedDate, c => DateTime.UtcNow)); |
|
Это еще одна мощная техника в арсенале, которая может заменить ручное написание SQL для массовых операций.
Работа с хранимыми процедурами через Entity Framework
В среде разработчиков часто возникают споры о хранимых процедурах. Некоторые считают их устаревшим подходом, но я на практике убедился, что для определенных задач они незаменимы, особенно когда речь идет о сложных расчетах или операциях, которые нужно выполнить "ближе к данным". Entity Framework предоставляет несколько способов работы с хранимыми процедурами. Самый простой - использование метода FromSqlRaw() :
C# | 1
2
3
4
| var customers = await context.Customers
.FromSqlRaw("EXEC GetCustomersByRegion @Region",
new SqlParameter("@Region", "North"))
.ToListAsync(); |
|
Однако для процедур, которые возвращают несколько наборов результатов, этот подход не подходит. В таких случаях я использую DbContext.Database.ExecuteSqlRawAsync() или, что еще лучше, комбинирую EF с Dapper:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| public async Task<(Customer customer, List<Order> orders)> GetCustomerWithOrdersAsync(int customerId)
{
using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
using var multi = await connection.QueryMultipleAsync(
"GetCustomerWithOrders",
new { CustomerId = customerId },
commandType: CommandType.StoredProcedure);
var customer = await multi.ReadFirstOrDefaultAsync<Customer>();
var orders = (await multi.ReadAsync<Order>()).ToList();
return (customer, orders);
} |
|
Этот гибридный подход дает нам лучшее из обоих миров: производительность Dapper и интеграцию с моделью данных EF Core.
Управление транзакциями в асинхронном коде
Транзакции в асинхронном коде - еще один источник потенциальных проблем. Неправильное управление транзакциями может привести к взаимоблокировкам или, что еще хуже, к потере данных. Основное правило - всегда явно управлять транзакциями в асинхронном коде:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| public async Task<bool> TransferFundsAsync(int fromAccountId, int toAccountId, decimal amount)
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var fromAccount = await _context.Accounts.FindAsync(fromAccountId);
var toAccount = await _context.Accounts.FindAsync(toAccountId);
if (fromAccount.Balance < amount)
return false;
fromAccount.Balance -= amount;
toAccount.Balance += amount;
// Создаем записи в истории транзакций
var transactionRecord = new TransactionRecord
{
FromAccountId = fromAccountId,
ToAccountId = toAccountId,
Amount = amount,
Date = DateTime.UtcNow
};
_context.TransactionRecords.Add(transactionRecord);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return true;
}
catch (Exception)
{
await transaction.RollbackAsync();
throw;
}
} |
|
Важный момент: при использовании TransactionScope с асинхронными операциями необходимо добавить TransactionScopeAsyncFlowOption.Enabled , иначе транзакция не будет корректно распространяться через асинхронные вызовы:
C# | 1
2
3
4
5
6
7
8
9
10
| using (var scope = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
// Асинхронные операции с БД
await _context.SaveChangesAsync();
scope.Complete();
} |
|
Работа с JSON-полями в реляционных БД
Современные реляционные СУБД, такие как SQL Server, PostgreSQL и MySQL, поддерживают работу с JSON-данными. Это открывает новые возможности для гибридных моделей хранения, особенно когда часть данных имеет динамическую структуру. Entity Framework Core поддерживает маппинг JSON-полей на свойства C#-объектов. Например, для SQL Server:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public string Attributes { get; set; } // JSON строка
}
// В OnModelCreating
modelBuilder.Entity<Product>()
.Property(p => p.Attributes)
.HasColumnType("nvarchar(max)");
// Использование
var products = await context.Products
.Where(p => EF.Functions.JsonContains(p.Attributes, @"{""color"": ""red""}"))
.ToListAsync(); |
|
Для более сложных сценариев я часто комбинирую EF Core с нативными SQL-функциями для работы с JSON:
C# | 1
2
3
4
5
6
| var products = await context.Products
.FromSqlRaw(@"
SELECT * FROM Products
WHERE JSON_VALUE(Attributes, '$.dimensions.width') > 100
AND JSON_VALUE(Attributes, '$.dimensions.height') > 50")
.ToListAsync(); |
|
Однажды этот подход помог мне реализовать гибкую систему атрибутов для интернет-магазина без необходимости создавать десятки связанных таблиц. Производительность была отличной, а код стал намного проще.
Оптимизация запросов с использованием индексов на уровне LINQ
LINQ-запросы не всегда транслируются в оптимальный SQL. Иногда нужно "подсказать" EF, как лучше использовать индексы. Я разработал несколько приемов, которые помогают оптимизировать запросы:
1. Правильный порядок условий в Where:
C# | 1
2
3
4
5
6
7
8
9
10
| // Неоптимально: начинает с неиндексированного поля
var customers = await context.Customers
.Where(c => c.Name.Contains("Smith") && c.Region == "North")
.ToListAsync();
// Лучше: сначала фильтр по индексированному полю
var customers = await context.Customers
.Where(c => c.Region == "North") // Индексированное поле
.Where(c => c.Name.Contains("Smith"))
.ToListAsync(); |
|
2. Использование специфичных методов вместо общих:
C# | 1
2
3
4
5
6
7
8
| // Плохо: не всегда использует индекс
var product = await context.Products
.Where(p => p.Id == productId)
.FirstOrDefaultAsync();
// Лучше: гарантированно использует первичный ключ
var product = await context.Products
.FindAsync(productId); |
|
3. Избегание функций на индексированных полях:
C# | 1
2
3
4
5
6
7
8
9
10
11
| // Плохо: не использует индекс по Date
var orders = await context.Orders
.Where(o => o.Date.Year == 2023)
.ToListAsync();
// Лучше: явно указываем диапазон для использования индекса
var startDate = new DateTime(2023, 1, 1);
var endDate = new DateTime(2024, 1, 1);
var orders = await context.Orders
.Where(o => o.Date >= startDate && o.Date < endDate)
.ToListAsync(); |
|
Я часто использую SQL Server Profiler или встроенные инструменты EF Core для анализа генерируемых запросов и их оптимизации. Разница в производительности может быть колоссальной: в одном из проектов простая реорганизация LINQ-запроса ускорила выполнение с 12 секунд до 200 миллисекунд.
Работа с оконными функциями через raw SQL
Оконные функции в SQL - мощный инструмент для аналитических запросов, но LINQ пока не предоставляет прямой поддержки для них. В таких случаях я комбинирую сырой SQL с EF Core:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
| var salesReport = await context.SalesReports
.FromSqlRaw(@"
SELECT
s.ProductId,
s.Date,
s.Amount,
SUM(s.Amount) OVER (PARTITION BY s.ProductId) AS TotalProductSales,
ROW_NUMBER() OVER (PARTITION BY s.ProductId ORDER BY s.Amount DESC) AS Rank
FROM Sales s
WHERE s.Date >= @StartDate AND s.Date <= @EndDate",
new SqlParameter("@StartDate", startDate),
new SqlParameter("@EndDate", endDate))
.ToListAsync(); |
|
В этой технике я особенно ценю возможность выполнять сложные аналитические расчеты прямо на уровне базы данных. Например, для отчета о продажах с расчетом процентов роста по месяцам оконные функции работают на порядок быстрее, чем эквивалентные вычисления на стороне приложения.
Использование проекций и DTO для уменьшения трафика
Частая ошибка разработчиков - загрузка всех полей сущности, когда нужны только несколько. Это создает избыточный трафик между приложением и базой данных. Особенно заметно в микросервисной архитектуре, где каждый лишний байт умножает задержку.
C# | 1
2
3
4
| // Антипаттерн: загружаем все поля
var customers = await _context.Customers
.Where(c => c.Region == "North")
.ToListAsync(); |
|
Намного эффективнее использовать проекции:
C# | 1
2
3
4
5
6
7
8
9
10
| // Оптимальный подход: загружаем только нужные поля
var customerDtos = await _context.Customers
.Where(c => c.Region == "North")
.Select(c => new CustomerSummaryDto
{
Id = c.Id,
FullName = c.FirstName + " " + c.LastName,
Email = c.Email
})
.ToListAsync(); |
|
На проекте с полными профилями пользователей (где каждый содержал фото, предпочтения и еще кучу инфы) такая оптимизация уменьшила размер ответа с 2-3МБ до 15-20КБ, что критично для мобильных клиентов.
Искусство эффективной пагинации
Пагинация - еще одно узкое место многих приложений. Наивная реализация часто выглядит так:
C# | 1
2
3
4
5
6
| // Очень плохая пагинация
var pagedCustomers = await _context.Customers
.OrderBy(c => c.LastName)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync(); |
|
Проблема этого подхода в том, что SQL Server все равно обрабатывает все записи до нужной страницы. Если у вас миллион записей и запрашивается страница 10000, база должна отсортировать все миллион записей и пропустить первые 9999 * pageSize. Я предпочитаю использовать технику "предположения позиции" для больших наборов данных:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| public async Task<(List<Customer> items, int totalCount)> GetCustomersPagedAsync(
int pageNumber, int pageSize, string searchTerm = null)
{
IQueryable<Customer> query = _context.Customers;
if (!string.IsNullOrEmpty(searchTerm))
{
query = query.Where(c => c.LastName.Contains(searchTerm) ||
c.FirstName.Contains(searchTerm) ||
c.Email.Contains(searchTerm));
}
// Получаем общее количество записей для корректного построения пагинации
// Используем оптимизированный запрос для подсчета
var totalCount = await query.CountAsync();
// Если страница слишком большая, возвращаем последнюю доступную
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
if (pageNumber > totalPages)
{
pageNumber = totalPages;
}
// Используем оконную функцию для эффективной пагинации
var items = await query
.OrderBy(c => c.LastName)
.ThenBy(c => c.FirstName)
.Select(c => new
{
Customer = c,
RowNum = EF.Functions.RowNumber(
over: o => o.OrderBy(x => x.LastName).ThenBy(x => x.FirstName))
})
.Where(x => x.RowNum > (pageNumber - 1) * pageSize &&
x.RowNum <= pageNumber * pageSize)
.Select(x => x.Customer)
.ToListAsync();
return (items, totalCount);
} |
|
Для Oracle и SQL Server я часто использую подход с ROW_NUMBER() напрямую через raw SQL, что еще эффективнее:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| var sql = @"
WITH NumberedCustomers AS (
SELECT *, ROW_NUMBER() OVER (ORDER BY LastName, FirstName) AS RowNum
FROM Customers
WHERE (@SearchTerm IS NULL OR
LastName LIKE '%' + @SearchTerm + '%' OR
FirstName LIKE '%' + @SearchTerm + '%' OR
Email LIKE '%' + @SearchTerm + '%')
)
SELECT * FROM NumberedCustomers
WHERE RowNum BETWEEN @Skip + 1 AND @Skip + @Take";
var customers = await connection.QueryAsync<Customer>(sql, new {
SearchTerm = searchTerm,
Skip = (pageNumber - 1) * pageSize,
Take = pageSize
}); |
|
NoTracking запросы для readonly-операций
Когда нужны данные только для чтения, я всегда использую AsNoTracking(). Это значительно снижает потребление памяти и улучшает производительность, так как EF не создает прокси-объекты и не отслеживает изменения:
C# | 1
2
3
4
5
| // До 5 раз быстрее для больших наборов данных
var products = await _context.Products
.AsNoTracking()
.Where(p => p.Category == "Electronics")
.ToListAsync(); |
|
В одном проекте эта простая оптимизация сократила потребление памяти на 60% и уменьшила время запроса с 3.2 до 0.8 секунды. Мелочь, а приятно. Но будьте осторожны: если вы потом захотите изменить полученные объекты, придется их явно присоединить к контексту или загрузить заново.
Интеграция Dapper и Entity Framework
Иногда лучшее решение - это комбинация инструментов. Я часто использую гибридный подход: Entity Framework для CRUD-операций и Dapper для сложных запросов:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| public class HybridRepository
{
private readonly ApplicationDbContext _context;
public HybridRepository(ApplicationDbContext context)
{
_context = context;
}
// Используем EF для стандартных операций
public async Task<Customer> GetCustomerByIdAsync(int id)
{
return await _context.Customers.FindAsync(id);
}
// Используем Dapper для сложных запросов
public async Task<List<SalesReportDto>> GetSalesReportAsync(DateTime start, DateTime end)
{
var query = @"
SELECT
p.Name AS ProductName,
c.Name AS CategoryName,
SUM(s.Quantity) AS TotalQuantity,
SUM(s.Quantity * s.UnitPrice) AS TotalRevenue,
COUNT(DISTINCT s.CustomerId) AS UniqueCustomers
FROM Sales s
JOIN Products p ON s.ProductId = p.Id
JOIN Categories c ON p.CategoryId = c.Id
WHERE s.Date BETWEEN @Start AND @End
GROUP BY p.Name, c.Name
ORDER BY TotalRevenue DESC";
// Используем соединение из контекста EF
var connection = _context.Database.GetDbConnection();
return (await connection.QueryAsync<SalesReportDto>(
query,
new { Start = start, End = end }
)).ToList();
}
} |
|
Такой гибридный подход позволяет использовать сильные стороны каждой технологии, не жертвуя удобством или производительностью.
Преодоление ограничений LINQ с помощью выражений
Иногда стандартных возможностей LINQ недостаточно для формирования сложных запросов, особенно когда условия фильтрации определяются динамически. В таких случаях я использую Expression Trees для построения запросов на лету:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
| public async Task<List<Product>> FindProductsAsync(
string nameFragment = null,
decimal? minPrice = null,
decimal? maxPrice = null,
List<string> categories = null)
{
// Начинаем с базового выражения (всегда истинно)
Expression<Func<Product, bool>> filter = p => true;
// Динамически добавляем условия
if (!string.IsNullOrEmpty(nameFragment))
{
filter = CombineFilters(filter, p => p.Name.Contains(nameFragment));
}
if (minPrice.HasValue)
{
filter = CombineFilters(filter, p => p.Price >= minPrice.Value);
}
if (maxPrice.HasValue)
{
filter = CombineFilters(filter, p => p.Price <= maxPrice.Value);
}
if (categories != null && categories.Any())
{
filter = CombineFilters(filter, p => categories.Contains(p.Category));
}
// Применяем собранное выражение
return await _context.Products
.Where(filter)
.ToListAsync();
}
// Метод для комбинирования выражений с логическим AND
private Expression<Func<T, bool>> CombineFilters<T>(
Expression<Func<T, bool>> filter1,
Expression<Func<T, bool>> filter2)
{
var parameter = Expression.Parameter(typeof(T));
var leftVisitor = new ReplaceExpressionVisitor(
filter1.Parameters[0], parameter);
var left = leftVisitor.Visit(filter1.Body);
var rightVisitor = new ReplaceExpressionVisitor(
filter2.Parameters[0], parameter);
var right = rightVisitor.Visit(filter2.Body);
return Expression.Lambda<Func<T, bool>>(
Expression.AndAlso(left, right), parameter);
}
private class ReplaceExpressionVisitor : ExpressionVisitor
{
private readonly Expression _oldValue;
private readonly Expression _newValue;
public ReplaceExpressionVisitor(Expression oldValue, Expression newValue)
{
_oldValue = oldValue;
_newValue = newValue;
}
public override Expression Visit(Expression node)
{
if (node == _oldValue)
return _newValue;
return base.Visit(node);
}
} |
|
Практические решения для реальных проектов
Расскажу о подходах, которые не раз выручали меня в боевых условиях, когда всё горит, клиент в панике, а дедлайн был вчера.
Паттерн Circuit Breaker для защиты от падения БД
Любая база данных может испытывать проблемы - от высокой нагрузки до полного отказа. Чтобы избежать каскадных сбоев, я активно использую паттерн Circuit Breaker.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
| // Простая реализация Circuit Breaker
public class DbCircuitBreaker
{
private int _failureCount = 0;
private DateTime _lastFailureTime = DateTime.MinValue;
private readonly int _failureThreshold;
private readonly TimeSpan _resetTimeout;
private bool _isOpen = false;
public DbCircuitBreaker(int failureThreshold = 3, int resetTimeoutSeconds = 30)
{
_failureThreshold = failureThreshold;
_resetTimeout = TimeSpan.FromSeconds(resetTimeoutSeconds);
}
public async Task<T> ExecuteAsync<T>(Func<Task<T>> operation)
{
if (_isOpen)
{
// Проверяем, можно ли попробовать восстановить цепь
if (DateTime.UtcNow - _lastFailureTime > _resetTimeout)
{
_isOpen = false; // Полуоткрытое состояние
}
else
{
throw new CircuitBreakerOpenException("Соединение с БД временно недоступно");
}
}
try
{
var result = await operation();
// Успешное выполнение - сбрасываем счетчик ошибок
_failureCount = 0;
return result;
}
catch (Exception ex)
{
_lastFailureTime = DateTime.UtcNow;
_failureCount++;
if (_failureCount >= _failureThreshold)
{
_isOpen = true; // Открываем цепь
}
throw; // Пробрасываем исключение дальше
}
}
} |
|
В продакшн-версии я обычно добавляю логгирование, метрики и более гибкую настройку политик. В одном из проектов этот паттерн спас нас, когда основная база данных начала "захлебываться" из-за скачка трафика - вместо полного отказа системы, пользователи получали уведомление о временной перегрузке, а сервисы переключались на резервную реплику.
Кэширование на уровне данных
Великая мудрость производительности - "самый быстрый запрос - это тот, который не нужно выполнять". Вот моя универсальная обертка для кэширования результатов запросов:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| public class CachedRepository<T> where T : class
{
private readonly IRepository<T> _repository;
private readonly IMemoryCache _cache;
private readonly TimeSpan _cacheDuration;
public CachedRepository(
IRepository<T> repository,
IMemoryCache cache,
TimeSpan? cacheDuration = null)
{
_repository = repository;
_cache = cache;
_cacheDuration = cacheDuration ?? TimeSpan.FromMinutes(10);
}
public async Task<T> GetByIdAsync(int id)
{
string cacheKey = $"{typeof(T).Name}_{id}";
if (!_cache.TryGetValue(cacheKey, out T entity))
{
entity = await _repository.GetByIdAsync(id);
if (entity != null)
{
var cacheOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(_cacheDuration)
.SetSlidingExpiration(TimeSpan.FromMinutes(2))
.SetSize(1); // Для отслеживания размера кэша
_cache.Set(cacheKey, entity, cacheOptions);
}
}
return entity;
}
// Другие методы репозитория с кэшированием
} |
|
Важный момент - правильная инвалидация кэша при изменении данных. Обычно я создаю специальный сервис для координации таких операций:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| public class CacheInvalidationService
{
private readonly IMemoryCache _cache;
private readonly ILogger<CacheInvalidationService> _logger;
public CacheInvalidationService(IMemoryCache cache, ILogger<CacheInvalidationService> logger)
{
_cache = cache;
_logger = logger;
}
public void InvalidateEntity<T>(int id)
{
string cacheKey = $"{typeof(T).Name}_{id}";
_cache.Remove(cacheKey);
_logger.LogDebug($"Кэш для {cacheKey} инвалидирован");
}
public void InvalidateCollection<T>(string collectionKey = null)
{
string cacheKey = collectionKey ?? $"{typeof(T).Name}_List";
_cache.Remove(cacheKey);
_logger.LogDebug($"Кэш для коллекции {cacheKey} инвалидирован");
}
} |
|
В одном из проектов электронной коммерции такой подход снизил нагрузку на базу на 80% и ускорил отклик API в 5-7 раз.
Мониторинг производительности и здоровья БД
Однажды я разрабатывал бэкэнд для системы управления автопарком с базой на SQL Server. Всё работало отлично, пока клиент не расширил бизнес и база не начала раздуваться. Первое, что я сделал - добавил систему мониторинга прямо в приложение:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
| public class DatabaseHealthMonitor : IHostedService
{
private readonly IServiceProvider _services;
private readonly ILogger<DatabaseHealthMonitor> _logger;
private Timer _timer;
public DatabaseHealthMonitor(
IServiceProvider services,
ILogger<DatabaseHealthMonitor> logger)
{
_services = services;
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_timer = new Timer(CheckHealth, null, TimeSpan.Zero, TimeSpan.FromMinutes(5));
return Task.CompletedTask;
}
private async void CheckHealth(object state)
{
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
// Проверка времени отклика
var stopwatch = Stopwatch.StartNew();
await db.Database.ExecuteSqlRawAsync("SELECT 1");
stopwatch.Stop();
_logger.LogInformation($"Время отклика БД: {stopwatch.ElapsedMilliseconds}мс");
// Другие проверки здоровья...
// Например, количество соединений, размер БД и т.д.
// Проверка процента занятого места на диске
var dbSizeInfo = await db.Database.ExecuteSqlRawAsync(@"
SELECT
(SUM(size) * 8) / 1024 AS 'SizeInMB',
(SUM(size) * 8) / 1024 / 1024 AS 'SizeInGB'
FROM sys.database_files");
// Отправка метрик в систему мониторинга
// ...
}
catch (Exception ex)
{
_logger.LogError(ex, "Ошибка проверки здоровья БД");
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
} |
|
Эти метрики помогли нам своевременно определить проблемные места: неоптимальные индексы, растущие временные таблицы и недостаток памяти для кэша запросов.
Обработка исключений и retry-логика
В распределенных системах сбои - не исключение, а норма. Важно правильно обрабатывать временные ошибки, особенно в мире облачных БД, где отключения и перезапуски случаются регулярно. Я разработал простую, но эффективную политику повторных попыток:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| public async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> operation)
{
int maxRetries = 3;
int currentRetry = 0;
TimeSpan delay = TimeSpan.FromMilliseconds(200);
while (true)
{
try
{
return await operation();
}
catch (SqlException ex) when (IsTransientError(ex.Number) && currentRetry < maxRetries)
{
currentRetry++;
_logger.LogWarning($"Транзиентная ошибка БД: {ex.Message}. Повторная попытка {currentRetry} из {maxRetries}");
// Экспоненциальное увеличение задержки
await Task.Delay(delay);
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
}
catch (Exception ex)
{
_logger.LogError(ex, "Необрабатываемая ошибка при работе с БД");
throw; // Не транзиентная ошибка - пробрасываем дальше
}
}
}
private bool IsTransientError(int errorNumber)
{
// Коды транзиентных ошибок SQL Server
int[] transientErrors = { 4060, 40197, 40501, 40613, 49918, 49919, 49920, 11001 };
return transientErrors.Contains(errorNumber);
} |
|
Для Entity Framework Core можно использовать встроенную политику повторов:
C# | 1
2
3
4
5
6
7
8
| services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
})); |
|
В проекте с микросервисной архитектурой эта стратегия помогла нам достичь 99.95% доступности API даже при регулярных обслуживаниях базы данных.
Распределенное кэширование с Redis
Для систем с несколькими инстансами приложения локальный кэш не подходит - данные быстро становятся неконсистентными. В таких случаях я использую Redis как распределенный кэш:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| public class RedisRepository<T> where T : class
{
private readonly IRepository<T> _repository;
private readonly IDatabase _redisDb;
private readonly TimeSpan _cacheDuration;
public RedisRepository(
IRepository<T> repository,
IConnectionMultiplexer redisConnection,
TimeSpan? cacheDuration = null)
{
_repository = repository;
_redisDb = redisConnection.GetDatabase();
_cacheDuration = cacheDuration ?? TimeSpan.FromMinutes(10);
}
public async Task<T> GetByIdAsync(int id)
{
string cacheKey = $"{typeof(T).Name}:{id}";
// Пробуем получить из кэша
var cachedValue = await _redisDb.StringGetAsync(cacheKey);
if (cachedValue.HasValue)
{
return JsonSerializer.Deserialize<T>(cachedValue);
}
// Если в кэше нет - получаем из БД
var entity = await _repository.GetByIdAsync(id);
if (entity != null)
{
var serialized = JsonSerializer.Serialize(entity);
await _redisDb.StringSetAsync(
cacheKey,
serialized,
_cacheDuration,
When.Always);
}
return entity;
}
} |
|
Для инвалидации кэша в Redis я разработал систему на основе событий. Когда данные меняются, мы публикуем сообщение, которое подхватывают все экземпляры приложения:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| public class RedisInvalidationService
{
private readonly IConnectionMultiplexer _redis;
private readonly ISubscriber _subscriber;
private readonly string _appInstanceId;
public RedisInvalidationService(IConnectionMultiplexer redis)
{
_redis = redis;
_subscriber = redis.GetSubscriber();
_appInstanceId = Guid.NewGuid().ToString("N");
// Подписываемся на события инвалидации
_subscriber.Subscribe("cache:invalidate", OnInvalidationMessage);
}
public async Task InvalidateEntityAsync<T>(int id)
{
string cacheKey = $"{typeof(T).Name}:{id}";
// Удаляем из локального кэша текущего экземпляра
await _redis.GetDatabase().KeyDeleteAsync(cacheKey);
// Уведомляем другие экземпляры
await _subscriber.PublishAsync("cache:invalidate",
JsonSerializer.Serialize(new {
Source = _appInstanceId,
Key = cacheKey
}));
}
private void OnInvalidationMessage(RedisChannel channel, RedisValue message)
{
var invalidation = JsonSerializer.Deserialize<dynamic>(message);
// Игнорируем сообщения от себя же
if (invalidation.Source == _appInstanceId)
return;
// Инвалидируем локальный кэш
_redis.GetDatabase().KeyDeleteAsync(invalidation.Key);
}
} |
|
Эта система позволяет поддерживать консистентность кэша между разными экземплярами приложения без лишних блокировок.
Профилирование SQL-запросов в production
В продакшене нельзя запускать полноценные профилировщики - это создаст непомерную нагрузку. Но можно собирать статистику по "дорогим" запросам. Я разработал промежуточное ПО для ASP.NET Core, которое отслеживает длительные запросы:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
| public class SqlProfilerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<SqlProfilerMiddleware> _logger;
public SqlProfilerMiddleware(
RequestDelegate next,
ILogger<SqlProfilerMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, ApplicationDbContext dbContext)
{
// Подписываемся на события выполнения команд
var diagnosticSource = dbContext.GetService<DiagnosticSource>();
using var listener = new SqlCommandListener(_logger);
await _next(context);
// Анализируем собранные данные
if (listener.SlowQueries.Any())
{
foreach (var query in listener.SlowQueries)
{
_logger.LogWarning(
"Медленный запрос: {Query} - {Duration}мс",
query.CommandText,
query.Duration.TotalMilliseconds);
}
}
}
}
public class SqlCommandListener : IObserver<DiagnosticListener>
{
private readonly ILogger _logger;
public List<SlowQueryInfo> SlowQueries { get; } = new();
public SqlCommandListener(ILogger logger)
{
_logger = logger;
}
// Имплементация IObserver<DiagnosticListener>
// ...
private void OnSqlCommandExecuted(string commandText, TimeSpan duration)
{
if (duration.TotalMilliseconds > 500) // Порог для "медленных" запросов
{
SlowQueries.Add(new SlowQueryInfo
{
CommandText = commandText,
Duration = duration
});
}
}
} |
|
Автоматическое переключение между репликами
Для высоконагруженных систем я часто настраиваю автоматическое распределение запросов между основной БД (для записи) и репликами (для чтения):
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| public class ReadWriteConnectionFactory
{
private readonly string _writeConnectionString;
private readonly List<string> _readConnectionStrings;
private int _currentReadIndex = 0;
private readonly object _lock = new();
public ReadWriteConnectionFactory(
string writeConnectionString,
List<string> readConnectionStrings)
{
_writeConnectionString = writeConnectionString;
_readConnectionStrings = readConnectionStrings;
}
public SqlConnection CreateReadConnection()
{
if (_readConnectionStrings.Count == 0)
return CreateWriteConnection();
string connectionString;
lock (_lock)
{
connectionString = _readConnectionStrings[_currentReadIndex];
_currentReadIndex = (_currentReadIndex + 1) % _readConnectionStrings.Count;
}
return new SqlConnection(connectionString);
}
public SqlConnection CreateWriteConnection()
{
return new SqlConnection(_writeConnectionString);
}
} |
|
Для Entity Framework можно создать более сложное решение с контекстами для чтения и записи:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| public class ReadDbContext : ApplicationDbContext
{
public ReadDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
// Все запросы по умолчанию без отслеживания
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}
// Запрещаем операции изменения
public override int SaveChanges() =>
throw new InvalidOperationException("ReadDbContext не поддерживает сохранение изменений");
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("ReadDbContext не поддерживает сохранение изменений");
} |
|
Метрики и телеметрия: что действительно важно
Я пришел к выводу, что при работе с базами данных нужно отслеживать следующие ключевые метрики:
1. Среднее время выполнения запросов по типам (SELECT, INSERT, UPDATE, DELETE).
2. Количество соединений в пуле (активных/ожидающих).
3. Коэффициент попаданий в кэш (cache hit ratio).
4. Время ожидания соединения (connection wait time).
5. Распределение длительности запросов (перцентили p50, p95, p99).
Для сбора этих метрик в .NET я использую комбинацию Prometheus.NET и OpenTelemetry:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| public class DatabaseMetrics
{
private readonly Counter _totalQueries;
private readonly Histogram _queryDuration;
private readonly Gauge _activeConnections;
public DatabaseMetrics(IMetricFactory metricFactory)
{
_totalQueries = metricFactory.CreateCounter(
"db_queries_total",
"Общее количество запросов к БД",
new CounterConfiguration {
LabelNames = new[] { "type", "status" }
});
_queryDuration = metricFactory.CreateHistogram(
"db_query_duration_seconds",
"Длительность выполнения запросов",
new HistogramConfiguration {
LabelNames = new[] { "type" },
Buckets = Histogram.ExponentialBuckets(0.001, 2, 10) // от 1мс до ~1с
});
_activeConnections = metricFactory.CreateGauge(
"db_active_connections",
"Количество активных соединений с БД"
);
}
public void RecordQuery(string type, TimeSpan duration, bool success)
{
_totalQueries.WithLabels(type, success ? "success" : "failure").Inc();
_queryDuration.WithLabels(type).Observe(duration.TotalSeconds);
}
public void SetActiveConnections(int count)
{
_activeConnections.Set(count);
}
} |
|
Эти метрики позволяют быстро выявлять проблемы с производительностью и принимать обоснованные решения по оптимизации.
Система с продвинутыми возможностями работы с данными
Для демонстрации всех описанных техник я создал небольшой, но полнофункциональный проект управления заказами. Это типичная система, с которой сталкивается каждый опытный разработчик: управление клиентами, товарами, заказами - в общем, классика жанра, но с акцентом на оптимизацию работы с данными.
Архитектура демонстрационного приложения
Я выбрал многослойную архитектуру с четким разделением ответственности:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
| // Domain Layer - бизнес-сущности
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public DateTime OrderDate { get; set; }
public OrderStatus Status { get; set; }
public decimal TotalAmount { get; set; }
public List<OrderItem> Items { get; set; }
// Бизнес-логика
public void Calculate()
{
TotalAmount = Items.Sum(i => i.Quantity * i.UnitPrice);
}
}
// Data Access Layer - EF Core + Dapper
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
private readonly IDbConnection _connection;
public OrderRepository(AppDbContext context, IDbConnectionFactory connectionFactory)
{
_context = context;
_connection = connectionFactory.CreateConnection();
}
// EF Core для простых операций
public async Task<Order> GetByIdAsync(int id)
{
return await _context.Orders
.AsNoTracking()
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id);
}
// Dapper для сложных запросов
public async Task<List<OrderSummaryDto>> GetOrdersWithFilterAsync(OrderFilterDto filter)
{
var query = @"
SELECT o.Id, o.OrderDate, c.Name as CustomerName, o.TotalAmount,
COUNT(i.Id) AS ItemCount
FROM Orders o
JOIN Customers c ON o.CustomerId = c.Id
JOIN OrderItems i ON o.Id = i.OrderId
WHERE (@StartDate IS NULL OR o.OrderDate >= @StartDate)
AND (@EndDate IS NULL OR o.OrderDate <= @EndDate)
AND (@Status IS NULL OR o.Status = @Status)
GROUP BY o.Id, o.OrderDate, c.Name, o.TotalAmount
ORDER BY o.OrderDate DESC
OFFSET @Skip ROWS
FETCH NEXT @Take ROWS ONLY";
return (await _connection.QueryAsync<OrderSummaryDto>(
query,
new {
StartDate = filter.StartDate,
EndDate = filter.EndDate,
Status = filter.Status,
Skip = (filter.Page - 1) * filter.PageSize,
Take = filter.PageSize
})).ToList();
}
} |
|
Конфигурация для разных окружений
Одно из практических решений, которое я реализовал - конфигурация соединений с БД для разных сред:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
| public static class DatabaseConfiguration
{
public static IServiceCollection ConfigureDatabase(
this IServiceCollection services,
IConfiguration configuration,
IHostEnvironment environment)
{
// Базовая конфигурация
services.AddDbContext<AppDbContext>(options =>
{
var connectionString = configuration.GetConnectionString("DefaultConnection");
options.UseSqlServer(connectionString, sqlOptions =>
{
// Общие настройки
sqlOptions.EnableRetryOnFailure();
sqlOptions.CommandTimeout(30);
if (environment.IsDevelopment())
{
// Для разработки - подробное логирование
options.EnableDetailedErrors();
options.EnableSensitiveDataLogging();
}
else if (environment.IsProduction())
{
// Для продакшена - оптимизация производительности
sqlOptions.CommandTimeout(60);
}
});
});
// Регистрация фабрики соединений
if (environment.IsProduction())
{
// В продакшене - разделение чтения/записи
services.AddSingleton<IDbConnectionFactory>(sp =>
{
var writeConnection = configuration.GetConnectionString("WriteConnection");
var readConnections = configuration.GetSection("ReadConnections")
.Get<List<string>>();
return new ReadWriteConnectionFactory(writeConnection, readConnections);
});
}
else
{
// В других средах - одно соединение
services.AddSingleton<IDbConnectionFactory>(sp =>
new SingleConnectionFactory(
configuration.GetConnectionString("DefaultConnection")));
}
return services;
}
} |
|
Тестирование с реальной БД
Я предпочитаю тестировать слой доступа к данным с использованием реальной базы, а не моков. Для этого отлично подходит контейнер SQL Server в Docker:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| public class OrderRepositoryTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fixture;
public OrderRepositoryTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task GetOrdersWithFilter_ReturnsCorrectData()
{
// Arrange
await _fixture.SeedTestDataAsync();
var repository = new OrderRepository(_fixture.Context, _fixture.ConnectionFactory);
var filter = new OrderFilterDto
{
StartDate = DateTime.UtcNow.AddDays(-7),
Status = OrderStatus.Completed,
Page = 1,
PageSize = 10
};
// Act
var results = await repository.GetOrdersWithFilterAsync(filter);
// Assert
Assert.NotEmpty(results);
Assert.All(results, r => Assert.Equal(OrderStatus.Completed.ToString(), r.Status));
}
} |
|
Такой подход хоть и сложнее в настройке, но дает уверенность, что код будет работать в реальных условиях.IDM
Основные понятия и приемы программирования Помогите ответить на вопросы по С#.
1)Создание объектов.Понятия ссылки.... Нужны сайты про C#, приемы, рецепты, трюки программирования Не советуйте msdn или книгу.
Справочник должен быть похож на другие стандартные справочники как у... Как распознать каптчу: приемы и алгоритмы Доброго всем времени суток,недавно стал вопрос ребром,стало весьма и весьма интересно как... Парсинг JSON: кто какие приемы использует Приветствую, Это не столько вопрос, сколько поле для рассуждений, кто какими методами парсит,... Интересны приемы программирования, о которых не пишут в книгах, а которые узнаются на практике интересны приемы программирования на C# те о которых не пишут в книгах, которые узнаются на... Приемы рисования в WPF Добрый день. Начал изучать C#. Интересует, какие инструменты отрисовки изображений сегодня... Продвинутые курсы по OpenGL Здравствуйте.
Подскажите пожалуйста продвинутые уроки или книги по OpenGL. Где бы рассматривалось... Продвинутые функции для консоли Постараюсь быть кратким:
Console.CursorVisible = (false);
Console.ForegroundColor =... Какие есть продвинутые редакторы HDL с автозаполнением и другими наворотами? Какие есть продвинутые редакторы HDL с автозаполнением и другими наворотами? Всё, что я видел,... Webpack - Продвинутые (динамический) require Всем доброго времени суток.
ВВОДНАЯ (Ilya Kantor - невнимательный автор! :rtfm: )
1. Видео урок... Задача "Простейшие приёмы работы с клавиатурой и экраном" 11 Приёмы работы с ADO таблицей Доброго времени суток.вот какой весьма насущный вопрос возник:
имеется бд на MSSQL сервере,с...
|