ATL-2026-017architectureexploratory

Два устройства изменили одну запись. Что именно синхронизировать?

Телефон и планшет меняют одну запись списка покупок. Как не потерять локальную правку: редакция записи, серверный конфликт, выбор пользователя и повтор после потерянного ответа.

Опубликовано
7 октября 2026 г.
Проверено
6 октября 2026 г.

Два устройства изменили одну запись. Что именно синхронизировать?

В общем списке покупок записано: молоко — одна упаковка. На телефоне человек меняет количество на две, но в этот момент связи нет. На планшете тот же список открыт из дома; там количество меняют на три, и сервер принимает правку. Позже телефон подключается к сети и отправляет свою сохранённую «двойку».

Теперь у системы три числа: исходная единица, подтверждённая сервером тройка и ещё не подтверждённая двойка на телефоне. Передать двойку после подключения несложно. Но почему ей разрешено вытеснить правку, которую сервер уже принял?

Если на обоих устройствах после синхронизации появится «2», технически они согласованы. Но решение, принятое на планшете, пропало без предупреждения. Если появится «3», человек на телефоне может решить, что его действие вообще не сохранилось. Слово «синхронизировано» в обоих случаях описывает одинаковые копии, а не правильный для пользователя результат.

Отсутствие сети здесь не главный виновник. Два открытых экрана могут прочитать одну запись и изменить её почти одновременно. Без сети устройство лишь дольше живёт с собственной версией событий.

Что хранится на устройстве

Начальное состояние списка приходит с сервера вместе с номером редакции записи. Пусть молоко в количестве одной упаковки имеет редакцию 17. Оба устройства прочитали именно её.

На телефоне теперь существуют две разные вещи: намерение установить количество 2 и подтверждённое значение 1, которое он последним получил от сервера. Экран может сразу показывать двойку — для списка покупок это вполне разумно. Но человеку нужно видеть, что она сохранена только на устройстве и ждёт отправки. Иначе локальная правка выглядит как решение всей системы.

Руководство Flutter по offline-first описывает локальную запись с последующей отправкой и предлагает отмечать данные, требующие синхронизации. Это полезный шаг, но одного флага synchronized: false мало, когда ту же запись меняет другое устройство. Флаг сообщает, что работа не закончена. Он не хранит редакцию, на основе которой было принято решение, и не выбирает исход столкновения.

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

Что проверяет сервер

Планшет первым отправляет «установить 3 на основе редакции 17». Сервер видит, что запись всё ещё имеет редакцию 17, принимает изменение и создаёт редакцию 18. Затем приходит телефон со своей двойкой, тоже основанной на редакции 17. Если продукт не разрешает молча терять правки, сервер не принимает её поверх тройки. Он сообщает о расхождении и возвращает текущее значение с редакцией 18.

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

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

UPDATE shopping_items
SET quantity = :new_quantity,
    revision = revision + 1
WHERE id = :item_id
  AND owner_id = :owner_id
  AND revision = :base_revision
RETURNING quantity, revision;

Условие по revision не даст применить правку к другой редакции строки; RETURNING вернёт значение только при успешном обновлении — именно так работает UPDATE в PostgreSQL. Если строка не обновлена, ещё предстоит выяснить причину: редакция устарела, запись удалена или доступа к ней нет. Ноль обновлённых строк сам по себе не является ответом для экрана.

Номер редакции упорядочивает подтверждённые изменения этой записи. Это не часы телефона и не общий счётчик всех изменений в системе. У сервера с несколькими независимыми пишущими узлами задача сложнее; здесь рассматривается один серверный контракт принятия записи, а не multi-master репликация.

На уровне HTTP ту же проверку можно оформить через сильный ETag и If-Match; при несовпадении RFC 9110 предусматривает 412 Precondition Failed, за исключением случая, когда сервер распознал уже выполненный запрос. Если редакция передаётся в теле собственного API, можно договориться, например, об ответе 409 с текущим значением. Это альтернативные способы выразить одно условие, а не два обязательных слоя проверки.

Конфликт — не транспортная ошибка

Сбой сети, успешное сохранение и конфликт редакций требуют от клиента разных действий.

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

Экрану нужен честный выбор. Например: «На этом устройстве — 2, в общем списке — 3. Оставить 3 или заменить на 2?» Если человек выбирает двойку, это уже новое изменение на основе редакции 18, а не повтор старой отправки на основе 17. Сервер может принять его как редакцию 19 — если к тому времени запись снова не изменилась.

