Реалізація конфліkt-резолюції при синхронізації даних
Конфлікт виникає, коли одну й ту ж запис змінили в двох місцях до синхронізації. Користувач редагував замітку на телефоні без інтернету — і в те ж час на планшеті. Обидва змін локально коректні, але протирічать один одному. Потрібно вирішити: яке переможе, чи як їх об'єднати. Немає універсального відповіді — стратегія залежить від типу даних та бізнес-логіки.
Векторні часи та timestamp
Найпростіша стратегія — Last Write Wins (LWW): перемагає запис з новішим timestamp. Мінус очевидний — при розбіжності часів клієнтів переможе неправильна версія. Клієнтські часи ненадійні: користувач може змінити час на пристрої.
Надійний варіант — серверне час. Клієнт не довіряє своїм часам, при запису сервер ставить 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) — структури даних, які можна безпечно об'єднувати без конфліктів математично. Кілька типів:
- 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)
})
}
Для production використання CRDT в мобільних додатках існують готові бібліотеки: Automerge (Rust-core, порти для 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 — хтось встиг змінити після нашої бази. Сервер повертає:
{
"status": "conflict",
"serverVersion": 10,
"serverContent": "...",
"baseContent": "...",
"clientVersion": 9
}
Клієнт отримує конфлікт та запускає 3-way merge локально.
Стратегії разрішення конфліктів за типами даних
| Тип даних | Рекомендована стратегія |
|---|---|
| Замітки, документи | 3-way merge, ручне розв'язання при пересіченні |
| Налаштування користувача | LWW з серверним часом |
| Лічильники (лайки, перегляди) | G-Counter CRDT |
| Корзина покупок | OR-Set CRDT (union обох версій) |
| Статус замовлення | Server wins — сервер авторитетен |
| Позиція на карті | LWW |
Вибір стратегії не технічний, а продуктовий: що важливіше — не втратити дані користувача чи не мати дублікатів?
Зберігання історії версій
Для коректної конфліkt-резолюції потрібна історія. Мінімум — зберігати 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 днів.
Реалізація конфліkt-резолюції з 3-way merge, CRDT чи LWW залежно від типів даних: 3–6 тижнів. Серверна частина — окрема оцінка. Вартість розраховується індивідуально.







