Ротація автентифікаційних даних виглядає просто: зробити поточний секрет недійсним, створити новий і зберегти його.
Проблема з’являється, коли два запити намагаються зробити це майже одночасно.
Обидва можуть прочитати однаковий поточний стан. Обидва можуть вирішити, що активний запис ще придатний для заміни. Але перемогти повинен лише один.
У AVN Center я зіткнулася з цим у модулі ProductRegistry під час ротації автентифікаційних даних реєстрації продукту.
Рішення складається з кількох частин: коротка транзакція, повторне читання актуального стану під lockForUpdate() і атомарна заміна старого запису новим.
Перша перевірка ще не гарантує правильний запис
На початку сценарію RotateProductRegistrationCredential завантажує поточні автентифікаційні дані та перевіряє, що вони відповідають автентифікованому контексту.
Перевіряються:
- ідентифікатор реєстрації продукту;
- середовище;
- профіль автентифікації.
Ця перевірка потрібна, але вона відбувається до запису в базу.
Між читанням і фактичним збереженням інший запит уже може виконати свою ротацію. Тому початковий об’єкт не можна вважати остаточною правдою під час запису.
$currentCredential = $this->repository->findById( $authenticatedContext->credentialId(),); if ($currentCredential === null) { throw ProductRegistrationCredentialNotFound::forId( $authenticatedContext->credentialId(), );} if ( ! $currentCredential->registrationId()->equals( $authenticatedContext->registrationId(), ) || $currentCredential->environment() !== $authenticatedContext->environment()Секрет я готую до відкриття транзакції
Наступний важливий момент - новий секрет і його хеш створюються до відкриття транзакції в базі.
Для самої генерації секрету не потрібне блокування рядка. Те саме стосується обчислення його хешу.
Якщо виконувати цю роботу вже після lockForUpdate(), заблокований запис залишатиметься зайнятим довше без необхідності.
Тому спочатку прикладний сценарій готує все, що можна підготувати незалежно від бази даних, і лише після цього переходить до атомарної заміни.
$rotatedAt = $this->clock->now(); $currentCredential->supersede( $rotatedAt,); $replacement = ProductRegistrationCredential::issue( $this->credentialIdGenerator->generate(), $currentCredential->registrationId(), $currentCredential->environment(), $currentCredential->authenticationProfile(), $rotatedAt,); $secret = $this->secretGenerator->generate(); Блокування має захищати критичну зміну стану, а не всю підготовчу роботу навколо неї.
У транзакції я повторно читаю актуальний стан
Коли починається фактичне збереження, сховище не довіряє стану, який був прочитаний раніше.
replaceActiveCredential() відкриває транзакцію, знаходить поточний запис за його ідентифікатором і читає його через lockForUpdate().
Це дає дві важливі властивості:
- поки транзакція працює з цим записом, інша конкурентна зміна не може одночасно завершити несумісний запис;
- рішення приймається за станом, який база підтвердила саме в момент запису, а не за старішою копією з пам’яті.
DB::transaction(function () use ( $currentCredential, $replacementCredential, $replacementSecretHash,): void { $locked = ProductRegistrationCredentialRecord::query() ->whereKey( $currentCredential->id()->toString(), ) ->lockForUpdate() ->first(); if ( $locked === null || $this->stringAttribute( $locked,Під блокуванням перевіряю не лише status
Перевірити лише status = active недостатньо.
Запис у базі також має належати тому самому безпековому контексту, який був перевірений раніше.
Тому під блокуванням повторно звіряються:
registration_id;environment;authentication_profile.
Якщо хоча б одна з цих умов більше не виконується, ротація завершується конфліктом замість того, щоб записати нові дані поверх стану, який уже змінився.
if ( $this->stringAttribute( $locked, 'registration_id', ) !== $currentCredential->registrationId()->toString() || $this->stringAttribute( $locked, 'environment', ) !== $currentCredential->environment()->value || $this->stringAttribute( $locked, 'authentication_profile', ) !== $currentCredential->authenticationProfile()->value) { throw ProductRegistrationCredentialWriteConflict ::forCredential(Стара і нова версії змінюються в одній транзакції
Після успішної перевірки поточний активний запис переводиться у стан superseded, а новий запис створюється як active.
Обидві зміни знаходяться в одній транзакції.
Це важливо, тому що я не хочу двох проміжних станів:
- старий секрет уже недійсний, але нового ще немає;
- новий уже створений, але старий усе ще активний.
Для цього сценарію правильний результат тільки один: стара версія замінена новою цілком або вся операція відкочується.
$locked->update([ 'status' => $currentCredential->status()->value, 'superseded_at' => $this->toNullableStorageDateTime( $currentCredential->supersededAt(), ),]); ProductRegistrationCredentialRecord::query()->create([ 'credential_id' => $replacementCredential->id()->toString(), 'registration_id' => $replacementCredential->registrationId()->toString(),Що відбувається з двома конкурентними ротаціями
Запит A Запит B читає active читає active | |готує новий секрет готує новий секрет | |lockForUpdate() |перевіряє active |superseded + new active |commit | отримує lock |Саме тут стає видно, чому початкової перевірки в прикладному шарі недостатньо.
Обидва запити могли чесно прочитати active до початку запису.
Але після завершення першої транзакції другий запит отримує блокування вже на змінений запис. Повторна перевірка бачить, що він більше не active, і зупиняє другу ротацію.
Тобто ми не намагаємося зробити так, щоб конкурентний запит "не стався". Ми робимо так, щоб лише один конкурентний запис міг стати остаточним.
База даних залишається останньою лінією захисту
Блокування і повторна перевірка стану не скасовують обмеження на рівні бази.
Для одного поєднання реєстрації продукту, середовища та профілю автентифікації не повинно існувати більше одного активного набору автентифікаційних даних.
Тому унікальність у базі залишається остаточним захистом. Якщо запис усе ж порушує цю умову, помилка бази перетворюється на контрольований ProductRegistrationCredentialWriteConflict.
Для конкурентного коду мені важливо мати обидва рівні: зрозумілу перевірку в коді та жорстку гарантію там, де фізично зберігається стан.
Чому я тримаю транзакцію короткою
У цьому сценарії транзакція потрібна для дуже конкретної критичної ділянки:
- заблокувати поточний запис;
- повторно перевірити його актуальний стан;
- позначити стару версію як замінену;
- вставити нову;
- зафіксувати результат.
Генерація і хешування секрету сюди не входять, тому що не потребують захисту рядка бази даних.
Чим менше роботи виконується між lockForUpdate() і commit, тим менше часу критичний запис залишається заблокованим.
Що я беру з цього кейсу
Для мене цей кейс добре показує різницю між звичайною перевіркою даних і справжнім захистом конкурентного запису.
Перевірка на початку сценарію корисна для швидкої відмови, але вона не може гарантувати, що стан залишиться таким самим до моменту запису.
Тому в критичній частині я повертаюся до джерела істини: база повторно читає запис під блокуванням і лише тоді дозволяє зміну.
Атомарна заміна гарантує, що стара версія не стане недійсною без нової, а нова не з’явиться поруч зі старою активною версією.
У конкурентних сценаріях для мене важливо не просто перевірити стан, а перевірити його ще раз саме там, де цей стан буде змінено.
Рання перевірка допомагає швидко знайти помилку. Повторна перевірка під блокуванням захищає правильність самого запису.



