32. Репликация. Leader Follower и Multi Leader

Репликация как основа надёжности и масштабирования

В предыдущей главе мы выяснили, как с помощью шардирования преодолеть ограничения объема диска и вычислительной мощности одного сервера. Однако само по себе шардирование не спасает от физических поломок: каждый шард остаётся уязвимым узлом. Если на сервере сгорит блок питания, выйдет из строя SSD-накопитель или оборвётся сетевой патчкорд стойки, соответствующая часть данных компании мгновенно станет недоступной.

Фундаментальным ответом компьютерных наук на проблему физической смертности оборудования является репликация — синхронное или асинхронное создание и поддержание идентичных копий данных на физически изолированных вычислительных узлах.

Кроме обеспечения отказоустойчивости (High Availability, HA) и выживания при катастрофах (Disaster Recovery), репликация служит главным рычагом горизонтального масштабирования операций чтения: распределяя поток SELECT между десятками реплик, мы снимаем нагрузку с ведущего сервера.

В этой главе мы досконально разберём устройство топологий Leader-Follower и Multi-Leader, исследуем алгоритмы разрешения конфликтов, погрузимся в механику сетевого поллера Go и пулов соединений, а также реализуем паттерн Read-Your-Own-Writes, защищающий пользователей от иллюзий рассинхронизации.


Модели репликации

В современной теории и практике распределённых баз данных выделяют три канонические топологии репликации:

  1. Leader-Follower (Single-Leader / Master-Slave / Primary-Replica) — классическая модель с единым центром записи.
  2. Multi-Leader (Master-Master / Active-Active) — модель с несколькими центрами записи, обычно разнесёнными по разным континентам.
  3. Leaderless (Беслидерная / Dynamo-style) — модель без выделенных лидеров, где клиент пишет напрямую на кворум узлов (Cassandra, ScyllaDB, Amazon Dynamo).

В промышленной бэкенд-разработке на Go подавляющее большинство сервисов взаимодействует с первыми двумя топологиями.


Leader-Follower (Ведущий-Ведомый)

В топологии Leader-Follower права узлов строго асимметричны:

  • Лидер (Primary, Master): Единственный узел в кластере, имеющий право принимать транзакции на изменение данных (INSERT, UPDATE, DELETE, DDL). Лидер фиксирует изменения в своём локальном журнале предзаписи (WAL, Write-Ahead Log) и транслирует непрерывный поток записей (Replication Stream) своим ведомым.
  • Фолловеры (Followers, Replicas, Standby): Получают поток изменений от лидера, накатывают его на свои копии данных в том же порядке и обслуживают исключительно запросы на чтение (SELECT).
flowchart LR
    Client["Go Сервис (Клиент)"]
    Leader["Лидер (Primary)<br>Запись и чтение"]
    Follower1["Фолловер 1 (Replica)<br>Только чтение"]
    Follower2["Фолловер 2 (Replica)<br>Только чтение"]

    Client -->|"1. Запись (INSERT/UPDATE)"| Leader
    Client -->|"2. Чтение (SELECT)"| Follower1
    Client -->|"2. Чтение (SELECT)"| Follower2
    Leader -->|"3. Поток репликации (WAL Stream)"| Follower1
    Leader -->|"3. Поток репликации (WAL Stream)"| Follower2

    classDef client fill:#131d33,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
    classDef leader fill:#1e1b4b,stroke:#818cf8,stroke-width:2px,color:#f8fafc;
    classDef replica fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#f8fafc;
    class Client client;
    class Leader leader;
    class Follower1,Follower2 replica;
  
Виды репликации в Leader-Follower
  1. Синхронная репликация:
    Лидер не подтверждает коммит транзакции клиенту до тех пор, пока хотя бы один синхронный фолловер не запишет полученные байты WAL на свой локальный диск и не пришлёт подтверждение (ACK).
    • Плюс: Гарантия нулевой потери данных при аварии лидера (RPO = 0, Recovery Point Objective).
    • Минус: Задержка каждой записи увеличивается на величину сетевого RTT до реплики и время дискового fsync. Если фолловер зависает, запись на лидере блокируется.
  2. Асинхронная репликация:
    Лидер коммитит транзакцию немедленно после записи в собственный локальный WAL, а передача данных на реплики происходит асинхронно в фоновом потоке.
    • Плюс: Максимальная пропускная способность и минимальное время отклика (Latency).
    • Минус: Риск потери данных. Если мастер сгорит до отправки WAL в сеть, свежие транзакции будут утрачены безвозвратно.
  3. Полусинхронная репликация (Semi-synchronous):
    Прагматичный компромисс: одна реплика настраивается как синхронная (гарантируя выживание данных), а остальные фолловеры получают данные асинхронно.
