Мобильная разработка Данные и оффлайн: локальные БД, синхронизация, разрешение конфликтов
0%

Данные и оффлайн: локальные БД, синхронизация, разрешение конфликтов

Данные и оффлайн: локальные БД, синхронизация, разрешение конфликтов

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

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

Отсюда вытекает единственная архитектурная идея этой статьи: источник истины для экрана — локальная база данных, а сеть — фоновый процесс, который её догоняет. Всё остальное — детали реализации этой идеи: где хранить, как описать схему, как поставить правку в очередь, как забрать дельту, и что делать, когда две реплики поменяли одну и ту же строку. Слои, в которые всё это укладывается, разобраны в статье об архитектуре; здесь мы живём на самом нижнем из них.

Чем мобильные данные отличаются от веб-данных

Что Веб-приложение Мобильное приложение
Источник истины для UI сервер локальная БД на устройстве
Отсутствие сети страница не открылась штатный режим работы, часы и дни
Качество канала стабильный или явно выключен «серая зона»: есть Wi-Fi, но пакеты не ходят
Стоимость запроса трафик провайдера батарея, мобильный трафик, лимит тарифа
Время жизни клиента до закрытия вкладки месяцы; та же БД, но 12 версий схемы спустя
Откат неудачной схемы миграция на сервере никак: у пользователя уже стоит старая версия
Параллельные реплики одна вкладка, один сервер телефон, планшет, веб — три реплики одной строки
Проверка изменений F5 релиз в стор, ревью, раскатка, часть людей не обновится

Последние три строки — то, что делает мобильную работу с данными принципиально другой задачей. Локальная БД у пользователя необратима: вы не можете «просто накатить фикс». Схема, которую вы выкатили сегодня, будет жить на чьём-то телефоне через два года, вместе с накопленным в ней мусором и с наполовину применённой миграцией после того, как система убила процесс.

Сеть на телефоне — не выключатель, а серая зона

Первая ошибка новичка — считать, что связь бинарна. На практике вы столкнётесь как минимум с:

  • lie-fi — Wi-Fi подключён, иконка горит, но пакеты не проходят (перегруженная точка в кафе, роутер без аплинка);
  • captive portal — сеть отвечает, но на любой запрос возвращает страницу авторизации отеля;
  • длинный хвост задержек — соединение установится, но через 40 секунд;
  • метрируемая сеть — трафик считается по деньгам, у пользователя включён режим экономии;
  • транзитные обрывы — переход с Wi-Fi на LTE меняет IP посреди загрузки.

Отсюда практическое правило, которое Apple прямо формулирует в документации к Network framework: не спрашивайте, есть ли сеть, — делайте запрос. Классический «Reachability-чек» перед вызовом одновременно и лжёт (сеть есть, но не работает), и вносит гонку (за миллисекунду между проверкой и запросом связь пропала). Состояние сети полезно для другого: решить, стоит ли качать 200 МБ видео прямо сейчас, и показать честную плашку «данные от 12:04, обновим, когда появится связь».

// iOS: NWPathMonitor — не «есть ли интернет», а «какой канал и сколько он стоит».
import Network

final class ConnectivityObserver {
    private let monitor = NWPathMonitor()
    private(set) var isExpensive = false   // сотовая сеть или персональная точка доступа
    private(set) var isConstrained = false // включён Low Data Mode

    func start() {
        monitor.pathUpdateHandler = { [weak self] path in
            self?.isExpensive = path.isExpensive
            self?.isConstrained = path.isConstrained
            // path.status == .satisfied означает «маршрут есть», а НЕ «сервер доступен».
            // Решение о фактической доступности принимает только успешный запрос.
        }
        monitor.start(queue: .global(qos: .utility))
    }
}
// Android: те же две величины через NetworkCapabilities.
// NET_CAPABILITY_VALIDATED — система сама сходила наружу и убедилась, что сеть живая.
// Это лучший доступный сигнал, но и он устаревает через секунду.
class Connectivity(private val cm: ConnectivityManager) {

    fun snapshot(): NetState {
        val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return NetState.Offline
        return NetState.Online(
            validated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
            metered = !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED),
            constrained = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED).not(),
        )
    }
}

sealed interface NetState {
    data object Offline : NetState
    data class Online(val validated: Boolean, val metered: Boolean, val constrained: Boolean) : NetState
}

