18 дек. 2025 г.·7 мин. чтения

Оптимистичная блокировка для предотвращения потерянных обновлений в веб‑приложениях

Узнайте, как оптимистичная блокировка предотвращает потерю обновлений при редактировании одной записи из двух вкладок или разными пользователями — с помощью столбца версии или ETag и понятной обработки конфликтов.

Оптимистичная блокировка для предотвращения потерянных обновлений в веб‑приложениях

Проблема потерянных обновлений простыми словами

«Потерянное обновление» происходит, когда два человека (или две вкладки браузера) редактируют одно и то же, и второй сохранённый вариант тихо перезаписывает первый. Ошибки не видно. Оба человека думают, что их изменения применились. Но в базе данных остаётся только последний сохранённый вариант.

Простой пример: вы открываете профиль в одной вкладке и изменяете отображаемое имя с «Sam» на «Samantha», но ещё не нажали «Сохранить». В другой вкладке вы меняете адрес электронной почты и нажимаете Сохранить. Потом вы возвращаетесь к первой вкладке и нажимаете Сохранить. Если приложение использует «последний записал — выиграл», то старый запрос из первой вкладки может вернуть старый email, перезаписав более свежие изменения, хотя вы и не трогали поле email в первой вкладке.

Это часто остаётся незаметным, потому что всё выглядит нормально: сервер возвращает 200 OK, интерфейс показывает уведомление об успехе, и страница обновляется. Баг проявляется позже, когда кто‑то замечает, что настройка откатилась, адрес изменился или админское обновление исчезло. К тому моменту кажется, что это случайность, и пользователи начинают винить «неустойчивое» ПО.

Чаще всего это случается в обычных CRUD‑экранах, где люди держат страницу открытой довольно долго: профили и настройки аккаунта, админ‑панели (пользователи, товары, права), страницы конфигурации команды (роли, биллинг), редакторы контента (заголовки, метаданные, описания) и любые формы редактирования, которые загружают данные один раз, а сохраняют позже.

«Последний сохранённый выигрывает» рискованно, потому что такое поведение считает каждое сохранение одинаково актуальным, даже если оно основано на устаревших данных. Это подрывает доверие: пользователи сделали всё правильно, но система молча выбросила их работу.

Оптимистичная блокировка — распространённый способ это предотвратить. При сохранении сервер проверяет, изменился ли объект с момента его загрузки. Если изменился, приложение блокирует перезапись и просит разрешить конфликт вместо того, чтобы делать вид, что всё прошло гладко.

Распространённые ситуации, вызывающие тихие перезаписи

Тихие перезаписи происходят, когда приложение позволяет редактировать данные на основе старого снимка и затем сохраняет их, не проверив, что произошло за это время. Результат — «последнее сохранение выигрывает», даже если последнее сохранение неправильное.

Самая частая причина — две вкладки (или окна) с одной и той же страницей. Вы обновляете запись в вкладке A, забываете, что вкладка B ещё открыта, а затем вкладка B сохраняет позже и незаметно возвращает старые значения. Это часто проявляется в админ‑панелях, дашбордах и страницах «Редактировать профиль», которые люди держат открытыми часами.

Это также случается, когда два разных человека правят одну запись. Подумайте о заметке клиента, статусе заказа или адресе. Человек 1 меняет номер телефона, человек 2 меняет инструкции по доставке, и кто нажмёт «Сохранить» последним, может стереть изменение другого, если приложение отправляет всю запись целиком, а не только изменённые поля.

Медленные или ненадёжные сети усугубляют проблему. Сохранение во время поездки или при плохом мобильном соединении может прийти позже. Если сервер примет его как актуальное, оно может перезаписать более свежие правки. Офлайн‑режим даёт тот же риск: вы редактируете локально, выходите в онлайн и пушите изменения, которые теперь устарели.

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

