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







