Недавно по одному из проектов мы столкнулись с “Race Condition”, известной проблемой в веб-разработке.
Будет полезным разобрать и проблему и решение на этом наглядном примере.
1. Захват аванпоста
Игрок нажимает в игре кнопку “Захватить аванпост”, игра выполняет захват и игроку начисляется награда.
Но награда начисляется в двойном размере! Как такое может быть? Разбираемся.
2. Двойное нажатие
Копаем, смотрим логи. Мы логируем каждый запрос к API сервера от клиента игры.
Выясняем, что запроса на захват было 2, оба выполнились успешно.
Значит, игрок нажал на захват два раза. Но почему дважды выполнился захват?
Смотрим код. Все проверки на месте. Что не так?
3. Долгая обработка запроса
В процессе обработки запроса с выдачей награды выполняется много сложной игровой логики по перемещению предметов, отправкой писем, уведомлений.
Это занимает столько времени что весь запрос выполняется примерно за 400 миллисекунд.
Игрок очень быстро нажимает на захват несколько раз.
Получается, что второй запрос успевает начать обработку до того как завершился первый запрос.
4. Race condition
Возникает состояние гонки.
Алгоритм выполнения запроса, в упрощённом виде:
- Проверить, захвачен ли аванпост.
- Начислить награду.
- Выставить статус “аванпост захвачен”.
При этом именно 2 пункт выполняется дольше всего.
Как возникает состояние гонки?
- Запрос #1: проверяем, захвачен ли аванпост. Не захвачен.
- Запрос #1: начинаем начисление награды.
- Запрос #2: проверяем, захвачен ли аванпост. Не захвачен.
- Запрос #2: начинаем начисление награды.
- Запрос #1: завершаем начисление награды.
- Запрос #1: пишем статус “аванпост захвачен”.
- Запрос #2: завершаем начисление награды.
- Запрос #2: пишем статус “аванпост захвачен”.
Вот так и получается, что оба запроса выполнились и награда начислена дважды.
5. Дешёвое решение: промежуточный статус
Не прибегая к сложным механикам, можем внести небольшое изменение, которое очень быстро решит проблему.
Поставим статус “выполняется захват” сразу после проверки статуса.
Как будет теперь выглядеть выполнение?
- Запрос #1: проверяем, захвачен ли аванпост. Не захвачен.
- Запрос #1: пишем статус “выполняется захват”.
- Запрос #1: начинаем начисление награды.
- Запрос #2: проверяем, захвачен ли аванпост. Не захвачен, но идёт захват!
- Запрос #2: выходим, так как захват уже идёт в другом запросе.
- Запрос #1: завершаем начисление награды.
- Запрос #1: пишем статус “аванпост захвачен”.
Таким образом, второй запрос завершится без двойного начисления наград.
Здесь мы свели вероятность возникновения Race Condition к минимуму, так как UPDATE одной строки в таблице будет выполнен за несколько миллисекунд, почти мгновенно.
Но теоретическая возможность остаётся. Если SELECT второго запроса сработает раньше, чем завершится UPDATE первого запроса, то снова попадём в ту же ситуацию.
6. Надёжное решение: SELECT FOR UPDATE + транзакция
Можем ли мы гарантировать что двойного начисления не будет?
Можем, и используем для этого встроенный в БД механизм SELECT FOR UPDATE.
Если мы в первом SELECT где читаем статус аванпоста добавим слова “FOR UPDATE” в конец запроса и обернём операции в транзакцию, произойдёт следующее.
- Запрос #1: Открываем транзакцию.
- Запрос #1: Читаем запись аванпоста. Так как в запросе указан FOR UPDATE, движок БД “поймёт” что мы собираемся прочесть, а потом изменить строку с записью аванпоста.
- Запрос #1: Движок БД отдаст текущее значение строки в SELECT и заблокирует строку аванпоста.
- Запрос #2: Открываем транзакцию.
- Запрос #2: Второй запрос тоже выполнит SELECT … FOR UPDATE, но строка уже заблокирована.
- Запрос #2: Второй запрос встаёт в ожидание разблокировки.
- Запрос #1: В первом запросе вы выдаём все награды.
- Запрос #1: Меняем статус аванпоста на “захвачен”.
- Запрос #1: Закрываем транзакцию.
- Запрос #1: Строка аванпоста разблокируется.
- Запрос #2: Второй запрос дождался своей очереди, для него выполняется SELECT … FOR UPDATE.
- Запрос #2: Движок БД отдаст значение строки “аванпост захвачен” в SELECT и заблокирует строку аванпоста.
- Запрос #2: Мы видим что аванпост захвачен и завершаем обработку запроса.
- Запрос #2: Закрываем транзакцию.
- Запрос #2: Разблокируем строку аванпоста.