---
title: 'Бази даних та розподілені транзакції: ізоляція, блокування та паттерни консенсусу'
description: 'Детальний посібник із транзакцій у базах даних, гонок даних, оптимістичних та песимістичних блокувань, розподілених блокувань у Redis, 2PC, оркестрації та хореографії Saga, а також паттерну Transactional Outbox з брокерами повідомлень.'
faq:
    - { question: 'У чому різниця між оптимістичним та песимістичним блокуванням?', answer: 'Песимістичне блокування запобігає конфліктам паралелізму, блокуючи рядки бази даних (за допомогою SELECT FOR UPDATE), що зупиняє інші транзакції до звільнення блокування. Оптимістичне блокування припускає, що конфлікти є рідкісними; воно дозволяє паралельне читання та запис, але перевіряє колонку версії або часової мітки під час оновлення. Якщо за цей час рядок був змінений іншою транзакцією, оновлення завершується помилкою, і додаток має повторити спробу.' }
    - { question: 'Чому двофазний коміт (2PC) рідко використовується у сучасних мікросервісах?', answer: 'Двофазний коміт — це синхронний блокуючий протокол. Він вимагає, щоб усі задіяні сервіси утримували блокування ресурсів протягом обох фаз. Якщо один із сервісів сповільнюється або вимикається, усі інші залишаються заблокованими, що створює єдину точку відмови та сильно обмежує масштабованість і доступність системи.' }
    - { question: "Як паттерн Transactional Outbox гарантує доставку повідомлень 'хоча б один раз' (at-least-once)?", answer: "Замість відправки повідомлення брокеру безпосередньо під час транзакції БД (що може дати збій, якщо брокер недоступний), сервіс записує бізнес-дані та тіло повідомлення у спеціальну таблицю 'outbox' в межах тієї ж локальної транзакції. Окремий фоновий процес асинхронно опитує цю таблицю, відправляє повідомлення брокеру та позначає їх як оброблені, гарантуючи, що жодне повідомлення не буде втрачено." }
published: '2026-06-14'
---
# Бази даних та розподілені транзакції: ізоляція, блокування та паттерни консенсусу

Уявіть користувача, який намагається забронювати останнє вільне місце на авіарейс. Два запити приходять на сервери додатків в одну й ту саму мілісекунду. Якщо система не спроектована з урахуванням паралелізму, обидва запити прочитають статус місця як «вільно», спишуть оплату та випустять два квитки на одне й те саме крісло. Ця класична проблема подвійного бронювання — кошмар як для розробників, так і для бізнесу. У монолітній системі з єдиною базою даних транзакції SQL легко запобігають цьому. Але в розподіленій системі, де сервіс бронювання, платіжний шлюз та система повідомлень є ізольованими мікросервісами, простої транзакції бази даних уже недостатньо.

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

