Ef Core

Збереження Даних та Транзакції (Частина 1)

SaveChanges і SaveChangesAsync в EF Core — атомарність за замовчуванням, що відбувається під капотом. Явні транзакції через BeginTransactionAsync, IsolationLevel, Savepoints. Обробка DbUpdateException та транзакційна відмова.

Збереження Даних та Транзакції

Чому транзакції — не опція, а необхідність

Уявіть інтернет-магазин: клієнт оформлює замовлення. Система має:

  1. Зменшити залишок товарів на складі
  2. Створити запис Order
  3. Створити записи OrderLineItem
  4. Списати кошти з балансу клієнта
  5. Відправити підтвердження email (опціонально)

Що станеться якщо крок 4 завершиться з помилкою? Якщо нема транзакції — залишки вже змінені, Order і LineItems вже записані, а гроші не списані. База даних в суперечливому стані: замовлення є, товар заброньований, але воно не оплачено. Відновити це вручну — nightmare.

Транзакція — фундаментальний механізм бази даних, що гарантує: або всі операції виконуються, або жодна. Це принцип Atomicity з ACID.

EF Core вбудовує транзакційну підтримку на кількох рівнях. Розуміти їх різницю — критично для правильної архітектури.


SaveChanges: вбудована транзакця за замовчуванням

Перше і найважливіше, що потрібно знати: кожен виклик SaveChanges() / SaveChangesAsync() автоматично загортає всі операції в транзакцію. Це не опція яку треба вмикати — це поведінка за замовчуванням.

Якщо EF Core генерує кілька SQL-команд (наприклад, INSERT Order + три INSERT OrderLineItem), всі вони виконуються в одній транзакції. Якщо хоч одна завершується помилкою — вся транзакція відкочується:

// Ці три зміни — в одній автоматичній транзакції
var order = new Order
{
    CustomerId  = customerId,
    Status      = "Pending",
    PlacedAt    = DateTime.UtcNow,
    TotalAmount = 15000m
};
context.Orders.Add(order);

context.OrderLineItems.Add(new OrderLineItem
{
    Order      = order,
    ProductId  = 1,
    Quantity   = 2,
    UnitPrice  = 5000m
});
context.OrderLineItems.Add(new OrderLineItem
{
    Order      = order,
    ProductId  = 3,
    Quantity   = 1,
    UnitPrice  = 5000m
});

// Один SaveChanges → одна транзакція:
// BEGIN TRANSACTION
//   INSERT INTO Orders (...) VALUES (...)      → Id=42
//   INSERT INTO OrderLineItems (...) VALUES (...) → FK OrderId=42
//   INSERT INTO OrderLineItems (...) VALUES (...) → FK OrderId=42
// COMMIT
await context.SaveChangesAsync();

Якщо третій INSERT завершиться помилкою (наприклад, порушення constraint) — ROLLBACK скасує і перший, і другий INSERT. Жодних «сирітських» Order без LineItems.

Що відбувається під капотом SaveChanges

Порядок дій при SaveChanges() строго визначений:

  1. ChangeTracker.DetectChanges(): виявити всі зміни (якщо AutoDetectChanges увімкнено)
  2. Топологічне сортування: EF Core будує граф залежностей між entity і сортує операції. INSERT мають виконуватись у правильному порядку (спочатку батьки, потім дочірні — через FK)
  3. Відкриття транзакції: BEGIN TRANSACTION (якщо немає зовнішньої)
  4. Виконання команд: INSERT, UPDATE, DELETE у групах (EF Core батчує до 42 команд за замовчуванням)
  5. Заповнення ключів: після INSERT з IDENTITY — зчитується згенерований Id і записується у C#-об'єкт
  6. Commit або Rollback: успіх → COMMIT, виняток → ROLLBACK
  7. Оновлення Change Tracker: стани entity переключаються в Unchanged (або Detached для видалених)

Кількість SQL-команд: батчування

EF Core не виконує кожен INSERT окремим викликом до бази. Він батчує команди:

// 100 нових продуктів → не 100 окремих викликів!
for (int i = 0; i < 100; i++)
    context.Products.Add(new Product { Name = $"P{i}", Price = 100m * i, CategoryId = 1 });

await context.SaveChangesAsync();
// SQL: один виклик з ~3 батчами по ~34 INSERT кожен
// (за замовчуванням MaxBatchSize залежить від провайдера: SQL Server ~42, PostgreSQL ~1000)

Налаштування розміру батчу:

