Оффлайн это свобода менять записи без сети, а синхронизация это момент, когда все изменения встречаются на сервере, и встречи не всегда мирные: одна и та же запись, отредактированная на телефоне в самолёте и на ноутбуке в офисе, превращается в два расходящихся варианта, и системе предстоит решить, что считать правдой. Конфликт это не экзотика распределённых систем, это их среда обитания, и качество продукта измеряется тем, насколько тихо он их разрешает: пользователь замечает хороший движок синхронизации только по отсутствию потерь, и не замечает вовсе.
Статья разбирает конфликтную механику целиком: виды расхождений, честность схемы последней записи, версионный параллелизм на сервере, следы удалений, автоматические слияния для структурных записей, интерфейс ручного разбора и архитектуру движка, который всё это исполняет. С кодом, правилами и границами каждой стратегии, потому что универсального разрешения конфликтов не существует, существует осознанный выбор под тип записей.
Почему оффлайн рождает конфликты и какие виды их бывают
Корень конфликтов физический: копии одной записи живут на нескольких устройствах без связи, каждая копия меняется по своей логике, и синхронизация приносит их вместе. Если бы человек работал строго с одного устройства в моменты связи, конфликтов бы не было, но оффлайн по определению разрешает одновременную независимую работу, и каждая такая сессия это потенциальный раскол.
Виды конфликтов пересчитываются по пальцам. Изменение против изменения: оба устройства правили одну запись, и правки задели одни поля или разные. Изменение против удаления: одно устройство продолжает редактировать то, что другое уже стёрло. Создание против создания: два устройства создали записи с одинаковым внешним ключом, например запись о клиенте по его номеру. Удаление против удаления выглядит мирно, но и оно требует решения, кому верить, если удаления случились в разное время.
Отдельный класс это конфликты порядка: операции приложились в другом порядке на разных копиях, и итог расходится без единого противоречия в значениях. Порядок решается версиями и журналами, и без явного учёт порядка хаос только маскируется под удачу.
Выбор стратегии начинается с классификации записей продукта: справочные значения терпят простые правила, документы требуют аккуратных слияний, а финансовые сущности не терпят ничего, кроме серверного авторитета. Один продукт обычно носит две-три стратегии одновременно, и знание, где какая, это половина архитектуры.
Последняя запись побеждает честность простой схемы и цена часов
Самое простое разрешение звучит как жизнь: побеждает версия с самым поздним временем изменения. Схема понятна пользователю, не требует интерфейсов слияния и работает за ноль миллисекунд, и половина продуктов останавливается на ней навсегда. Честность схемы в её открытости: проигравшая правка теряется целиком, система об этом честно молчит, и пользователь, чья правка исчезла, узнаёт об этом глазами.
Первая проблема схемы это часы устройств: время на телефонах и ноутбуках расходится на минуты и годы, и сравнение по клиентским показаниям сравнивает показания разных будильников. Лечится серверным временем в момент приёма, но серверное время лечит только порядок приёма, а не порядок реальной работы: правка из самолёта приезжает на сервер позже приёма утренней правки, и побеждает не та, что сделана позже в жизни.
Вторая проблема это грубость: запись из двадцати полей проигрывает целиком, хотя конфликт случился в одном поле. Слияние на уровне полей спасает большую часть: сравнение времён по каждому полю независимо сохраняет правку заголовка с одного устройства и правку описания с другого, и записи такого профиля составляют большинство в типовых продуктах.
Третья проблема это потери без следа: побеждённая версия исчезает, и пользователь не имеет ни истории, ни шанса вернуть своё. Зрелая реализация схемы хранит проигравшую ветку короткое время и показывает тост о слиянии, превращая молчаливую потерю в осознанный компромисс, и это стоит одного журнала версий на сервере.
Версии и оптимистичный параллелизм как сервер держит порядок
Версионный параллелизм строится на числе: каждая запись несёт номер версии, клиент читает с номером, пишет с номером, и сервер отказывает, если номер устарел. Отказ с кодом конфликта означает, что кто-то уже записал ранее, и клиент обязан перечитать, разрешить расхождение и повторить с новой версией. Схема зовётся оптимистичной, потому что запись пытается без блокировок, и конфликт это не авария, а штатный путь.
const res = await fetch("/api/notes/42", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ version: 7, title: local.title })
});
if (res.status === 409) {
const server = await res.json();
local = merge(local, server);
}
Отказ с кодом конфликта это точка честности: сервер не угадывает, кто прав, он заявляет расхождение и отдаёт текущую версию для решения. Клиентское решение может быть любым из стратегий статьи, и версионность не диктует политику, она лишь гарантирует, что решение принимается с полным знанием ситуации.
Версии решают и проблему порядка: приложение операций по номеру одинаково на всех копиях, и расхождения из-за порядка приёма исчезают как класс. Журнал версий на сервере добавляет историю, восстановление и аудит, и хранить короткую историю проигравших веток дешевле, чем разбирать жалобы о потерянных правках.
Идентификаторы записей живут рядом с версиями: записи, появившиеся в оффлайне, получают глобально уникальные ключи с устройства, и создание против создания решается слиянием по ключу вместо удвоения. Серверные порядковые номера для оффлайна бесполезны, и генерация ключей на клиенте это не компромисс, а необходимость.
Удаления и заметки о них почему стёртое обязано оставлять след
Удаление в распределённой системе это не отсутствие записи, а запись об отсутствии: если одно устройство стёрло заметку, а второе продолжает её редактировать и синхронизировать, отсутствие записи на сервере выглядит как команда к созданию заново, и заметка появляется. Памятка об удалении, чёткая и с временем, запрещает появление: сервер видит, что запись удалена позже, чем правилась, и отклоняет попытку вернуть её молча.
Памятка об удалении хранится ограниченное время, сравнимое с горизонтом оффлайн-сессий: неделя покрывает почти все сценарии, месяц закрывает параноиков. Вечные памятки о каждом удалении раздули бы хранилище, и компромисс срока это осознанное решение, а не настройка по умолчанию из учебника.
Удаление против удаления живёт по тем же правилам разрешений: оба устройства стёрли, памятка одна, расхождений нет. Изменение против удаления это единственная пара, где без политики не обойтись: побеждает удаление по времени, побеждает правка, или решение уходит человеку, и выбор зависит от драгоценности записей: заметка прощает потерю, платёжный документ нет.
Мягкое удаление снимает половину драмы: запись скрывается, хранится и восстанавливается из корзины, и конфликт удаления превращается в конфликт скрытия с теми же механиками, но с гуманным откатом. Для продуктов с недоверчивыми пользователями корзина это не украшение, а часть стратегии разрешений.
Автоматические слияния структур где машины мирят правки сами
Слияние это высшая форма разрешения: обе правки сохраняются, и алгоритм собирает из них одну запись, которая содержит намерения обеих сторон. Для структурных записей слияние по полям уже знакомо: независимые поля живут независимо, и два правдивых временных штампа на поле дают результат без единого вопроса человеку.
Списки и множества мирятся по операциям вместо значений: добавить элемент с одного устройства и добавить с другого даёт объединение, порядок решается явным ключом сортировки, и такие записи составляют значительную часть бытовых сценариев от списков покупок до тегов. Операционный подход, где синхронизируются действия вместо состояний, расширяет автоматические слияния на более сложные структуры.
Совместное редактирование текста это отдельная дисциплина с собственными структурами, которые сливают параллельные правки по математике, а не по последней букве: добавления с обеих сторон сохраняются, порядок определяется детерминированно на всех копиях, и два абзаца, написанные одновременно, оказываются в документе оба. Реализация таких структур это библиотечная задача, а не самописная, и продукты берут готовое.
Пределы автомата проговариваются честно: слияние по полям не понимает семантики, и правки двух полей, смысл которых связан, например цена и валюта, могут разойтись в бессмыслицу. Семантические инварианты проверяются после слияния, и нарушение инварианта отправляет запись на следующий уровень разрешения, к человеку.
Интерфейс конфликта когда автомату не верят и зовут человека
Ручное разрешение это последний рубеж, и его интерфейс решает, будет ли конфликт катастрофой или рутиной. Экран сравнения показывает обе версии с подсветкой расхождений и третьим полем результата: выбрать одну, выбрать другую, собрать вручную. Кнопки называются действиями, а не судьями: оставить мою, оставить их, сохранить обе версии.
Сохранить обе это недооценённая опция: конфликтная копия становится ответвлением записи, и пользователь разбирает её в спокойствии, а не под давлением диалога. Разделение записей стоит дешевле паники, и продукты с драгоценным содержимым признают его штатным исходом.
Тайминг экрана конфликта выбирается осторожно: выскакивать в момент сохранения жестоко, копить конфликты пачкой до отдельного экрана спокойнее, и зрелые продукты собирают расхождения в центр разборов с числом ожидающих, как собирают уведомления. Человек открывает разборы в подходящее состояние духа, и это отношение переносится на весь продукт.
Профилактика конфликтов дешевле их разборов: индикатор того, что запись редактируется на другом устройстве, блокировка на время активной сессии при связи, уведомление о параллельной правке. Половина конфликтов предотвращается намёком, и намёк стоит одной строки статуса.
Архитектура движка синхронизации очередь версий и разрешение на сервере
Движок синхронизации это отдельный слой со своей судьбой: локальный журнал операций, очередь отправки, приём удалённых изменений, разрешение и запись результата. Локальный журнал хранит операции с временем и устройством, и на его основе строятся и слияния, и разборы, и диагностика, а его отсутствие превращает каждый конфликт в сессию догадок.
Серверная сторона держит канонические версии и принимает решения по заявленной политике: серверный арбитр честнее клиентского, потому что видит все копии и единые часы. Клиентские стратегии остаются для офлайн-предварительных слияний, финальное слово всё равно за сервером, и гибридная схема из локального слияния и серверного подтверждения переживает любые сценарии.
function mergeFields(local, server) {
const out = {};
for (const key of new Set([
...Object.keys(local),
...Object.keys(server)
])) {
const t1 = local.updatedAt?.[key] ?? 0;
const t2 = server.updatedAt?.[key] ?? 0;
out[key] = t1 > t2 ? local[key] : server[key];
}
return out;
}
Идемпотентность завершает фундамент: операции приходят с ключами, повторная пересылка не создаёт дублей, и движок с лёгким сердцем повторяет отправки после любых сбоев сети. Разрешение конфликтов, построенное поверх очереди без идемпотентности, однажды встречает конфликт с самим собой, и эта встреча не из приятных.
Порядок внедрения разрешения конфликтов свёрнут в короткую лестницу:
- классифицировать записи продукта по драгоценности и структуре;
- назначить политику каждой категории: время, версия, слияние, человек;
- ввести глобальные ключи записей и памятки удалений с горизонтом;
- построить журнал операций на клиенте и очередь отправки;
- включить версионный отказ на сервере и слияние по полям в клиенте;
- собрать экран разборов с опцией сохранить обе версии;
- журналить каждое разрешение для истории и разбора жалоб.
Конфликты это не сбой оффлайна, а его цена, и продукты различаются не числом конфликтов, а тем, как тихо они их проживают. Записи с версией, слияния по полям, памятки удалений и спокойный экран разборов складываются в систему, в которой пользователь ни разу не теряет правку, а худший исход любой встречи версий это один честный клик вместо тихой потери.