Реализация разделения пулов чтения и записи в Go

Стандартный пакет database/sql не умеет самостоятельно анализировать SQL-запросы и определять, куда их слать. В архитектуре Go-сервиса ответственность за маршрутизацию чтения и записи лежит на приложении:

Go
package replication

import (
    "context"
    "database/sql"
    "fmt"
    "math/rand"
    "sync/atomic"
)

type Order struct {
    ID     string
    Amount int64
}

type DB struct {
    leader    *sql.DB
    followers []*sql.DB
    rrCounter uint64
}

func NewDB(leader *sql.DB, followers []*sql.DB) *DB {
    return &DB{
        leader:    leader,
        followers: followers,
    }
}

// CreateOrder всегда направляется в Лидер
func (db *DB) CreateOrder(ctx context.Context, order *Order) error {
    query := "INSERT INTO orders (id, amount) VALUES ($1, $2)"
    _, err := db.leader.ExecContext(ctx, query, order.ID, order.Amount)
    if err != nil {
        return fmt.Errorf("failed to insert order to leader: %w", err)
    }
    return nil
}

// GetOrder балансирует чтение между фолловерами по алгоритму Round-Robin
func (db *DB) GetOrder(ctx context.Context, id string) (*Order, error) {
    if len(db.followers) == 0 {
        // Фоллбэк на лидера, если реплик нет
        return db.getOrderFromDB(ctx, db.leader, id)
    }

    // Атомарный инкремент для равномерного распределения по пулам
    idx := atomic.AddUint64(&db.rrCounter, 1) % uint64(len(db.followers))
    targetFollower := db.followers[idx]

    return db.getOrderFromDB(ctx, targetFollower, id)
}

func (db *DB) getOrderFromDB(ctx context.Context, pool *sql.DB, id string) (*Order, error) {
    var o Order
    err := pool.QueryRowContext(ctx, "SELECT id, amount FROM orders WHERE id = $1", id).Scan(&o.ID, &o.Amount)
    if err != nil {
        return nil, err
    }
    return &o, nil
}

Multi-Leader (Несколько ведущих)

В топологии Multi-Leader (Active-Active) два или более независимых сервера баз данных имеют право одновременно принимать операции записи. Узлы обмениваются потоками изменений между собой в двунаправленном режиме (Bidirectional Replication).

flowchart LR
    ClientA["Клиент EU (Лондон)"]
    ClientB["Клиент US (Нью-Йорк)"]
    LeaderEU["Лидер EU (Frankfurt)"]
    LeaderUS["Лидер US (Virginia)"]

    ClientA -->|"1. Запись и чтение"| LeaderEU
    ClientB -->|"1. Запись и чтение"| LeaderUS
    LeaderEU <-->|"2. Двунаправленная асинхронная репликация"| LeaderUS

    classDef client fill:#131d33,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
    classDef leader fill:#1e1b4b,stroke:#818cf8,stroke-width:2px,color:#f8fafc;
    class ClientA,ClientB client;
    class LeaderEU,LeaderUS leader;
  

Когда оправдан Multi-Leader:

  1. Мультирегиональная топология (Multi-Datacenter):
    Чтобы пользователь в Сиднее не ждал 250 мс сетевого пинга до дата-центра в Амстердаме при каждом клике «Купить», в Сиднее разворачивается локальный мастер.
  2. Оффлайн-клиенты:
    Мобильное или десктопное приложение локально работает как лидер, а при появлении интернета синхронизирует изменения с облачным сервером.
Проблема конфликтов записи (Write Conflicts)

Главный кошмар архитектуры Multi-Leader — конкурентные конфликты.
Представьте: пользователь Алиса меняет адрес доставки в заказе на склад в Лондоне через узел EU, а в эту же миллисекунду менеджер поддержки в Нью-Йорке отменяет этот же заказ через узел US. Оба мастера приняли запись локально. При обмене репликацией узлы обнаруживают конфликт: какое состояние считать истинным?