options.UseSqlServer(connectionString, opts =>
    opts.MaxBatchSize(100)); // повільніше якщо занадто велике

Явні транзакції: BeginTransactionAsync

Автоматична транзакція SaveChanges охоплює один виклик. Але що якщо потрібно обхопити кілька SaveChanges або комбінацію EF Core + Raw SQL?

Для цього — явна транзакція:

// Явна транзакція через EF Core API
await using var transaction = await context.Database.BeginTransactionAsync();

try
{
    // Операція 1: створити Order і рядки
    var order = new Order { CustomerId = customerId, Status = "Pending", TotalAmount = 15000m };
    context.Orders.Add(order);
    await context.SaveChangesAsync(); // SaveChanges в межах явної транзакції

    // Операція 2: зменшити залишок (ОКРЕМИЙ SaveChanges!)
    var product = await context.Products.FindAsync(productId);
    product!.Stock -= quantity;
    await context.SaveChangesAsync(); // Той самий DbConnection → та сама транзакція

    // Операція 3: raw SQL для атомарного оновлення балансу
    await context.Database.ExecuteSqlInterpolatedAsync(
        $"UPDATE CustomerWallets SET Balance = Balance - {totalAmount} WHERE CustomerId = {customerId}");
    // Теж у тій самій транзакції!

    // Все успішно → Commit
    await transaction.CommitAsync();
}
catch (Exception ex)
{
    // Будь-яка помилка → Rollback всіх трьох операцій
    await transaction.RollbackAsync();
    _logger.LogError(ex, "Order creation failed, transaction rolled back");
    throw;
}

Ключова деталь: поки транзакція відкрита (між BeginTransactionAsync і CommitAsync/RollbackAsync) — всі операції через той самий DbContext (і той самий DbConnection) виконуються в межах цієї транзакції. Не потрібно передавати транзакцію явно — EF Core «знає» що вона відкрита.

IsolationLevel: рівні ізоляції

Isolation Level визначає як транзакція «бачить» зміни інших одночасних транзакцій:

using var transaction = await context.Database.BeginTransactionAsync(
    IsolationLevel.ReadCommitted); // ← явне зазначення рівня ізоляції

Чотири стандартних рівні (від найменш до найбільш ізольованого):

ReadUncommitted — транзакція бачить незафіксовані зміни інших транзакцій («брудне читання»). Найшвидший, найменш безпечний. Ніколи не використовуйте для написання критичних даних.

ReadCommitted — бачить лише зафіксовані зміни. Стандарт для більшості баз (SQL Server, PostgreSQL default). Запобігає «брудному читанню», але допускає «non-repeatable read»: одна транзакція може отримати різні значення при двох послідовних читаннях одного рядка.

