У попередній статті ми розглянули ConcurrencyToken і RowVersion як інструменти. Тепер — крок назад, щоб зрозуміти чому ці проблеми взагалі виникають і які механізми бази даних їх породжують.
Кожен веб-застосунок, що обслуговує більше одного користувача одночасно, стикається з конкурентністю. Сотні HTTP-запитів виконуються паралельно, кожен зі своїм DbContext, своєю транзакцією. І всі вони звертаються до спільних рядків у базі даних.
Без правильної координації виникають феномени конкурентного читання — ситуації коли паралельні транзакції «бачать» нераціональний стан даних. Стандарт SQL визначає чотири таких феномени, і кожен IsolationLevel захищає від певної підмножини з них.
Важливіше — розуміти ці феномени не через визначення з підручника, а через конкретні бізнес-сценарії: що саме може піти не так у вашому застосунку, і яку ціну за це платить користувач.
Dirty Read — транзакція читає дані, які ще не зафіксовані іншою транзакцією. Якщо та транзакція відкочується — перша прочитала дані, яких ніколи не існувало.
Класичний сценарій: банківський переказ.
Транзакція A (Переказ):
1. UPDATE Accounts SET Balance = Balance - 1000 WHERE Id = 1
(Баланс: 5000 → 4000, але транзакція ще не закрита)
Транзакція B (Читання балансу) — ReadUncommitted:
2. SELECT Balance FROM Accounts WHERE Id = 1
→ Повертає 4000 (незафіксовані дані від A!)
Транзакція A:
3. ROLLBACK (помилка у переказі — відміна)
(Баланс повертається до 5000)
Транзакція B вже показала клієнту 4000 — цього ніколи не було!
EF Core захист: будь-який IsolationLevel вище ReadUncommitted захищає від dirty read. Ніколи не використовуйте ReadUncommitted для даних, що впливають на бізнес-рішення.
Non-Repeatable Read — транзакція читає один рядок двічі і отримує різні значення, бо інша транзакція змінила його між цими читаннями.
Транзакція A (Перевірка і розрахунок):
1. SELECT Price FROM Products WHERE Id = 5 → Price = 10000
(Обчислення суми замовлення: 3 × 10000 = 30000)
Транзакція B (Зміна ціни):
2. UPDATE Products SET Price = 9000 WHERE Id = 5
3. COMMIT
Транзакція A:
4. SELECT Price FROM Products WHERE Id = 5 → Price = 9000 (інше!)
(Підтвердження ціни: невідповідність із кроком 1)
Реальний наслідок: замовлення виставлене за ціною 10000, а при повторній перевірці ціна вже 9000. Система показує неузгоджені дані в одному бізнес-процесі.
Захист: RepeatableRead або вище. ReadCommitted не захищає.
Phantom Read — транзакція виконує один і той самий SELECT двічі і отримує різну кількість рядків, бо інша транзакція вставила або видалила рядки.
Транзакція A (Звіт):
1. SELECT COUNT(*) FROM Orders WHERE Status='Pending' → 10
(Підготовка до обробки: 10 замовлень)
Транзакція B:
2. INSERT INTO Orders (Status) VALUES ('Pending') ← нове замовлення
3. COMMIT
Транзакція A:
4. SELECT * FROM Orders WHERE Status='Pending' → 11 рядків!
(Неочікуваний «фантом» — замовлення #11 не існувало при кроці 1)
Захист: Serializable. RepeatableRead захищає від змін існуючих рядків, але не від нових.
Write Skew не впливає на читання, але стосується логіки запису: дві транзакції читають один стан, кожна приймає рішення що «виглядає правильним» на основі прочитаного, і обидві записують — разом порушуючи інваріант системи.
Бізнес-правило: у черговій бригаді має бути хоча б 1 лікар.
Зараз: Doctor_A.OnShift = true, Doctor_B.OnShift = true
Транзакція A (Doctor_A хоче відпросити):
1. SELECT COUNT(*) FROM Doctors WHERE OnShift = true → 2
(Можна — залишиться ще Doctor_B)
2. UPDATE Doctors SET OnShift = false WHERE Id = A (ЩЕ НЕ COMMIT)
Транзакція B (Doctor_B хоче відпросити — одночасно!):
3. SELECT COUNT(*) FROM Doctors WHERE OnShift = true → 2 (ще бачить A як OnShift)
(Теж можна — залишиться Doctor_A)
4. UPDATE Doctors SET OnShift = false WHERE Id = B (ЩЕ НЕ COMMIT)
Транзакція A: COMMIT
Транзакція B: COMMIT
Результат: обидва лікарі OnShift = false — інваріант порушено!
Не Dirty Read, не Phantom, не Non-Repeatable — кожна транзакція «бачила» коректний стан.
Захист від Write Skew: Serializable або Pessimistic Locking (явне блокування при читанні).
Різні СУБД вирішують проблему конкурентності по-різному. Розуміння цієї різниці допомагає обирати правильні стратегії.
SQL Server у режимі ReadCommitted (за замовчуванням) використовує блокування читання: при SELECT рядок блокується на час читання, при UPDATE — на час транзакції. Читачі чекають на письменників і навпаки.
Читач → SELECT * FROM Products WHERE Id=1 → SHARED LOCK (на час читання)
Письменник → UPDATE Products WHERE Id=1 → EXCLUSIVE LOCK (чекає на Shared)
Або:
Письменник → UPDATE Products WHERE Id=1 → EXCLUSIVE LOCK
Читач → SELECT * FROM Products WHERE Id=1 → чекає на Exclusive!
Це може призвести до Reader-Writer Contention: велика кількість читачів блокує записи і навпаки.
Вирішення у SQL Server: RCSI (Read Committed Snapshot Isolation). При увімкненні — читачі ніколи не чекають на письменників. Вони читають «знімок» даних на момент початку запиту. Це поведінка ближча до MVCC:
-- Увімкнути RCSI для бази:
ALTER DATABASE YourDb SET READ_COMMITTED_SNAPSHOT ON;
Після цього ReadCommitted у SQL Server поводиться як Snapshot Isolation — без блокувань при читанні.
Multi-Version Concurrency Control — PostgreSQL зберігає кілька версій кожного рядка. Кожна транзакція «бачить» snapshot даних на момент свого початку. Читачі ніколи не блокують письменників і навпаки.
Рядок Products.Id=1 в PostgreSQL (версії через xmin/xmax):
[xmin=100, xmax=null, Price=10000] ← активна версія
[xmin=95, xmax=100, Price=9500] ← старіша версія (для старих транзакцій)
Транзакція B (xid=102, почала після 100): бачить Price=10000
Транзакція A (xid=98, почала до 100): бачить Price=9500
Переваги MVCC:
RepeatableRead у PostgreSQL захищає навіть від Phantom Read (на відміну від стандарту SQL)Недолік: VACUUM — PostgreSQL має прибирати застарілі версії рядків. При надмірній кількості UPDATE без VACUUM — «bloat» і деградація продуктивності.
| SQL Server (Lock) | PostgreSQL (MVCC) | |
|---|---|---|
| Читачі блокують письменників | Так (без RCSI) | Ні |
| Письменники блокують читачів | Так | Ні (readers see snapshot) |
| Overhead при читанні | Низький | Версійний overhead |
| Overhead при записі | Середній (locks) | Вищий (версіонування) |
| Deadlock ймовірність | Вища | Нижча |
| Vacuum потрібен | Ні | Так |
Тепер розглянемо конкретні race conditions що трапляються у реальних застосунках на EF Core і їх вирішення.
Найпоширеніший паттерн: перевірити умову, прийняти рішення, виконати дію — але між перевіркою і дією стан змінився:
// ❌ НЕБЕЗПЕЧНО: Race Condition між AnyAsync і Add
public async Task RegisterUserAsync(string email)
{
// Крок 1: Перевірка унікальності
bool exists = await context.Users.AnyAsync(u => u.Email == email);
// ← Між тут і кроком 2 інша транзакція може вставити того ж email!
if (exists)
throw new BusinessException("Email вже зареєстрований");
// Крок 2: Вставка
context.Users.Add(new User { Email = email });
await context.SaveChangesAsync(); // ← Дублікат можливий!
}
Рішення A: Unique Index + обробка виключення (найефективніше):
// UNIQUE INDEX у міграції:
builder.HasIndex(u => u.Email).IsUnique();
public async Task RegisterUserAsync(string email)
{
context.Users.Add(new User { Email = email });
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex))
{
throw new BusinessException("Email вже зареєстрований");
}
}
private static bool IsUniqueConstraintViolation(DbUpdateException ex)
{
return ex.InnerException is SqlException sqlEx &&
(sqlEx.Number == 2627 || sqlEx.Number == 2601); // SQL Server
// або PostgreSQL: ex.InnerException is PostgresException pgEx && pgEx.SqlState == "23505"
}
Рішення B: Optimistic Concurrency (якщо немає Unique Index):
// Перевіряємо і вставляємо — якщо між ними вставили дублікат,
// UNIQUE INDEX спіймає і SaveChanges кине виключення
// ❌ НЕБЕЗПЕЧНО: Read-Modify-Write без захисту
public async Task ReserveProductAsync(int productId, int quantity)
{
var product = await context.Products.FindAsync(productId);
// ← Між Read і Write 10 паралельних запитів можуть прочитати Stock=5
if (product!.Stock < quantity)
throw new InsufficientStockException();
product.Stock -= quantity; // 5 - 1 = 4 (всі 10 прочитали 5 і всі пишуть 4!)
await context.SaveChangesAsync();
// Результат: Stock = 4, але мало б бути 5 - 10 = -5!
}
Вирішення через Optimistic Concurrency:
public async Task ReserveProductAsync(int productId, int quantity)
{
// Зовнішній retry loop
while (true)
{
var product = await context.Products.FindAsync(productId);
if (product!.Stock < quantity)
throw new InsufficientStockException();
product.Stock -= quantity;
try
{
await context.SaveChangesAsync();
return; // Успіх
}
catch (DbUpdateConcurrencyException)
{
// Хтось інший змінив Stock → перезавантажити і повторити
await context.Entry(product).ReloadAsync();
// Якщо тепер Stock < quantity → InsufficientStockException на наступній ітерації
}
}
}
Вирішення через атомарний UPDATE:
// Атомарний UPDATE: якщо Stock >= quantity — зменшити, інакше — нічого
public async Task ReserveProductAsync(int productId, int quantity)
{
int rowsAffected = await context.Products
.Where(p => p.Id == productId && p.Stock >= quantity)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Stock, p => p.Stock - quantity));
if (rowsAffected == 0)
{
// Або продукт не існує, або Stock недостатній
var product = await context.Products.FindAsync(productId);
if (product is null) throw new NotFoundException();
throw new InsufficientStockException($"Available: {product.Stock}");
}
}
// SQL: UPDATE Products SET Stock = Stock - @qty WHERE Id = @id AND Stock >= @qty
// Атомарна операція: перевірка і зміна в одному SQL — race condition неможливий!
// ❌ НЕБЕЗПЕЧНО: подвійний POST (мережева помилка → retry від клієнта)
[HttpPost("orders")]
public async Task<IActionResult> CreateOrder(CreateOrderDto dto)
{
var order = new Order { CustomerId = dto.CustomerId, ... };
context.Orders.Add(order);
await context.SaveChangesAsync();
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
// Якщо відповідь не дійшла → клієнт retries → дублікат Order!
}
Вирішення: Idempotency Key:
// Клієнт генерує унікальний ключ запиту і надсилає у заголовку
// X-Idempotency-Key: "a1b2c3d4-..."
[HttpPost("orders")]
public async Task<IActionResult> CreateOrder(
CreateOrderDto dto,
[FromHeader(Name = "X-Idempotency-Key")] Guid idempotencyKey)
{
// Перевірити чи вже обробляли цей ключ
var existing = await context.IdempotencyKeys
.AsNoTracking()
.FirstOrDefaultAsync(k => k.Key == idempotencyKey);
if (existing is not null)
{
// Повернути той самий результат що і першого разу
var existingOrder = await context.Orders.FindAsync(existing.ResultId);
return CreatedAtAction(nameof(GetOrder), new { id = existingOrder!.Id }, existingOrder);
}
await using var tx = await context.Database.BeginTransactionAsync();
var order = new Order { ... };
context.Orders.Add(order);
await context.SaveChangesAsync();
// Зберегти ключ разом з Id результату
context.IdempotencyKeys.Add(new IdempotencyKey
{
Key = idempotencyKey,
ResultId = order.Id,
ExpiresAt = DateTime.UtcNow.AddDays(7)
});
await context.SaveChangesAsync();
await tx.CommitAsync();
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}
HTTP стандарт визначає заголовок ETag (Entity Tag) — унікальний ідентифікатор версії ресурсу. Браузери і клієнти можуть кешувати відповідь і при наступному запиті передавати If-None-Match (для умовного GET) або If-Match (для умовного PUT/DELETE). Це природний спосіб реалізувати Optimistic Concurrency у REST API без «протікання» деталей БД у клієнтський код.
RowVersion (байтовий масив) або Version (Guid) — природні джерела ETag значення. Клієнт отримує ETag у відповіді, зберігає його і при PUT відправляє назад. Сервер порівнює з поточним значенням у БД:
// Хелпер для перетворення RowVersion в ETag рядок
public static class ETagHelper
{
// RowVersion byte[] → ETag рядок (Base64)
public static string ToETag(byte[] rowVersion)
=> $"\"{Convert.ToBase64String(rowVersion)}\"";
// ETag рядок → RowVersion byte[]
public static byte[] FromETag(string etag)
{
var value = etag.Trim('"');
return Convert.FromBase64String(value);
}
// Guid Version → ETag
public static string ToETag(Guid version)
=> $"\"{version:N}\"";
}
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(int id)
{
var product = await context.Products
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == id);
if (product is null) return NotFound();
var etag = ETagHelper.ToETag(product.RowVersion);
// Умовний GET: якщо ETag не змінився — 304 Not Modified
if (Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch)
&& ifNoneMatch == etag)
{
return StatusCode(304); // Not Modified
}
Response.Headers.ETag = etag;
return Ok(new ProductDto
{
Id = product.Id,
Name = product.Name,
Price = product.Price,
// Не передаємо RowVersion напряму — він в ETag заголовку
});
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateProduct(int id, UpdateProductDto dto)
{
// 1. Перевірити наявність If-Match заголовку
if (!Request.Headers.TryGetValue("If-Match", out var ifMatch))
return StatusCode(428, "If-Match header required"); // 428 Precondition Required
// 2. Завантажити entity
var product = await context.Products.FindAsync(id);
if (product is null) return NotFound();
// 3. Порівняти ETag з поточним RowVersion
var currentETag = ETagHelper.ToETag(product.RowVersion);
if (ifMatch != currentETag)
return StatusCode(412, "Precondition Failed — resource was modified"); // 412
// 4. Застосувати зміни
product.Name = dto.Name;
product.Price = dto.Price;
try
{
await context.SaveChangesAsync();
// RowVersion оновлюється автоматично сервером
Response.Headers.ETag = ETagHelper.ToETag(product.RowVersion);
return Ok(); // або 204 No Content
}
catch (DbUpdateConcurrencyException)
{
// Конкурентна зміна між перевіркою і збереженням (race condition edge case)
return StatusCode(412, "Concurrent modification detected");
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteProduct(int id)
{
if (!Request.Headers.TryGetValue("If-Match", out var ifMatch))
return StatusCode(428, "If-Match header required");
var product = await context.Products.FindAsync(id);
if (product is null) return NotFound();
if (ETagHelper.ToETag(product.RowVersion) != ifMatch)
return StatusCode(412, "Resource version mismatch");
context.Products.Remove(product);
await context.SaveChangesAsync();
return NoContent(); // 204
}
RowVersion чи Guid — він просто зберігає непрозорий рядок. HTTP стандарт чітко визначає семантику 412 Precondition Failed. Браузерний кеш автоматично використовує ETags для умовних GET — безкоштовна оптимізація.Система бронювання готельних номерів — класичний high-contention сценарій. Той самий номер може намагатись забронювати десяток клієнтів одночасно:
public class RoomBookingService
{
private readonly AppDbContext _context;
// Наївна реалізація — race condition!
public async Task<Booking> BookRoomNaiveAsync(
int roomId, DateOnly checkIn, DateOnly checkOut, int guestId)
{
// ❌ Check-Then-Act: між AnyAsync і Add → конфлікт!
var isAvailable = !await _context.Bookings.AnyAsync(b =>
b.RoomId == roomId &&
b.CheckIn < checkOut &&
b.CheckOut > checkIn &&
b.Status != "Cancelled");
if (!isAvailable) throw new RoomNotAvailableException();
var booking = new Booking
{
RoomId = roomId,
GuestId = guestId,
CheckIn = checkIn,
CheckOut = checkOut,
Status = "Confirmed"
};
_context.Bookings.Add(booking);
await _context.SaveChangesAsync();
return booking;
}
// ✅ Правильна реалізація: Unique Constraint + Exception handling
public async Task<Booking> BookRoomSafeAsync(
int roomId, DateOnly checkIn, DateOnly checkOut, int guestId)
{
// Без попередньої перевірки — покладаємось на UNIQUE CONSTRAINT:
// ALTER TABLE Rooms ADD CONSTRAINT no_overlap
// EXCLUDE USING gist (RoomId WITH =, daterange(CheckIn, CheckOut) WITH &&)
// (PostgreSQL: Exclusion Constraint для діапазонів)
// SQL Server: через тригер або CHECK CONSTRAINT + INDEX
var booking = new Booking
{
RoomId = roomId,
GuestId = guestId,
CheckIn = checkIn,
CheckOut = checkOut,
Status = "Confirmed"
};
_context.Bookings.Add(booking);
try
{
await _context.SaveChangesAsync();
return booking;
}
catch (DbUpdateException ex) when (IsOverlapConstraintViolation(ex))
{
throw new RoomNotAvailableException(
$"Room {roomId} is already booked for {checkIn:d}–{checkOut:d}");
}
}
// Альтернатива: Pessimistic Lock для room
public async Task<Booking> BookRoomWithLockAsync(
int roomId, DateOnly checkIn, DateOnly checkOut, int guestId)
{
await using var tx = await _context.Database.BeginTransactionAsync(
IsolationLevel.RepeatableRead);
// Блокуємо Room на рівні рядка
var room = await _context.Rooms
.FromSqlRaw("SELECT * FROM Rooms WITH (UPDLOCK, ROWLOCK) WHERE Id = {0}", roomId)
.FirstOrDefaultAsync();
if (room is null) throw new NotFoundException();
// Тепер можна перевіряти без race condition (інші чекають блокування)
var isAvailable = !await _context.Bookings.AnyAsync(b =>
b.RoomId == roomId &&
b.CheckIn < checkOut &&
b.CheckOut > checkIn &&
b.Status != "Cancelled");
if (!isAvailable)
{
await tx.RollbackAsync();
throw new RoomNotAvailableException();
}
var booking = new Booking { RoomId = roomId, GuestId = guestId,
CheckIn = checkIn, CheckOut = checkOut };
_context.Bookings.Add(booking);
await _context.SaveChangesAsync();
await tx.CommitAsync();
return booking;
}
private static bool IsOverlapConstraintViolation(DbUpdateException ex)
=> ex.InnerException?.Message.Contains("no_overlap") == true ||
ex.InnerException?.Message.Contains("IX_Bookings") == true;
}
E-commerce: захист від продажу більше ніж є на складі:
public class InventoryService
{
private readonly AppDbContext _context;
// ✅ Атомарний UPDATE з перевіркою залишку
public async Task<ReservationResult> ReserveItemsAsync(
IReadOnlyList<(int ProductId, int Quantity)> items)
{
var reservations = new List<StockReservation>();
var failures = new List<(int ProductId, int Available, int Requested)>();
using var tx = await _context.Database.BeginTransactionAsync();
foreach (var (productId, quantity) in items)
{
// Атомарний: зменшити якщо Stock достатній
var rowsAffected = await _context.Products
.Where(p => p.Id == productId && p.Stock >= quantity)
.ExecuteUpdateAsync(s =>
s.SetProperty(p => p.Stock, p => p.Stock - quantity));
if (rowsAffected == 1)
{
reservations.Add(new StockReservation
{
ProductId = productId,
ReservedQty = quantity,
ReservedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddMinutes(30)
});
}
else
{
// Знайти реальний залишок для повідомлення про помилку
var available = await _context.Products
.Where(p => p.Id == productId)
.Select(p => p.Stock)
.FirstOrDefaultAsync();
failures.Add((productId, available, quantity));
}
}
if (failures.Count > 0)
{
// Якщо хоч один товар недоступний → відкотити всі резервування
await tx.RollbackAsync();
return ReservationResult.Failed(failures.Select(f =>
$"Product {f.ProductId}: requested {f.Requested}, available {f.Available}"));
}
// Зберегти резервування
_context.StockReservations.AddRange(reservations);
await _context.SaveChangesAsync();
await tx.CommitAsync();
return ReservationResult.Success(reservations);
}
// Звільнення резервування (при скасуванні або закінченні часу)
public async Task ReleaseReservationAsync(Guid reservationId)
{
using var tx = await _context.Database.BeginTransactionAsync();
var reservation = await _context.StockReservations
.FirstOrDefaultAsync(r => r.Id == reservationId &&
r.Status == "Active");
if (reservation is null)
{
await tx.RollbackAsync();
return;
}
// Повернути товар на склад
await _context.Products
.Where(p => p.Id == reservation.ProductId)
.ExecuteUpdateAsync(s =>
s.SetProperty(p => p.Stock, p => p.Stock + reservation.ReservedQty));
reservation.Status = "Released";
reservation.ReleasedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
await tx.CommitAsync();
}
}
Завдання 1.1: Read Phenomena симуляція
Напишіть три тести що демонструють:
ReadCommitted запити до одного рядка отримують різні значення (змоделюйте через два DbContext)RepeatableRead: перший сценарій не відтворюється при цьому рівні ізоляціїЗавдання 1.2: Check-Then-Act безпека
Для Username в таблиці Users:
Username через міграціюRegisterAsync без попередньої перевірки — лише обробка DbUpdateExceptionBusinessExceptionЗавдання 1.3: Атомарний UPDATE для інвентаризації
Реалізуйте ReserveStockAsync(int productId, int quantity) через ExecuteUpdateAsync з умовою Stock >= quantity. Напишіть тест з 10 паралельними запитами резервування по 1 штуці з 5 наявних — рівно 5 мають повернути успіх.
Завдання 2.1: Write Skew Prevention
Система чергувань лікарів: не меньше 1 лікаря у зміні. Відтворіть Write Skew за ReadCommitted:
Вирішіть через:
Serializable isolation levelЗавдання 2.2: Idempotency Key для order creation
Реалізуйте IdempotencyKey таблицю і CreateOrderAsync що приймає Guid idempotencyKey. При повторному виклику з тим самим ключем — повертає існуючий Order (без дублікату). При новому ключі — створює новий. Напишіть тест що симулює network retry.
Завдання 3.1: Distributed Lock для критичних секцій
EF Core + SQL Server: реалізуйте IDistributedLock через sp_getapplock (SQL Server Application Lock):
public async Task<bool> TryAcquireAsync(string resource, int timeoutMs);
public async Task ReleaseAsync(string resource);
-- sp_getapplock: іменоване блокування на рівні БД
EXEC sp_getapplock @Resource='order_123', @LockMode='Exclusive', @LockTimeout=5000
Використайте для захисту ReserveStockAsync на рівні конкретного продукту (не всієї таблиці).
Перша частина розкрила теоретичну і практичну основу конкурентності:
ExecuteUpdateAsync з умовою або Optimistic Concurrency retry loop.У другій частині — Lock Escalation і дедлоки, детектування та розрив дедлоків, SELECT FOR UPDATE SKIP LOCKED для queue processing, та monitoring concurrency проблем у production.
Збереження Даних — Concurrency та Outbox (Частина 2)
Optimistic Concurrency з ConcurrencyToken і RowVersion — виявлення конфліктів при одночасному записі. Pessimistic Locking через SELECT FOR UPDATE. Outbox Pattern для надійного запису разом із зовнішніми сервісами. Unit of Work.
Конкурентність — Дедлоки та Queue Processing (Частина 2)
Lock Escalation і дедлоки в EF Core — як вони виникають і як їх уникати. SELECT FOR UPDATE SKIP LOCKED для надійного queue processing. Monitoring конкурентних проблем у production. Concurrency Patterns для реальних систем.