Три уровня зрелости работы с данными

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

  1. Online-only. Каждый экран — запрос. Дёшево, быстро пишется, годится для админок и разовых сценариев. Ломается в метро; при потере связи экран пустой.
  2. Кэш чтения. Ответы складываются в БД, экран рисуется из кэша и обновляется по сети. Приложение открывается мгновенно и что-то показывает без сети. Записи по-прежнему требуют онлайна. Это оптимум для большинства продуктов: примерно 20% усилий, 80% ощущаемого качества.
  3. Offline-first с записью. Правки применяются локально и уходят в очередь. Появляются очередь, идемпотентность, конфликты, ID-маппинг и UI для «не отправлено». Дороже уровня 2 в два-три раза по трудозатратам — и оправдано, когда работа без сети является сценарием, а не аварией: полевые сотрудники, заметки, чек-листы, мессенджеры, трекеры.

Схема потоков данных в offline-first приложении

Ключевое следствие схемы: в сеть ходит ровно один компонент. Не экран, не ViewModel, не репозиторий — sync-движок. Это не эстетика, а тестируемость: движок с фейковым временем и фейковым транспортом проверяется тысячей сценариев за секунды, а UI-тест на устройстве с реальной сетью не проверяет ничего воспроизводимо.

Где хранить: выбор локального хранилища

Хранилище Платформа Для чего Чем расплачиваетесь
UserDefaults / SharedPreferences iOS / Android флаги, настройки, до сотен килобайт не транзакционно, всё грузится в память, не для данных
DataStore (Preferences/Proto) Android замена SharedPreferences асинхронный API, требует корутин
Файлы (JSON, Protobuf) обе конфиги, снапшоты, вложения нет запросов, нет частичной записи, ручные блокировки
SQLite напрямую обе всё, где есть выборки и связи нужен слой поверх, иначе строки SQL по всему коду
Room / SQLDelight Android, KMP типобезопасный SQLite кодогенерация, свои правила миграций
GRDB.swift iOS типобезопасный SQLite со Swift-моделями сторонняя зависимость
Core Data / SwiftData iOS граф объектов, автосохранение, iCloud своя модель мышления, тяжёлая отладка, привязка к Apple
drift / sqflite Flutter SQLite в Dart тяжёлые запросы — только в изоляте
op-sqlite / WatermelonDB React Native SQLite через JSI производительность упирается в JS-поток
MMKV обе быстрый key-value не БД: нет запросов и транзакций по нескольким ключам

Практическая рекомендация одна и она скучная: если данных больше, чем пара экранов настроек, — это SQLite. Не потому, что он моден, а потому, что он одинаковый на iOS, Android, в Flutter и в React Native; его знают все; у него понятный план запроса; он переживает смерть процесса; и навыки работы с ним переносятся между проектами. Core Data и Realm решают ту же задачу иначе, но цена ошибки выше: у Core Data — своя объектная семантика и контексты, которые ловят новичка на многопоточности, а история Realm показательна сама по себе — MongoDB объявила о прекращении поддержки Atlas Device Sync и Device SDK, после чего командам пришлось мигрировать данные пользователей на другое хранилище. Это лучший из известных аргументов против выбора закрытого хранилища с проприетарной синхронизацией.

Обязательные настройки SQLite на мобильном

-- Выполняется один раз при открытии соединения.
PRAGMA journal_mode = WAL;      -- читатели не блокируют писателя: UI не «залипает» на записи
PRAGMA synchronous = NORMAL;    -- разумный компромисс: fsync реже, данные при падении процесса целы
PRAGMA foreign_keys = ON;       -- в SQLite ВЫКЛЮЧЕНЫ по умолчанию, включать нужно на каждом соединении
PRAGMA busy_timeout = 5000;     -- вместо мгновенного SQLITE_BUSY подождать освобождения блокировки

Две мобильные ловушки, о которых не пишут в общих руководствах по SQLite:

  • WAL и защита данных на iOS. Файлы по умолчанию получают класс защиты, при котором они недоступны, пока устройство заблокировано. Фоновая задача, проснувшаяся на заблокированном телефоне, получит ошибку ввода-вывода на ровном месте. Для БД, к которой нужен доступ в фоне, класс защиты выставляется явно (например, «доступно после первой разблокировки»), и это осознанный компромисс между удобством и безопасностью — подробности в статье о безопасности.
  • Автобэкап на Android и iCloud на iOS. Резервная копия восстановится на другом устройстве, а ключ шифрования, привязанный к Keystore или Secure Enclave старого телефона, — нет. Итог: приложение падает при первом запуске у нового владельца устройства. Либо исключайте зашифрованную БД из бэкапа, либо предусматривайте сценарий «ключа нет, данные читать нечем, чистим и синхронизируемся заново». Плюс требование Apple: перекачиваемые данные нельзя складывать так, чтобы они попадали в iCloud-бэкап, — за это приложения отклоняют на ревью.

