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

Позвольте мне провести вас через архитектуру распределённых систем — хорошие части, запутанные части и части, которые заставляют спросить: «Зачем кто-то вообще так проектировал?». К концу вы поймёте не только как работают распределённые системы, но и как на самом деле построить одну, не создавая при этом собственного кошмара.

Проблема, которую мы пытаемся решить

Прежде чем мы углубимся в решения, давайте поговорим о том, почему распределённые системы важны. Представьте, что вы управляете популярным веб-приложением. Ваш единственный сервер прекрасно обрабатывает запросы, пока однажды утром ваше приложение не становится вирусным в социальных сетях. Вдруг трафик увеличивается в 10 раз. Ваш сервер перегружен. Пользователи злятся. Вы начинаете потеть.

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

Звучит просто, правда? На самом деле нет. Как знаменитый Тим Берглунд отмечает, обычно простые задачи, такие как запуск программы или хранение и извлечение данных, становятся намного сложнее, когда вы начинаете делать их на нескольких машинах.

Основные принципы: фундамент

Думайте об архитектуре распределённых систем как о строительстве города вместо небольшого дома. Один дом прост — один человек, одна крыша, одна система коммуникаций. Но город? Вам нужна координация, инфраструктура и системы для управления сложностью.

1. Абстракция и многоуровневость

Ключевое понимание, которое делает распределённые системы управляемыми, — это абстракция через уровни. Мы начинаем с самого нижнего уровня — сокетов API, где вы общаетесь с необработанными сетевыми протоколами, такими как TCP и UDP, и строим вверх.

Каждый уровень предоставляет более простой и мощный интерфейс, чем предыдущий:

  • Сокеты (TCP/UDP) — необработанная сетевая коммуникация.
  • HTTP/RPC — протоколы прикладного уровня.
  • Очереди сообщений — асинхронные шаблоны коммуникации.
  • Сервисные фреймворки — абстракции более высокого уровня.

Преимущество этого подхода? Вы можете понять, как работает весь веб, поняв, как эти уровни накладываются друг на друга.

2. Прозрачность

Одна из основных целей проектирования распределённых систем — прозрачность. Вы хотите скрыть сложность от пользователей и разработчиков:

  • Скрыть, где находятся ресурсы (они могут быть на сервере A или сервере Z).
  • Скрыть сбои и восстановление (когда что-то ломается, система должна восстанавливаться изящно).
  • Скрыть, находятся ли ресурсы в памяти или на диске.
  • Скрыть, что несколько копий данных существуют в разных местах.

Именно поэтому существует middleware — это невидимый слой инфраструктуры, который обрабатывает все эти сложности, пока вы пишете код приложения.

Архитектурные шаблоны: подходы из реального мира

Давайте перейдём к практике. Вот основные шаблоны, с которыми вы столкнётесь при проектировании распределённых систем:

Шаблон 1: Реплицированные сервисы с балансировкой нагрузки

Это подход «бросьте несколько идентичных серверов на проблему». У вас есть несколько серверов, выполняющих один и тот же код, и балансировщик нагрузки распределяет трафик между ними.

Трафик пользователей
    ↓
  Балансировщик нагрузки
   /    |    \
  S1   S2   S3
  \    |    /
   Общая база данных

Плюсы: Простота понимания, лёгкость горизонтального масштабирования, встроенная избыточность. Минусы: База данных становится узким местом, не помогает с проблемами хранения данных.

Шаблон 2: Разбиение сервисов (шардинг базы данных)

Когда ваша база данных не может справиться с нагрузкой, вы разделяете данные по нескольким базам данных. Возможно, идентификаторы пользователей 1–1M идут вShard A, 1M–2M идут в Shard B и так далее.

Трафик пользователей
    ↓
  Сервис маршрутизатора
   /    |    \
 S1→DB1 S2→DB2 S3→DB3

Пример кода: простой маршрутизатор шардов