Именно в таких случаях оптимистичная блокировка окупает себя. Добавьте простую проверку версии (или проверку ETag), и сервер сможет определить «вы редактировали устаревшую копию» и отказать в сохранении вместо тихой перезаписи.

Что такое оптимистичная блокировка и как она работает

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

Проще понять через сравнение с пессимистичной блокировкой. Пессимистичная блокировка похожа на физическую табличку «не трогать» на записи в то время, пока вы редактируете её. Это предотвращает конфликты, но создаёт ожидания, таймауты и проблемы, когда кто‑то оставил страницу открытой. Оптимистичная блокировка предполагает, что конфликты редки, поэтому не блокирует, а останавливает сохранение только если действительно произошёл конфликт.

«Версия» — это небольшой фрагмент данных, который меняется при каждом изменении строки или документа. В строке базы данных это часто целочисленный столбец вроде version, который начинается с 1 и инкрементируется на каждое обновление. В API это может быть ETag — отпечаток текущего состояния.

Базовый процесс:

  • При загрузке записи вы также читаете её текущую версию.
  • При сохранении вы отправляете ту версию, которую видели при загрузке.
  • Обновление проходит только если хранимая версия совпадает.
  • Если совпадает — запись обновляется и версия увеличивается.
  • Если не совпадает — сохранение отклоняется как конфликт.

Это несоответствие и есть суть. Система сообщает: «Кто‑то (или другая вкладка) изменил это после того, как вы загрузили запись». Приложение может показать понятное сообщение и предложить безопасный шаг: перезагрузить, просмотреть отличия или скопировать ваши изменения перед повторной отправкой. Перезапись никогда не происходит молча.

Такой подход подходит для большинства CRUD‑приложений: редкие одновременные редактирования позволят сохранять интерфейс отзывчивым (без удержания блокировок), при этом данные защищены.

Короткий мысленный пример: вы открываете форму профиля в двух вкладках. Вкладка A сохраняет первой, повышая версию с 3 до 4. Вкладка B пытается сохранить с версией 3. База данных (или API) отказывает, и вкладке B придётся обновить или слить изменения. Эта маленькая проверка версии делает скрытую потерю данных видимой и исправимой.

Шаг за шагом: подход со столбцом версии

Столбец версии — самый простой способ остановить тихие перезаписи в CRUD‑приложении. Храните число в каждой строке, отправляйте его клиенту при чтении записи и требуйте, чтобы клиент присылал его обратно при сохранении. Если число изменилось с момента загрузки, отклоняйте обновление.

1) Добавьте поле version в таблицу

Добавьте целочисленный столбец, часто называемый version, который начинается с 1. (Можно использовать updated_at как запасной вариант, но метки времени могут путаться из‑за часовых поясов и очень быстрых правок.)

ALTER TABLE documents ADD COLUMN version INTEGER NOT NULL DEFAULT 1;

2) Передавайте версию через API

Пусть версия путешествует с записью от края до края. При чтении включайте version в ответ API, чтобы UI мог её сохранить. В форме храните эту версию (даже если она скрыта). При обновлении требуйте, чтобы клиент прислал последнюю увиденную версию (удобнее всего в теле запроса).

Теперь сервер может понять, сохраняет ли клиент старую копию.

3) Обновляйте только если версия совпадает

Это ядро оптимистичной блокировки: обновлять строку только когда id и version совпадают, затем повышать версию.

Обычный шаблон — один атомарный запрос:

UPDATE documents
SET title = ?, body = ?, version = version + 1
WHERE id = ? AND version = ?;

Если запрос обновил 0 строк, значит версия не совпала. Верните конфликт (часто HTTP 409) и включите последнюю запись (и её новую версию), чтобы UI мог показать, что изменилось.

4) Увеличивайте при успехе, отклоняйте при несовпадении

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

Шаг за шагом: использование ETag и заголовка If-Match

Remove Risky Full Saves
Replace full-record saves with safer update patterns that don’t wipe unrelated fields.