Схема с метаданными синхронизации

Схема offline-first приложения — это не «зеркало серверных таблиц». К каждой синхронизируемой сущности добавляется служебный набор колонок, без которых синхронизация невозможна.

Разберём, зачем нужна каждая нетривиальная колонка.

  • local_id как первичный ключ. Запись существует до того, как о ней узнал сервер. Если первичным ключом сделать серверный идентификатор, вы не сможете ни сослаться на черновик, ни положить его в список. Клиентский UUID снимает проблему целиком.
  • server_id заполняется при первом успешном подтверждении и дальше используется в URL запросов.
  • server_version — то, что вы отправите обратно как базу изменения. Без неё сервер не отличит «я осознанно меняю актуальную запись» от «я затираю чужую работу вслепую».
  • base_json — снимок версии, от которой пользователь отталкивался. Это ровно тот «общий предок», без которого трёхстороннее слияние вырождается в выбор «мой или чужой».
  • dirty — флаг наличия локальных правок. Дельта с сервера не имеет права затирать грязную строку молча.
  • deleted_at — удаление всегда мягкое. Физическое удаление строки убивает информацию о том, что её нужно удалить и на сервере.

Выбор UUID тоже не произволен. Случайный UUIDv4 в качестве первичного ключа рассыпает вставки по всему B-дереву индекса и заметно замедляет запись на больших таблицах. UUIDv7 (RFC 9562) содержит временную метку в старших битах, поэтому новые записи ложатся в конец индекса — это тот же приём, что и ULID, но стандартизованный.

// Android, Room: сущность с метаданными синхронизации.
@Entity(
    tableName = "orders",
    indices = [Index("server_id", unique = true), Index("dirty")],
)
data class OrderEntity(
    @PrimaryKey val localId: String,              // UUIDv7, создаётся клиентом
    @ColumnInfo(name = "server_id") val serverId: String? = null,
    @ColumnInfo(name = "server_version") val serverVersion: String? = null,
    @ColumnInfo(name = "base_json") val baseJson: String? = null,
    val title: String,
    @ColumnInfo(name = "price_minor") val priceMinor: Long,   // деньги — только в целых копейках
    val dirty: Boolean = false,
    @ColumnInfo(name = "deleted_at") val deletedAt: Long? = null,
)
// iOS, GRDB: та же таблица. Индекс по dirty нужен для запроса «что ещё не отправлено».
try db.create(table: "orders") { t in
    t.primaryKey("local_id", .text)
    t.column("server_id", .text).unique()
    t.column("server_version", .text)
    t.column("base_json", .text)
    t.column("title", .text).notNull()
    t.column("price_minor", .integer).notNull()
    t.column("dirty", .boolean).notNull().defaults(to: false)
    t.column("deleted_at", .integer)
}
try db.create(index: "orders_dirty", on: "orders", columns: ["dirty"])

Миграции локальной схемы: билет в один конец

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

Три правила, нарушение которых стоит дорого:

  1. Ни одна версия схемы не пропускается. Пользователь мог не обновляться год: приложение должно уметь пройти путь 3 → 4 → 5 → … → 12 за один запуск. Значит, миграции пишутся инкрементально и хранятся вечно, даже если код, который их породил, давно удалён.
  2. Миграция транзакционна и идемпотентна. Она либо применилась целиком, либо не применилась вовсе. SQLite это обеспечивает, если вся миграция идёт в одной транзакции; ручные «прочитать-переписать файл» — не обеспечивают.
  3. Каждая миграция проверена автотестом на реальном снимке старой БД. Room умеет экспортировать схемы в JSON и тестировать переходы; для SQLite вручную это делается через заранее сохранённые файлы БД разных версий, лежащие в ресурсах тестов.
// Room: явная миграция. Обратите внимание — данные переносятся, а не теряются.
val MIGRATION_7_8 = object : Migration(7, 8) {
    override fun migrate(db: SupportSQLiteDatabase) {
        // Новая колонка для трёхстороннего слияния. NOT NULL без DEFAULT здесь невозможен —
        // у существующих строк значения нет, и это нормально.
        db.execSQL("ALTER TABLE orders ADD COLUMN base_json TEXT")
        // Индекс под запрос «что отправить»: без него полный скан на каждом тике синхронизации.
        db.execSQL("CREATE INDEX IF NOT EXISTS orders_dirty ON orders(dirty)")
    }
}

fallbackToDestructiveMigration() — не стратегия, а аварийный люк. Он стирает локальные данные; для кэша чтения это приемлемо, для offline-first — это потеря неотправленной работы пользователя. Если люк всё-таки нужен, он должен срабатывать только когда очередь исходящих операций пуста.

