Состояние гонки на примере Laravel и MySQL
Состояние гонки (race condition), обычно, плавающая ошибка, и воспроизвести ее бывает сложно под нагрузкой. В веб-разработке реляционная БД (например, MySQL или PostgreSQL) – один из элементов системы, который явно укажет на наличие проблемы.
На одном из рабочих проектов мне попалась задача с багом записи истории операций пользователя в БД. Приведу упрощенный пример для понимания.
Аномалия данных в MySQL
Предположим, у нас есть контроллер в который поступают запросы от пользователей для списания баллов. По итогу мы фиксируем операцию и регулируем баланс пользователя.
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\Transaction;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Exception;
class TestController extends Controller
{
public function index(Request $request)
{
$userId = (int) $request->input('user_id');
$points = (int) $request->input('points');
$user = User::findOrFail($userId);
if ($user->points < $points) {
return response()->json(['Not enough points'], 400);
}
$transaction = Transaction::create([
'user_id' => $userId,
'points' => $points,
'status' => 'pending',
]);
try {
$user->points -= $transaction->points;
$user->save();
$transaction->status = 'completed';
$transaction->save();
} catch (Exception $e) {
$transaction->status = 'failed';
$transaction->save();
logger()->error(__CLASS__, ['exception' => $e]);
}
return response()->json($transaction->toArray(), 200);
}
}
Начислим пользователю баллы.
mysql> update users set points=25 where id=1; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0 mysql> select * from users; +----+------+--------+---------------------+---------------------+ | id | name | points | created_at | updated_at | +----+------+--------+---------------------+---------------------+ | 1 | user | 25 | 2026-07-17 12:00:00 | 2026-07-17 13:43:30 | +----+------+--------+---------------------+---------------------+ 1 row in set (0.00 sec)
Запустим несколько одновременных запросов (у меня их 4).
for points in 10 20 5 15; do
curl -X POST http://127.0.0.1/api/test \
-H "Content-Type: application/json" \
-d "{\"user_id\":1,\"points\":$points}" &
done
wait
[1] 1634495
[2] 1634496
[3] 1634497
[4] 1634498
{"user_id":1,"points":10,"status":"completed","updated_at":"2026-07-17T13:43:32.000000Z","created_at":"2026-07-17T13:43:32.000000Z","id":1}[1] Done curl -X POST http://127.0.0.1/api/test -H "Content-Type: application/json" -d "{\"user_id\":1,\"points\":$points}"
{"user_id":1,"points":15,"status":"completed","updated_at":"2026-07-17T13:43:32.000000Z","created_at":"2026-07-17T13:43:32.000000Z","id":2}{"user_id":1,"points":5,"status":"completed","updated_at":"2026-07-17T13:43:32.000000Z","created_at":"2026-07-17T13:43:32.000000Z","id":3}{"user_id":1,"points":20,"status":"completed","updated_at":"2026-07-17T13:43:32.000000Z","created_at":"2026-07-17T13:43:32.000000Z","id":4}[2] Done curl -X POST http://127.0.0.1/api/test -H "Content-Type: application/json" -d "{\"user_id\":1,\"points\":$points}"
[3]- Done curl -X POST http://127.0.0.1/api/test -H "Content-Type: application/json" -d "{\"user_id\":1,\"points\":$points}"
[4]+ Done curl -X POST http://127.0.0.1/api/test -H "Content-Type: application/json" -d "{\"user_id\":1,\"points\":$points}"
Ответы пришли в разном порядке, и это нормально.
Проверяем данные и видим, что у пользователя 0 баллов, но при этом в таблице transactions у нас все 4 отмечены как completed.
mysql> select * from users; +----+------+--------+---------------------+---------------------+ | id | name | points | created_at | updated_at | +----+------+--------+---------------------+---------------------+ | 1 | user | 0 | 2026-07-17 12:00:00 | 2026-07-17 13:43:32 | +----+------+--------+---------------------+---------------------+ 1 row in set (0.00 sec) mysql> select * from transactions; +----+---------+--------+-----------+---------------------+---------------------+ | id | user_id | points | status | created_at | updated_at | +----+---------+--------+-----------+---------------------+---------------------+ | 1 | 1 | 10 | completed | 2026-07-17 13:43:32 | 2026-07-17 13:43:32 | | 2 | 1 | 15 | completed | 2026-07-17 13:43:32 | 2026-07-17 13:43:32 | | 3 | 1 | 5 | completed | 2026-07-17 13:43:32 | 2026-07-17 13:43:32 | | 4 | 1 | 20 | completed | 2026-07-17 13:43:32 | 2026-07-17 13:43:32 | +----+---------+--------+-----------+---------------------+---------------------+ 4 rows in set (0.00 sec)
Это явно вызывает вопрос корректности данных, поскольку количество созданных операций превышает лимит изначально доступных баллов у пользователя.
Проблема в том, что проверка остатка и списание выполняются в два разных запроса к БД. Между ними другой параллельный запрос может тоже прочитать тот же остаток и начать списание, что приводит к перерасходу баллов.
Ключевая проблема: Проверка и обновление не атомарны.
Пессимистическая блокировка
Пессимистическая блокировка на уровне транзакции MySQL – стандартное решение для описанной проблемы. Она гарантирует, что строка пользователя будет заблокирована на время выполнения транзакции, и другие запросы не смогут ее изменить до завершения текущей.
Обернем логику кода контроллера в транзакцию и добавим lockForUpdate() для блокировки данных пользователя.
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\Transaction;
use App\Http\Controllers\Controller;
use App\Exceptions\UserPointsException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Exception;
class TestController extends Controller
{
public function index(Request $request)
{
try {
$transaction = DB::transaction(function () use ($request) {
$userId = (int) $request->input('user_id');
$points = (int) $request->input('points');
// lockForUpdate блокирует строку для других транзакций до завершения текущей
$user = User::lockForUpdate()->findOrFail($userId);
if ($user->points < $points) {
throw new UserPointsException('Not enough points');
}
$transaction = Transaction::create([
'user_id' => $userId,
'points' => $points,
'status' => 'pending',
]);
$user->points -= $transaction->points;
$user->save();
$transaction->status = 'completed';
$transaction->save();
return $transaction;
});
return response()->json($transaction->toArray(), 200);
} catch (UserPointsException $e) {
return response()->json([$e->getMessage()], 200);
} catch (Exception $e) {
logger()->error(__CLASS__, ['exception' => $e]);
return response()->json(['Internal Server Error'], 500);
}
}
}
Обновим баланс пользователя.
mysql> update users set points=25 where id=1; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0 mysql> select * from users; +----+------+--------+---------------------+---------------------+ | id | name | points | created_at | updated_at | +----+------+--------+---------------------+---------------------+ | 1 | user | 25 | 2026-07-17 12:00:00 | 2026-07-17 13:48:07 | +----+------+--------+---------------------+---------------------+ 1 row in set (0.00 sec)
Запустим несколько запросов.
for points in 10 20 5 15; do
curl -X POST http://127.0.0.1/api/test \
-H "Content-Type: application/json" \
-d "{\"user_id\":1,\"points\":$points}" &
done
wait
[1] 1635038
[2] 1635039
[3] 1635040
[4] 1635041
{"user_id":1,"points":20,"status":"completed","updated_at":"2026-07-17T13:48:09.000000Z","created_at":"2026-07-17T13:48:09.000000Z","id":5}["Not enough points"][1] Done curl -X POST http://127.0.0.1/api/test -H "Content-Type: application/json" -d "{\"user_id\":1,\"points\":$points}"
[2] Done curl -X POST http://127.0.0.1/api/test -H "Content-Type: application/json" -d "{\"user_id\":1,\"points\":$points}"
{"user_id":1,"points":5,"status":"completed","updated_at":"2026-07-17T13:48:09.000000Z","created_at":"2026-07-17T13:48:09.000000Z","id":6}[3]- Done curl -X POST http://127.0.0.1/api/test -H "Content-Type: application/json" -d "{\"user_id\":1,\"points\":$points}"
["Not enough points"][4]+ Done curl -X POST http://127.0.0.1/api/test -H "Content-Type: application/json" -d "{\"user_id\":1,\"points\":$points}"
Теперь видим, что корректно прошло только 2 запроса и у пользователя списалось правильное число баллов. Остальные 2 запроса вернули ошибку Not enough points.
mysql> select * from users; +----+------+--------+---------------------+---------------------+ | id | name | points | created_at | updated_at | +----+------+--------+---------------------+---------------------+ | 1 | user | 0 | 2026-07-17 12:00:00 | 2026-07-17 13:48:09 | +----+------+--------+---------------------+---------------------+ 1 row in set (0.00 sec) mysql> select * from transactions; +----+---------+--------+-----------+---------------------+---------------------+ | id | user_id | points | status | created_at | updated_at | +----+---------+--------+-----------+---------------------+---------------------+ | 5 | 1 | 20 | completed | 2026-07-17 13:48:09 | 2026-07-17 13:48:09 | | 6 | 1 | 5 | completed | 2026-07-17 13:48:09 | 2026-07-17 13:48:09 | +----+---------+--------+-----------+---------------------+---------------------+ 2 rows in set (0.00 sec)
Таким образом, аномалия данных устранена, списание баллов и история операций происходят корректно.
Есть и другие варианты решения, например, очереди. Но цель заметки – наглядно показать проблему.