ETag — это короткий отпечаток, представляющий текущее состояние ресурса. Если ресурс меняется, отпечаток меняется тоже. Это удобно для оптимистичной блокировки, когда вы не хотите менять схему базы данных или когда серверу удобнее сам решать, что считать «тем же состоянием».

1) Возвращайте ETag при чтении (GET)

Когда клиент загружает запись для редактирования, ваш API должен вернуть запись и ETag, соответствующий этому точному состоянию. Вы можете вычислить ETag из версии строки, метки updated_at или хеша JSON, который возвращаете.

Простой поток:

  • Клиент посылает GET /items/123
  • Сервер отвечает JSON‑телом и заголовком ETag: "abc123"
  • Клиент сохраняет этот ETag рядом с данными формы, которые редактируются

2) Требуйте If-Match при записи (PUT/PATCH)

Когда пользователь сохраняет, клиент включает ETag, который он видел при загрузке. Это говорит серверу: «Примени моё обновление только если ресурс всё ещё в том состоянии, которое я редактировал.»

  • Клиент посылает PATCH /items/123 с заголовком If-Match: "abc123"
  • Сервер сравнивает If-Match с текущим ETag для item 123
  • Если совпадает — применяет изменение и возвращает обновлённый ресурс с новым ETag

Если не совпадает — возникает конфликт. Не принимайте запись молча.

3) Возвращайте правильный ответ при конфликте

Большинство API возвращают либо 412 Precondition Failed когда If-Match не совпал (это наиболее точный вариант), либо 409 Conflict, если вы предпочитаете более общий ответ.

Включите достаточно данных для восстановления: короткий код/сообщение об ошибке и, часто, последнюю версию ресурса (и её новый ETag), чтобы UI мог показать, что изменилось.

Когда ETag подходит лучше, чем столбец версии

ETag удобен, когда вы не можете легко менять схему базы данных, когда несколько бэкендов могут обновлять один ресурс, или когда вы уже используете кэширование. Он также хорош, когда «состояние ресурса» — это не одна строка, а документ, собранный из нескольких таблиц.

Пример: две вкладки редактируют один профиль. Вкладка A загрузила страницу и получила ETag "v1". Вкладка B сначала сохраняет, и профиль получает ETag "v2". Когда вкладка A пытается сохранить с If-Match: "v1", сервер возвращает 412. UI может предложить пользователю перезагрузить или показать маленький экран для слияния, вместо того чтобы перезаписывать изменения вкладки B.

Как обрабатывать конфликты, не раздражая пользователей

Конфликт — это не просто ошибка для пользователя. Это неожиданность. Ваша задача — объяснить, что произошло, и помочь пользователю сохранить свою работу.

Используйте простое сообщение, которое объясняет проблему и её последствия: «Запись была изменена в другом месте, пока вы её редактировали. Ваши изменения ещё не сохранены.» Избегайте расплывчатых сообщений вроде «409 Conflict» или «Update failed». Людям важно знать, что это не их вина.

Дайте простые варианты (и сделайте безопасный вариант удобным)

Большинству приложений подходят те же три варианта. Сделайте их простыми и сделайте безопасный путь основным.

  • Загрузить последнюю версию: получить самую свежую версию и показать её.
  • Сохранить мои правки: сохранить введённые пользователем данные в форме, чтобы он мог применить их снова.
  • Перезаписать всё: разрешить только после явного подтверждения, если пользователь действительно хочет заменить чужие изменения.

Сделайте «Загрузить последнюю версию» основной кнопкой. «Перезаписать» должно быть вторичным и требовать подтверждения, с пояснением, что будет перезаписано.

Сохраняйте несохранённые вводы пользователя

Самый быстрый способ потерять доверие — стереть форму после конфликта. Сохраняйте то, что пользователь ввёл, даже если вы обновляете данные с сервера.