Запись как команда: очередь исходящих операций

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

Инвариант номер один: изменение сущности и постановка задания в очередь происходят в одной транзакции. Нарушите его — и получите либо правку, о которой сервер никогда не узнает, либо задание на отправку того, чего нет.

// Android, Room: единственная точка записи. @Transaction гарантирует атомарность пары.
@Dao
interface OrderDao {

    @Transaction
    suspend fun renameOrder(localId: String, newTitle: String) {
        val now = System.currentTimeMillis()
        updateTitle(localId, newTitle, dirty = true, updatedAt = now)
        enqueue(
            OutboxOp(
                entity = "order",
                entityLocalId = localId,
                op = "update",
                payloadJson = """{"title":${JSONObject.quote(newTitle)}}""",
                // Ключ создаётся ЗДЕСЬ и не меняется между повторами — иначе сервер
                // при ретрае после таймаута создаст вторую копию операции.
                idempotencyKey = UUID.randomUUID().toString(),
                nextAttemptAt = now,
            )
        )
    }

    @Query("UPDATE orders SET title = :t, dirty = :dirty, updated_at_local = :updatedAt WHERE local_id = :localId")
    suspend fun updateTitle(localId: String, t: String, dirty: Boolean, updatedAt: Long)

    @Insert
    suspend fun enqueue(op: OutboxOp)
}
// iOS, GRDB: тот же инвариант. Транзакция охватывает обе записи.
func renameOrder(localId: String, newTitle: String) throws {
    try dbQueue.write { db in
        try db.execute(
            sql: "UPDATE orders SET title = ?, dirty = 1 WHERE local_id = ?",
            arguments: [newTitle, localId]
        )
        var op = OutboxOp(
            entity: "order",
            entityLocalId: localId,
            op: "update",
            payloadJSON: #"{"title":"\#(newTitle)"}"#,
            idempotencyKey: UUID().uuidString,
            attempts: 0,
            nextAttemptAt: Date()
        )
        try op.insert(db)                 // всё или ничего: rollback снимет и правку, и задание
    }
}

Идемпотентность: почему без неё двоятся заказы

Классический сбой: клиент отправил POST /orders, сервер создал заказ, ответ потерялся в сети, клиент по таймауту повторил — и заказов стало два. Лечится это не на клиенте и не на сервере по отдельности, а контрактом между ними: клиент прикладывает к операции уникальный ключ, сервер запоминает пары «ключ → результат» и на повтор возвращает тот же самый ответ, ничего не создавая. Канонический публичный пример такого контракта — заголовок Idempotency-Key в API Stripe.

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

Зависимости между операциями и переназначение идентификаторов

Пользователь создал заказ, добавил в него три позиции и всё это без сети. В очереди четыре операции, и три последние ссылаются на заказ, у которого ещё нет серверного идентификатора. Есть два выхода:

  • Клиентские идентификаторы принимает сервер. Клиент присылает свой UUIDv7 как первичный ключ, сервер его сохраняет. Тогда никакого переназначения нет вовсе. Это самый дешёвый вариант, и его стоит просить у бэкенда до начала работы.
  • Сервер выдаёт свои идентификаторы. Тогда очередь обрабатывается строго по порядку, а после подтверждения создания клиент подставляет полученный server_id во все ожидающие операции, которые на него ссылаются. Это работает, но требует хранить в задании ссылки на локальные идентификаторы, а не готовый JSON с уже вшитым null.

Жизненный цикл локальной записи

Два состояния здесь принципиальны и чаще всего забываются. Rejected — операция, которую бессмысленно повторять: сервер ответил 400 или 422, потому что данные невалидны. Если её не снять с очереди, приложение будет вечно колотиться в сеть и жечь батарею. И NeedsUserChoice — признание, что автоматическое слияние не всесильно; без него любой нерешаемый конфликт превращается в тихую потерю данных.

Чтение: дельта-синхронизация по курсору

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

Точку изменения соблазнительно задать временем последнего успешного синка. Так делать нельзя, и причин три:

  1. Расхождение часов. Часы телефона живут своей жизнью; пользователь может перевести их вручную.
  2. Одинаковые метки. Несколько записей с точностью до секунды имеют один updated_at — при пагинации по времени вы либо пропустите часть, либо зациклитесь.
  3. Транзакционная видимость. Запись с меткой 12:00:01 может стать видимой позже, чем запись с меткой 12:00:03, если её транзакция была длиннее.

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

