Офлайн-синхронизация в мобильном приложении — паттерны

Пользователь правит данные без сети, потом ловит метро или вай-фай — и тут начинается. Разбираем 4 паттерна офлайн-синха: last-write-wins, CRDT, очередь команд, event sourcing.

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

Offline-sync паттерны: last-write-wins, очередь команд, CRDT, event sourcing
Сначала ответить на вопрос «что считается источником истины», потом выбирать алгоритм синхронизации.

Базовые вопросы перед выбором

  • Кто источник истины? Сервер всегда прав, клиент всегда прав, или это договорённость по полям.
  • Что может редактироваться офлайн? Чем меньше — тем проще. Многие конфликты решаются ограничением области офлайн-правок.
  • Какие конфликты допустимы? Потеря изменения, дубль, неверная сумма — где какой риск критичный.
  • Multi-device. Один и тот же пользователь с двух устройств одновременно — отдельная история.

Паттерн 1. Last-write-wins (LWW)

Самый простой. Каждой записи — timestamp последнего изменения. Кто позже — побеждает. Реализация в 50 строк.

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

Грабли.

  • Часы устройств расходятся. Лучше использовать HLC (Hybrid Logical Clock) или серверное время с поправкой.
  • Потеря изменений: если два устройства отредактировали поле, изменение того, кто пришёл «раньше», теряется молча.
  • Не работает для коллекций (добавили элемент в список с двух устройств — один потеряется).

Паттерн 2. Очередь команд (Command Queue)

Клиент не правит данные напрямую — он генерит команды: «отметить заказ #1234 доставленным», «добавить позицию в чек». Команды копятся в локальной очереди, при появлении сети — отправляются по порядку с retry и idempotency-key.

Когда работает. CRUD-операции, где важна последовательность, и сервер — источник истины. Курьерские приложения, торговый агент в полях, кассы.

Реализация.

  • Каждая команда — JSON с уникальным `client_id` (UUID v4) для дедупликации.
  • Сервер хранит таблицу обработанных `client_id` — повторный запрос возвращает кэшированный результат.
  • На клиенте — статус каждой команды: `pending`, `sent`, `confirmed`, `failed`. Пользователь видит, что синхронизация идёт.
  • Конфликт (сервер вернул 409) — команда переходит в `failed`, UI показывает «требует разбора».

Грабли.

  • Команда базируется на данных, которые на сервере уже изменились. Включать в команду version (optimistic locking) того, что правим.
  • Очень длинная очередь после долгого офлайна — отправлять батчами, не по одной.
  • UI должен отрабатывать optimistic update — иначе пользователь жмёт кнопку и ничего не происходит.

Паттерн 3. CRDT (Conflict-free Replicated Data Types)

Структуры данных, которые мерджатся автоматически без конфликтов. Любые операции коммутативны: порядок не важен, результат одинаков. Реализации: Yjs, Automerge, Loro.

Когда работает. Совместное редактирование документов, многопользовательские заметки, канбан-доски с офлайн, рисование. Реальное применение — Linear, Notion, Figma (отчасти).

Плюсы. Multi-device, multi-user без центрального сервера. Конфликтов нет по построению.

Минусы.

  • Размер. CRDT хранит историю операций или версии. Документ в 10 МБ текста = CRDT в 50–200 МБ. Garbage collection помогает, но требует осторожности.
  • Сложность. Программисты должны мыслить в терминах CRDT, а не в терминах «записал поле». На вход проекту — кривая обучения 1–2 недели.
  • Не для денег. CRDT — про мерж, не про «сумма должна сойтись». Финансовые транзакции — отдельная история.

Паттерн 4. Event sourcing на клиенте

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

Когда работает. Системы с аудитом и историей: банковские, складские, медкарты. Возможность откатить, заглянуть в любой момент времени.

Грабли.

  • Сложнее в реализации, чем командная очередь. Каждое изменение состояния — отдельное событие со схемой.
  • Версионирование событий — событие 2025 года должно читаться кодом 2026.
  • Хранение событий растёт линейно. Снэпшоты каждые N событий.

Что брать для типового приложения

  • Доставка / выездной торговый агент / касса. Очередь команд + idempotency. Сервер — источник истины, optimistic UI.
  • Заметки / задачи / профиль. LWW с HLC. Просто, легко.
  • Совместная работа над документом / доской. CRDT (Yjs или Automerge).
  • Финансы / медкарты / аудит. Event sourcing.

Слой хранения на клиенте

  • SQLite + миграции. Дефолт. SQLDelight, Room, GRDB — типовые обёртки.
  • WatermelonDB / RxDB. Готовые offline-first DB с механикой синхронизации. Хорошо для React Native / Cordova.
  • PowerSync / ElectricSQL. Серверные движки sync поверх Postgres. Берут на себя дельты и конфликты. Платно, но экономят месяцы.

Что тестировать обязательно

  1. Холодный старт без сети.
  2. Длинный офлайн (24+ часа) с большой очередью.
  3. Сеть пропала в момент отправки — клиент должен повторить безопасно (idempotency).
  4. Конфликт версий: один и тот же объект изменён на сервере и на клиенте.
  5. Multi-device: одно и то же действие с двух устройств.
  6. Падение приложения посреди синхронизации — очередь не должна потеряться.

Итог: «офлайн-первое» — это архитектурное решение, а не фича. Команды + идемпотентность закрывают 80% бизнес-приложений. CRDT — для совместного редактирования. LWW — для простого. Event sourcing — для аудита. Главное — определиться с источником истины до того, как написана первая строка.