Практичный паттерн: храните незавершённые правки отдельно (локальное состояние или черновик), загрузите последние данные сервера, затем заполните форму снова, используя черновик там, где это имеет смысл. По возможности подсвечивайте поля, которые отличаются от серверной версии, чтобы пользователь быстро увидел изменения.

Когда достаточно перезагрузки, а когда нужен UI для слияния

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

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

Реалистичный поток: двое правят одну карточку клиента. Один меняет номер телефона и сохраняет. Другой меняет адрес и позже нажимает сохранить. При правильной обработке конфликтов второй увидит: «Номер телефона изменён кем‑то ещё. Ваше изменение адреса осталось.» Он может загрузить свежие данные и снова сохранить, не перепечатывая всё.

Распространённые ошибки и ловушки, которых следует избегать

Catch Hidden Race Bugs
We diagnose retries, slow requests, and race conditions that cause random-looking data reverts.

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

Ловушки, приводящие к тихим перезаписям

  • Использование updated_at как «версии», когда метки времени недостаточно точны. Если два обновления попали в одну секунду (или БД округляет), оба могут выглядеть валидными, и одно перезапишет другое.
  • Добавление проверки версии/ETag в один эндпоинт, но пропуск её в другом. Например, основной экран редактирования использует проверку, а быстрый переключатель, автосохранение, админ‑панель или фоновая задача обновляют ту же запись без неё.
  • Повышение версии при чтении или при операциях записи, которые не меняют поля, управляемые пользователем. Если вы инкрементируете версию при простом просмотре, вы создаёте конфликты, которые кажутся случайными и несправедливыми.
  • Ловля конфликта и автоматический повтор без участия пользователя. Слепые ретраи могут превратить явный сигнал «вы редактировали старую копию» в путаный цикл.
  • Пакетные обновления, которые обходят проверку конкурентности. Один SQL‑запрос или инструмент пакетного обновления, обновляющий многие строки, может игнорировать условие по версии и стереть свежие правки.

Небольшие привычки, которые предотвращают большие баги

Будьте последовательны: если запись редактируема, каждый путь записи должен либо (1) требовать версии/ETag, либо (2) быть явно спроектирован как принудительное перекрытие и логироваться как такое.

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

Простой тест‑проверка: откройте одну и ту же форму редактирования в двух вкладках, сохраните в вкладке A, затем сохраните в вкладке B. Если вкладка B проходит без явного конфликта, у вас всё ещё есть путь для потерянного обновления.

Быстрые проверки перед релизом

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

Начните с простейшего реального теста: откройте одну и ту же запись в двух вкладках браузера. Измените разные поля в каждой вкладке, затем сохраните вкладку A и вкладку B. Вкладка B не должна молча «выиграть». Она должна получить ответ о конфликте (часто HTTP 409) и сообщение, которое подскажет приложению, что произошло.

Медленные сети — где эти баги прячутся. Используйте эмуляцию медленной сети в браузере (или добавьте искусственную задержку на сервере), чтобы одно сохранение занимало несколько секунд. Пока оно в пути, сохраните из другой вкладки. Когда задержанный запрос наконец вернётся, он должен безопасно провалиться.

Короткий предрелизный чек‑лист:

  • Две вкладки: откройте одну запись в двух вкладках, сохраните в обеих, убедитесь, что второе сохранение получает конфликт.
  • Медленное сохранение: задержьте один запрос, сохраните другой, убедитесь, что задержанный отклонён.
  • Возобновление на мобильном: редактируйте, сверните приложение, вернитесь позже и сохраните — версия/ETag должны по‑прежнему проверяться.
  • Офлайн‑восстановление: потеряйте соединение во время редактирования, потом восстановите и сохраните — убедитесь, что вы не перезаписываете более свежие данные.
  • Безопасность черновиков: после конфликта UI должен сохранять несохранённый текст пользователя.

Также проверьте детали, которые важны пользователям. При конфликте ответ должен быть достаточно конкретным для клиента: понятный статус, читаемое сообщение и, по возможности, актуальная серверная версия, чтобы UI мог показать «ваши изменения» против «текущей версии».

