Что такое конкурентные запросы? Два оператора открыли одну заявку и выбрали для неё разных исполнителей. Оба увидели сообщение «Сохранено», но после обновления страницы осталось только последнее назначение.

Если по правилам продукта последнее сохранение не должно молча побеждать, перед нами потерянное обновление. Начинать с двух быстрых нажатий на Save не стоит, поскольку сперва нужно понять, умеет ли сервер распознавать устаревшие данные.

Для этой проверки точная синхронизация не нужна. Параллельный запуск пригодится позже, чтобы проверить блокировки и атомарность сравнения версии с записью.

Еще здесь важно разделить две ситуации. Устаревшая запись возникает, когда пользователь отправляет изменение на основе данных, которые уже успели поменяться. Запросы при этом могут выполняться с разницей в несколько минут.

Гонка начинается, когда обработка запросов пересекается во времени, а результат зависит от порядка выполнения операций. Версия или ETag помогает обнаружить устаревшие данные, но только при условии, что сервер проверяет версию и сохраняет изменение как одну атомарную операцию.

Поэтому последовательный тест проверяет поведение API при старой версии, а параллельный — корректность реализации в момент пересечения запросов.

Сначала договариваемся о результате

Одновременное изменение одной сущности не всегда должно заканчиваться ошибкой. Допустимое поведение зависит от требований продукта.

Политика

Ответы и итоговое состояние

Устаревшее изменение отклоняется

Один запрос выполняется, второй получает конфликт

Независимые поля объединяются

Оба запроса выполняются, в результате остаются оба изменения

Последняя запись побеждает (last write wins)

Оба запроса выполняются, последнее изменение заменяет предыдущее

Операции выполняются последовательно

Второй запрос ждёт, а затем выполняется или отклоняется по правилам продукта

Для пользовательской настройки правило «последняя запись побеждает» может быть нормальным выбором.

Для статуса заказа, остатка товара или назначения исполнителя молчаливая перезапись уже опасна. Тестировщику нужно знать не только общую стратегию, но и границу конфликта. Версия может относиться ко всей записи, отдельному полю или объекту вместе с вложенными данными.

Дальше будем считать, что приложение защищает заявку целиком. Если два пользователя получили одну версию, сохранить изменение сможет только один из них. Второй должен узнать, что его данные устарели.

На проекте с другой политикой изменится ожидаемый результат, но сам порядок проверки останется тем же.

Собираем сценарий, который можно повторить

Возьмём заявку службы поддержки.

GET /tickets/481

HTTP/1.1 200 OK
ETag: "ticket-481-v17"
Content-Type: application/json

{
  "id": 481,
  "assignee": null,
  "priority": "normal",
  "status": "open"
}

В этом API ETag используется как валидатор версии представления. Само значение остаётся непрозрачным для клиента. Строка ticket-481-v17 выбрана только для наглядности, вычислять из неё следующую версию не нужно. В другом API та же информация может находиться в поле version внутри JSON.

Операторы А и Б открывают заявку в двух независимых сессиях и получают одинаковый ETag. Лучше использовать разные учётные записи. Некоторые приложения последовательно обрабатывают запросы одной сессии, и тогда настоящего пересечения не возникает.

Перед каждым прогоном заявку нужно возвращать в известное состояние. Значения для А и Б тоже должны отличаться. Иначе по итоговой записи будет сложно понять, какой запрос выполнился.

Старую версию проще проверить последовательно

Сначала оператор А назначает себя исполнителем.

PATCH /tickets/481
If-Match: "ticket-481-v17"
Content-Type: application/merge-patch+json

{
  "assignee": "operator-a"
}

Сервер принимает изменение и возвращает новый ETag.

HTTP/1.1 204 No Content
ETag: "ticket-481-v18"

После этого оператор Б отправляет свой запрос. У него всё ещё осталась версия 17.

PATCH /tickets/481
If-Match: "ticket-481-v17"
Content-Type: application/merge-patch+json

{
  "assignee": "operator-b"
}

Условие If-Match больше не выполняется, поэтому изменение не применяется.

HTTP/1.1 412 Precondition Failed

Повторный GET должен вернуть operator-a и версию 18. Отклонённый запрос не меняет заявку и не запускает действия, которые положены успешному назначению. При этом само неудачное действие может отдельно попасть в технический или пользовательский аудит.