Для Flutter-экрана это не просто ещё один текст ошибки. Если приложение обещает сохранять правки без сети, двойка должна пережить обновление списка и перезапуск. Когда приходит конфликт, нельзя записать серверную тройку поверх единственной локальной строки, а затем предложить выбор: двойки уже нет. Подтверждённую серверную версию и ожидающую решения правку придётся хранить раздельно. Выбор «оставить 3» снимет локальную правку; «заменить на 2» создаст новую отправку с новой базовой редакцией.

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

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

Можно ли объединить изменения автоматически

Иногда — да. Допустим, телефон изменил количество, а планшет уточнил название товара. Если сервер хранит исходную редакцию и знает, какие именно поля изменил каждый клиент, эти правки можно попытаться объединить. Но «поля разные» ещё не является доказательством безопасности. Название и количество могут участвовать в общих правилах продукта; удаление позиции на одном устройстве вообще меняет смысл правки другого.

Объединять такие правки можно лишь по правилам самого списка: какие изменения независимы, что делать с удалением, какие сочетания допустимы. Для количества 2 против количества 3 универсальной арифметики нет. 2 + 3 означало бы пять упаковок, хотя никто не просил добавить пять. Выбор большего числа столь же произволен.

Другой осознанный вариант — last-write-wins: поздняя запись заменяет прежнюю. Cloud Firestore прямо указывает такой порядок для нескольких изменений одного документа при offline-синхронизации. Он может подходить, когда потеря прежнего значения допустима или его легко восстановить. Но если каждое изменение количества важно, такое правило должно быть решением продукта, а не случайным свойством выбранной платформы. Часы устройств не превратят его в арбитра смысла.

Повтор запроса — ещё не новая правка

Есть и другой исход, без правки планшета. Телефон отправил двойку на основе редакции 17, сервер принял её как редакцию 18, но ответ потерялся. Для телефона результат неизвестен. Если повторить запрос с прежней редакцией 17, сервер увидит уже 18 и может ответить «конфликт». Человек получит конфликт со своей же успешно сохранённой правкой.

Сравнение редакций решает столкновение разных правок, но не узнаёт повтор той же. Клиент может присвоить правке устойчивый идентификатор, а сервер — запомнить, чем закончилась её обработка и с какими параметрами она пришла. Повтор с тем же идентификатором вернёт тот же по смыслу результат; новое решение получит новый идентификатор. Такой способ безопасного повтора разобран в Amazon Builders’ Library.

Но и здесь есть граница: идентификатор и результат изменения нужно сохранять атомарно. Если offline-очередь хранит правки дольше, чем сервер помнит их идентификаторы, безопасный повтор уже не гарантирован. В этой статье такой сервер не построен; это условие, которое пришлось бы проверить при реализации.

Что в итоге проверять на ревью

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

  1. Что на экране уже подтверждено сервером, а что лишь сохранено локально?
  2. На какой редакции основано отправляемое изменение и где эта редакция проверяется атомарно?
  3. Кто выбирает результат при столкновении: серверное правило, автоматическое предметное объединение или пользователь?
  4. Как отличить повтор прежней операции после потери ответа от нового решения человека?

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

Не каждому приложению нужен журнал операций, универсальный merge-движок и отдельный экран конфликтов. Где-то достаточно запретить запись без сети. Где-то нужны редакции записей и понятный ответ о столкновении. А там, где потеря старого значения допустима, last-write-wins может быть честной политикой. Выбор определяется ценой потерянного решения, а не модностью механизма синхронизации.

Здесь разобран контракт одной записи, а не результат эксперимента: сервер и Flutter-клиент не запускались, конкурентные последовательности не проверялись кодом, удобство диалога не исследовалось. Но главный вопрос уже можно поставить на ревью: на основании какой редакции принято локальное решение и кто вправе разрешить расхождение? Без ответа синхронизация способна аккуратно разнести потерю данных на все устройства.


ArkTelos: официальный сайт · новости RU · news EN

ArkTelos Lab: лаборатория · Telegram RU · Telegram EN

Границы результата

  • Аналитический пример для одной записи с одним авторитетным сервером. Работающие сервер и Flutter-клиент не создавались; конкурентные последовательности не проверялись кодом, а удобство предлагаемого выбора — на пользователях.

Версия в Telegram

Читать в ArkTelos Lab

Читать в ArkTelos Lab ↗

КАНАЛЫ ARKTELOS

Новости и инженерные материалы — без смешивания языков.

Основные каналы рассказывают о развитии ArkTelos. ArkTelos Lab публикует архитектурные разборы, эксперименты и воспроизводимые исследования.