TL;DR
Реализация надежной offline-first стратегии в React Native требует:
- Tombstone-подхода вместо hard-delete
- Outbox-паттерна для гарантированной доставки изменений
- Детерминированного алгоритма слияния на основе
updated_atиdeleted_at - Строгой последовательности pull -> push -> ack
Введение: вызовы offline-first разработки
В современных мобильных приложениях offline-режим — не опция, а must-have. Особенно в таких сценариях, как fitness-трекеры, где:
- Пользователи часто находятся в зонах с плохим покрытием (спортзалы, подвалы)
- Скорость ввода данных критична (5 секунд на подход в упражнении)
- Консистентность данных между устройствами обязательна
Классические проблемы, с которыми сталкиваются разработчики:
- Ghost data: удаленные на одном устройстве элементы появляются после синхронизации
- Конфликты изменений: разные правки одних и тех же данных на разных устройствах
- Потеря данных: изменения, сделанные в offline, не синхронизируются
Core Architecture
1. Схема базы данных
// db/schema.ts
import * as SQLite from "expo-sqlite";
export async function initDb(db: SQLite.SQLiteDatabase) {
await db.execAsync(`PRAGMA journal_mode = WAL;`);
await db.execAsync(`PRAGMA foreign_keys = ON;`);
await db.execAsync(`
CREATE TABLE IF NOT EXISTS sets (
id TEXT PRIMARY KEY,
workout_id TEXT NOT NULL,
exercise_id TEXT NOT NULL,
reps INTEGER NOT NULL,
weight REAL NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE TABLE IF NOT EXISTS outbox (
id TEXT PRIMARY KEY,
entity TEXT NOT NULL,
entity_id TEXT NOT NULL,
op TEXT NOT NULL,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT
);
`);
}
Ключевые элементы:
- Tombstone-механизм:
deleted_atвместо физического удаления - Outbox-таблица: очередь изменений для синхронизации
- WAL-режим: для конкурентного доступа
2. Работа с данными
// db/sets.ts
export async function deleteSet(db: SQLite.SQLiteDatabase, setId: string) {
const now = Date.now();
await db.runAsync(
`UPDATE sets SET deleted_at = ?, updated_at = ? WHERE id = ?`,
[now, now, setId]
);
await enqueueOutbox(db, {
entity: "sets",
entity_id: setId,
op: "delete",
payload: { id: setId, deleted_at: now, updated_at: now },
});
}
Важные нюансы:
- Всегда сначала изменяем данные, затем добавляем в outbox
- Для delete используем soft-delete с timestamp
- Payload содержит всю необходимую метаинформацию
Алгоритм синхронизации
Sync Loop Implementation
// sync/syncNow.ts
let syncInFlight: Promise<void> | null = null;
export function syncNow(db: SQLite.SQLiteDatabase) {
if (!syncInFlight) {
syncInFlight = (async () => {
try {
await pull(db);
await push(db);
} finally {
syncInFlight = null;
}
})();
}
return syncInFlight;
}
Критически важные моменты:
- Мьютекс на уровне syncInFlight предотвращает race condition
- Строгий порядок: сначала pull, затем push
- Транзакционность всех операций
Conflict Resolution
// sync/merge.ts
export async function mergeRemoteSet(db: SQLite.SQLiteDatabase, remote: RemoteSet) {
const local = await db.getFirstAsync<{
updated_at: number;
deleted_at: number | null;
}>(`SELECT updated_at, deleted_at FROM sets WHERE id = ?`, [remote.id]);
// Remote tombstone always wins
if (remote.deleted_at !== null) {
if (local?.deleted_at === null || remote.updated_at >= local.updated_at) {
await applyTombstone(db, remote);
}
return;
}
// Local tombstone blocks remote upsert
if (local?.deleted_at !== null) return;
// Last write wins for non-deleted rows
if (!local || remote.updated_at > local.updated_at) {
await upsertRemoteRow(db, remote);
}
}
Правила слияния:
- Tombstone всегда побеждает: если удалено на одном устройстве, должно удалиться везде
- Last-write-wins: для конкурирующих изменений
- Невозможность воскрешения: удаленные элементы не могут быть восстановлены новыми изменениями
Практические рекомендации
Производительность
-
Индексы для часто используемых полей:
CREATE INDEX IF NOT EXISTS idx_sets_updated ON sets(updated_at); CREATE INDEX IF NOT EXISTS idx_outbox_created ON outbox(created_at); -
Батчинг при синхронизации:
const items = await db.getAllAsync( `SELECT id, entity, entity_id, op, payload FROM outbox ORDER BY created_at ASC LIMIT 100` ); -
WAL-режим SQLite для конкурентного доступа:
await db.execAsync(`PRAGMA journal_mode = WAL;`);
Отладка синхронизации
- Логирование всех операций синхронизации
- Верификация через физические устройства
- Stress-тесты с частыми переключениями airplane mode
Заключение
Реализация надежной offline-стратегии требует дисциплины в трех областях:
-
Data Modeling:
- UUID вместо автоинкремента
- Обязательные updated_at/deleted_at
- Outbox как source of truth
-
Conflict Resolution:
- Детерминированные правила слияния
- Tombstone вместо hard-delete
- Запрет на воскрешение удаленных данных
-
Sync Mechanics:
- Гарантированный порядок операций
- Идемпотентность всех операций
- Транзакционность изменений
Предложенное решение прошло проверку в production-среде с интенсивным использованием и демонстрирует стабильную работу даже в условиях плохого соединения. Ключевой инсайт: относитесь к удалению как к первоклассной операции, а не как к вторичному состоянию.
Источник: https://dev.to/sathish_daggula/react-native-offline-first-conflict-safe-sqlite-sync-549a