Коли кілька запитів можуть одночасно змінювати один і той самий запис, мені часто потрібні транзакція і блокування рядка.
Але саме тут легко зробити іншу помилку: відкрити транзакцію занадто рано і виконувати під блокуванням усе підряд.
Перетворення вхідних даних, побудова допоміжних об’єктів, складні обчислення або зовнішні виклики не стають безпечнішими лише тому, що виконуються всередині транзакції.
Зате блокування тримається довше.
Тому я намагаюся залишати всередині критичної ділянки тільки те, що справді залежить від актуального стану бази даних.
Я розділяю підготовку і критичну зміну
prepare | vtransaction | vlock | vverify | vwrite | vcommitДо транзакції я виконую все, що не потребує блокування актуального рядка.
Після цього відкриваю транзакцію, повторно читаю потрібний запис із блокуванням, перевіряю його поточний стан і одразу виконую зміну.
Чим менше роботи між lockForUpdate() і завершенням транзакції, тим менше часу інший конкурентний запит змушений чекати.
Надто рання транзакція тримає блокування довше, ніж потрібно
DB::transaction(function () use ($recordId, $input): void { $record = Record::query() ->whereKey($recordId) ->lockForUpdate() ->firstOrFail(); $normalized = $this->normalizer->normalize( $input, ); $payload = $this->builder->build( $normalized, ); $record->apply( $payload,У цьому прикладі нормалізація і побудова даних не залежать від поточного стану запису.
Але рядок уже заблокований.
Поки виконується допоміжна робота, інший запит, якому потрібен той самий рядок, просто чекає.
Транзакція формально правильна, але її критична ділянка ширша, ніж потрібно.
Роботу, яка не потребує блокування, я виконую раніше
$normalized = $this->normalizer->normalize( $input,); $payload = $this->builder->build( $normalized,); DB::transaction(function () use ( $recordId, $payload,): void { $record = Record::query() ->whereKey($recordId) ->lockForUpdate() ->firstOrFail();Тепер під блокуванням залишилися лише три важливі кроки:
- прочитати актуальний запис;
- перевірити, чи операція все ще дозволена;
- виконати запис.
Це і є частина, яка справді потребує послідовності відносно конкурентних змін.
Перед записом я повторно перевіряю актуальний стан
Підготовка до транзакції не означає, що я довіряю старому об’єкту з пам’яті.
Навпаки: якщо рішення залежить від стану, який міг змінитися, я повторно читаю його вже всередині транзакції.
Саме цей актуальний запис після lockForUpdate() є основою для остаточної перевірки.
Підготовлені раніше дані можна використати для запису лише після того, як актуальний стан підтвердив, що операція все ще допустима.
Не всі перевірки повинні виконуватися під блокуванням
До транзакції: - формат вхідних даних- обов'язкові поля- нормалізація- перетворення типів- підготовка значень Під блокуванням: - актуальний стан запису- чи операція все ще дозволена- чи не змінився критичний зв'язок- остаточний записЯ розділяю перевірки за тим, від чого вони залежать.
Якщо перевірка стосується лише самого запиту, її немає сенсу тримати під блокуванням бази.
Якщо ж вона відповідає на питання про поточний стан даних, її потрібно виконувати після повторного читання актуального запису.
Зовнішній виклик під блокуванням для мене є окремим сигналом небезпеки
DB::transaction(function () use ($recordId): void { $record = Record::query() ->whereKey($recordId) ->lockForUpdate() ->firstOrFail(); $result = $externalService->request(); $record->applyExternalResult( $result, ); $record->save();});Зовнішня система може відповісти за 20 мілісекунд, а може за кілька секунд.
Поки ми чекаємо, блокування бази залишається активним.
Тому мережевий виклик усередині заблокованої ділянки для мене завжди привід ще раз перевірити межу операції.
Іноді така послідовність справді необхідна, але вона повинна бути усвідомленою, а не випадковою.
Час блокування впливає не лише на один запит
Запит A lock | |---- зайва робота ----| | | |---- verify + write ---| |commit Запит B lock | +---- WAIT ----+Коли я скорочую транзакцію, це не просто локальна оптимізація одного методу.
Я скорочую час, протягом якого інші запити не можуть працювати з тим самим ресурсом.
Особливо це важливо для записів, які можуть часто змінюватися конкурентно.
Але не можна винести за транзакцію те, що залежить від актуального стану
$record = Record::query() ->findOrFail($recordId); if (! $record->canBeChanged()) { throw WriteConflict::detected();} DB::transaction(function () use ($record): void { $record->applyChange(); $record->save();});Скоротити транзакцію не означає перенести всі перевірки назовні.
У цьому прикладі між canBeChanged() і фактичним записом інший запит може змінити той самий рядок.
Перевірка була правильною в момент читання, але вже не гарантує правильність у момент запису.
Тому стан, який захищає інваріант операції, я перевіряю вже після блокування.
Межа транзакції повинна відповідати межі атомарної зміни
Я не намагаюся зробити транзакцію короткою будь-якою ціною.
Її головна задача все одно полягає в тому, щоб зміни, які повинні відбутися разом, справді відбулися разом.
Якщо для правильності потрібно змінити два записи в одній атомарній операції, обидва записи залишаються в транзакції.
Я прибираю лише те, що не є частиною цієї атомарності.
Помилка на етапі підготовки краще трапляється до блокування
Є ще одна проста перевага підготовки до транзакції.
Якщо вхідні дані неможливо нормалізувати, перетворити або підготувати, запит може завершитися ще до того, як заблокує будь-який рядок.
Тобто очевидно невдалий сценарій не створює зайвої конкуренції для інших запитів.
Що я перевіряю тестами
1. Валідні дані -> актуальний рядок читається під lock -> стан перевіряється -> зміна зберігається 2. Стан змінився до входу в transaction -> повторне читання бачить новий стан -> запис не виконується 3. Два конкурентні запити -> другий не обходить перевірку -> обидва не виконують суперечливий write Що я залишаю всередині блокування
Перед тим як завершити такий сценарій, я перевіряю:
- Чи можна підготувати вхідні дані до відкриття транзакції?
- Чи немає під блокуванням обчислень, які не залежать від стану бази?
- Чи немає випадкового зовнішнього виклику під lock?
- Чи повторно читається актуальний запис перед критичною зміною?
- Чи перевіряється інваріант уже після блокування?
- Чи всі атомарно пов’язані записи залишаються в одній транзакції?
- Чи завершується транзакція одразу після необхідних записів?
Блокування повинно захищати зміну, а не оточувати весь метод
Для мене коротка транзакція - це не транзакція з мінімально можливою кількістю рядків коду.
Це транзакція, всередині якої залишена тільки робота, що справді потребує атомарності або актуального заблокованого стану.
Дані можна підготувати заздалегідь. Потім відкрити транзакцію, заблокувати потрібний рядок, повторно перевірити його стан, виконати запис і одразу завершити операцію.
Так блокування захищає саме критичну зміну, а не випадкову допоміжну роботу навколо неї.
Моя мета не "менше транзакційного коду", а менше часу між lock і commit без втрати гарантій правильності.
Я намагаюся тримати під блокуванням не весь сценарій, а лише ту його частину, яка справді повинна бачити і змінювати актуальний стан послідовно.