Способы разрешения конфликтов:
  1. Last Write Wins (LWW):
    Побеждает изменение с наибольшим таймстемпом физических часов.
  2. Версионирование и явное слияние (Causal Vector Clocks):
    Оба конфликтующих значения сохраняются в виде версий (сиблингов, как в CouchDB или старом Riak), а решение о слиянии делегируется приложению.
  3. CRDT (Conflict-free Replicated Data Types):
    Математические структуры, гарантирующие непротиворечивое слияние без блокировок.
  4. Политика на уровне приложения в Go:
Go
package multileader

import (
    "time"
)

type Order struct {
    ID        string
    Status    string
    UpdatedAt time.Time
}

// MergeOrders разрешает конфликт двух параллельных обновлений по политике LWW
func MergeOrders(local, remote *Order) *Order {
    if remote.UpdatedAt.After(local.UpdatedAt) {
        return remote
    }
    return local
}

Репликация в конкретных базах данных: взгляд со стороны Go

PostgreSQL: Потоковая репликация WAL и pgxpool

В PostgreSQL репликация работает на физическом уровне: фоновый процесс walwriter сбрасывает изменения страниц памяти на диск, а процесс walsender передаёт эти байты по сети фолловеру, где процесс walreceiver записывает их в локальный каталог и процесс startup применяет их к таблицам.

Параметр synchronous_commit управляет гарантией надёжности:

  • off — асинхронный сброс (риск потери транзакций).
  • local — коммит после fsync на мастере.
  • on — коммит только после получения подтверждения от синхронного фолловера.

В Go для эффективной работы с репликами создаются раздельные пулы соединений:

Go
package postgres

import (
    "context"

    "github.com/jackc/pgx/v5/pgxpool"
)

func InitPools(ctx context.Context) (*pgxpool.Pool, *pgxpool.Pool, error) {
    // Пул к мастеру: для записи
    leaderPool, err := pgxpool.New(ctx, "postgres://user:pass@leader-host:5432/orders_db")
    if err != nil {
        return nil, nil, err
    }

    // Пул к репликам: драйвер автоматически выберет доступную Read-Only ноду
    replicaConnStr := "postgres://user:pass@replica-host-1:5432,replica-host-2:5432/orders_db?target_session_attrs=read-only"
    replicaPool, err := pgxpool.New(ctx, replicaConnStr)
    if err != nil {
        leaderPool.Close()
        return nil, nil, err
    }

    return leaderPool, replicaPool, nil
}

Redis Sentinel и Failover

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

Go
package redisclient

import (
    "github.com/redis/go-redis/v9"
)

func InitRedisFailover() *redis.Client {
    return redis.NewFailoverClient(&redis.FailoverOptions{
        MasterName:    "mymaster",
        SentinelAddrs: []string{"sentinel-1:26379", "sentinel-2:26379", "sentinel-3:26379"},
        // Клиент автоматически обновляет маршруты при перевыборах мастера
    })
}

Mechanical Sympathy: репликация и Go-рантайм

  1. Влияние синхронной репликации на планировщик Go:
    При синхронной репликации каждый вызов tx.Commit() заставляет горутину ожидать ответа удалённой реплики. Время блокировки горутины вырастает с 0.5 мс до 15–30 мс.
    Хотя планировщик Go (runtime scheduler) переводит горутину в состояние ожидания и освобождает поток ОС (M), память стека (от 2 до 8 КБ) остаётся занятой. При 10 000 одновременных транзакций это замораживает до 80 МБ оперативной памяти и держит занятыми дескрипторы сокетов в ядре Linux.
  2. Асинхронная репликация и паттерн Read-Your-Own-Writes:
    При асинхронной репликации возникает лаг репликации (Replication Lag): реплика отстаёт от лидера на десятки миллисекунд или секунды.
    Сценарий катастрофы UX: пользователь нажимает «Оформить заказ», сервис успешно коммитит запись на лидере и перенаправляет браузер на страницу личного кабинета. Браузер запрашивает GET /orders, балансировщик направляет запрос на реплику, реплика ещё не получила WAL от мастера, и пользователь видит надпись: «У вас пока нет заказов»! Пользователь в панике жмёт кнопку ещё раз, создавая дублирующий заказ.
