6. Conflict resolution

В прошлой статье 5. Vector clocks мы научились с помощью строгой математики выявлять параллельные события. Мы поняли: если векторные часы двух записей конкурентны (V1 || V2), ни одна из них не имеет превосходства над другой.

В этот момент распределенное хранилище отказывается принимать волевые решения и сохраняет оба варианта — создавая так называемых «братьев» (Siblings или ветви реальности).

Но база данных не может вечно пребывать в состоянии квантовой неопределенности. Рано или поздно эти параллельные ветки реальности обязаны сойтись в единое итоговое состояние.

Процесс превращения набора конфликтующих версий в одно согласованное значение называется Conflict Resolution (Разрешением конфликтов).

В архитектуре распределенных систем существует три принципиально разных пути решения этой дилеммы: грубый автоматический, осознанный прикладной (на стороне клиента) и элегантный математический.


1. Автоматическое разрешение: Last Write Wins (LWW)

Самый распространенный, самый быстрый и одновременно... самый коварный и опасный подход в индустрии.

При стратегии Last Write Wins (LWW) координатор базы данных просто смотрит на физический timestamp записи, проставленный сервером или клиентом. Тот, чье число оказалось больше — объявляется победителем. Все остальные конфликтующие записи просто молча удаляются из хранилища.

flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph Conflict ["Конфликт версий"]
        V1["Версия А: x = 10<br/>ts: 10:00:01.100"]:::process
        V2["Версия Б: x = 20<br/>ts: 10:00:01.050"]:::process
    end

    Conflict -->|"Стратегия LWW"| Arbiter["Сравнение таймстемпов"]
    Arbiter -->|"Победитель (ts выше)"| Winner["Сохранено: x = 10"]
    Arbiter -.->|"Молча стерто!"| Loser["Версия Б (x = 20) уничтожена"]
  

2. Разрешение на стороне клиента (Application-side)

Этот подход исповедуют по-настоящему надежные децентрализованные хранилища (такие как Riak KV или Dynamo-кластеры с отключенным LWW).

База данных не пытается угадать намерения человека. Обнаружив конкурентные версии, база данных при вызове GET возвращает в Go-приложение полный срез всех конфликтующих сиблингов вместе с их векторными часами.

Ответственность за примирение версий целиком перекладывается на плечи бэкенд-разработчика, который лучше любого алгоритма понимает семантику своей предметной области.


Пример: Корзина интернет-магазина

Представьте корзину покупателя интернет-магазина. Пользователь открыл приложение на смартфоне и на ноутбуке одновременно:

  • На телефоне он положил в корзину: [Apple, Banana].
  • На ноутбуке он в ту же секунду добавил: [Apple, Orange].

Векторные часы зафиксировали конфликт. Что должен сделать Go-бэкенд?
Удалить апельсин по таймстампу LWW было бы бизнес-катастрофой (покупатель разозлится и уйдет к конкурентам).

Единственно верное бизнес-решение — выполнить объединение множеств (Set Union), получив в результате: [Apple, Banana, Orange].

Go
package cart

import (
    "myproject/vclock"
)

type Cart struct {
    Items  []string
    VClock vclock.VectorClock
}

// ResolveConflicts выполняет доменное слияние сиблингов
func ResolveConflicts(siblings []Cart) Cart {
    if len(siblings) == 0 {
        return Cart{}
    }
    if len(siblings) == 1 {
        return siblings[0]
    }

    // Дедупликация товаров с помощью хэш-таблицы
    finalItems := make(map[string]struct{})
    mergedVClock := vclock.New("app-resolver")

    for _, s := range siblings {
        // Объединяем товары из всех конфликтующих ветвей
        for _, item := range s.Items {
            finalItems[item] = struct{}{}
        }
        // Сливаем векторные часы, аккумулируя максимумы версий
        mergedVClock.Merge(s.VClock.Clone())
    }

    result := Cart{
        VClock: *mergedVClock,
        Items:  make([]string, 0, len(finalItems)),
    }

    for item := range finalItems {
        result.Items = append(result.Items, item)
    }

    return result
}

Важнейшее правило (Write-Back):
После того как Go-бэкенд объединил сиблингов, он обязан отправить результат обратно в базу данных (Write-Back) с объединенным векторным часом. Только тогда база данных поймет, что конфликт исчерпан, и удалит старых сиблингов с диска!


3. Магия слияния: CRDT

А что, если бы мы могли изначально проектировать структуры данных так, чтобы они сходились к единому верному результату автоматически, без необходимости писать кастомные резолверы в Go-коде и без риска потерять данные?

Такие математические конструкции существуют и называются CRDT (Conflict-free Replicated Data Types).


Принцип работы CRDT

CRDT опираются на фундаментальную алгебраическую структуру — полурешетку (Join-Semilattice).

Если операция слияния двух состояний обладает тремя свойствами:

  1. Коммутативность: $A \cup B = B \cup A$ (порядок прихода пакетов из сети не имеет значения).
  2. Ассоциативность: $(A \cup B) \cup C = A \cup (B \cup C)$ (группировка реплик не влияет на итог).
  3. Идемпотентность: $A \cup A = A$ (повторная доставка дубликатов сообщений безопасна).

то распределенные узлы гарантированно придут к абсолютно идентичному состоянию при получении одних и тех же данных в любом хаотичном порядке!


G-Counter (Grow-only Counter)

Обычный int64 инкремент в распределенной системе без блокировок невозможен — обновления потеряются.

G-Counter решает это, превращая счетчик в вектор: каждый узел увеличивает только свой персональный слот:

  • Узел А прибавляет единицу: [A:1, B:0].
  • Узел Б параллельно прибавляет единицу: [A:0, B:1].
  • Слияние выполняется тривиальной функцией max() по каждой ячейке: [A:1, B:1].
  • Текущее глобальное значение счетчика — это сумма всех ячеек: $1 + 1 = 2$. Ни один клик или просмотр не потерян!

PN-Counter (Positive-Negative Counter)

Что, если счетчик нужно не только увеличивать, но и уменьшать (например, остаток товаров на складе или баланс бонусов)?

PN-Counter объединяет два независимых G-Counter:

  • Счётчик P (Positive) аккумулирует все прибавления;
  • Счётчик N (Negative) аккумулирует все вычитания.

Итоговое значение вычисляется как детерминированная разность: $\text{Value} = \sum P - \sum N$.


Mechanical Sympathy: Реализация слияния в Go

В высоконагруженных Go-сервисах наивное слияние сиблингов через регулярное создание map (как в коде корзины выше) способно создать колоссальное давление на Garbage Collector.

При тысячах разрешений конфликтов в секунду постоянные аллокации бакетов карт (bmap) в куче вызовут спайки задержек Stop-The-World.


Итог

  1. Conflict Resolution — неизбежная реальность любой системы с Eventual Consistency.
  2. Last Write Wins (LWW): Привлекателен скоростью, но разрушителен для бизнеса из-за неизбежного дрейфа физических таймстемпов.
  3. Application-side Merge: Золотой стандарт для бизнес-критичных сущностей (корзины покупок, профили), требующий дисциплины Write-Back для очистки сиблингов.
  4. CRDT: Вершина математической мысли распределенных систем, обеспечивающая детерминированное слияние без блокировок.

Глубокому математическому устройству бесконфликтных типов данных, их видам (CvRDT и CmRDT) и реализации распределенных множеств (OR-Set) посвящена наша следующая статья: 7. CRDT.

А после освоения CRDT мы перейдем к фундаментальной вершине координации данных — тому, как гарантировать атомарность сразу для множества сущностей через протоколы распределенных транзакций: 8. Distributed transactions.