Псевдокод одного тика синхронизации:

sync_tick():
    # 1. Сначала забираем чужое: иначе push уйдёт с заведомо устаревшей базой
    cursor <- read_cursor(table)
    repeat:
        page <- GET /changes?table=T&since=cursor&limit=500
        BEGIN TRANSACTION
            for change in page.changes:
                apply_change(change)      # с учётом локального dirty
            write_cursor(table, page.next_cursor)   # курсор двигается В ТОЙ ЖЕ транзакции
        COMMIT
    until page.has_more == false

    # 2. Потом отдаём своё, по одной операции, в порядке очереди
    while op <- next_ready_op():
        result <- POST/PATCH с Idempotency-Key и базовой версией
        match result:
            ok       -> BEGIN; mark_synced(op); delete_op(op); COMMIT
            conflict -> resolve(op, result.server_state)
            retryable-> schedule_retry(op, backoff_with_jitter(op.attempts))
            fatal    -> mark_rejected(op)

Сложность. Пусть n — число строк в локальной базе, k — число изменений с момента последнего синка, p — размер страницы. Дельта-синхронизация выполняет O(k/p) сетевых запросов и O(k·log n) работы на диске (по одному обновлению индексированной строки на изменение), трафик — O(k). Полная перезагрузка — O(n) по трафику и O(n·log n) по диску. Именно поэтому переход с полного синка на дельту обычно уменьшает потребление трафика на порядок и заметно продлевает жизнь батареи: радиомодуль не только передаёт данные, но и держит энергозатратное состояние ещё несколько секунд после последнего пакета.

Критично то, что курсор двигается в той же транзакции, что и применение страницы. Если процесс убьют между «применили» и «сохранили курсор», следующий запуск переприменит страницу — это безопасно, потому что применение изменения идемпотентно (UPSERT по идентификатору). А вот наоборот — сдвинуть курсор и не применить данные — означает молча потерять изменения навсегда.

// Flutter, drift: применение страницы дельты. Транзакция охватывает и данные, и курсор.
Future<void> applyPage(ChangePage page) async {
  await db.transaction(() async {
    for (final ch in page.changes) {
      if (ch.deleted) {
        await (db.delete(db.orders)..where((o) => o.serverId.equals(ch.id))).go();
        continue;
      }
      final local = await db.findByServerId(ch.id);
      // Главное правило: дельта НЕ затирает строку с неотправленными правками.
      if (local != null && local.dirty) {
        await db.saveIncomingBase(local.localId, ch.json, ch.version); // копим предка для слияния
        continue;
      }
      await db.upsertFromServer(ch);
    }
    await db.setCursor('orders', page.nextCursor);
  });
}

Удаления и время жизни tombstone

Удаление нельзя передать «отсутствием строки в ответе» — при дельта-синхронизации клиент не видит всей коллекции. Поэтому сервер отдаёт удаления явно, как записи-надгробия. Надгробия нельзя хранить вечно, их чистят через некоторое окно (30–90 дней — типовая величина). Отсюда обязательный сценарий: клиент, который не синхронизировался дольше окна хранения, обязан сделать полный ресинк. Сервер сообщает об этом отдельным кодом (410 Gone на устаревший курсор), клиент чистит таблицы, сохраняя очередь исходящих операций, и качает всё заново.

Повторы: бэкофф с джиттером, а не «раз в 5 секунд»

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

// React Native / TypeScript: полный джиттер (AWS: Exponential Backoff And Jitter).
// Случайность обязательна — без неё все устройства повторят одновременно.
const BASE_MS = 1_000;
const CAP_MS = 60 * 60 * 1_000; // час: дальше растить бессмысленно, ждём сигнала о появлении сети

function nextDelay(attempt: number): number {
  const exp = Math.min(CAP_MS, BASE_MS * 2 ** attempt);
  return Math.random() * exp; // full jitter: равномерно от 0 до exp
}

async function processOutbox(ops: OutboxOp[], now: number) {
  for (const op of ops) {
    if (op.nextAttemptAt > now) continue;      // ещё не время
    try {
      const res = await send(op);              // внутри — заголовок Idempotency-Key
      await db.markSynced(op, res);
    } catch (e) {
      if (isFatal(e)) { await db.markRejected(op, e); continue; }
      await db.scheduleRetry(op, now + nextDelay(op.attempts + 1));
      break; // порядок операций важен: не отправляем следующие, пока не прошла эта
    }
  }
}

Дополнительный сигнал, который дешевле любого таймера: разбудить очередь при появлении сети. Слушатель NWPathMonitor или ConnectivityManager сбрасывает счётчик ожидания — и правка уходит через секунду после выхода из метро, а не через запланированные 16 минут.

