Реализация конфликт-резолюции при синхронизации данных
Конфликт возникает, когда одну и ту же запись изменили в двух местах до синхронизации. Пользователь редактировал заметку на телефоне без интернета — и в то же время на планшете. Оба изменения локально корректны, но противоречат друг другу. Нужно решить: какое победит, или как их объединить. Нет универсального ответа — стратегия зависит от типа данных и бизнес-логики.
Векторные часы и 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)
})
}
Для полноценного использования 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 |
Выбор стратегии не технический, а продуктовый: что важнее — не потерять данные пользователя или не иметь дубликатов?
Хранение истории версий
Для корректной конфликт-резолюции нужна история. Минимум — хранить 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 дней.
Реализация конфликт-резолюции с 3-way merge, CRDT или LWW в зависимости от типов данных: 3–6 недель. Серверная часть — отдельная оценка. Стоимость рассчитывается индивидуально.







