2. Strong vs eventual consistency
В прошлой статье 1. Consistency модели мы выяснили, что согласованность — это строгий контракт между распределенной базой данных и кодом вашего приложения. Мы изучили всю шкалу возможных гарантий — от математически выверенной линеаризуемости до полного расслабления в eventual consistency.
Теперь пришло время столкнуть лбами две ключевые парадигмы, вокруг которых крутятся 99% архитектурных баталий в современном бэкенде: Strong Consistency (Строгая согласованность / Линеаризуемость) и Eventual Consistency (Согласованность в конечном счете).
Выбор между ними — это не вопрос эстетики или личных симпатий к реляционным СУБД или NoSQL-решениям. Это фундаментальный инженерный расчет: сколько миллисекунд задержки готов терпеть пользователь, какую нагрузку выдержит пул соединений в Go, и готов ли бизнес к тому, что клиент на пару секунд увидит данные из прошлого.
Strong Consistency: Иллюзия монолита
Строгая согласованность (Linearizability) создает кристальную и надежную иллюзию: распределенная система ведет себя так, словно в природе существует всего одна-единственная физическая копия данных.
Как только операция записи успешно завершена (клиент получил подтверждающий сетевой статус HTTP 200 OK), любое последующее чтение, к какому бы узлу кластера в любой точке планеты оно ни обратилось, гарантированно вернет именно это свежее значение.
Для инженера-разработчика это райский сад:
- Код прост и линеен;
- Никаких состояний гонки на уровне чтения;
- Никаких «призрачных» откатов баланса;
- Бизнес-логика защищена от двойных трат и конфликтов.
Но физические законы Вселенной и скорость распространения света в оптоволокне неумолимы. За эту идеальную иллюзию мы обязаны заплатить жесточайший налог на производительность.
Mechanical Sympathy: Налог на синхронизацию
Давайте детально проследим, что происходит в «железе», сетевом стеке Linux и рантайме Go, когда ваш бэкенд выполняет операцию INSERT в строгую распределенную базу данных (например, кластер etcd или синхронную группу PostgreSQL).
Ваша прикладная горутина вызывает метод:
_, err := db.ExecContext(ctx, "INSERT INTO accounts (id, balance) VALUES ($1, $2)", accountID, 1000)
- Go Runtime: Драйвер базы данных формирует бинарный пакет и совершает системный вызов
writeв TCP-сокет. Планировщик Go немедленно переводит текущую горутину в состояние ожиданияGwaitingи паркует ее на системном селектореnetpoll(под капотом — Linuxepoll). Поток ОСMосвобождается и берет на исполнение другие задачи. - Master-узел СУБД: Сервер принимает пакет. Он не может просто записать данные в оперативную память. Он выполняет системный вызов
writeи тяжелейшийfsyncв локальный журнал упреждающей записи (Write-Ahead Log, WAL), принудительно сбрасывая кэш дискового контроллера. - Сетевой транзит к репликам: Мастер формирует репликационные пакеты и отправляет их через сетевые коммутаторы на подчиненные узлы (Фолловеры).
- Реплики: Каждая реплика принимает изменения, выполняет локальный
writeи собственныйfsyncна свой физический накопитель. - Подтверждение (ACK): Реплики отправляют сетевой ответ с подтверждением Мастеру.
- Кворум: Мастер подсчитывает голоса. Как только он убеждается, что строгое большинство узлов (Кворум) надежно зафиксировало байты на диске, он наконец-то отправляет ответный пакет в TCP-сокет вашего Go-приложения.
- Пробуждение в Go: Системный вызов
epoll_waitв потоке рантайма Go ловит событие поступления данных, горутина переводится изGwaitingвGrunnable, подхватывается свободным процессоромP, и функцияdb.ExecContext(ctx, query)завершает свою работу.
sequenceDiagram
autonumber
participant Go as Go Runtime (Client)
participant L as Лидер (DB Master)
participant F as Фолловер (DB Replica)
Go->>L: db.ExecContext (INSERT)
Note over Go: Горутина запаркована в netpoll (Gwaiting)
L->>L: Локальный WAL write + fsync
L->>F: Репликация по сети (AppendEntries)
F->>F: Локальный WAL write + fsync
F-->>L: Подтверждение (ACK)
Note over L: Кворум достигнут (большинство записало)
L-->>Go: Успех (HTTP 200 OK)
Note over Go: netpoll будит горутину (Grunnable)
Цена вопроса: Бутылочное горлышко соединений
Реальная задержка вызова db.ExecContext складывается из физической цепочки:
$$\text{Latency} = \text{RTT}_{\text{master}} + \text{Fsync}_{\text{master}} + \text{RTT}_{\text{replica}} + \text{Fsync}_{\text{replica}} + \text{RTT}_{\text{client}}$$
В реальном облачном кластере это составляет от 15 до 60 миллисекунд на каждый единичный чих!
Горутины в Go стоят дешево (всего ~2–4 КБ на стек), и миллион спящих в Gwaiting горутин память не разорят. Но пул соединений к базе данных в стандартной библиотеке database/sql строго ограничен (параметры SetMaxOpenConns и SetMaxIdleConns)!
Если ваш сервис испытывает всплеск входящей нагрузки до 10 000 RPS, а каждый запрос удерживает драгоценное TCP-соединение из пула на протяжении 40 миллисекунд в ожидании кворумного fsync, пул соединений мгновенно исчерпывается. Новые клиентские запросы выстраиваются в многотысячную очередь ожидания, таймеры контекстов начинают взрываться по ctx.Err() == context.DeadlineExceeded, и сервис захлебывается в каскадном отказе.
Строгая согласованность всегда драматически ограничивает предельную пропускную способность (Throughput) системы.
Eventual Consistency: Скорость ценой хаоса
Согласованность в конечном счете (Eventual Consistency) смело срывает оковы синхронных блокировок. Гарантия звучит сдержанно:
Если в систему перестанут поступать новые изменения, то спустя некоторое время все узлы кластера придут к абсолютно идентичному состоянию.
Это царство высокодоступных AP-систем (Apache Cassandra, Amazon DynamoDB, ScyllaDB, асинхронные реплики PostgreSQL и Redis). Они спроектированы для того, чтобы утилизировать сетевой стек и многоядерные процессоры на 100%, перемалывая сотни тысяч запросов в секунду с минимальными задержками.
Mechanical Sympathy: Fire and Forget
Что происходит, когда Go-код пишет данные в AP-систему (например, в Cassandra с уровнем согласованности Write Consistency = 1 или асинхронную очередь):
- Узел кластера принимает входящий пакет.
- Он мгновенно записывает структуру данных в быструю оперативную память (в Cassandra это кольцевой буфер
Memtable) и дописывает байты в последовательный журнал без вызова блокирующегоfsync. - Узел немедленно отправляет вашему Go-клиенту успешный ответ.
- Ваша горутина в Go просыпается через 0.8–1.5 миллисекунды! Соединение из
database/sqlтут же возвращается в пул и готово обслуживать следующего пользователя. - И только потом, в фоновом режиме, узел начинает асинхронно распространять обновления соседям по кластеру (через протоколы Gossip, репликационные очереди или Anti-Entropy фоновые задачи).
sequenceDiagram
autonumber
participant Go as Go Runtime (Client)
participant N1 as Узел 1 (Координатор)
participant N2 as Узел 2 (Реплика)
Go->>N1: db.ExecContext (INSERT)
N1->>N1: Быстрая запись в Memtable (RAM)
N1-->>Go: Успех (HTTP 200 OK) за 1 ms!
Note over Go: Горутина мгновенно свободна,<br/>пул соединений не забит!
N1-.->N2: Фоновая асинхронная репликация (Gossip)
Note over N2: Данные доберутся сюда<br/>через десятки миллисекунд...
Система демонстрирует ошеломляющую пропускную способность: 100 000 RPS утилизируют сервер без малейших признаков очередей. Но за все в жизни приходится платить.
Как это выглядит в коде (Эмуляция подходов)
Если бы мы разрабатывали собственный распределенный движок на Go, архитектурный водораздел между синхронной и асинхронной моделью выглядел бы следующим образом:
Строгий подход (Synchronous Replication)
Мы держим горутину клиента на привязи до тех пор, пока большинство узлов кластера не подтвердят дисковую фиксацию:
package cluster
import (
"context"
"fmt"
)
type StrongServer struct {
wal WALStorage
replicas []*ReplicaClient
}
func (s *StrongServer) HandleWriteStrong(ctx context.Context, data []byte) error {
// ШАГ 1: Локальная запись с обязательным сбросом кэша на диск
if err := s.wal.WriteAndFsync(data); err != nil {
return fmt.Errorf("локальная запись в WAL провалена: %w", err)
}
// ШАГ 2: Параллельная сетевая рассылка репликам
ackCh := make(chan struct{}, len(s.replicas))
for _, replica := range s.replicas {
go func(r *ReplicaClient) {
// Выполняем синхронный RPC-вызов
if err := r.SendAppend(ctx, data); err == nil {
ackCh <- struct{}{}
}
}(replica)
}
// ШАГ 3: Блокирующее ожидание сбора Кворума большинства
acks := 1 // Учитываем собственный подтвержденный голос
quorum := (len(s.replicas) / 2) + 1
for acks < quorum {
select {
case <-ackCh:
acks++
case <-ctx.Done():
// Если сеть тормозит, клиент получает ошибку по таймауту!
return fmt.Errorf("таймаут сбора кворума: %w", ctx.Err())
}
}
// Клиент получает подтверждение ТОЛЬКО после согласия большинства
return nil
}
Eventual подход (Asynchronous Replication)
Мы мгновенно отпускаем клиентский запрос, сбрасывая задачу синхронизации в фоновый конвейер каналов:
package cluster
import (
"context"
"errors"
"log"
)
type EventualServer struct {
memtable MemoryStore
asyncQueue chan []byte
replicas []*ReplicaClient
}
func (s *EventualServer) HandleWriteEventual(ctx context.Context, data []byte) error {
// ШАГ 1: Молниеносная запись в оперативную память (RAM)
if err := s.memtable.Insert(data); err != nil {
return err
}
// ШАГ 2: Сброс в асинхронную очередь репликации без блокировки
select {
case s.asyncQueue <- data:
// Успешно поставлено в очередь
default:
// Защита от исчерпания памяти при переполнении буфера (Backpressure)
return errors.New("очередь репликации переполнена, сработал backpressure")
}
// ШАГ 3: Мгновенно возвращаем клиенту 200 OK (задержка < 1 мс)
return nil
}
// Фоновый воркер работает абсолютно независимо от пользовательских HTTP-горутин
func (s *EventualServer) backgroundReplicationWorker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case entry := <-s.asyncQueue:
for _, replica := range s.replicas {
// Ошибки репликации не аффектят клиента:
// реплика догонит состояние позже через механизмы Anti-Entropy
if err := replica.SendAsync(context.Background(), entry); err != nil {
log.Printf("Внимание: реплика временно недоступна: %v", err)
}
}
}
}
}
Сравнение лоб в лоб
| Критерий | Strong Consistency (Строгая) | Eventual Consistency (В конечном счете) |
|---|---|---|
| Гарантия корректности | Чтение в любой момент возвращает самую последнюю запись. | Чтение может вернуть устаревшую версию данных. |
| Задержка (Latency) | Высокая: ожидание сетевых RTT и системных вызовов fsync. |
Минимальная: запись в память (RAM) за доли миллисекунды. |
| Поведение при сбоях сети | Падение доступности в изолированном сегменте (CP). | Непрерывная работа всех доступных сегментов (AP). |
| Нагрузка на пул БД | Высокая: горутины подолгу удерживают открытые сокеты. | Низкая: сокет освобождается почти мгновенно. |
| Сложность бизнес-кода | Минимальная: система предсказуема как локальная переменная. | Высокая: необходимость версионирования, токенов и слияний. |
| Где применять в проде? | Финансовые балансы, оформление заказов, складские остатки. | Лайки, счетчики просмотров, комментарии, IoT-телеметрия. |
Итог
- Strong Consistency спасает от логических багов и бережет нервы продуктовой команды, но беспощадно наказывает задержками и исчерпанием пула соединений при высоких нагрузках.
- Eventual Consistency позволяет бэкенду выдерживать миллионные потоки событий без деградации времени отклика, но требует применения сессионных паттернов для защиты пользовательского опыта.
- Профессиональный архитектор никогда не выбирает одну модель для всего монолита: внутри одной системы финансовые транзакции пишутся со строгим кворумом, а лента уведомлений — в режиме согласованности в конечном счете.
Но что происходит, если два независимых клиента в режиме Eventual Consistency одновременно обновят один и тот же ключ на разных репликах? В системе возникнет конфликт версий данных!
Как распределенные системы обнаруживают такие расхождения прямо в момент чтения и тихо «залечивают» поврежденные реплики на лету?
Об этом мы поговорим в следующей статье: 3. Read repair.