class ShardRouter:
    def __init__(self, num_shards=3):
        self.num_shards = num_shards
        self.shards = [f"shard_{i}" for i in range(num_shards)]
    def get_shard(self, user_id):
        """Определить, к какому шард принадлежит пользователь"""
        shard_index = user_id % self.num_shards
        return self.shards[shard_index]
    def route_query(self, user_id, query):
        """Маршрутизация запроса к правильному шард"""
        shard = self.get_shard(user_id)
        # На самом деле вы бы подключились к фактической базе данных шард
        return f"Выполняется '{query}' на {shard}"
# Использование
router = ShardRouter(num_shards=4)
print(router.route_query(user_id=42, query="SELECT * FROM users"))
# Вывод: Выполняется 'SELECT * FROM users' на shard_2

Минусы: Сложная логика маршрутизации, перераспределение данных болезненно, запросы, охватывающие несколько шардов, дороги.

Шаблон 3: Событийно-ориентированная архитектура (шаблон Saga)

Вместо синхронного запроса-ответа вы используете асинхронные события. Сервис A делает что-то, публикует событие, сервис B реагирует на него.

Сервис заказов
     ↓ (событие OrderCreated)
Шина событий
     ↓
Платёжный сервис → Сервис инвентаризации → Сервис доставки

Это decouples сервисы и делает систему более устойчивой.

Шаблон 4: Двухфазный коммит (2PC)

Когда вам абсолютно необходимо обеспечить согласованность между несколькими базами данных, 2PC гарантирует, что либо все базы данных зафиксируют транзакцию, либо ни одна не зафиксирует.

Шаг за шагом:

  1. Фаза подготовки: Координатор спрашивает всех участников: «Можете ли вы зафиксировать это?».
  2. Каждый участник блокирует ресурсы и отвечает «да»/«нет».
  3. Фаза фиксации: Координатор говорит всем «продолжайте» или «отменить».
class TwoPhaseCommit:
    def __init__(self):
        self.participants = []
    def add_participant(self, name):
        self.participants.append(name)
    def execute_transaction(self, transaction_id):
        # Фаза 1: Подготовка
        can_commit = []
        for participant in self.participants:
            response = self._ask_prepare(participant, transaction_id)
            can_commit.append(response)
        # Фаза 2: Фиксация или отмена
        if all(can_commit):
            for participant in self.participants:
                self._commit(participant, transaction_id)
            return "SUCCESS"
        else:
            for participant in self.participants:
                self._abort(participant, transaction_id)
            return "ABORTED"
    def _ask_prepare(self, participant, txn_id):
        # Имитация запроса участнику, могут ли они зафиксировать
        return True
    def _commit(self, participant, txn_id):
        print(f"{participant}: COMMIT {txn_id}")
    def _abort(self, participant, txn_id):
        print(f"{participant}: ABORT {txn_id}")
# Использование
coordinator = TwoPhaseCommit()
coordinator.add_participant("Database_A")
coordinator.add_participant("Database_B")
coordinator.execute_transaction("txn_001")

Теорема CAP: выберите два из трёх

Вот неприятная правда: вы не можете иметь всё. Теорема CAP утверждает, что распределённые системы могут гарантировать только два из трёх свойств:

  • Согласованность: Каждый прочитанный ответ возвращает самое последнее изменение.
  • Доступность: Система всегда отвечает на запросы.
  • Устойчивость к разделению: Система работает, даже если происходят сетевые разделения.

Большинство систем выбирают устойчивость к разделению (сети будут сбоить, смиритесь с этим) и затем идут на компромисс между согласованностью и доступностью.

Примеры:

  • CP (Согласованность + Устойчивость к разделению): Традиционные SQL-базы данных — если происходит разделение сети, некоторые узлы перестают отвечать.
  • AP (Доступность + Устойчивость к разделению): NoSQL-базы данных, такие как DynamoDB — всегда отвечают, но вы можете прочитать устаревшие данные.
  • CA (Согласованность + Доступность): Одноуз