Разрешение конфликтов

Как из офлайн-работы двух устройств рождается конфликт версий

Конфликт — это ситуация, когда две реплики изменили одну сущность, отталкиваясь от разных базовых версий. Он неизбежен в любой системе, где запись возможна без связи: это следствие теоремы CAP, а не недоработка. Вопрос не «как избежать», а «какую цену мы готовы заплатить за сохранность правок».

Last-write-wins по часам клиента — самая распространённая и самая коварная стратегия. Она бесплатна в реализации и молча уничтожает данные: телефон с часами, отставшими на десять минут, проиграет любую гонку, а пользователь никогда не узнает, что его правка исчезла. Если LWW всё-таки выбран, «время» должен назначать сервер в момент приёма — тогда хотя бы порядок будет детерминированным.

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

Слияние по полям исходит из того, что чаще всего люди правят разные атрибуты. Если A изменил цену, а B — адрес доставки, конфликта по существу нет, и объединить их можно автоматически.

Трёхстороннее слияние — то же самое, но с общим предком, как в git: для каждого поля известно, менял ли его клиент, менял ли сервер, и совпадают ли новые значения.

Реализация слияния короткая и полностью тестируемая без сети и без UI:

// Трёхстороннее слияние по полям. base — общий предок, mine — локальные правки,
// theirs — состояние сервера. Возвращает либо результат, либо список спорных полей.
sealed interface MergeResult {
    data class Merged(val value: Map<String, Any?>) : MergeResult
    data class NeedsChoice(val fields: List<String>) : MergeResult
}

fun merge3(
    base: Map<String, Any?>,
    mine: Map<String, Any?>,
    theirs: Map<String, Any?>,
): MergeResult {
    val result = theirs.toMutableMap()          // за основу берём свежую версию сервера
    val disputed = mutableListOf<String>()

    for (field in base.keys + mine.keys + theirs.keys) {
        val b = base[field]; val m = mine[field]; val t = theirs[field]
        when {
            m == b -> Unit                       // мы не трогали поле — остаётся серверное
            t == b -> result[field] = m          // сервер не трогал — берём наше
            m == t -> result[field] = m          // изменили одинаково — конфликта нет
            else   -> disputed += field          // оба изменили по-разному — арбитра нет
        }
    }
    return if (disputed.isEmpty()) MergeResult.Merged(result)
           else MergeResult.NeedsChoice(disputed)
}

Сложность — O(f) по времени и памяти, где f — число полей сущности; на практике это доли микросекунды и десятки строк тестов, покрывающих все четыре ветки. Это тот редкий случай, когда самое ценное в коде — его тестируемость: конфликт невозможно надёжно воспроизвести на устройстве, но тривиально — в юнит-тесте.

CRDT: что они дают и чего не дают

Бесконфликтные реплицируемые типы данных (CRDT) — структуры, у которых операция слияния коммутативна, ассоциативна и идемпотентна. Реплики, применившие один и тот же набор операций в любом порядке, гарантированно приходят к одному состоянию. Каноническое описание — работа Шапиро и соавторов «A Comprehensive Study of Convergent and Commutative Replicated Data Types» (INRIA, 2011); практические реализации — Automerge и Yjs.

CRDT отлично решают ровно один класс задач: совместное редактирование структур — текста, списков, наборов, счётчиков. Для мобильного приложения с заметками, чек-листами или досками это почти магия: два человека правят один документ без сети, и после синхронизации не теряется ни один символ.

Важно понимать границы. Во-первых, CRDT гарантируют сходимость, а не корректность с точки зрения бизнеса: если последнее место на рейс забронировали две реплики, любой CRDT-набор честно сохранит обе брони — и это ровно то, чего вы не хотели. Инварианты предметной области остаются обязанностью сервера. Во-вторых, CRDT платят памятью: история операций и метаданные растут, что требует компактизации, а объём документа на телефоне — не бесконечный ресурс. В-третьих, они меняют формат хранения: положить CRDT-документ в реляционную таблицу и делать по нему SQL-запросы не получится, это блоб.

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

Когда автоматика запрещена

Есть класс изменений, где любое автоматическое слияние — ошибка: деньги, остатки на складе, бронирование, медицинские назначения, юридически значимые документы. Здесь работает единственный правильный подход: клиент отправляет намерение, инвариант проверяет сервер. Не «установи баланс в 500», а «спиши 120»; не «состояние брони = занято», а «займи место, если оно свободно». Сервер имеет право ответить «нельзя», и приложение обязано показать это человеку внятно, а не откатить экран молча.