Реалистичный пример: двое людей редактируют одни и те же данные

Repair a Broken AI Project
Inherited an AI-built CRUD app? We’ll repair the codebase and verify every fix by hand.

Двое коллег, Майя и Джордан, обновляют одно и то же правило цен в админ‑панели. Правило: «10% скидка при сумме корзины больше $100». Майя хочет изменить порог на $120. Джордан хочет изменить скидку на 15%.

Они оба открывают страницу редактирования в 10:00. В тот момент у записи version = 7 (или эквивалент).

Что происходит без блокировки

Майя сохраняет первой в 10:02. Сервер записывает threshold = 120 в базу.

Джордан сохраняет в 10:03. Его браузер всё ещё содержит старые значения формы, поэтому его обновление записывает discount = 15% и также отправляет старое значение порога, которое он видел при загрузке. В результате — тихая перезапись: изменение порога Майи исчезает, и никто не получает предупреждение. Интерфейс часто показывает «Сохранено» оба раза, поэтому команда доверяет неправильным данным.

Что происходит со столбцом версии

С оптимистичной блокировкой оба запроса включают версию, с которой они начали.

  • Запрос Майи говорит: «обновить правило, где id=123 и version=7»
  • Сервер обновляет строку и повышает версию до 8
  • Запрос Джордана тоже говорит «где version=7»
  • База не находит совпадения (теперь версия 8)
  • Сервер возвращает ответ о конфликте вместо перезаписи

Что увидит пользователь: Джордан получит понятное сообщение вроде «Это правило скидки изменено кем‑то ещё. Просмотрите последнюю версию перед сохранением.» Страница загрузит свежие данные (threshold 120, version 8). Несохранённые правки Джордана можно сохранить локально, чтобы он мог заново применить «15%» и сохранить снова.

Что сохраняется: последняя сохранённая запись остаётся нетронутой, а намерение Джордана не теряется — оно откладывается до подтверждения на основе актуальной версии.

Следующие шаги: безопасный rollout (и помощь при необходимости)

Начните с подхода, который лучше вписывается в вашу архитектуру. Столбец версии чаще всего проще, когда вы контролируете базу данных и ORM и большинство обновлений идут через ваш сервер. ETag с If-Match удобен, когда у вас чистый REST API, несколько клиентов или сильные требования к кэшированию.

Раскатуйте по небольшим частям. Выберите один важный путь редактирования (профили, заказы, настройки) и добавьте оптимистичную блокировку end‑to‑end: чтение, редактирование, обновление и понятное сообщение при конфликте. Когда этот путь будет стабильным, повторяйте для других ресурсов.

Безопасный чек‑лист для rollout:

  • Добавьте версию (или ETag) в каждый ответ при чтении и в каждый запрос при обновлении.
  • Возвращайте понятный ответ о конфликте, когда версии не совпадают (никаких молчаливых перезаписей).
  • Покажите простой выбор в UI: загрузить, сохранить свои правки или повторить после просмотра.
  • Логируйте конфликты с типом ресурса и частотой, чтобы находить горячие участки.
  • Добавьте пару тестов, симулирующих две вкладки, обновляющие одну запись.

Не ограничивайтесь только API. Если UI просто пишет «Сохранение не получилось», люди будут повторять попытку и всё ещё могут перезаписать чужие изменения. Дайте контекст: что изменилось и что делать дальше. Для простых форм хорошей практикой является загрузка последних данных и сохранение черновика пользователя, чтобы он мог снова применить свои изменения.

Если вы унаследовали AI‑сгенерированное CRUD‑приложение, стоит проверить, что каждый путь записи следует единому правилу конкурентности. Teams like FixMyMess (fixmymess.ai) focus on turning fragile AI-generated prototypes into production-ready software, and a quick audit often finds missing version or ETag checks before they cause real data loss.