Форум программистов, компьютерный форум, киберфорум
stackOverflow
Войти
Регистрация
Восстановить пароль

C# и продвинутые приемы работы с БД

Запись от stackOverflow размещена 17.06.2025 в 21:36
Показов 10440 Комментарии 0

Нажмите на изображение для увеличения
Название: C# и продвинутые приемы работы с БД.jpg
Просмотров: 163
Размер:	210.0 Кб
ID:	10906
Каждый .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 сервере,с...

Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 0
Комментарии
 
Новые блоги и статьи
Раскрываем внутренние механики Android с помощью контекста и манифеста
mobDevWorks 07.07.2025
Каждый Android-разработчик сталкивается с Context и манифестом буквально в первый день работы. Но много ли мы задумываемся о том, что скрывается за этими обыденными элементами? Я, честно говоря,. . .
API на базе FastAPI с Python за пару минут
AI_Generated 07.07.2025
FastAPI - это относительно молодой фреймворк для создания веб-API, который за короткое время заработал бешеную популярность в Python-сообществе. И не зря. Я помню, как впервые запустил приложение на. . .
Основы WebGL. Раскрашивание вершин с помощью VBO
8Observer8 05.07.2025
На русском https:/ / vkvideo. ru/ video-231374465_456239020 На английском https:/ / www. youtube. com/ watch?v=oskqtCrWns0 Исходники примера:
Мониторинг микросервисов с OpenTelemetry в Kubernetes
Mr. Docker 04.07.2025
Проблема наблюдаемости (observability) в Kubernetes - это не просто вопрос сбора логов или метрик. Это целый комплекс вызовов, которые возникают из-за самой природы контейнеризации и оркестрации. К. . .
Проблемы с Kotlin и Wasm при создании игры
GameUnited 03.07.2025
В современном мире разработки игр выбор технологии - это зачастую балансирование между удобством разработки, переносимостью и производительностью. Когда я решил создать свою первую веб-игру, мой. . .
Создаем микросервисы с Go и Kubernetes
golander 02.07.2025
Когда я только начинал с микросервисами, все спорили о том, какой язык юзать. Сейчас Go (или Golang) фактически захватил эту нишу. И вот почему этот язык настолько заходит для этих задач: . . .
C++23, квантовые вычисления и взаимодействие с Q#
bytestream 02.07.2025
Я всегда с некоторым скептицизмом относился к громким заявлениям о революциях в IT, но квантовые вычисления - это тот случай, когда революция действительно происходит прямо у нас на глазах. Последние. . .
Вот в чем сила LM.
Hrethgir 02.07.2025
как на английском будет “обслуживание“ Слово «обслуживание» на английском языке может переводиться несколькими способами в зависимости от контекста: * **Service** — самый распространённый. . .
Использование Keycloak со Spring Boot и интеграция Identity Provider
Javaican 01.07.2025
Два года назад я получил задачу, которая сначала показалась тривиальной: интегрировать корпоративную аутентификацию в микросервисную архитектуру. На тот момент у нас было семь Spring Boot приложений,. . .
Содержание темы с примерами на WebGL
8Observer8 01.07.2025
Все примеры из книги Мацуды и Ли в песочнице JSFiddle Пример выводит точку красного цвета размером 10 пикселей на WebGL 1. 0 и 2. 0 WebGL 1. 0. Передача координаты точки из главной программы в. . .
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2025, CyberForum.ru