Мобильная специфика, которой нет в бэкенде

  • Процесс умирает посреди синхронизации. Это не исключение, а норма: система убивает приложение в фоне без предупреждения. Отсюда требование к движку — быть возобновляемым: состояние прогресса живёт в БД (курсор, попытки, ключи), а не в памяти. Подробности того, как ОС распоряжается вашим процессом, разобраны в статье о платформах.
  • Фон почти не принадлежит вам. Рассчитывать, что синхронизация «просто идёт по таймеру», нельзя: на iOS фоновое время выдаёт система по своим эвристикам, на Android — планировщик с Doze и ограничениями на приложения в ждущем режиме. Как этим пользоваться грамотно — тема следующей статьи.
  • Батарея. Каждый выход в сеть будит радиомодуль, который держит энергозатратное состояние ещё несколько секунд после передачи. Двадцать мелких запросов по одному стоят кратно дороже одного пакета из двадцати изменений. Отсюда — батчинг, откладывание тяжёлых загрузок (вложения, медиа) до Wi-Fi и зарядки, и запрет на опрос сервера чаще, чем этого требует продукт.
  • Трафик стоит денег. Метрируемая сеть и режим экономии данных — сигнал, который нужно уважать: дельта и текст идут всегда, картинки и вложения ждут Wi-Fi.
  • Разрешения. Данные, которые вы кэшируете, часто не ваши: фото, контакты, геометки. Их локальное хранение попадает под правила приватности сторов и под требование «удалить всё по выходу из аккаунта». Проверка простая: после logout в файлах приложения не должно остаться ничего, что относится к прошлому пользователю.
  • Ревью в сторе. Хранение перекачиваемых данных в резервируемых каталогах на iOS — реальная причина отклонения. Плюс требование обеих площадок к описанию собираемых данных: то, что вы положили в локальную БД, попадает в декларацию о приватности.

Нативно или кроссплатформа: цена слоя данных

Слой данных — самое интересное место для этого сравнения, потому что здесь платформенная разница минимальна: SQLite один и тот же везде. Экран нужно рисовать по-разному, а «применить дельту в транзакции и не затереть dirty-строку» — одинаково на всех платформах.

Подход Стоимость разработки Качество и риски Найм
Две нативные реализации самая высокая: логика синхронизации пишется дважды главный риск — расхождение поведения: iOS и Android по-разному разрешают один и тот же конфликт, и это находят пользователи проще всего: iOS- и Android-инженеры на рынке есть, SQL знают все
Нативный UI + общий модуль KMP один раз пишется движок, дважды — экраны лучшее соотношение: платформенный UI без компромиссов, единая логика синхронизации пул KMP-инженеров узкий, но переучиваются Kotlin-разработчики
Flutter (drift/sqflite) один код целиком зрелые библиотеки; тяжёлые запросы нужно уносить в изолят, иначе дропы кадров пул меньше нативного, зато одна команда вместо двух
React Native (op-sqlite, WatermelonDB) один код целиком JSI снял старую проблему сериализации через мост, но JS-поток один: слияние больших дельт нужно уводить с него самый широкий пул за счёт веб-разработчиков; мобильный контекст им приходится осваивать

Практический вывод, который выдерживает проверку на проектах: если делить код, начинать надо снизу. Слой данных и синхронизации — самая переиспользуемая, самая тестируемая и наименее платформенная часть приложения; UI — наоборот. Именно поэтому KMP-подход «общий data-слой, нативные экраны» оказывается экономически выгоднее, чем кажется на первый взгляд, а полностью кроссплатформенные фреймворки выигрывают там, где UI типовой. Развёрнутое сравнение подходов — в статье о кроссплатформе.

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

Покупать или писать: готовые решения синхронизации

Решение Модель Когда подходит Чем платите
PowerSync зеркало Postgres/MongoDB в локальный SQLite есть своя реляционная база, нужен оффлайн быстро внешний сервис в критическом пути, своя модель правил доступа
ElectricSQL синхронизация подмножеств Postgres на клиент Postgres-центричная архитектура молодой стек, ограничения на форму данных
Couchbase Lite документная БД с деревом ревизий давняя экспертиза в CouchDB-подобной модели документная модель вместо реляционной, лицензия
Firestore документная БД с офлайн-персистентностью «из коробки» MVP и небольшие приложения цена по операциям чтения, слабый контроль над конфликтами, привязка к платформе
Replicache / Zero синхронизация через переигрывание мутаций продукт, где логика естественно выражается мутациями нужно переписать доступ к данным под их модель
Ditto обмен между устройствами напрямую, без сервера нет интернета в принципе: борт самолёта, склад, стройка самая сложная модель согласованности из перечисленных

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

