Состояние гонки на примере 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)

Таким образом, аномалия данных устранена, списание баллов и история операций происходят корректно.

Есть и другие варианты решения, например, очереди. Но цель заметки – наглядно показать проблему.