RepeatableRead — гарантує що одне й те саме читання поверне однакові дані впродовж транзакції. Запобігає non-repeatable read, але допускає «фантомне читання» (нові рядки що з'явились між двома запитами).

Serializable — найвища ізоляція: транзакції виконуються ніби послідовно. Запобігає всім аномаліям, але найповільніший і найбільш схильний до дедлоків.

// PostgreSQL: додатково підтримує Snapshot Isolation
using var tx = await context.Database.BeginTransactionAsync(
    System.Data.IsolationLevel.Snapshot);
// Читання — snapshot на момент початку транзакції (MVCC)
// Без блокувань при читанні
IsolationLevelDirty ReadNon-RepeatablePhantom
ReadUncommitted✅ можливо
ReadCommitted✅ можливо
RepeatableRead✅ можливо
Serializable

Savepoints: часткове скасування

Savepoint — «точка збереження» всередині транзакції. Дозволяє відкотитися до конкретного моменту транзакції, не скасовуючи всю її:

await using var transaction = await context.Database.BeginTransactionAsync();

try
{
    // Крок 1: Базові операції (обов'язкові)
    context.Orders.Add(order);
    await context.SaveChangesAsync();

    // Встановлюємо Savepoint — «контрольна точка»
    await transaction.CreateSavepointAsync("after_order_creation");

    try
    {
        // Крок 2: Необов'язкові/ризиковані операції
        await SendNotificationToWarehouseAsync(order.Id);
        await UpdateInventorySystemAsync(order.LineItems);
        await context.SaveChangesAsync();
    }
    catch (Exception innerEx)
    {
        // Помилка у необов'язковій частині → відкотитись до SavePoint
        // Крок 1 (Order) — залишається!
        await transaction.RollbackToSavepointAsync("after_order_creation");
        _logger.LogWarning(innerEx, "Inventory update failed, rolled back to savepoint");
        // Продовжуємо без inventory update
    }

    await transaction.CommitAsync(); // Order збережено
}
catch
{
    await transaction.RollbackAsync(); // Повний відкат
    throw;
}

Savepoints — ідеальний інструмент для часткового відкату у складних бізнес-процесах де деякі кроки є опціональними.

Підтримка Savepoints: SQL Server (2005+), PostgreSQL, MySQL (8.0+) підтримують Savepoints. SQLite — обмежена підтримка. Перевіряйте документацію провайдера.

Обробка DbUpdateException та DbUpdateConcurrencyException

SaveChanges може завершитися помилкою. EF Core кидає два основних типи винятків:

DbUpdateException: загальна помилка запису

try
{
    await context.SaveChangesAsync();
}
catch (DbUpdateException ex)
{
    // ex.Entries: список EntityEntry що спричинили помилку
    foreach (var entry in ex.Entries)
    {
        _logger.LogError(
            "Failed to save {EntityType} with state {State}: {Message}",
            entry.Entity.GetType().Name,
            entry.State,
            ex.InnerException?.Message);
    }

    // Перевіряємо тип помилки через inner exception
    if (ex.InnerException is Microsoft.Data.SqlClient.SqlException sqlEx)
    {
        switch (sqlEx.Number)
        {
            case 2627: // Unique Constraint Violation
            case 2601:
                throw new ConflictException("Запис з таким значенням вже існує", ex);

            case 547:  // Foreign Key Constraint
                throw new ValidationException("Неприпустиме значення зовнішнього ключа", ex);

            case 1205: // Deadlock
                throw new RetryableException("Deadlock detected, please retry", ex);

            default:
                throw; // невідома помилка — перекидаємо
        }
    }

    throw;
}

Типові причини DbUpdateException

Порушення UNIQUE constraint: спроба вставити дублюючий email в таблицю де є UNIQUE INDEX. Необхідно або перевіряти наперед (AnyAsync), або обробляти виняток.

Порушення FK constraint: INSERT з CategoryId що не існує у Categories. Необхідна валідація або CASCADE налаштування.

Порушення NOT NULL: NULL у стовпці без default. Помилка конфігурації або маппінгу.

Дедлок (Deadlock): дві транзакції чекають одна на одну. Рідкісний, але потребує retry-логіки.

Retry-логіка для transient помилок

// Polly для retry з exponential backoff
using Polly;
using Polly.Retry;

var retryPolicy = Policy<int>
    .Handle<DbUpdateException>(ex =>
        ex.InnerException is SqlException sqlEx && sqlEx.Number == 1205) // Deadlock
    .Or<TimeoutException>()
    .WaitAndRetryAsync(
        retryCount: 3,
        sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)),
        onRetry: (exception, delay, attempt, _) =>
        {
            _logger.LogWarning(
                exception.Exception,
                "Retry {Attempt} after {Delay}s due to: {Message}",
                attempt, delay.TotalSeconds, exception.Exception.Message);
        });

int affected = await retryPolicy.ExecuteAsync(async () =>
{
    // Важливо: при retry DbContext може бути у поганому стані
    // Для retry — або новий scope, або скидання стану трекера
    context.ChangeTracker.Clear(); // Скидаємо трекер перед повторною спробою
    context.Orders.Add(order);
    return await context.SaveChangesAsync();
});

ExecutionStrategy: вбудована retry-логіка EF Core

EF Core має вбудований механізм retry через ExecutionStrategy. Для SQL Server та PostgreSQL є готові стратегії що обробляють transient errors (тимчасові відмови мережі, перезапуски сервера):

// Налаштування EnableRetryOnFailure для SQL Server
services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString, sqlOpts =>
    {
        sqlOpts.EnableRetryOnFailure(
            maxRetryCount:       5,               // до 5 спроб
            maxRetryDelay:       TimeSpan.FromSeconds(30),
            errorNumbersToAdd:   [1205, 40613]    // Deadlock + SQL Azure timeout
        );
    });
});

ExecutionStrategy і явні транзакції

Важливе обмеження: якщо EnableRetryOnFailure увімкнено, явні транзакції вимагають обгортання у strategy.Execute:

// БЕЗ цього — InvalidOperationException при спробі retry всередині явної транзакції
var strategy = context.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(async () =>
{
    await using var transaction = await context.Database.BeginTransactionAsync();

    try
    {
        // Всі операції всередині strategy.ExecuteAsync
        context.Orders.Add(order);
        await context.SaveChangesAsync();

        await context.Database.ExecuteSqlInterpolatedAsync(
            $"UPDATE Wallets SET Balance = Balance - {amount} WHERE Id = {walletId}");

        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw; // ExecutionStrategy перехопить і повторить
    }
});

