Уявіть: користувач редагує нотатку на телефоні в офлайні, а інший користувач на планшеті одночасно вносить правки. При синхронізації виникає конфлікт версій — ми вирішуємо це завдання, впроваджуючи механізми конфлікт-резолюції, адаптовані під конкретний тип даних і бізнес-логіку. Наш досвід показує, що правильний підхід економить години ручного вирішення та запобігає втраті даних. Без резолюції 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+ років досвіду в мобільній розробці та гарантують цілісність даних. Для оцінки вартості та термінів отримайте консультацію.







