Данные и оффлайн: локальные БД, синхронизация, разрешение конфликтов
В вебе данные живут на сервере. Браузер — тонкая оболочка: он запрашивает, показывает, забывает. Если сети нет, страница просто не открылась, и это нормальное, понятное всем поведение. Перенесите эту модель на телефон — и получите приложение, которое показывает спиннер в метро, теряет заполненную форму при потере связи в лифте и отдаёт белый экран человеку, который открыл его, чтобы посмотреть свой же билет.
Мобильное приложение установлено. Оно живёт на устройстве, у него есть своя файловая система, свой процесс и свой кусок диска. Пользователь ожидает, что оно работает как инструмент, а не как вкладка: открылось мгновенно, показало то, что уже знает, приняло правку и разобралось с доставкой само. Это ожидание — не «фича для гиков», это базовая планка, заданная почтой, заметками, картами и мессенджерами, которые у человека уже стоят.
Отсюда вытекает единственная архитектурная идея этой статьи: источник истины для экрана — локальная база данных, а сеть — фоновый процесс, который её догоняет. Всё остальное — детали реализации этой идеи: где хранить, как описать схему, как поставить правку в очередь, как забрать дельту, и что делать, когда две реплики поменяли одну и ту же строку. Слои, в которые всё это укладывается, разобраны в статье об архитектуре; здесь мы живём на самом нижнем из них.
Чем мобильные данные отличаются от веб-данных
| Что | Веб-приложение | Мобильное приложение |
|---|---|---|
| Источник истины для 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
}
Три уровня зрелости работы с данными
Не каждому приложению нужен полноценный оффлайн. Полезно честно назвать уровень, на котором вы находитесь, и посчитать цену перехода на следующий.
- Online-only. Каждый экран — запрос. Дёшево, быстро пишется, годится для админок и разовых сценариев. Ломается в метро; при потере связи экран пустой.
- Кэш чтения. Ответы складываются в БД, экран рисуется из кэша и обновляется по сети. Приложение открывается мгновенно и что-то показывает без сети. Записи по-прежнему требуют онлайна. Это оптимум для большинства продуктов: примерно 20% усилий, 80% ощущаемого качества.
- Offline-first с записью. Правки применяются локально и уходят в очередь. Появляются очередь, идемпотентность, конфликты, ID-маппинг и UI для «не отправлено». Дороже уровня 2 в два-три раза по трудозатратам — и оправдано, когда работа без сети является сценарием, а не аварией: полевые сотрудники, заметки, чек-листы, мессенджеры, трекеры.
Ключевое следствие схемы: в сеть ходит ровно один компонент. Не экран, не 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% батареи, который выключится посередине.
Три правила, нарушение которых стоит дорого:
- Ни одна версия схемы не пропускается. Пользователь мог не обновляться год: приложение должно уметь пройти путь 3 → 4 → 5 → … → 12 за один запуск. Значит, миграции пишутся инкрементально и хранятся вечно, даже если код, который их породил, давно удалён.
- Миграция транзакционна и идемпотентна. Она либо применилась целиком, либо не применилась вовсе. SQLite это обеспечивает, если вся миграция идёт в одной транзакции; ручные «прочитать-переписать файл» — не обеспечивают.
- Каждая миграция проверена автотестом на реальном снимке старой БД. 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 —
признание, что автоматическое слияние не всесильно; без него любой нерешаемый конфликт превращается
в тихую потерю данных.
Чтение: дельта-синхронизация по курсору
Полная перезагрузка всех данных при каждом запуске — самый простой и самый дорогой вариант: трафик растёт линейно с объёмом базы, батарея тратится, а пользователь на медленной сети ждёт. Правильный режим — дельта: «дай всё, что изменилось после этой точки».
Точку изменения соблазнительно задать временем последнего успешного синка. Так делать нельзя, и причин три:
- Расхождение часов. Часы телефона живут своей жизнью; пользователь может перевести их вручную.
- Одинаковые метки. Несколько записей с точностью до секунды имеют один
updated_at— при пагинации по времени вы либо пропустите часть, либо зациклитесь. - Транзакционная видимость. Запись с меткой 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 | обмен между устройствами напрямую, без сервера | нет интернета в принципе: борт самолёта, склад, стройка | самая сложная модель согласованности из перечисленных |
Своё решение оправдано, когда синхронизируемых сущностей немного, правила слияния специфичны для предметной области, и вы не готовы отдавать внешнему сервису критический путь. Готовое — когда сущностей десятки, а конкурентное преимущество продукта лежит не в них. Ошибка в обе стороны стоит одинаково дорого: самописный движок на сорок сущностей превращается в отдельный проект, а готовое решение с чужой моделью конфликтов приходится обходить костылями ровно там, где у вас и есть бизнес-логика.
Как это тестировать
Синхронизация — код, который обязан проверяться в юнит-тестах, потому что вручную нужные состояния не воспроизводятся. Минимальный набор сценариев:
- Правка без сети → появление сети → ровно один запрос, ровно один результат на сервере.
- Двойная отправка одного задания (эмуляция потери ответа) → идемпотентность соблюдена.
- Дельта приходит на строку с
dirty = 1→ локальные правки не затёрты. - Процесс «убит» между применением страницы и сохранением курсора → переприменение безопасно.
- Курсор протух (
410) → полный ресинк, очередь исходящих операций не потеряна. - Конфликт с пересечением полей → состояние
NeedsUserChoice, а не тихий выбор. - Миграция с версии N на N+1 на реальном снимке БД со всеми типами записей.
Для этого движку нужны две вещи: инъекция часов (иначе тест бэкоффа длится час) и фейковый транспорт, умеющий отвечать таймаутом, 409 и 500. Реальную деградацию канала на устройстве проверяют Network Link Conditioner на iOS и профили сети эмулятора на Android — но это проверка ощущений, а не корректности. Подробно инструменты разобраны в статье о тестировании.
Типичные ошибки
- Сеть как источник истины для экрана. Экран напрямую ждёт ответ, БД используется как кэш «на всякий случай». Любой провал связи виден пользователю.
- Правка и задание в очередь вне одной транзакции. Классический источник «правка есть, а на сервере её нет» — и наоборот.
- Ключ идемпотентности генерируется при отправке. Ретрай после таймаута создаёт дубликат.
- Дельта по
updated_atклиента. Расхождение часов и одинаковые метки времени приводят к потерянным и продублированным записям. - Курсор сохраняется отдельно от применения страницы. Тихая потеря целой страницы изменений при убийстве процесса.
- Жёсткое удаление строк. Удаление не доезжает до сервера и «воскресает» при следующей синхронизации.
- Дельта затирает
dirty-строку. Пользователь видит, как его текст исчезает прямо на экране. - LWW по часам клиента как стратегия по умолчанию. Самая дешёвая в реализации и самая дорогая в поддержке: потери данных не воспроизводятся и приходят как жалобы «оно съело мои правки».
- Нет состояния
Rejected. Невалидная операция вечно повторяется, батарея садится, очередь не двигается. - Фиксированный интервал повтора без джиттера. Разряд батареи на клиенте, «громовое стадо» на сервере.
fallbackToDestructiveMigrationв продакшне. Одно обновление — и у пользователей нет неотправленной работы.- Нет пути «полный ресинк». Как только сервер почистит tombstones, старые клиенты навсегда расходятся с сервером без единой ошибки в логах.
- Данные прошлого пользователя остаются после выхода. Приватность, требования сторов и очень неприятные отзывы.
- Экран не показывает состояние синхронизации. «Не отправлено», «отправляется», «есть конфликт» — это состояния данных, и пользователь имеет право их видеть.
Мини-итог
Мобильный контекст переворачивает работу с данными: сервер перестаёт быть источником истины для
экрана, им становится локальная база, а сеть превращается в фоновый процесс, который её догоняет.
Из этого выводится всё остальное. Хранилище — SQLite, потому что он одинаков на всех платформах и
переживает смерть процесса; схема содержит служебные колонки server_version, base_json, dirty
и deleted_at, без которых синхронизация невыразима; миграции инкрементальны, транзакционны и
покрыты тестами, потому что на клиенте их не откатить. Запись — это команда в очереди с ключом
идемпотентности, а не мутация. Чтение — дельта по непрозрачному курсору сервера, применяемая в одной
транзакции с продвижением курсора, с обязательным путём аварийного полного ресинка. Конфликт —
не баг, а неизбежность: LWW по часам клиента бесплатен и молча теряет данные, версии с повтором
операции дают честный минимум, слияние по полям с общим предком закрывает большинство реальных
случаев, CRDT решают совместное редактирование структур, но не бизнес-инварианты, а деньги и
бронирования разрешает только сервер. И, наконец, слой данных — лучший кандидат на общий код между
платформами: логика синхронизации одинакова везде, а её дублирование стоит не двойной, а тройной
цены за счёт расследования расхождений.
Источники
- SQLite. Write-Ahead Logging и PRAGMA statements — режимы журналирования и настройки соединения.
- Android Developers. Save data in a local database using Room и Migrate your Room database.
- Android Developers. DataStore — современная замена SharedPreferences.
- Apple. Core Data и SwiftData — объектные хранилища Apple.
- Apple. File System Programming Guide — куда класть данные, чтобы не получить отказ на ревью.
- Apple. Network framework и NWPathMonitor — почему проверять доступность заранее не нужно.
- GRDB.swift, SQLDelight, drift — типобезопасные обёртки над SQLite.
- IETF. RFC 9562: Universally Unique IDentifiers — UUIDv7 и почему он лучше подходит для первичных ключей.
- Stripe. Idempotent requests — образцовый контракт повторной отправки.
- AWS Architecture Blog. Exponential Backoff And Jitter — почему джиттер обязателен.
- Shapiro M. и др. A Comprehensive Study of Convergent and Commutative Replicated Data Types — фундаментальная работа по CRDT.
- Automerge и Yjs — практические реализации CRDT для приложений.
- PowerSync, ElectricSQL, Replicache — готовые движки синхронизации.
- Kleppmann M. «Designing Data-Intensive Applications», главы о репликации и разрешении конфликтов — лучший общий текст по теме.
Что дальше
Фоновая работа и пуши: ограничения ОС, планировщики, уведомления — когда именно операционная система разрешит вашему sync-движку проснуться, как устроены планировщики на iOS и Android, чем silent push отличается от обычного и почему «синхронизация раз в 15 минут» на телефоне не гарантируется никем.