Уявіть інтернет-магазин: клієнт оформлює замовлення. Система має:
Що станеться якщо крок 4 завершиться з помилкою? Якщо нема транзакції — залишки вже змінені, Order і LineItems вже записані, а гроші не списані. База даних в суперечливому стані: замовлення є, товар заброньований, але воно не оплачено. Відновити це вручну — nightmare.
Транзакція — фундаментальний механізм бази даних, що гарантує: або всі операції виконуються, або жодна. Це принцип Atomicity з ACID.
EF Core вбудовує транзакційну підтримку на кількох рівнях. Розуміти їх різницю — критично для правильної архітектури.
Перше і найважливіше, що потрібно знати: кожен виклик 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() строго визначений:
ChangeTracker.DetectChanges(): виявити всі зміни (якщо AutoDetectChanges увімкнено)BEGIN TRANSACTION (якщо немає зовнішньої)IDENTITY — зчитується згенерований Id і записується у C#-об'єктUnchanged (або Detached для видалених)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)); // повільніше якщо занадто велике
Автоматична транзакція 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 «знає» що вона відкрита.
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)
// Без блокувань при читанні
| IsolationLevel | Dirty Read | Non-Repeatable | Phantom |
|---|---|---|---|
| ReadUncommitted | ✅ можливо | ✅ | ✅ |
| ReadCommitted | ❌ | ✅ можливо | ✅ |
| RepeatableRead | ❌ | ❌ | ✅ можливо |
| Serializable | ❌ | ❌ | ❌ |
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 — ідеальний інструмент для часткового відкату у складних бізнес-процесах де деякі кроки є опціональними.
SaveChanges може завершитися помилкою. EF Core кидає два основних типи винятків:
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;
}
Порушення UNIQUE constraint: спроба вставити дублюючий email в таблицю де є UNIQUE INDEX. Необхідно або перевіряти наперед (AnyAsync), або обробляти виняток.
Порушення FK constraint: INSERT з CategoryId що не існує у Categories. Необхідна валідація або CASCADE налаштування.
Порушення NOT NULL: NULL у стовпці без default. Помилка конфігурації або маппінгу.
Дедлок (Deadlock): дві транзакції чекають одна на одну. Рідкісний, але потребує retry-логіки.
// 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();
});
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
);
});
});
Важливе обмеження: якщо 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: Атомарність SaveChanges
Реалізуйте метод CreateOrderAsync(CreateOrderDto dto) що в одному SaveChangesAsync():
OrderOrderLineItemsПеревірте: ні Order, ні жоден LineItem не зберігається при помилці (перевірте через SELECT після виключення).
Завдання 1.2: Явна транзакція для кількох SaveChanges
Реалізуйте TransferStockAsync(int fromProductId, int toProductId, int quantity):
stock у fromProductSaveChangesAsync()stock у toProductSaveChangesAsync()Навмисно викличте помилку між кроком 2 та 4 — перевірте що fromProduct НЕ зберігається.
Завдання 1.3: Savepoint для необов'язкового кроку
PlaceOrderAsync:
Завдання 2.1: DbUpdateException handler
Реалізуйте ISaveChangesHandler middleware що:
UniqueConstraintException → повертає 409 Conflict з повідомленням яке поле дублюєтьсяForeignKeyConstraintException → 400 Bad RequestDeadlockException → 503 Retry-After з часом очікуванняДля кожного визначте нормер помилки (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.1: Saga Pattern основа
E-commerce Saga для PlaceOrder:
CreateOrderStep: Order + LineItemsReserveStockStep: зменшити StockChargeCreditStep: виклик зовнішнього API (може відмовити)SendConfirmationStep: EmailКожен крок має Execute() і Compensate(). Якщо ChargeCreditStep.Execute() відмовляє → ReserveStockStep.Compensate() (відновити Stock) + CreateOrderStep.Compensate() (видалити Order).
Реалізуйте ISagaStep<TContext> і SagaOrchestrator<TContext> що автоматично викликає Compensate у зворотному порядку при помилці.
Перша частина розкрила всі рівні транзакційності в EF Core:
BEGIN TRANSACTION + батч команд + COMMIT/ROLLBACK — все автоматично. Якщо один INSERT завершується помилкою — відкочується весь SaveChanges.BeginTransactionAsync + CommitAsync/RollbackAsync. Охоплює кілька SaveChanges і Raw SQL в одній транзакції.CreateSavepointAsync і RollbackToSavepointAsync — часткове скасування без повного Rollback.ex.Entries, SQL Error Numbers для Unique/FK/Deadlock.strategy.ExecuteAsync.У другій частині — Optimistic Concurrency з ConcurrencyToken і RowVersion, Pessimistic Locking, транзакції між кількома DbContext, Outbox Pattern для надійного запису і паттерн Unit of Work.
Change Tracker — Графи Об'єктів та Disconnected (Частина 2)
Change Tracker у зв'язаних графах — connected vs disconnected сценарії, проблема «подвійного трекування», керування навігаційними властивостями через ChangeTracker. Overriding SaveChangesAsync для автоматичного аудиту, ClearContext, Repository pattern lifecycle.
Збереження Даних — Concurrency та Outbox (Частина 2)
Optimistic Concurrency з ConcurrencyToken і RowVersion — виявлення конфліктів при одночасному записі. Pessimistic Locking через SELECT FOR UPDATE. Outbox Pattern для надійного запису разом із зовнішніми сервісами. Unit of Work.