0% прочитано

Чому я не довіряю об'єкту, який прочитала кілька кроків тому

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

2 жовтня 2026 р. 7 хв читанняЛаравель

Одна з небезпечних ілюзій у коді виглядає дуже просто: я прочитала об'єкт із бази, перевірила його стан і продовжила роботу.

У цей момент легко сприймати цей об'єкт як актуальну правду.

Але між читанням і записом можуть пройти десятки рядків коду, зовнішній виклик, створення нового об'єкта або просто кілька мілісекунд.

За цей час інший запит може змінити той самий запис.

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

Проблема не в тому, що перше читання було неправильним. Воно могло бути абсолютно правильним у свій момент. Проблема в тому, що цей момент уже минув.

Початкова перевірка може бути правильною

PHP - Перевірка відбувається раніше за запис
$record = $repository->findById($id); if ($record === null) {    throw ResourceNotFound::forId($id);} if (! $record->canBeChanged()) {    throw ChangeNotAllowed::forCurrentState();} $replacement = $this->prepareReplacement($record); // More work happens here... $repository->saveReplacement(    $record,

У цьому коді немає очевидної помилки.

Ми прочитали запис, перевірили, що його можна змінити, підготували новий стан і передали все до сховища.

Проблема виникає лише тоді, коли з'являється другий запит.

Початкова перевірка відповідає на питання: чи була операція допустимою в момент читання?

А для запису потрібно інше питання: чи вона все ще допустима зараз?

Два запити можуть чесно прочитати однаковий стан

Обидва запити бачать однакову картину
Запит A                  Запит B read: active             read: active    |                        |перевірка OK             перевірка OK    |                        |підготовка               підготовка    |                        |write                    write

Обидва запити можуть бути абсолютно коректними до моменту запису.

Обидва прочитали active. Обидва пройшли перевірку. Обидва підготували допустиму зміну.

Якщо після цього кожен просто виконає запис, результат уже залежатиме від випадкового порядку виконання.

Для некритичної інформації це іноді допустимо. Для зміни, де лише один запит має право перемогти, цього недостатньо.

Об'єкт у пам'яті є знімком, а не живим станом бази

Коли Eloquent або repository повертає мені об'єкт, він не продовжує автоматично синхронізуватися з базою.

Це знімок стану на момент читання.

Якщо інший процес змінить рядок через мілісекунду, мій PHP-об'єкт про це не дізнається.

Саме тому я не використовую раніше завантажений стан як остаточний доказ перед критичним записом.

Об'єкт у пам'яті говорить, що було правдою під час читання. База даних говорить, що є правдою під час запису.

Перед критичним записом я повертаюся до джерела істини

PHP - Повторне читання актуального стану
DB::transaction(function () use ($record): void {    $current = ResourceRecord::query()        ->whereKey(            $record->id(),        )        ->lockForUpdate()        ->first();     if (        $current === null        || $current->status !== 'active'    ) {        throw WriteConflict::detected();    }     // Critical write happens here.

У критичній частині я відкриваю коротку транзакцію і повторно читаю потрібний рядок.

lockForUpdate() тут вирішує дві задачі.

По-перше, я отримую актуальний стан саме перед зміною.

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

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

Це не означає, що рання перевірка зайва

Повторне читання перед записом не означає, що початкову перевірку потрібно прибрати.

Рання перевірка все ще корисна.

Вона дозволяє швидко завершити явно неправильний сценарій і не виконувати зайву підготовчу роботу.

А повторна перевірка в транзакції вирішує іншу задачу: захищає правильність запису від стану, який змінився між першим читанням і збереженням.

Дві перевірки мають різне призначення
Рання перевірка    |    +--> швидко відхилити неправильний запит    +--> не робити зайву роботу  Перевірка перед записом    |    +--> побачити актуальний стан    +--> захистити критичну зміну

Якщо стан змінився, я не намагаюся тихо продовжити

Якщо повторне читання показало, що стан уже змінився, це не просто технічна незручність.

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

У такій ситуації я віддаю перевагу явному конфлікту запису, а не спробі "якось завершити" стару операцію.

Після цього вищий рівень може вирішити, що робити далі: повернути конфлікт клієнту, повторити безпечний сценарій або перечитати новий стан.

Я не тримаю блокування під час усієї підготовки

З цього не випливає, що всю операцію потрібно виконувати під lockForUpdate().

Навпаки, я намагаюся залишити транзакцію якомога коротшою.

Усе, що не потребує актуального заблокованого рядка, можна підготувати раніше.

Усередині критичної ділянки залишаються тільки дії, яким справді потрібна гарантія:

  • повторно прочитати стан;
  • перевірити важливі умови;
  • виконати запис;
  • зафіксувати транзакцію.

Іноді замість блокування достатньо умовного update

PHP - Зміна лише очікуваного стану
$updated = ResourceRecord::query()    ->whereKey($id)    ->where('status', 'active')    ->update([        'status' => 'completed',    ]); if ($updated !== 1) {    throw WriteConflict::detected();}

lockForUpdate() не є єдиним способом захистити конкурентну зміну.

Для простого переходу стану іноді достатньо виконати умовний UPDATE: змінити рядок лише тоді, коли він досі має очікуваний стан.

Якщо змінено нуль рядків, я знаю, що між читанням і записом хтось уже змінив дані.

Вибір між блокуванням і умовним оновленням залежить від самої операції. Головний принцип залишається однаковим: запис не повинен мовчки покладатися на старий стан.

Перевірка в коді не замінює гарантії бази даних

Навіть правильна повторна перевірка в коді не означає, що всі важливі правила потрібно залишити лише на рівні PHP.

Якщо правило можна виразити через UNIQUE, зовнішній ключ або інше обмеження бази, я використовую цю гарантію як останню лінію захисту.

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

Код повинен правильно обробити цей результат, а не припускати, що попередня перевірка зробила конфлікт неможливим.

Коли я особливо підозріло ставлюся до раніше прочитаного стану

Я окремо перевіряю конкурентність, якщо між читанням і записом є хоча б одна з таких ознак:

  • операція змінює критичний стан;
  • два запити можуть працювати з одним записом одночасно;
  • дозволений лише один переможець;
  • існує унікальне правило, яке не можна порушити;
  • між читанням і записом виконується додаткова робота;
  • рішення залежить від конкретного поточного status;
  • неправильний проміжний стан може вплинути на наступні операції.

У таких місцях питання "я вже це перевірила?" для мене недостатнє. Я питаю: коли саме я це перевірила і чи може стан змінитися після цього?

Читання і запис належать до різних моментів часу

Для мене це головний висновок із таких конкурентних сценаріїв.

Об'єкт, завантажений із бази, не є живим відображенням поточного стану. Він представляє стан у конкретний момент часу.

Тому рання перевірка добре підходить для швидкого рішення, але критичний запис може вимагати ще однієї перевірки ближче до бази.

Іноді для цього потрібен lockForUpdate(). Іноді достатньо умовного UPDATE. Для важливих правил додатковою гарантією залишаються обмеження бази даних.

Я не намагаюся зробити старий об'єкт актуальним. Я просто перестаю вимагати від нього гарантії, якої він не може дати.

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