## Зміст
* [Локальні транзакції БД: ACID та рівні ізоляції](#acid-isolation)
* [Стан гонки: песимістичні та оптимістичні блокування](#locking-strategies)
* [Розподілені блокування: рішення на базі Redis](#redis-locks)
* [Розподілені транзакції: крах двофазного коміту (2PC)](#distributed-2pc)
* [Узгодженість у кінцевому підсумку: Saga та Transactional Outbox](#saga-outbox)
* [Практичний приклад коду на PHP та Laravel](#code-demo)
* [Обмеження та компроміси](#limitations)
* [Практичні висновки](#takeaways)

---

<a id="acid-isolation"></a>
## Локальні транзакції БД: ACID та рівні ізоляції

В основі цілісності даних лежить модель ACID: атомарність (Atomicity), узгодженість (Consistency), ізольованість (Isolation) та довговічність (Durability). Якщо атомарність («все або нічого») та довговічність (гарантія запису на диск) інтуїтивно зрозумілі, то ізольованість — це область постійного компромісу між продуктивністю та коректністю.

Бази даних пропонують чотири стандартні рівні ізоляції для управління паралельним доступом, кожен з яких запобігає певним аномаліям:

1. **Read Uncommitted (Читання незафіксованих даних)**: Найнижчий рівень. Транзакція може читати зміни, зроблені іншою транзакцією, ще до того, як вони будуть зафіксовані (Dirty Reads — брудне читання).
2. **Read Committed (Читання зафіксованих даних)**: Запобігає брудному читанню. Запит бачить лише ті дані, які були зафіксовані на момент початку запиту. Однак повторний запит всередині тієї ж транзакції може повернути змінені дані (Non-repeatable Reads — неповторюване читання).
3. **Repeatable Read (Повторюваність читання)**: Запобігає неповторюваному читанню. Дані, прочитані в транзакції, залишаються незмінними. Проте, інші транзакції можуть додавати нові рядки, через що повторний запит поверне нові записи (Phantom Reads — фантомне читання).
4. **Serializable (Серіалізованість)**: Найвищий рівень. Транзакції виконуються послідовно, повністю виключаючи всі аномалії паралелізму ціною критичного падіння продуктивності.

Більшість реляційних СУБД (таких як PostgreSQL та MySQL) за замовчуванням використовують **Read Committed** або **Repeatable Read**. Щоб гарантувати повну надійність без шкоди для швидкості всієї системи, розробникам доводиться керувати блокуваннями вручну на рівні окремих запитів.

---

<a id="locking-strategies"></a>
## Стан гонки: песимістичні та оптимістичні блокування

Коли кілька клієнтів одночасно намагаються змінити один і той самий рядок у базі даних, необхідно вибрати стратегію блокування.

### Песимістичне блокування (Pessimistic Locking)
* **Теза**: Припускаємо, що конфлікти паралельного доступу неминучі, і блокуємо ресурс заздалегідь.
* **Чому це важливо**: Це гарантує відсутність гонки даних безпосередньо силами СУБД.
* **Приклад**: Запит `SELECT ... FOR UPDATE` блокує вибрані рядки. Будь-яка інша транзакція, що намагається прочитати їх за допомогою `FOR UPDATE` або змінити, чекатиме, поки перша транзакція не виконає `COMMIT` або `ROLLBACK`.
* **Наслідки**: Висока надійність, але низька пропускна здатність. Якщо транзакція затягується (наприклад, очікує відповіді від стороннього API), інші потоки швидко вичерпають пул з'єднань СУБД, що призведе до відмови додатку.

### Оптимістичне блокування (Optimistic Locking)
* **Теза**: Припускаємо, що конфлікти відбуваються рідко, дозволяючи вільне читання та перевіряючи конкурентні зміни під час запису.
* **Чому це важливо**: Виключає утримання блокувань в БД, максимізуючи швидкість читання та масштабованість.
* **Приклад**: До таблиці додається колонка `version` (ціле число) або `updated_at` (час зміни). SQL-запит на оновлення перевіряє вихідну версію:
  ```sql
  UPDATE inventory SET quantity = quantity - 1, version = version + 1 
  WHERE id = 1 AND version = 3;
  ```
* **Наслідки**: Якщо інша транзакція встигла оновити рядок раніше, версія змінилася на 4, запит оновить 0 рядків. Додаток фіксує цей факт і вирішує: повторити операцію чи повернути помилку. Проте при високій частоті одночасних записів постійні повтори транзакцій знижують загальну продуктивність.

---

<a id="redis-locks"></a>
## Розподілені блокування: рішення на базі Redis

У високонавантажених системах блокування на рівні бази даних перевантажують її процесор. Крім того, якщо потрібно заблокувати абстрактний бізнес-процес або операцію, не прив'язану до конкретного рядка таблиці, блокування СУБД не підходять.

* **Теза**: Використовуємо швидке сховище в оперативній пам'яті (наприклад, Redis) для керування статусами блокувань поза СУБД.
* **Чому це важливо**: Redis працює в один потік і виконує команди атомарно, забезпечуючи субмілісекундний відгук і звільняючи ресурси основної бази даних.
* **Приклад**: Процес запитує блокування за допомогою атомарної команди `SET lock_key unique_token NX PX 5000` (встановити ключ, якщо його немає, на 5000 мс). Звільнення блокування відбувається через скрипт Lua, який видаляє ключ лише в тому випадку, якщо токен збігається із збереженим.
* **Наслідки**: Висока масштабованість. Однак у розподіленому кластері Redis (master-replica) збій майстра до реплікації ключа може призвести до видачі дублюючого блокування. Для захисту від цього застосовується алгоритм Redlock, який запитує блокування у більшості незалежних нод Redis.

---

<a id="distributed-2pc"></a>
## Розподілені транзакції: крах двофазного коміту (2PC)

При переході на мікросервіси дані розносяться по ізольованих БД (Database-per-Service). Бізнес-логіка покупки тепер зачіпає сервіс користувачів, сервіс складу та сервіс оплати.

Історичним рішенням для розподілених транзакцій був **Двофазний коміт (2PC)**:
1. **Фаза підготовки (Prepare)**: Координатор запитує у всіх сервісів готовність виконати операцію. Сервіси локально блокують ресурси та відповідають «так».
2. **Фаза фіксації (Commit)**: Якщо всі відповіли «так», координатор дає команду на фіксацію змін. Якщо хоча б один відповів «ні», координатор дає команду на відкат (Rollback).

Незважаючи на простоту концепції, 2PC є **синхронним блокуючим протоколом**. Якщо у фазі фіксації відбувається збій мережі або один із сервісів йде в офлайн, ресурси інших сервісів залишаються заблокованими на невизначений термін. Це суперечить вимогам доступності (теорема CAP). Сучасні системи вибирають узгодженість у кінцевому підсумку (eventual consistency).

---

<a id="saga-outbox"></a>
## Узгодженість у кінцевому підсумку: Saga та Transactional Outbox

Для забезпечення надійності в розподіленому середовищі без блокування ресурсів застосовуються асинхронні архітектурні шаблони.

### Паттерн Saga
Saga — це ланцюжок локальних транзакцій. Кожен мікросервіс виконує свою транзакцію у своїй БД та публікує подію. Інші сервіси слухають подію та запускають свої кроки. Якщо на якомусь кроці відбувається збій, Saga запускає ланцюжок **компенсуючих транзакцій** у зворотному порядку для скасування змін.

* **Хореографія (Choreography)**: Сервіси обмінюються подіями безпосередньо без єдиної точки контролю. Архітектура гнучка, але складна в налагодженні при розростанні ланцюжків.
* **Оркестрація (Orchestration)**: Виділяється окремий сервіс-оркестратор, який покроково викликає мікросервіси та керує логікою компенсації. Простіше контролювати, але оркестратор стає критичною точкою системи.

### Паттерн Transactional Outbox
Поширена помилка — надсилати повідомлення в брокер (наприклад, RabbitMQ) прямо всередині транзакції бази даних. Якщо брокер недоступний, транзакція відкотиться. Якщо брокер прийме повідомлення, але транзакція БД впаде при комміті, виникне фантомна подія.

Рішення дає **Transactional Outbox**:
1. Бізнес-дані та подія (в таблицю `outbox`) записуються в локальну БД в межах однієї й тієї ж транзакції.
2. Окремий фоновий процес (Message Relay) опитує таблицю `outbox`, надсилає події в брокер та позначає їх як надіслані.
3. Цей підхід гарантує доставку **«хоча б один раз»**. Отримувач повідомлення обов'язково має бути **ідемпотентним** (повторна обробка тієї ж події не повинна призводити до дублювання даних).

---

<a id="code-demo"></a>
## Практичний приклад коду на PHP та Laravel

Ось приклади реалізації описаних підходів у Laravel.

### 1. Транзакція БД з песимістичним блокуванням
```php
// app/Services/BookingService.php
namespace App\Services;

use Illuminate\Support\Facades\DB;
use App\Models\FlightSeat;

class BookingService
{
    public function bookSeat(int $seatId, int $userId): bool
    {
        return DB::transaction(function () use ($seatId, $userId) {
            // Блокуємо рядок. Інші паралельні запити будуть очікувати тут.
            $seat = FlightSeat::where('id', $seatId)
                ->lockForUpdate()
                ->first();

            if (!$seat || $seat->is_booked) {
                return false;
            }

            $seat->update([
                'is_booked' => true,
                'user_id' => $userId,
            ]);

            return true;
        });
    }
}
```

### 2. Розподілене блокування в Redis
```php
// app/Services/InventoryService.php
namespace App\Services;

use Illuminate\Support\Facades\Cache;

class InventoryService
{
    public function decreaseStock(int $productId, int $quantity): bool
    {
        $lockKey = "lock:product:{$productId}";
        
        // Запитуємо блокування на 10 сек, чекаємо звільнення до 3 сек.
        $lock = Cache::lock($lockKey, 10);

        if ($lock->get()) {
            try {
                // Швидка робота з даними без блокування таблиць БД
                // ... логіка оновлення залишків
                return true;
            } finally {
                $lock->release();
            }
        }

        return false;
    }
}
```

### 3. Запис події в Transactional Outbox
```php
// app/Services/OrderService.php
namespace App\Services;

use Illuminate\Support\Facades\DB;
use App\Models\Order;
use App\Models\OutboxMessage;

class OrderService
{
    public function createOrder(array $data): Order
    {
        return DB::transaction(function () use ($data) {
            $order = Order::create($data);

            // Зберігаємо подію в тій же транзакції, де створюється замовлення
            OutboxMessage::create([
                'event_type' => 'order.created',
                'payload' => json_encode([
                    'order_id' => $order->id,
                    'user_id' => $order->user_id,
                    'total' => $order->total_amount,
                ]),
                'processed' => false,
            ]);

            return $order;
        });
    }
}
```

---

<a id="limitations"></a>
## Обмеження та компроміси

* **Оптимістичні блокування**: При вкрай високій частоті одночасних записів в одну сутність кількість помилок та повторних спроб зростає, навантажуючи процесор додатку.
* **Блокування Redis**: Безпека блокування жорстко прив'язана до часу життя (TTL). Якщо процес «зависне» (наприклад, через довге складання сміття у JVM/PHP), блокування зніметься завчасно, і інший воркер порушить ізоляцію.
* **Saga**: Компенсуючі транзакції не можуть фізично «відкотити» зовнішні дії (відправку Email, списання грошей банком). Тому бізнес-логіку доводиться проектувати через проміжні стани (холдування коштів, чернетки листів).

---

<a id="takeaways"></a>
## Практичні висновки

1. Використовуйте **оптимістичні блокування** для операцій з високою часткою читання та рідкісними конфліктами (редагування контенту, особисті кабінети).
2. Використовуйте **песимістичні блокування** (`SELECT ... FOR UPDATE`) для критичних фінансових операцій з помірним паралелізмом, де конфлікти мають вирішуватися всередині СУБД.
3. Застосовуйте **блокування Redis** для захисту від дублювання фонових завдань та важких бізнес-операцій на рівні додатку.
4. У мікросервісах забудьте про синхронні розподілені транзакції (2PC). Будуйте процеси на базі **Saga** для координації кроків та **Transactional Outbox** для гарантованої асинхронної доставки подій.