Коды ответа зависят от способа обнаружения конфликта. Невыполненный If-Match соответствует 412 Precondition Failed. Если API требует условный запрос, но клиент не передал заголовок, сервер может ответить 428 Precondition Required. 409 Conflict чаще используют для прикладного конфликта или когда API проверяет состояние без HTTP‑предусловия. Различия описаны в RFC 9110, RFC 6585 и RFC 5789.

Для If-Match требуется сильное сравнение. Слабый ETag с префиксом W/ не подходит для этой задачи. Ещё один нюанс касается повторов. RFC 9110 допускает успешный ответ, если запрошенное изменение уже оказалось применено. Поэтому в основном тесте А и Б отправляют разные значения.

Если запрос со старой версией успешно заменил актуальные данные, защита уже не работает. Пытаться поймать миллисекундное пересечение в этот момент бессмысленно.

Когда действительно нужны параллельные запросы

Последовательный тест проверяет контракт устаревшей версии. Он не доказывает, что сервер атомарно сравнивает версию и записывает изменение. Реализация может сначала прочитать версию, затем выполнить часть логики и только после этого сохранить данные. Между чтением и записью появляется окно, куда успевает попасть второй запрос.

Для такого теста можно использовать Burp Repeater или другой клиент, способный отправить группу запросов параллельно. Тестировщик вручную готовит сценарий и разбирает результат, а синхронизацию лучше поручить инструменту.

В Burp это делается так.

  1. Получаем актуальный ETag и подставляем его в два запроса с разными исполнителями.

  2. Добавляем запросы в одну группу Repeater.

  3. Отправляем группу через Send group in sequence, чтобы получить контрольный результат.

  4. Возвращаем заявку в исходное состояние и снова получаем общий ETag.

  5. Выбираем Send group in parallel и повторяем прогон несколько раз.

Два быстрых клика в браузере не дают такого контроля. Даже если кнопки нажаты одновременно, запросы могут пройти через разные соединения и попасть на сервер в разное время. PortSwigger рекомендует сначала измерить обычное последовательное поведение, а затем сравнить его с параллельным запуском.

При выбранной нами политике допустимы два симметричных результата.

Ответ А

Ответ Б

Оценка

204, версия 18

412

Сохранилось изменение А

412

204, версия 18

Сохранилось изменение Б

204

204

Нарушена политика отклонения устаревшей версии

412

412

Скорее всего, тест начался уже с устаревшего ETag

5xx или зависание

Любой ответ

Нужно проверить обработку блокировки или внутреннего конфликта

После каждого прогона нужно читать заявку заново. Пара успешных HTTP‑ответов сама по себе ничего не говорит о том, что осталось в базе. Проверяются исполнитель, версия, аудит и связанные действия. Перед следующим повтором состояние снова сбрасывается.

Какие сочетания ещё стоит проверить

Два значения одного поля дают основной сценарий, но не покрывают другие способы потерять данные.

Сочетание

Возможная проблема

Разные поля одной заявки

Одно изменение возвращает старые значения остальных полей

Изменение и удаление

Устаревший запрос повторно создаёт удалённую заявку, хотя API этого не предусматривает

Два перехода между статусами

В результате появляется состояние, запрещённое бизнес‑процессом

Изменение вложенных данных

Версия заявки не учитывает комментарий или другую часть объекта

Особенно полезно сравнить PUT и PATCH. PUT передаёт полное представление ресурса. Если оператор Б отправит старую копию заявки с новым исполнителем, вместе с ней на сервер может вернуться прежний приоритет. PATCH передаёт только изменения, но сам по себе тоже не защищает от устаревшей версии.

В примерах выше используется JSON Merge Patch с типом application/merge-patch+json, определённым в RFC 7396. Если проект принимает частичный JSON с обычным application/json, это уже собственный контракт API, и его семантику нужно зафиксировать отдельно.

RFC 5789 требует применять весь набор изменений из одного PATCH‑запроса атомарно. Клиент не должен увидеть частично обновлённый ресурс, а после ошибки в базе не должна остаться только часть изменений. Те же проверки нужны для уведомлений, задач и событий. Отклонённое назначение не должно запускать действия, которые положены успешному запросу. Автоматический повтор транзакции тоже не должен создавать их дважды.

Что должен увидеть пользователь

Правильный 412 на уровне API ещё не означает, что функция работает целиком. Основной сценарий нужно повторить в двух браузерных профилях.

