Класична гонка: два процеси читають баланс 100, кожен додає 50, обидва пишуть 150 - одне списання зникло. Читання й запис мають бути однією неподільною операцією.
Атомарний вираз - найдешевше, коли значення рахується з поточного:
Account::whereKey($id)->increment('balance', 50);
// UPDATE accounts SET balance = balance + 50 WHERE id = ?
База сама рахує нове значення, читати наперед не потрібно.
Песимістичне блокування - коли між читанням і записом є логіка:
DB::transaction(function () use ($id) {
$account = Account::whereKey($id)->lockForUpdate()->first();
if ($account->balance < 50) {
throw new InsufficientFunds;
}
$account->decrement('balance', 50);
});
lockForUpdate() тримає рядок до кінця транзакції - інші процеси чекають. Обовʼязково всередині DB::transaction(), інакше блокування знімається одразу. Є ще sharedLock() - дозволяє читати, забороняє змінювати.
Оптимістичне блокування - без блокувань, через версію рядка:
$updated = Account::whereKey($id)
->where('version', $account->version)
->update(['balance' => $new, 'version' => $account->version + 1]);
if ($updated === 0) {
// хтось випередив - перечитати й повторити
}
Дешевше під навантаженням, але вимагає обробки повтору.
Поза базою - Cache::lock() для операцій, що зачіпають не тільки БД:
Cache::lock('import:'.$id, 10)->block(5, function () {
// виконується лише в одному процесі
});
Що обирати: просте збільшення - атомарний вираз; логіка між читанням і записом - lockForUpdate(); висока конкуренція за рідкісні конфлікти - оптимістичне.