CAP-теорема: почему «выбрать два из трёх» — неточная формула
Точный и практический разбор CAP-теоремы: что означают C, A и P, почему «выбрать два из трёх» неточно и как проектировать поведение API при разделении сети.
CAP-теорему часто пересказывают как меню из трёх пунктов: consistency, availability и partition tolerance, из которых распределённая система якобы может выбрать любые два. Эта мнемоника помогает запомнить буквы, но мешает проектировать реальные сервисы. Разделение сети — не функция продукта, которую команда по желанию выключает. Это условие отказа, при котором узлы перестают вовремя обмениваться сообщениями. Настоящий выбор возникает уже внутри такого отказа: ждать или отклонить часть запросов ради согласованного результата либо ответить всем достижимым клиентам, допуская расхождение реплик.
Разберём точные свойства из работы Сета Гилберта и Нэнси Линч, проиграем отказ на двух репликах и превратим теорему в практический список вопросов для API, хранилища и эксплуатации.
Что означают C, A и P в теореме
Буквы CAP легко спутать с более широкими бытовыми понятиями. В формальной постановке у них довольно узкий смысл.
Consistency — атомарная, или линеаризуемая, согласованность. Операции выглядят так, будто существует одна актуальная копия данных и все успешные чтения и записи выстроены в единый порядок, совместимый с реальным временем. Если запись завершилась до начала чтения, это чтение не должно вернуть предыдущее значение.
Availability — каждый запрос, который получил не отказавший узел, в конечном счёте завершается ответом. Это не обещание низкой задержки и не процент из SLA. Ошибка вида «сейчас не могу подтвердить результат» тоже не превращает недоступную операцию в доступную в смысле теоремы, если контракт требует нормального результата для каждого запроса.
Partition tolerance — система продолжает работать в модели, где сеть может потерять произвольное число сообщений между группами узлов. Partition — это не только оборванный кабель. Практически неотличимы друг от друга потеря пакетов, очень большая задержка, перегруженный канал, зависший процесс и пауза сборщика мусора: удалённый узел не может надёжно узнать, придёт ли ответ позже или не придёт вовсе.
Гилберт и Линч доказали, что в асинхронной сети при разделении нельзя одновременно гарантировать атомарную согласованность и доступность для каждого запроса. Теорема не утверждает, что любая распределённая система постоянно обладает ровно двумя буквами. Она описывает невозможность совмещать конкретные гарантии в конкретной модели отказа.
Две реплики и один потерянный канал
Пусть профиль пользователя хранится на репликах A и B. До отказа обе содержат значение email = old@example.test. Сеть разделилась: A и B доступны своим клиентам, но сообщения между ними не проходят.
Клиент рядом с A записывает new@example.test и получает подтверждение. Затем другой клиент читает профиль через B. У B нет способа выяснить, была ли запись на A: сообщение могло потеряться, а ответ нельзя ждать бесконечно.
Есть два принципиальных решения.
- B отказывается отвечать или ждёт восстановления связи. Тогда система защищает единый порядок операций, но не выполняет требование availability для запроса к живому узлу.
- B отвечает локальным значением. Тогда запрос завершается, но после уже подтверждённой записи клиент может увидеть старый email, то есть атомарная согласованность нарушена.
Третий вариант нельзя получить более умным таймаутом. Таймаут помогает выбрать момент перехода в аварийный режим, но не приносит информацию с недоступной стороны partition.
Наблюдаемая модель на Python
Следующий код не моделирует протокол репликации целиком. Он делает явным решение, которое часто прячется за настройкой consistency level:
from dataclasses import dataclass
@dataclass
class Replica:
value: str
version: int
peer_reachable: bool
def read_during_partition(
local: Replica,
*,
require_latest: bool,
) -> str:
if require_latest and not local.peer_reachable:
raise TimeoutError("нельзя подтвердить актуальность значения")
return local.value
replica_a = Replica("new@example.test", version=2, peer_reachable=False)
replica_b = Replica("old@example.test", version=1, peer_reachable=False)
print(read_during_partition(replica_b, require_latest=False))
# old@example.test — ответ есть, но он может быть устаревшим
read_during_partition(replica_b, require_latest=True)
# TimeoutError — актуальность защищена ценой отказа операцииВ промышленной системе вместо булева флага будут кворумы, сроки аренды лидера, fencing tokens, векторные часы, разрешение конфликтов или сессии с разными уровнями согласованности. Но источник компромисса остаётся тем же: локальный узел не знает состояние недоступной реплики.
Почему формула «выберите любые два» вводит в заблуждение
У системы в нескольких зонах доступности нет надёжного режима «не учитывать P». Она либо определяет поведение при потере связи, либо получает его случайно. Поэтому полезнее говорить так:
Точная формулировка для практики: когда сеть разделена, для затронутой операции приходится пожертвовать атомарной согласованностью или полной доступностью.
В нормальном режиме, когда реплики общаются, система может одновременно давать согласованные ответы и обслуживать запросы. Более того, решение необязательно едино для всего продукта. Баланс пользователя можно блокировать без подтверждённого кворума, а счётчик просмотров — принимать локально и объединять позже. Один сервис способен выбрать разное поведение для разных команд, сущностей и этапов инцидента.
Ярлыки CP и AP поэтому описывают направление дизайна, а не исчерпывающую спецификацию.
- Система, которую называют CP, обычно прекращает часть чтений или записей, если не может собрать нужный кворум. Но доступные операции, выбранный consistency level и поведение старых сессий всё равно нужно уточнять.
- Система, которую называют AP, продолжает принимать операции на разделённых сторонах и затем должна обнаружить и разрешить расхождения. Но отдельные запросы в ней могут использовать кворум или более строгий режим.
- «CA без P» — описание поведения только пока сеть надёжна либо системы, которая при partition перестаёт удовлетворять C или A. Это не способ отменить сетевые отказы.
В статье 2012 года Гилберт и Линч отдельно подчёркивают, что свойства можно рассматривать на интервалах времени и для частей системы, а между строгими крайностями существует пространство ослабленных гарантий. Это ещё одна причина не превращать CAP в постоянную наклейку на продукт.
Кворум помогает, но не отменяет теорему
Репликацию часто настраивают числами N, W и R:
- N — число реплик;
- W — сколько реплик должны подтвердить запись;
- R — сколько реплик участвуют в чтении.
Условие W + R > N создаёт пересечение наборов чтения и записи. Например, при N = 3 можно потребовать W = 2 и R = 2. Тогда успешное чтение встречает хотя бы одну реплику, участвовавшую в успешной записи. Однако при разделении «одна реплика против двух» меньшая сторона не соберёт кворум. Согласованность оплачена отказом части операций.
Само пересечение также недостаточно без правил сравнения версий и обработки конкурентных записей. Узел должен отличить новое значение от старого, а система — определить, что делать с двумя изменениями, которые не образуют простой порядок. Кворум — элемент протокола, не доказательство всех пользовательских гарантий.
Что выбрать для конкретной операции
Начинать стоит не с вопроса «наша база CP или AP?», а с последствий неверного ответа и временного отказа.
Для списания денег, выдачи уникального имени или смены владельца ресурса опасен конфликт двух одновременно подтверждённых операций. Часто разумнее вернуть повторяемую ошибку, поставить команду в очередь или направить её к подтверждённому лидеру. Клиенту понадобятся idempotency key, понятный статус и безопасный retry.
Для лайков, телеметрии, кэша рекомендаций или черновика текста временно устаревший ответ может быть приемлемее отказа. Тогда дизайн обязан объяснить последующее слияние: последнее изменение по физическому времени, монотонный счётчик, CRDT, предметное разрешение конфликта или ручная проверка дают разные результаты.
Полезно зафиксировать решение таблицей, а не одной буквой:
| Операция | Что делаем без кворума | Что видит клиент | Как восстанавливаемся |
|---|---|---|---|
| Списание | Не подтверждаем | Повторяемый статус «не определено» | Проверяем idempotency key и журнал |
| Чтение каталога | Возвращаем локальную копию | Метка времени или допустимая давность | Фоновая синхронизация |
| Уникальная регистрация | Блокируем запись | Явный временный отказ | Повтор после выбора лидера |
| Счётчик события | Принимаем локально | Приблизительное значение | Коммутативное объединение |
Такая таблица сразу раскрывает обязанности API и эксплуатации, которые скрывает фраза «мы выбрали AP».
Partition начинается с неопределённости
Распределённый узел видит не «разделение сети», а отсутствие ожидаемого сообщения. Поэтому переход между режимами тоже является частью продукта:
- Какой timeout объявляет peer недоступным и на основании каких измерений он выбран?
- Может ли старый лидер продолжить запись после появления нового и чем его отсекают?
- Какие операции уже подтверждены клиентам, но ещё не попали на другую сторону?
- Как клиент узнает разницу между окончательным отказом и неизвестным результатом?
- Как система обнаружит восстановление связи и ограничит нагрузку от массового repair?
Короткий timeout ускоряет реакцию, но увеличивает число ложных подозрений при обычных задержках. Длинный снижает ложные переключения, но дольше удерживает запросы. CAP не выбирает это число и не заменяет измерение latency tail.
Что CAP не доказывает
Теорема не отвечает на множество соседних вопросов:
- сохранятся ли подтверждённые данные после падения диска;
- будут ли транзакции изолированы при конкурентном доступе;
- какова задержка чтения и записи без partition;
- сколько отказов выдержит конкретная топология;
- гарантирован ли порядок событий внутри очереди;
- является ли конфликт разрешимым без участия пользователя;
- какое значение SLA возможно при выбранных таймаутах.
Она также не говорит, что eventual consistency означает «данные когда-нибудь сами станут правильными». Нужно определить условие сходимости, функцию объединения, доставку пропущенных изменений и поведение при новых конкурентных записях. Без этого слово eventual не является инженерной гарантией.
Наконец, CAP нельзя использовать как оправдание любого сбоя. Если сервис недоступен из-за исчерпанного пула соединений или медленного запроса к одной базе, это проблема ёмкости и реализации, а не неизбежное следствие теоремы.
Практический эксперимент перед production
Команда может проверить свой выбор контролируемым тестом:
- Разместить реплики в двух изолируемых сетевых сегментах и создать исходное значение.
- Оборвать трафик между сегментами, оставив клиентский доступ к каждому.
- Одновременно выполнить конфликтующие записи и чтения с обеих сторон.
- Зафиксировать ответы, таймауты и операции, которые клиент считает успешными.
- Восстановить связь и наблюдать выбор лидера, repair, разрешение конфликтов и повтор запросов.
- Сравнить результат с публичным контрактом API и инструкцией дежурного инженера.
Важно тестировать не только счастливый финал. Если запрос оборвался после фиксации записи, клиент может повторить его; без идемпотентности это отдельный источник двойного эффекта. Если обе стороны приняли изменение, автоматическое правило слияния может потерять предметно значимые данные. Если меньшая сторона отказала, балансировщик не должен продолжать направлять туда трафик бесконечно.
Чек-лист архитектурного решения
Перед выбором технологии и consistency level ответьте на восемь вопросов:
- Какую именно согласованность обещает каждая операция: linearizable read, read-your-writes, monotonic reads или допустимую давность?
- Что считается успешным ответом и что клиент делает при неизвестном результате?
- Какие запросы блокируются без кворума, а какие продолжают работать локально?
- Как предотвращается работа устаревшего лидера после переключения?
- Как идентифицируются версии и разрешаются конкурентные изменения?
- Какие idempotency keys и журналы позволяют безопасно повторять команды?
- Как измеряются partition, рост конфликтов, lag репликации и время восстановления?
- Проверены ли эти обещания fault-injection тестом, а не только документацией поставщика?
Точная польза CAP-теоремы не в классификации баз данных тремя буквами. Она заставляет признать предел информации во время сетевого отказа и заранее выбрать наблюдаемое поведение каждой важной операции. Когда этот выбор записан в контракте, тестах и runbook, абстрактная теорема становится практическим инструментом разработки.
Связанные уроки
- HTTP из терминала: curl, заголовки, тело и коды ответа — curl помогает разделить сетевое соединение, TLS, HTTP-status, headers и response body. По умолчанию HTTP 404 не делает сам curl неуспешным, поэтому надёжная проверка API должна…
- DNS-диагностика: getent, dig и путь имени до адреса — DNS сопоставляет имена с записями, но приложение в Linux может использовать более широкий Name Service Switch: локальный hosts, DNS, mDNS или корпоративный provider.
- Порты и сокеты: ss и lsof для поиска слушающего процесса — TCP-порт — число внутри сетевого namespace, а socket — объект ядра с локальным и удалённым endpoint и состоянием.
- Диагностика недоступного сервиса: от DNS до процесса и логов — Фраза «сайт не работает» объединяет разные сбои: имя не разрешается, TCP недоступен, TLS не подтверждён, proxy возвращает ошибку, процесс упал или dependency отвечает медленно.
Источники
Формат и права
- Формат
- Авторский разбор
Атрибуция
Самостоятельный редакционный разбор ЯдроКода по первичным публикациям Seth Gilbert, Nancy Lynch и Eric Brewer. Факты и идеи изложены редакцией самостоятельно; фрагменты исходного текста, код и иллюстрации не воспроизводятся.
Код, данные и иллюстрации
Иллюстрации, таблицы и программные примеры созданы редакцией; материалы и графика первоисточников не копируются.