Практическая ситуация: два клиента изменяют одну сущность почти одновременно. Как API предотвратит тихое затирание изменений одного клиента?
API должен применять оптимистический контроль конкурентного доступа: клиент отправляет версию сущности, которую он читал, а сервер принимает изменение только при совпадении этой версии с текущей. При несовпадении сервер отклоняет запрос как конфликт, поэтому более поздняя запись не затирает изменения незаметно.
Такой подход появился как практическая альтернатива длительным блокировкам. Во многих системах вероятность одновременного изменения одной сущности невелика, поэтому постоянно удерживать блокировку дорого и ухудшает параллельную работу.
Оптимистическая модель допускает конкуренцию, но перед фиксацией проверяет, что исходные данные не устарели. Она особенно полезна для API, где клиент может долго редактировать данные между чтением и отправкой изменения.
Клиент сначала получает сущность, затем изменяет её локально и отправляет сохранённое представление. За это время другой клиент может изменить тот же ресурс.
Если сервер без проверки просто заменит текущие данные, последнее сохранение уничтожит изменения первого клиента. Такая ошибка опасна тем, что технически запросы завершатся успешно, а потеря данных останется незаметной пользователям.
При чтении сервер возвращает версию сущности или её тег представления — например, ETag. Клиент сохраняет это значение и передаёт его при изменении. Сервер атомарно проверяет условие: версия в запросе должна совпадать с текущей версией ресурса.
Если условие выполнено, сервер применяет изменение и увеличивает версию. Если другой клиент уже изменил сущность, проверка не проходит, сервер возвращает конфликт, а данные клиента не записываются поверх новых данных.
Минимальная схема взаимодействия:
Здесь If-Match означает: применить изменение только к версии v7. Если текущая версия уже v8, сервер должен отклонить запрос, обычно с ответом 412 Precondition Failed; 409 Conflict также может использоваться для явно описанного конфликта домена.
Проверка должна выполняться атомарно вместе с изменением в хранилище. Простая последовательность «сначала прочитать версию, затем отдельно обновить запись» создаёт гонку: два запроса могут увидеть одну версию и оба успешно записать изменения.
После конфликта клиент может перечитать ресурс, показать пользователю различия и повторить операцию осознанно. Автоматическое слияние допустимо только при чётко определённых правилах; для произвольных полей оно может скрыть конфликт так же опасно, как и безусловная перезапись.
Версия должна изменяться при каждом изменении, влияющем на защищаемое представление. Если система проверяет только часть полей, это ограничение нужно явно описать: иначе изменения в неучтённых полях могут быть потеряны.
В системе согласования договора менеджер и юрист открывают одну карточку. Менеджер меняет сумму, а юрист почти одновременно меняет юридический комментарий.
Рассматривались три варианта. Без проверки версии реализация была простой, но одно сохранение могло уничтожить изменения другого. Длительная блокировка карточки не допускала бы конфликтов, однако блокировка могла зависнуть из-за закрытого браузера и ухудшала совместную работу. Полное автоматическое слияние уменьшало число конфликтов, но для некоторых полей могло привести к некорректному договору.
Выбрали версионную проверку с отклонением устаревшего сохранения. Сервер атомарно сравнивал версию карточки, а интерфейс при конфликте показывал актуальные данные и предлагал перенести изменения вручную. В результате потеря изменений стала явной и управляемой, а блокировки между пользователями не потребовались.
1. Достаточно ли передавать версию в запросе, если сервер проверяет её отдельным чтением?
Нет. Отдельная проверка и последующая запись не защищают от гонки: два запроса могут одновременно прочитать одну и ту же версию. Сравнение версии и изменение должны быть одной атомарной операцией на уровне хранилища или транзакции; если условие не выполнено, запись не производится.
2. Чем контроль версии отличается от идемпотентности запроса?
Идемпотентность отвечает на вопрос, изменится ли результат повторной отправки той же операции. Контроль версии отвечает на другой вопрос: не устарели ли данные, на основании которых сформировано изменение. Запрос может быть идемпотентным, но всё равно затереть более свежие изменения, если не содержит условия актуальности.
3. Что должен сделать клиент после конфликта?
Он не должен безусловно повторять тот же запрос: версия в нём уже устарела. Обычно клиент получает актуальное состояние, сравнивает его с локальными изменениями, предлагает пользователю разрешить конфликт или применяет безопасное доменное слияние, после чего отправляет новую версию ресурса.