Практичні завдання (Частина 1)

Рівень 1 — Базовий

Завдання 1.1: Атомарність SaveChanges

Реалізуйте метод CreateOrderAsync(CreateOrderDto dto) що в одному SaveChangesAsync():

  1. Додає Order
  2. Додає OrderLineItems
  3. Навмисно порушуйте FK (неіснуючий ProductId) для одного рядка

Перевірте: ні Order, ні жоден LineItem не зберігається при помилці (перевірте через SELECT після виключення).

Завдання 1.2: Явна транзакція для кількох SaveChanges

Реалізуйте TransferStockAsync(int fromProductId, int toProductId, int quantity):

  1. Зменшити stock у fromProduct
  2. SaveChangesAsync()
  3. Збільшити stock у toProduct
  4. SaveChangesAsync()
  5. Обидва збереження — в одній явній транзакції

Навмисно викличте помилку між кроком 2 та 4 — перевірте що fromProduct НЕ зберігається.

Завдання 1.3: Savepoint для необов'язкового кроку

PlaceOrderAsync:

  • Обов'язково: створити Order і LineItems → Savepoint
  • Необов'язково: відіслати WebHook до зовнішнього сервісу (може впасти)
  • Якщо WebHook впав → RollbackToSavepoint (Order залишається), логуємо і продовжуємо
  • Commit → Order збережено незалежно від WebHook

Рівень 2 — Логіка

Завдання 2.1: DbUpdateException handler

Реалізуйте ISaveChangesHandler middleware що:

  • UniqueConstraintException → повертає 409 Conflict з повідомленням яке поле дублюється
  • ForeignKeyConstraintException → 400 Bad Request
  • DeadlockException → 503 Retry-After з часом очікування
  • Інші → 500

Для кожного визначте нормер помилки (SqlException.Number) для SQL Server і PostgreSQL Npgsql окремо.

Завдання 2.2: EnableRetryOnFailure і явна транзакція

Налаштуйте EnableRetryOnFailure(maxRetryCount: 3) у DbContext options. Реалізуйте strategy.ExecuteAsync wrapper для PlaceOrderAsync. Напишіть тест що симулює transient error (mock SaveChangesAsync щоб падав перші 2 рази і успішно виконувався на 3-й).

Рівень 3 — Архітектура

Завдання 3.1: Saga Pattern основа

E-commerce Saga для PlaceOrder:

  1. CreateOrderStep: Order + LineItems
  2. ReserveStockStep: зменшити Stock
  3. ChargeCreditStep: виклик зовнішнього API (може відмовити)
  4. SendConfirmationStep: Email

Кожен крок має Execute() і Compensate(). Якщо ChargeCreditStep.Execute() відмовляє → ReserveStockStep.Compensate() (відновити Stock) + CreateOrderStep.Compensate() (видалити Order).

Реалізуйте ISagaStep<TContext> і SagaOrchestrator<TContext> що автоматично викликає Compensate у зворотному порядку при помилці.


Підсумок частини 1

Перша частина розкрила всі рівні транзакційності в EF Core:

  • SaveChanges = автоматична транзакція: BEGIN TRANSACTION + батч команд + COMMIT/ROLLBACK — все автоматично. Якщо один INSERT завершується помилкою — відкочується весь SaveChanges.
  • Під капотом SaveChanges: DetectChanges → топологічне сортування → батчування → заповнення ключів → оновлення трекера.
  • Явна транзакція: BeginTransactionAsync + CommitAsync/RollbackAsync. Охоплює кілька SaveChanges і Raw SQL в одній транзакції.
  • IsolationLevel: ReadUncommitted / ReadCommitted / RepeatableRead / Serializable. Таблиця аномалій.
  • Savepoints: CreateSavepointAsync і RollbackToSavepointAsync — часткове скасування без повного Rollback.
  • DbUpdateException: обробка через ex.Entries, SQL Error Numbers для Unique/FK/Deadlock.
  • EnableRetryOnFailure: вбудований retry для transient errors. Явні транзакції вимагають strategy.ExecuteAsync.

У другій частині — Optimistic Concurrency з ConcurrencyToken і RowVersion, Pessimistic Locking, транзакції між кількома DbContext, Outbox Pattern для надійного запису і паттерн Unit of Work.