32. Репликация. Leader Follower и Multi Leader
Репликация как основа надёжности и масштабирования
В предыдущей главе мы выяснили, как с помощью шардирования преодолеть ограничения объема диска и вычислительной мощности одного сервера. Однако само по себе шардирование не спасает от физических поломок: каждый шард остаётся уязвимым узлом. Если на сервере сгорит блок питания, выйдет из строя SSD-накопитель или оборвётся сетевой патчкорд стойки, соответствующая часть данных компании мгновенно станет недоступной.
Фундаментальным ответом компьютерных наук на проблему физической смертности оборудования является репликация — синхронное или асинхронное создание и поддержание идентичных копий данных на физически изолированных вычислительных узлах.
Кроме обеспечения отказоустойчивости (High Availability, HA) и выживания при катастрофах (Disaster Recovery), репликация служит главным рычагом горизонтального масштабирования операций чтения: распределяя поток SELECT между десятками реплик, мы снимаем нагрузку с ведущего сервера.
В этой главе мы досконально разберём устройство топологий Leader-Follower и Multi-Leader, исследуем алгоритмы разрешения конфликтов, погрузимся в механику сетевого поллера Go и пулов соединений, а также реализуем паттерн Read-Your-Own-Writes, защищающий пользователей от иллюзий рассинхронизации.
Модели репликации
В современной теории и практике распределённых баз данных выделяют три канонические топологии репликации:
- Leader-Follower (Single-Leader / Master-Slave / Primary-Replica) — классическая модель с единым центром записи.
- Multi-Leader (Master-Master / Active-Active) — модель с несколькими центрами записи, обычно разнесёнными по разным континентам.
- 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
- Синхронная репликация:
Лидер не подтверждает коммит транзакции клиенту до тех пор, пока хотя бы один синхронный фолловер не запишет полученные байты WAL на свой локальный диск и не пришлёт подтверждение (ACK).- Плюс: Гарантия нулевой потери данных при аварии лидера (RPO = 0, Recovery Point Objective).
- Минус: Задержка каждой записи увеличивается на величину сетевого RTT до реплики и время дискового
fsync. Если фолловер зависает, запись на лидере блокируется.
- Асинхронная репликация:
Лидер коммитит транзакцию немедленно после записи в собственный локальный WAL, а передача данных на реплики происходит асинхронно в фоновом потоке.- Плюс: Максимальная пропускная способность и минимальное время отклика (Latency).
- Минус: Риск потери данных. Если мастер сгорит до отправки WAL в сеть, свежие транзакции будут утрачены безвозвратно.
- Полусинхронная репликация (Semi-synchronous):
Прагматичный компромисс: одна реплика настраивается как синхронная (гарантируя выживание данных), а остальные фолловеры получают данные асинхронно.
Реализация разделения пулов чтения и записи в Go
Стандартный пакет database/sql не умеет самостоятельно анализировать SQL-запросы и определять, куда их слать. В архитектуре 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:
- Мультирегиональная топология (Multi-Datacenter):
Чтобы пользователь в Сиднее не ждал 250 мс сетевого пинга до дата-центра в Амстердаме при каждом клике «Купить», в Сиднее разворачивается локальный мастер. - Оффлайн-клиенты:
Мобильное или десктопное приложение локально работает как лидер, а при появлении интернета синхронизирует изменения с облачным сервером.
Проблема конфликтов записи (Write Conflicts)
Главный кошмар архитектуры Multi-Leader — конкурентные конфликты.
Представьте: пользователь Алиса меняет адрес доставки в заказе на склад в Лондоне через узел EU, а в эту же миллисекунду менеджер поддержки в Нью-Йорке отменяет этот же заказ через узел US. Оба мастера приняли запись локально. При обмене репликацией узлы обнаруживают конфликт: какое состояние считать истинным?
Способы разрешения конфликтов:
- Last Write Wins (LWW):
Побеждает изменение с наибольшим таймстемпом физических часов. - Версионирование и явное слияние (Causal Vector Clocks):
Оба конфликтующих значения сохраняются в виде версий (сиблингов, как в CouchDB или старом Riak), а решение о слиянии делегируется приложению. - CRDT (Conflict-free Replicated Data Types):
Математические структуры, гарантирующие непротиворечивое слияние без блокировок. - Политика на уровне приложения в 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 для эффективной работы с репликами создаются раздельные пулы соединений:
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) и при его падении автоматически промоутят лучшую реплику в новый мастер:
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-рантайм
- Влияние синхронной репликации на планировщик Go:
При синхронной репликации каждый вызовtx.Commit()заставляет горутину ожидать ответа удалённой реплики. Время блокировки горутины вырастает с 0.5 мс до 15–30 мс.
Хотя планировщик Go (runtime scheduler) переводит горутину в состояние ожидания и освобождает поток ОС (M), память стека (от 2 до 8 КБ) остаётся занятой. При 10 000 одновременных транзакций это замораживает до 80 МБ оперативной памяти и держит занятыми дескрипторы сокетов в ядре Linux. - Асинхронная репликация и паттерн Read-Your-Own-Writes:
При асинхронной репликации возникает лаг репликации (Replication Lag): реплика отстаёт от лидера на десятки миллисекунд или секунды.
Сценарий катастрофы UX: пользователь нажимает «Оформить заказ», сервис успешно коммитит запись на лидере и перенаправляет браузер на страницу личного кабинета. Браузер запрашиваетGET /orders, балансировщик направляет запрос на реплику, реплика ещё не получила WAL от мастера, и пользователь видит надпись: «У вас пока нет заказов»! Пользователь в панике жмёт кнопку ещё раз, создавая дублирующий заказ.
Решение: Стратегия Read-Your-Own-Writes в Go
После выполнения операции записи мы сохраняем в быстрый локальный кэш (Redis) флаг недавней модификации с коротким TTL (например, 2 секунды). Если при последующем чтении флаг присутствует, запрос принудительно направляется на лидера, минуя отстающие реплики:
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
}
- Netpoller и пулы соединений:
При разделении на лидер и реплики Go-приложение открывает множество TCP-сокетов. Асинхронный сетевой поллер Go (netpoller) мультиплексирует их через системный вызовepoll_wait. Для предотвращения исчерпания файловых дескрипторов ОС (ulimit -n) необходимо строго ограничивать параметры пула:SetMaxOpenConnsиSetMaxIdleConns.
Репликация и CAP
Репликация — это практическое воплощение теоремы CAP (30. CAP теорема и реальные компромиссы):
- Синхронная репликация выбирает CP: при обрыве связи с синхронной репликой лидер отказывается принимать записи, сохраняя строгую согласованность ценой доступности.
- Асинхронная репликация выбирает AP: лидер продолжает принимать записи, фолловеры продолжают отдавать устаревшие данные, гарантируя максимальный аптайм ценой Eventual Consistency и готовности приложения к
stale reads.
Антипаттерны и распространённые ошибки
- Чтение с лидера, когда можно с реплики:
Направление тяжёлых аналитическихSELECTзапросов на лидер перегружает процессор мастера, увеличивает время коммита транзакций и деградирует latency записи всей системы. - Игнорирование лага репликации:
Разработка сервиса в предположении, что репликация мгновенна. Это ведёт к коварным багам «исчезающих записей» в продакшене. - Отсутствие мониторинга лага репликации:
Лаг репликации (в байтах WAL или секундах отставания) — это критическая метрика здоровья системы. Если реплика начинает отставать на гигабайты, диск лидера может переполниться неудалёнными сегментами WAL. - Использование единого пула соединений без маршрутизации:
Смешивание операций записи и чтения в едином пуле соединений делает невозможным балансировку и масштабирование.
Итог
Репликация превращает хрупкий одиночный сервер в живучий распределённый кластер, способный переживать падения оборудования и масштабировать операции чтения до сотен тысяч запросов в секунду.
Однако надежность не даётся даром: асинхронность неизбежно порождает лаг репликации, а стремление к географически распределённой записи (Multi-Leader) сталкивает разработчиков с вызовами разрешения конфликтов. Владение паттерном Read-Your-Own-Writes и дисциплина разделения пулов соединений в Go — обязательный признак зрелого архитектора.
Когда у нас есть множество реплик и серверов, возникает следующая фундаментальная задача: как эффективно и справедливо распределить миллионы входящих запросов между ними?
Встречайте следующую главу: 33. Load Balancing на уровне архитектуры.