Решение: Стратегия Read-Your-Own-Writes в Go

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

Go
package ryow

import (
    "context"
    "database/sql"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

type Service struct {
    db    *DBService
    cache *redis.Client
}

type DBService struct {
    leader  *sql.DB
    replica *sql.DB
}

func (s *Service) ConfirmOrder(ctx context.Context, id string) error {
    // 1. Запись на мастере
    _, err := s.db.leader.ExecContext(ctx, "UPDATE orders SET status = 'confirmed' WHERE id = $1", id)
    if err != nil {
        return err
    }

    // 2. Read-your-own-writes: взводим временный маркер в кэше
    // В течение 2 секунд все чтения для этого заказа пойдут строго в лидер
    cacheKey := fmt.Sprintf("ryow:order:%s", id)
    _ = s.cache.Set(ctx, cacheKey, "read-from-leader", 2*time.Second).Err()

    return nil
}

func (s *Service) GetOrder(ctx context.Context, id string) (*Order, error) {
    cacheKey := fmt.Sprintf("ryow:order:%s", id)
    mustReadLeader, _ := s.cache.Exists(ctx, cacheKey).Result()

    var targetDB *sql.DB
    if mustReadLeader > 0 {
        targetDB = s.db.leader // Обход лага репликации
    } else {
        targetDB = s.db.replica // Обычное чтение с масштабируемой реплики
    }

    return s.fetchOrder(ctx, targetDB, id)
}

func (s *Service) fetchOrder(ctx context.Context, db *sql.DB, id string) (*Order, error) {
    return &Order{ID: id}, nil
}
  1. Netpoller и пулы соединений:
    При разделении на лидер и реплики Go-приложение открывает множество TCP-сокетов. Асинхронный сетевой поллер Go (netpoller) мультиплексирует их через системный вызов epoll_wait. Для предотвращения исчерпания файловых дескрипторов ОС (ulimit -n) необходимо строго ограничивать параметры пула: SetMaxOpenConns и SetMaxIdleConns.

Репликация и CAP

Репликация — это практическое воплощение теоремы CAP (30. CAP теорема и реальные компромиссы):

  • Синхронная репликация выбирает CP: при обрыве связи с синхронной репликой лидер отказывается принимать записи, сохраняя строгую согласованность ценой доступности.
  • Асинхронная репликация выбирает AP: лидер продолжает принимать записи, фолловеры продолжают отдавать устаревшие данные, гарантируя максимальный аптайм ценой Eventual Consistency и готовности приложения к stale reads.

Антипаттерны и распространённые ошибки

  1. Чтение с лидера, когда можно с реплики:
    Направление тяжёлых аналитических SELECT запросов на лидер перегружает процессор мастера, увеличивает время коммита транзакций и деградирует latency записи всей системы.
  2. Игнорирование лага репликации:
    Разработка сервиса в предположении, что репликация мгновенна. Это ведёт к коварным багам «исчезающих записей» в продакшене.
  3. Отсутствие мониторинга лага репликации:
    Лаг репликации (в байтах WAL или секундах отставания) — это критическая метрика здоровья системы. Если реплика начинает отставать на гигабайты, диск лидера может переполниться неудалёнными сегментами WAL.
  4. Использование единого пула соединений без маршрутизации:
    Смешивание операций записи и чтения в едином пуле соединений делает невозможным балансировку и масштабирование.


Итог

Репликация превращает хрупкий одиночный сервер в живучий распределённый кластер, способный переживать падения оборудования и масштабировать операции чтения до сотен тысяч запросов в секунду.

Однако надежность не даётся даром: асинхронность неизбежно порождает лаг репликации, а стремление к географически распределённой записи (Multi-Leader) сталкивает разработчиков с вызовами разрешения конфликтов. Владение паттерном Read-Your-Own-Writes и дисциплина разделения пулов соединений в Go — обязательный признак зрелого архитектора.

Когда у нас есть множество реплик и серверов, возникает следующая фундаментальная задача: как эффективно и справедливо распределить миллионы входящих запросов между ними?

Встречайте следующую главу: 33. Load Balancing на уровне архитектуры.