Оператор А сохраняет своё назначение. Оператор Б остаётся на форме с версией 17 и нажимает Save. Интерфейс должен объяснить, что заявка успела измениться. Общий текст «Что‑то пошло не так» здесь бесполезен. Ещё хуже, если форма просто перезагрузится и сотрёт введённые данные.

Для разрешения конфликта могут понадобиться три набора значений. Это исходные данные, предложенное пользователем изменение и текущее состояние на сервере. Интерфейс может показать различия или хотя бы сохранить ввод, загрузить актуальную заявку и предложить повторить действие. Новый запрос должен уйти уже с новой версией. Слепой повтор старого запроса фактически обходит защиту.

Если второй пользователь потерял права на изменение заявки, повторная попытка должна учитывать и это. После конфликта нельзя считать авторизацию и бизнес‑валидацию уже пройденными.

Что проверить вместе с разработчиком

При оптимистической блокировке сервер не удерживает строку всё время редактирования. Вместо этого обновление содержит ожидаемую версию прямо в условии.

UPDATE tickets
SET assignee = :assignee,
    version = version + 1
WHERE id = :id
  AND version = :expected_version;

Одна изменённая строка означает успех. Ноль строк говорит о том, что версия уже сменилась или заявку удалили. Приложение должно распознать этот результат как конфликт, а не вернуть 204. Такой механизм с проверкой количества изменённых строк показан в документации EF Core.

При пессимистическом подходе строку блокируют через SELECT ... FOR UPDATE, и вторая транзакция ждёт освобождения блокировки. Но блокировка живёт только внутри открытой транзакции. Оставлять её на всё время, пока пользователь редактирует форму, нельзя. Поэтому уровень изоляции или строковая блокировка сами по себе не защищают пару запросов GET сейчас и PATCH через пять минут.

Если СУБД прерывает транзакцию из‑за взаимоблокировки или ошибки сериализации, повторять нужно всю операцию с новым чтением данных. Снаружи это не должно выглядеть как случайный 500, частично сохранённая заявка или бесконечно загружающаяся форма.

Как зафиксировать результат

Один из успешных параллельных прогонов может выглядеть так.

Параметр

Значение

Заявка

481

Политика

Устаревшее изменение отклоняется для всей заявки

Исходная версия

ticket-481-v17 у обоих операторов

Изменение А

Назначить operator-a

Изменение Б

Назначить operator-b

Запуск

Параллельный

Ответ А

204, новая версия ticket-481-v18

Ответ Б

412 Precondition Failed

Итоговое состояние

Исполнитель operator-a, версия 18

Побочные действия

Одно успешное назначение, отклонённый запрос не запустил уведомление

Эту таблицу можно использовать как шаблон для других сущностей. В каждом прогоне нужны исходное состояние, общая версия, два изменения, способ запуска, оба ответа и финальное чтение. Если система ведёт аудит или отправляет события, они тоже входят в результат.

Где этот маршрут не поможет

Сценарий рассчитан на изменение одной сущности через обычный HTTP API. Для совместного посимвольного редактирования нужны другие модели разрешения конфликтов. Распределённые изменения нескольких сервисов придётся проверять с учётом очередей, повторной доставки и компенсирующих действий. Защита от двойного списания тоже требует отдельного сценария.

Для обычного веб‑приложения этого маршрута достаточно, чтобы перейти от случайных кликов в двух вкладках к воспроизводимой проверке. Сначала фиксируем политику продукта, затем отправляем заведомо устаревшую версию и только после этого ищем настоящее пересечение запросов.

Конкурентные запросы, блокировки и потеря данных — это проблемы, которые часто проявляются уже после выхода системы в продакшен, когда несколько пользователей одновременно работают с одними и теми же данными. Чтобы проектировать надёжные сервисы, важно понимать, как API обрабатывает сбои и нагрузку, а база данных — параллельные операции и конфликты изменений.

Разобраться в этих подходах на практике помогут открытые уроки:

  • 3 сентября, 20:00. «ASP.NET Core API под нагрузкой: как сделать сервис устойчивым к сбоям и росту трафика». Записаться

  • 8 сентября, 20:00. «Борьба с блокировками в PostgreSQL: как достичь высокой параллельности при большой нагрузке». Записаться

Полный список открытых уроков на конец августа и начало сентября собрали в дайджесте.

Комментарии (0)