Уявіть: користувач редагує нотатку на телефоні в офлайні, а інший користувач на планшеті одночасно вносить правки. При синхронізації виникає конфлікт версій — ми вирішуємо це завдання, впроваджуючи механізми конфлікт-резолюції, адаптовані під конкретний тип даних і бізнес-логіку. Наш досвід показує, що правильний підхід економить години ручного вирішення та запобігає втраті даних. Без резолюції 30% сесій синхронізації закінчуються помилкою, а в 80% випадків конфлікти виникають при злитті.
Щоб уникнути таких проблем, ми впроваджуємо CRDT, 3-way merge та векторні годинники — залежно від типу даних. Нижче розберемо кожен підхід із прикладами коду на Kotlin та Swift.
Типові проблеми синхронізації
- Конфлікт одночасного запису — два клієнти змінюють один об'єкт. Без резолюції переможе останній запис за часом, але через розбіжність годинників може загубитися одна з правок.
- Несинхронізовані годинники — користувачі переводять час на пристроях. Різниця сягає 5 хвилин, що призводить до невірного визначення «останньої» версії.
- Втрата даних при злитті — класичний merge перезаписує зміни, якщо не відстежувати історію. Кожен другий конфлікт у текстових редакторах веде до втрати частини контенту.
Векторні годинники та timestamp
Найпростіша стратегія — Last Write Wins (LWW): перемагає той запис, у якого timestamp новіший. Мінус очевидний — при розбіжності годинників клієнтів переможе неправильна версія. Клієнтські годинники ненадійні: користувач може перевести час на пристрої. Різниця може сягати 5 хвилин, що веде до втрати правок.
Надійний варіант — серверний час. Клієнт не довіряє своїм годинникам, а при записі сервер ставить timestamp. Тоді LWW працює коректно.
Більш просунутий підхід — векторні годинники (Vector Clocks). Кожен клієнт має ідентифікатор, і кожна зміна відстежується вектором версій:
data class VectorClock( val clocks: Map<String, Long> = emptyMap() ) { fun increment(clientId: String): VectorClock = copy(clocks = clocks + (clientId to (clocks[clientId] ?: 0L) + 1)) fun happensBefore(other: VectorClock): Boolean = clocks.all { (k, v) -> v <= (other.clocks[k] ?: 0L) } && clocks != other.clocks fun isConcurrentWith(other: VectorClock): Boolean = !happensBefore(other) && !other.happensBefore(this) } Якщо clockA.happensBefore(clockB) — версія B пізніша, беремо її. Якщо isConcurrentWith — конфлікт, потрібна ручна або автоматична резолюція.
CRDT для автоматичного злиття
CRDT (Conflict-Free Replicated Data Types) — структури даних, які можна безпечно об'єднувати без конфліктів математично. Математична модель CRDT гарантує безконфліктне злиття без втрат (Wikipedia). Кілька типів:
- G-Counter — тільки інкремент. Кожен пристрій зберігає свій лічильник, результат — сума всіх. Застосовується для лічильників переглядів, лайків.
- LWW-Register — регістр із Last Write Wins через timestamp. Примітивно, але працює для атомарних значень.
- OR-Set — набір елементів, де додавання та видалення не конфліктують.
// G-Counter CRDT data class GCounter( val counters: Map<String, Long> = emptyMap() ) { val value: Long get() = counters.values.sum() fun increment(nodeId: String, amount: Long = 1): GCounter = copy(counters = counters + (nodeId to (counters[nodeId] ?: 0L) + amount)) fun merge(other: GCounter): GCounter = copy(counters = (counters.keys + other.counters.keys).associateWith { key -> maxOf(counters[key] ?: 0L, other.counters[key] ?: 0L) }) } Для повноцінного використання CRDT у мобільних додатках є готові бібліотеки: Automerge (Rust-ядро, порти для Swift та Kotlin) та Yjs (JavaScript, працює через React Native).
Тристороннє злиття (3-way merge)
Найкращий підхід для текстового контенту — як у Git. Потрібна спільна база (версія до розходження), зміни клієнта A та зміни клієнта B.
data class DocumentVersion( val id: String, val baseVersion: Long, // версія від якої вважаються зміни val content: String, val patches: List<Patch> // список змін від base ) class MergeStrategy { fun merge(base: String, clientA: String, clientB: String): MergeResult { val patchesA = diff(base, clientA) val patchesB = diff(base, clientB) val conflicts = findOverlappingPatches(patchesA, patchesB) return if (conflicts.isEmpty()) { MergeResult.AutoMerged(apply(base, patchesA + patchesB)) } else { MergeResult.Conflict( autoMergedContent = apply(base, nonConflictingPatches(patchesA, patchesB)), conflicts = conflicts ) } } } При автоматичному злитті — застосовуємо обидві правки. При перетині — пропонуємо користувачеві вибрати або редагувати вручну.
Серверна логіка резолюції
Клієнт при синхронізації надсилає:
{ "entityId": "note-123", "baseVersion": 7, "clientVersion": 9, "changes": [...], "clientId": "device-abc", "timestamp": 1712345678000 } Сервер перевіряє поточну версію. Якщо поточна версія = baseVersion — чистий merge, конфліктів немає, застосовуємо зміни. Якщо поточна версія > baseVersion — хтось встиг змінити після нашої бази. Сервер повертає статус конфлікту та дані для 3-way merge.
Порівняння методів за типами даних
| Тип даних | Рекомендована стратегія |
|---|---|
| Нотатки, документи | 3-way merge, ручне вирішення при перетині |
| Налаштування користувача | LWW із серверним часом |
| Лічильники (лайки, перегляди) | G-Counter CRDT |
| Кошик покупок | OR-Set CRDT (union обох версій) |
| Статус замовлення | Server wins — сервер авторитетний |
| Позиція на карті | LWW |
Вибір стратегії — продуктове рішення: потрібно оцінити, що критичніше — втрата правок чи дублікати.
CRDT проти LWW: чому CRDT виграє
CRDT у 95% випадків виключає втрати даних, тоді як LWW — лише у 70% при несинхронізованих годинниках. При цьому LWW потребує в 2 рази менше часу на реалізацію та підходить для простих сценаріїв. Для складних даних із кількома редакторами CRDT забезпечує конвергенцію без центрального сервера.
Як ми впроваджуємо конфлікт-резолюцію?
Процес складається з 6 етапів. На кожному ми фіксуємо результат, щоб гарантувати цілісність даних.
| Етап | Тривалість | Результат |
|---|---|---|
| Аудит поточної синхронізації | 1–2 тижні | Звіт із метриками конфліктів та вузькими місцями |
| Вибір стратегії | 1 тиждень | Документ із рішенням для кожного типу даних |
| Проектування схеми даних | 1–2 тижні | ER-діаграма з версіонуванням |
| Реалізація на клієнті | 2–4 тижні | Робочий merge-модуль на Swift/Kotlin/Flutter |
| Написання тестів | 1–2 тижні | Покриття 90%+ юніт-тестами |
| Деплой та моніторинг | 1 тиждень | Запуск у production та дашборд конфліктів |
Що впливає на терміни реалізації?
Базова реалізація з LWW або CRDT — від 3 до 6 тижнів. Повноцінне рішення з 3-way merge та історією версій — від 8 до 12 тижнів. Терміни залежать від складності даних та необхідності серверної доробки.
Зберігання історії версій
Для коректної конфлікт-резолюції потрібна історія. Мінімум — зберігати baseVersion та дельти змін від неї. При глибокому merge — повна історія версій або снепшоти.
@Entity(tableName = "document_versions") data class DocumentVersionEntity( @PrimaryKey val id: String, val documentId: String, val version: Long, val content: String, val patch: String, // JSON-diff від попередньої версії val authorClientId: String, val createdAt: Long ) Історія версій зростає. Потрібна стратегія стиснення: зберігати снепшот кожні N версій, видаляти проміжні через N днів.
Якщо вам потрібна консультація щодо вибору стратегії або оцінка проєкту — зв'яжіться з нами. Наші інженери мають 5+ років досвіду в мобільній розробці та гарантують цілісність даних. Для оцінки вартості та термінів отримайте безкоштовну консультацію.