Как это тестировать

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

  1. Правка без сети → появление сети → ровно один запрос, ровно один результат на сервере.
  2. Двойная отправка одного задания (эмуляция потери ответа) → идемпотентность соблюдена.
  3. Дельта приходит на строку с dirty = 1 → локальные правки не затёрты.
  4. Процесс «убит» между применением страницы и сохранением курсора → переприменение безопасно.
  5. Курсор протух (410) → полный ресинк, очередь исходящих операций не потеряна.
  6. Конфликт с пересечением полей → состояние NeedsUserChoice, а не тихий выбор.
  7. Миграция с версии N на N+1 на реальном снимке БД со всеми типами записей.

Для этого движку нужны две вещи: инъекция часов (иначе тест бэкоффа длится час) и фейковый транспорт, умеющий отвечать таймаутом, 409 и 500. Реальную деградацию канала на устройстве проверяют Network Link Conditioner на iOS и профили сети эмулятора на Android — но это проверка ощущений, а не корректности. Подробно инструменты разобраны в статье о тестировании.

Типичные ошибки

  1. Сеть как источник истины для экрана. Экран напрямую ждёт ответ, БД используется как кэш «на всякий случай». Любой провал связи виден пользователю.
  2. Правка и задание в очередь вне одной транзакции. Классический источник «правка есть, а на сервере её нет» — и наоборот.
  3. Ключ идемпотентности генерируется при отправке. Ретрай после таймаута создаёт дубликат.
  4. Дельта по updated_at клиента. Расхождение часов и одинаковые метки времени приводят к потерянным и продублированным записям.
  5. Курсор сохраняется отдельно от применения страницы. Тихая потеря целой страницы изменений при убийстве процесса.
  6. Жёсткое удаление строк. Удаление не доезжает до сервера и «воскресает» при следующей синхронизации.
  7. Дельта затирает dirty-строку. Пользователь видит, как его текст исчезает прямо на экране.
  8. LWW по часам клиента как стратегия по умолчанию. Самая дешёвая в реализации и самая дорогая в поддержке: потери данных не воспроизводятся и приходят как жалобы «оно съело мои правки».
  9. Нет состояния Rejected. Невалидная операция вечно повторяется, батарея садится, очередь не двигается.
  10. Фиксированный интервал повтора без джиттера. Разряд батареи на клиенте, «громовое стадо» на сервере.
  11. fallbackToDestructiveMigration в продакшне. Одно обновление — и у пользователей нет неотправленной работы.
  12. Нет пути «полный ресинк». Как только сервер почистит tombstones, старые клиенты навсегда расходятся с сервером без единой ошибки в логах.
  13. Данные прошлого пользователя остаются после выхода. Приватность, требования сторов и очень неприятные отзывы.
  14. Экран не показывает состояние синхронизации. «Не отправлено», «отправляется», «есть конфликт» — это состояния данных, и пользователь имеет право их видеть.

Мини-итог

Мобильный контекст переворачивает работу с данными: сервер перестаёт быть источником истины для экрана, им становится локальная база, а сеть превращается в фоновый процесс, который её догоняет. Из этого выводится всё остальное. Хранилище — SQLite, потому что он одинаков на всех платформах и переживает смерть процесса; схема содержит служебные колонки server_version, base_json, dirty и deleted_at, без которых синхронизация невыразима; миграции инкрементальны, транзакционны и покрыты тестами, потому что на клиенте их не откатить. Запись — это команда в очереди с ключом идемпотентности, а не мутация. Чтение — дельта по непрозрачному курсору сервера, применяемая в одной транзакции с продвижением курсора, с обязательным путём аварийного полного ресинка. Конфликт — не баг, а неизбежность: LWW по часам клиента бесплатен и молча теряет данные, версии с повтором операции дают честный минимум, слияние по полям с общим предком закрывает большинство реальных случаев, CRDT решают совместное редактирование структур, но не бизнес-инварианты, а деньги и бронирования разрешает только сервер. И, наконец, слой данных — лучший кандидат на общий код между платформами: логика синхронизации одинакова везде, а её дублирование стоит не двойной, а тройной цены за счёт расследования расхождений.

Источники

Что дальше

Фоновая работа и пуши: ограничения ОС, планировщики, уведомления — когда именно операционная система разрешит вашему sync-движку проснуться, как устроены планировщики на iOS и Android, чем silent push отличается от обычного и почему «синхронизация раз в 15 минут» на телефоне не гарантируется никем.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов