- Уровень 1: Фундамент
- Глава 1. Основы сетевого взаимодействия (Часть 1)
- Глава 1. Основы сетевого взаимодействия (Часть 2)
- Глава 2. Базы данных: SQL и NoSQL
- Глава 3. Масштабирование и Балансировка
- Уровень 2: Компоненты системы
- Глава 1. Кэширование (Caching)
- 1. Зачем нам кэш? Физика процесса
- 2. Уровни кэширования
- 3. Стратегии кэширования (Caching Patterns)
- 4. Политики вытеснения (Eviction Policies)
- 5. Проблема: Cache Invalidation (Инвалидация)
- 6. Практика: Реализация Cache-Aside на Python + Redis
- 7. Особые проблемы кэширования (Advanced)
- Итог Главы 1 (Уровень 2)
- Глава 2. Асинхронность и Очереди сообщений
- Глава 3. Прокси и CDN
- Глава 1. Кэширование (Caching)
- Уровень 3: Теория распределенных систем
- Глава 1. Теоремы CAP и PACELC. Доступность (SLA).
- Глава 2. Согласованность и Распределенные транзакции
- Глава 3. Алгоритмы Консенсуса
- Уровень 4: Продвинутые паттерны
- Глава 1. Микросервисы, API Gateway и Discovery
- Глава 2. Устойчивость: Circuit Breaker, Retry, Rate Limiting
- Глава 3. Observability: Логи, Метрики и Трассировка
- Уровень 5: Экспертные системы
- Глава 1. Batch vs Stream Processing. Архитектуры Lambda и Kappa.
- Глава 2. Time-Series, Гео-индексы и Полнотекстовый поиск
- Глава 3. Безопасность: OAuth 2.0, JWT и Шифрование
- Уровень 6: Практика и Реальный мир
- Глава 1. Фреймворк решения задач (System Design Framework)
- Шаг 1: Уточнение требований (Clarify Requirements) — 5-10 минут
- Шаг 2: Оценка нагрузок (Back-of-the-envelope calculations) — 5 минут
- Шаг 3: Высокоуровневый дизайн (High-Level Design) — 10-15 минут
- Шаг 4: Детальный дизайн (Deep Dive) — 15-20 минут
- Шаг 5: Поиск узких мест (Wrap up) — 5 минут
- Шпаргалка: Паттерны для разных типов задач
- Глава 1. Фреймворк решения задач (System Design Framework)
Прежде чем строить небоскреб, нужно понять, как работает кирпич и цемент.
- Модель OSI и TCP/IP: Понимание уровней (особенно L4 Transport и L7 Application).
- Протоколы: HTTP/1.1 vs HTTP/2 vs HTTP/3, HTTPS (TLS/SSL handshake), TCP vs UDP, DNS (как работает резолвинг).
- API Design:
- REST (ресурсы, методы, статус-коды).
- GraphQL (плюсы и минусы, проблема N+1).
- RPC (gRPC, Protocol Buffers).
- WebSockets (для real-time).
- Реляционные СУБД (RDBMS): PostgreSQL, MySQL. Нормализация, индексы (B-Tree), транзакции (ACID).
- NoSQL:
- Key-Value (Redis).
- Document (MongoDB).
- Column-family (Cassandra).
- Graph (Neo4j).
- SQL vs NoSQL: Критерии выбора.
- Vertical Scaling (Scale Up) vs Horizontal Scaling (Scale Out).
- Load Balancing (Балансировка нагрузки):
- L4 vs L7 балансировка.
- Алгоритмы: Round Robin, Least Connections, IP Hash.
- Software (Nginx, HAProxy) vs Hardware.
Инструментарий архитектора для решения типовых задач.
- Стратегии: Cache-Aside, Write-Through, Write-Back, Write-Around.
- Уровни кэширования: Client-side, CDN, Load Balancer, Server-side, Database.
- Проблемы: Cache Invalidation (инвалидация), Eviction policies (LRU, LFU), Thundering Herd problem.
- Message Queues: RabbitMQ, ActiveMQ. Модели Point-to-Point и Publish/Subscribe.
- Message Brokers vs Event Streaming: Разница между RabbitMQ и Apache Kafka.
- Обработка: Idempotency (идемпотентность — критически важно для очередей), Dead Letter Queues.
- Reverse Proxy vs Forward Proxy.
- CDN (Content Delivery Network): Принципы работы (Edge servers), Push vs Pull.
Здесь начинается настоящий System Design. Это то, что отличает Senior инженера.
- CAP Theorem: Consistency, Availability, Partition Tolerance. Почему нельзя получить все три.
- PACELC Theorem: Расширение CAP (учет Latency при отсутствии Partition).
- Availability Patterns: Fail-over (Active-Passive, Active-Active). Расчет доступности (SLA/SLO/SLI).
- Формула доступности: $$ Availability=\frac{Uptime}{Uptime+Downtime} $$
- Модели: Strong Consistency, Eventual Consistency, Causal Consistency.
- Distributed Transactions:
- Two-Phase Commit (2PC).
- Saga Pattern (Choreography vs Orchestration).
- Three-Phase Commit (3PC).
- Как узлы договариваются о правде?
- Paxos, Raft, Zab.
- Лидерские выборы (Leader Election).
- Gossip Protocols: (используется в Cassandra/Dynamo).
- Sharding (Шардирование): Горизонтальное разделение данных.
- Replication: Master-Slave, Master-Master.
- Consistent Hashing: Как добавлять/удалять узлы без перераспределения всех ключей (Ring architecture).
Как сделать так, чтобы система не упала, когда все идет не так.
- Decomposition: Как делить монолит (DDD — Domain Driven Design).
- Service Discovery: (Consul, Eureka, ZooKeeper).
- API Gateway: (Zuul, Kong) — единая точка входа, аутентификация, rate limiting.
- Circuit Breaker: Предотвращение каскадных сбоев.
- Bulkhead: Изоляция ресурсов.
- Retry & Exponential Backoff: Правильные стратегии повторных запросов.
- Rate Limiting: Алгоритмы (Token Bucket, Leaky Bucket, Sliding Window).
- Logging: Централизованный сбор логов (ELK Stack: Elasticsearch, Logstash, Kibana).
- Metrics: Prometheus, Grafana.
- Distributed Tracing: Jaeger, Zipkin (отслеживание запроса через 20 микросервисов).
Темы для проектирования специфических и сверхнагруженных систем.
- Batch Processing: MapReduce, Hadoop.
- Stream Processing: Apache Spark, Apache Flink, Kafka Streams.
- Lambda vs Kappa Architecture.
- Time-Series DB: (InfluxDB, Prometheus) — для метрик.
- Spatial DB: (PostGIS, Quadtree, Geohash) — для карт и Uber-like сервисов.
- Full-text Search: (Elasticsearch, Solr) — обратные индексы (Inverted Index).
- OAuth 2.0 / OIDC.
- JWT vs Session-based auth.
- Шифрование данных (At rest, In transit).
Применение знаний на классических задачах.
- Выяснение требований (функциональные / нефункциональные).
- Оценка нагрузок (Back-of-the-envelope calculations).
- Высокоуровневый дизайн.
- Детальный дизайн (узкие места).
- URL Shortener (TinyURL) — основы, хэширование, уникальные ID.
- Pastebin — хранение текста, clean-up policies.
- Design Instagram/Twitter — Fan-out on write vs Fan-out on read, лента новостей.
- Design WhatsApp/Telegram — вебсокеты, шифрование, история сообщений.
- Design Youtube/Netflix — хранение блобов, CDN, адаптивный битрейт.
- Design Uber/Grab — геохеширование, matching водителей.
- Web Crawler — многопоточность, дедупликация.
Если нужно выбрать только одну книгу, начните с первой:
- "Designing Data-Intensive Applications" (DDIA) — Martin Kleppmann. Это золотой стандарт. Must read.
- "System Design Interview – An insider's guide" (Vol 1 & 2) — Alex Xu. Отлично для подготовки к собеседованиям и понимания схем.
- "Microservices Patterns" — Chris Richardson.
====================
Прежде чем говорить о микросервисах, нужно понимать, как байты перемещаются по проводам. В System Design большинство проблем с производительностью (latency) и надежностью кроются именно здесь.
Существует теоретическая модель (OSI — 7 уровней) и практическая (TCP/IP — 4 уровня), на которой работает современный интернет. Для нас, как инженеров, критически важны два уровня: L4 (Transport) и L7 (Application).
| Уровень OSI | Название | Примеры протоколов | Задача в System Design |
|---|---|---|---|
| L7 (Application) | Прикладной | HTTP, FTP, SMTP, SSH, GraphQL | Логика приложения. Здесь работают балансировщики нагрузки L7 (Nginx), понимающие URL и заголовки. |
| L4 (Transport) | Транспортный | TCP, UDP | Доставка данных "процессу" на машине (порты). L4 балансировщики просто перекидывают пакеты, не читая содержимое. |
| L3 (Network) | Сетевой | IP (IPv4, IPv6), ICMP | Маршрутизация между сетями (IP-адреса). |
| L2 (Data Link) | Канальный | Ethernet, Wi-Fi (802.11) | Доставка внутри одной локальной сети (MAC-адреса). |
| L1 (Physical) | Физический | Кабель, радиоволны | Передача битов (0/1). |
Почему это важно: Когда вы слышите "L7 Load Balancer", вы должны понимать, что он "умный" (может расшифровать HTTPS, посмотреть Cookie), но медленный (требует CPU). "L4 Load Balancer" — глупый, но невероятно быстрый (просто пересылает пакеты).
Это основа транспортного уровня. Все прикладные протоколы (HTTP, DB connections) строятся поверх одного из них.
Это протокол с гарантированной доставкой.
- Ориентирован на соединение: Требует "рукопожатия" (3-way handshake) перед отправкой данных.
- Надежность: Гарантирует, что пакеты придут без потерь и в правильном порядке. Если пакет потерян, TCP отправит его снова (Retransmission).
- Flow Control: Если получатель не успевает, отправитель снижает скорость.
Применение: Веб-сайты (HTTP), базы данных, почта, передача файлов. Везде, где потеря одного байта критична.
Это протокол "выстрелил и забыл".
- Без соединения: Просто шлет пакеты на IP:Port.
- Ненадежность: Пакеты могут теряться, дублироваться или приходить в разном порядке. Протокол это не волнует.
- Скорость: Нет накладных расходов на рукопожатия и подтверждения.
Применение: Видеостриминг, онлайн-игры (шутеры), DNS, VoIP. Там, где лучше потерять кадр, чем ждать его повторную отправку (лаг).
Давайте посмотрим на код, чтобы понять разницу в реализации. Мы используем встроенную библиотеку socket.
Обратите внимание на процесс соединения.
TCP Server:
import socket
def start_tcp_server():
# AF_INET = IPv4, SOCK_STREAM = TCP
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Биндим сокет на localhost и порт 8080
server_socket.bind(('127.0.0.1', 8080))
# Переводим сокет в режим прослушивания.
# Аргумент 5 - размер очереди ожидающих подключений (backlog)
server_socket.listen(5)
print("TCP Server слушает на порту 8080...")
while True:
# accept() - БЛОКИРУЮЩИЙ вызов.
# Код останавливается здесь, пока не придет клиент.
# Возвращает НОВЫЙ объект сокета (client_sock) специально для этого клиента
# и адрес клиента (addr).
client_sock, addr = server_socket.accept()
print(f"Подключение от: {addr}")
# Получаем данные (макс 1024 байта)
data = client_sock.recv(1024)
print(f"Получено: {data.decode('utf-8')}")
# Отправляем ответ
client_sock.sendall(b"TCP Message Received!")
# В TCP соединение нужно закрывать
client_sock.close()
# Запуск (в реальности нужен if __name__ == '__main__':)
# start_tcp_server()
TCP Client:
import socket
def run_tcp_client():
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# КЛЮЧЕВОЕ ОТЛИЧИЕ: connect() инициирует 3-way handshake.
# SYN -> SYN-ACK -> ACK
client_socket.connect(('127.0.0.1', 8080))
client_socket.sendall(b"Hello TCP World")
response = client_socket.recv(1024)
print(f"Сервер ответил: {response.decode('utf-8')}")
client_socket.close()
Здесь нет listen, accept или connect.
UDP Server:
import socket
def start_udp_server():
# SOCK_DGRAM = UDP
server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_socket.bind(('127.0.0.1', 9090))
print("UDP Server готов принимать данные...")
while True:
# recvfrom() получает данные и адрес отправителя мгновенно.
# Нет создания отдельного канала связи.
data, addr = server_socket.recvfrom(1024)
print(f"Пришло от {addr}: {data.decode('utf-8')}")
# Отправляем ответ (sendto) конкретному адресу.
server_socket.sendto(b"UDP ACK", addr)
UDP Client:
import socket
def run_udp_client():
client_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# НЕТ connect(). Мы просто кидаем данные в "пустоту" по адресу.
# Если сервера нет, ошибка может даже не вернуться (зависит от настроек сети).
msg = b"Hello UDP World"
client_socket.sendto(msg, ('127.0.0.1', 9090))
# Ждем ответ (с таймаутом, т.к. гарантий нет)
client_socket.settimeout(2)
try:
data, server = client_socket.recvfrom(1024)
print(f"Ответ: {data.decode('utf-8')}")
except socket.timeout:
print("Ответ не пришел (пакет потерян или сервер лежит)")
client_socket.close()
DNS — это телефонная книга интернета. Она превращает google.com (человеческое имя) в 142.250.185.78 (машинный IP-адрес).
В System Design DNS важен по трем причинам:
- Latency: DNS-запрос занимает время (20-100+ мс).
- Geo-Routing: DNS может вернуть разный IP пользователю из США и пользователю из РФ (ближайший дата-центр).
- Availability: Если DNS упал — ваш сервис недоступен, даже если сервера работают.
Когда вы вводите www.example.com в браузере:
- Browser Cache: Браузер проверяет, заходил ли я сюда недавно? Если да — берет IP из памяти.
- OS Cache (hosts file): Операционная система проверяет свой кэш.
- Resolver (ISP): Запрос уходит провайдеру интернета (или Google DNS 8.8.8.8).
- Root Server (.): Резолвер спрашивает корневой сервер: "Кто отвечает за зону
.com?" - TLD Server (.com): Корневой отправляет к серверу доменной зоны
.com. Тот говорит: "Информацию оexample.comзнает серверns1.example.com". - Authoritative Name Server: Резолвер идет к
ns1и получает финальный IP.
- A (Address): Имя -> IPv4 (
example.com -> 1.2.3.4). - AAAA: Имя -> IPv6.
- CNAME (Canonical Name): Псевдоним.
m.google.com -> mobile.google.com.- Важно: CNAME требует дополнительного DNS-запроса (резолвинг псевдонима в реальное имя, потом имени в IP). Это чуть медленнее.
- MX (Mail Exchange): Куда слать почту.
- NS (Name Server): Кто обслуживает этот домен.
Это время, которое запись живет в кэше (у провайдера, в ОС).
- Высокий TTL (24 часа): Меньше нагрузки на DNS сервера, быстрее ответ для юзера. Но если вы сменили IP сервера, юзеры сутки будут стучаться на старый адрес.
- Низкий TTL (60 сек): Быстрая смена адресов (полезно для Failover), но выше нагрузка и latency.
Здесь мы разберем эволюцию HTTP, шифрование (HTTPS) и основные стили проектирования API. Это "язык", на котором говорят ваши микросервисы и фронтенд.
HTTP (HyperText Transfer Protocol) — это протокол прикладного уровня (L7).
- Текстовый формат: Данные передаются как текст (можно читать глазами через Sniffer).
- Keep-Alive: Позволяет использовать одно TCP-соединение для нескольких запросов (раньше для каждой картинки открывался новый сокет).
- Проблема: Head-of-Line (HOL) Blocking на уровне приложения. Если вы отправили запрос на картинку, а следом на CSS, и сервер завис на генерации картинки — CSS не загрузится, пока не придет картинка. Браузеры обходят это, открывая 6 параллельных TCP-соединений к одному домену.
- Бинарный протокол: Эффективнее парсится машинами, не читаем для человека без декодера.
- Multiplexing (Мультиплексирование): Одно TCP-соединение на всё. Запросы разбиваются на фреймы и летят вперемешку. Если картинка тормозит, CSS пролетит в соседних фреймах.
- Header Compression (HPACK): Заголовки (User-Agent, Cookies) сжимаются, экономя трафик.
- Проблема: HOL Blocking на уровне TCP. Если потерялся один TCP-пакет, операционная система тормозит все потоки внутри этого соединения, пока пакет не будет перепослан.
- Работает поверх UDP: Да, мы отказались от TCP, чтобы сделать свой надежный транспорт.
- Решение проблем: Если потерялся пакет одного потока (стрима), остальные потоки продолжают работать.
- Быстрый старт: Соединение устанавливается почти мгновенно (0-RTT или 1-RTT).
| Характеристика | HTTP/1.1 | HTTP/2 | HTTP/3 (QUIC) |
|---|---|---|---|
| Транспорт | TCP | TCP | UDP |
| Параллелизм | 6 соединений | 1 соединение (Мультиплекс) | 1 соединение (Мультиплекс) |
| Формат | Текст | Бинарный | Бинарный |
| HOL Blocking | Есть (App level) | Есть (TCP level) | Нет |
HTTPS = HTTP + TLS (Transport Layer Security).
В System Design важно понимать, что шифрование стоит ресурсов (CPU) и времени (Latency).
Прежде чем отправить байт данных, клиент и сервер должны договориться о шифре.
- Client Hello: "Я умею шифровать алгоритмами А и Б".
- Server Hello: "Выбираем А. Вот мой Сертификат (с Публичным ключом)".
- Key Exchange: Клиент проверяет сертификат, генерирует сессионный ключ, шифрует его публичным ключом сервера и отправляет серверу.
- Secure Connection: Теперь у обоих есть симметричный ключ, и они общаются шифрованно.
Важный паттерн: TLS Termination (SSL Offloading) Расшифровка трафика — тяжелая операция для CPU. В высоконагруженных системах её не делают на серверах приложений (Java/Python/Go). Решение: Ставят мощный Load Balancer (Nginx/HAProxy) или Cloud Load Balancer на входе. Он расшифровывает трафик (HTTPS) и передает его бэкенду уже по HTTP (внутри защищенного контура дата-центра). Это снимает нагрузку с бэкенда.
Выбор стиля API определяет производительность и удобство разработки.
Самый популярный подход. Всё есть "Ресурс".
- Методы: GET (читать), POST (создать), PUT (обновить полностью), PATCH (обновить частично), DELETE.
- Stateless: Сервер не хранит состояние клиента между запросами.
- Проблема: Over-fetching (получаем лишние поля) и Under-fetching (нужно делать 3 запроса, чтобы собрать данные пользователя, его посты и комментарии).
Пример (Python/Flask):
from flask import Flask, jsonify, request
app = Flask(__name__)
# База данных в памяти
users = {
1: {"id": 1, "name": "Alice", "role": "admin"},
2: {"id": 2, "name": "Bob", "role": "user"}
}
# GET - Получение ресурса
@app.route('/users/<int:user_id>', methods=['GET'])
def get_user(user_id):
user = users.get(user_id)
if user:
return jsonify(user), 200 # 200 OK
return jsonify({"error": "User not found"}), 404 # 404 Not Found
# POST - Создание ресурса
@app.route('/users', methods=['POST'])
def create_user():
data = request.json
new_id = len(users) + 1
# Валидация данных должна быть здесь
users[new_id] = {"id": new_id, "name": data['name'], "role": "user"}
# 201 Created + заголовок Location
return jsonify(users[new_id]), 201
if __name__ == '__main__':
app.run(port=5000)
Решает проблему гибкости выборки данных. Клиент сам говорит, что ему нужно.
- Один Endpoint: Обычно
/graphql(POST). - Схема: Строгая типизация.
- Минус: Сложнее кэшировать (так как все запросы POST и разные тела), можно легко "положить" базу сложным вложенным запросом.
Сравнение запроса: REST: GET /users/1 -> Возвращает ВСЁ (id, name, email, address, history...) GraphQL:
query {
user(id: 1) {
name # Я прошу ТОЛЬКО имя. Экономия трафика.
}
}
Используется для общения между микросервисами (Internal traffic).
- Идея: Вызов удаленной процедуры выглядит как вызов локальной функции
getUser(id). - Protocol Buffers: Бинарный формат сериализации от Google. Данные весят в разы меньше JSON и сериализуются быстрее.
- Транспорт: Работает поверх HTTP/2.
Когда использовать:
- REST/GraphQL: Для общения с фронтендом (Public API).
- gRPC: Для общения микросервисов друг с другом (низкая задержка, компактность).
Для Real-time приложений (чаты, биржевые котировки, игры).
- Принцип: Клиент отправляет HTTP-запрос с заголовком
Upgrade: websocket. Сервер соглашается, и соединение превращается в постоянный двусторонний канал TCP.
Пример (Python/websockets):
# Асинхронный сервер
import asyncio
import websockets
connected_clients = set()
async def chat_handler(websocket):
# Регистрация клиента
connected_clients.add(websocket)
try:
async for message in websocket:
# Получили сообщение от одного, рассылаем всем (Broadcasting)
print(f"Received: {message}")
for client in connected_clients:
if client != websocket: # Не отправляем самому себе
await client.send(f"New msg: {message}")
except websockets.exceptions.ConnectionClosed:
pass
finally:
# Клиент отключился
connected_clients.remove(websocket)
async def main():
async with websockets.serve(chat_handler, "localhost", 8765):
await asyncio.Future() # run forever
# Запуск: asyncio.run(main())
Мы прошли путь от кабеля до API.
- L4 vs L7: Понимаем разницу между пересылкой пакетов и логикой приложения.
- TCP/UDP: Надежность против скорости.
- DNS: Как работает адресация.
- HTTP/HTTPS: Эволюция протоколов и шифрование.
- API: REST (стандарт), GraphQL (гибкость), gRPC (скорость внутри), WebSockets (реал-тайм).
Примеры: PostgreSQL, MySQL, Oracle, MS SQL.
Это классика. Данные хранятся в строгих таблицах (строки и столбцы), связанных между собой (Relations).
Если вы строите банковскую систему, вам нужна гарантия, что деньги не исчезнут в воздухе. Эту гарантию дает стандарт ACID.
- A — Atomicity (Атомарность): "Всё или ничего". Транзакция (группа операций) либо выполняется целиком, либо не выполняется вовсе.
- Пример: Перевод денег. Снять со счета А и зачислить на счет Б. Если "снять" удалось, а на "зачислить" сервер упал — база данных отменит (Rollback) снятие. Деньги вернутся.
- C — Consistency (Согласованность): Данные всегда соответствуют правилам (constraints). Нельзя записать текст в поле для даты или нарушить уникальность ID.
- I — Isolation (Изолированность): Параллельные транзакции не должны мешать друг другу.
- Проблема: Если два юзера одновременно покупают последний билет, база должна выстроить их в очередь.
- D — Durability (Долговечность): Если база сказала "ОК" (commit), значит данные записаны на жесткий диск. Даже если через миллисекунду выключат электричество, данные выживут.
Почему поиск в базе быстрый? Без индекса базе пришлось бы читать всю таблицу (Full Table Scan) — это
Самая популярная структура данных для индексов в SQL — B-Tree (Сбалансированное дерево).
- Как это работает: Данные отсортированы в дерево. Чтобы найти число 50, мы идем от корня: 50 больше 30? Идем направо. Меньше 70? Идем налево.
- Цена индекса:
- Чтение: Очень быстро.
- Запись: Медленнее. При каждой вставке (
INSERT) нужно перебалансировать дерево индексов. - Место: Индексы занимают место на диске.
Примеры: MongoDB, Redis, Cassandra, Neo4j.
NoSQL возникли, когда данные стали слишком большими (Big Data) или слишком разнообразными для таблиц. Они часто жертвуют ACID ради скорости и масштабируемости.
Существует 4 основных типа NoSQL. В интервью нужно четко знать, какую когда брать.
- Пример: Redis, Memcached, DynamoDB.
- Суть: Огромная хэш-таблица. Есть ключ (ID) — есть значение (blob).
- Сценарий: Кэширование, сессии пользователей, корзина покупок.
-
Скорость: Самая высокая (
$O\left(1\right)$ ).
- Пример: MongoDB, CouchDB.
- Суть: Храним данные в формате JSON (BSON). Нет жесткой схемы. У одного юзера есть поле "хобби", у другого нет — и это нормально.
- Сценарий: CMS, каталоги товаров (где у утюга и ноутбука разные характеристики), профили пользователей.
- Пример: Cassandra, HBase.
- Суть: Данные хранятся не по строкам, а по колонкам. Оптимизировано под огромную скорость записи и чтение больших объемов данных.
- Сценарий: Логи, история чатов (Discord использует Cassandra), метрики, IoT.
- Архитектура: Обычно используют LSM-Tree (Log-Structured Merge-tree) вместо B-Tree, что позволяет писать данные последовательно, не прыгая по диску.
- Пример: Neo4j.
- Суть: Хранят узлы и связи между ними как физические ссылки.
- Сценарий: Социальные сети ("друзья друзей"), рекомендательные системы, построение маршрутов. SQL очень плох в связях "многие-ко-многим" на большую глубину (слишком много JOIN-ов).
Это классический вопрос на System Design Interview.
| Характеристика | SQL (RDBMS) | NoSQL |
|---|---|---|
| Структура данных | Строгая схема (Schema-on-write). Сначала создай таблицу, потом пиши. | Гибкая схема (Schema-on-read). Пиши что угодно. |
| Связи (Relations) | Отлично (JOINs). | Плохо или отсутствуют. JOIN приходится делать в коде приложения. |
| Масштабирование | Вертикальное (Scale Up): Нужен сервер мощнее (больше RAM/CPU). Горизонтально (Sharding) — сложно. | Горизонтальное (Scale Out): Легко добавить 10 дешевых серверов. Изначально созданы для кластеров. |
| Транзакции | ACID (строгие). | BASE (Basically Available, Soft state, Eventual consistency). Часто жертвуют мгновенной согласованностью. |
| Оптимально для | Финансы, биллинг, структурированные данные, сложные отчеты. | Big Data, Real-time аналитика, контент, быстро меняющиеся данные. |
Посмотрим, как отличается работа с данными на уровне кода (Python).
Мы обязаны сначала определить структуру.
import sqlite3
# Подключение к файловой БД
conn = sqlite3.connect('example.db')
cursor = conn.cursor()
# 1. Жесткое создание схемы
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL,
email TEXT
)
''')
# 2. Транзакция
try:
# Вставка данных
cursor.execute("INSERT INTO users (username, email) VALUES ('alice', 'alice@ya.ru')")
# Симуляция ошибки: пытаемся вставить строку вместо числа (если бы не было проверок драйвера)
# или нарушить UNIQUE constraint.
# Фиксация изменений (Commit)
conn.commit()
print("User Alice saved securely.")
except Exception as e:
# Откат изменений при ошибке (Rollback)
conn.rollback()
print(f"Error: {e}. Transaction rolled back.")
conn.close()
Мы можем менять структуру на лету.
# Представим, что мы используем pymongo
# db = client.my_database
user_doc_1 = {
"username": "bob",
"email": "bob@gmail.com",
"age": 25
}
# Вставка первого документа
# db.users.insert_one(user_doc_1)
# Вставка второго документа СОВСЕМ ДРУГОЙ СТРУКТУРЫ
user_doc_2 = {
"username": "charlie",
"skills": ["python", "system design"], # Массив! В SQL нужна была бы отдельная таблица.
"address": { # Вложенный объект!
"city": "Moscow",
"zip": 123456
}
}
# MongoDB съест это без проблем
# db.users.insert_one(user_doc_2)
Мы научились хранить данные.
- ACID — это про надежность (SQL).
- B-Tree — это про быстрый поиск в SQL.
- NoSQL — это про гибкость и масштаб.
- Если данные связаны и важна транзакционность — берем PostgreSQL.
- Если данные — это огромный поток логов или гибкие документы — смотрим в сторону Cassandra или MongoDB.
Когда ваш сервер начинает "захлебываться" (CPU 100%, RAM заканчивается), у вас есть два пути.
Вы покупаете сервер мощнее. Заменяете 4 ядра на 64, 8 ГБ RAM на 1 ТБ.
- Плюсы:
- Простота. Не нужно менять код.
- Не нужны сложные настройки сети.
- Минусы:
- Hard Limit: У любого железа есть потолок. Нельзя купить процессор с бесконечной частотой.
- SPOF (Single Point of Failure): Единая точка отказа. Если этот супер-сервер сгорит, вы оффлайн.
- Цена: Мощное железо стоит экспоненциально дороже.
Вы покупаете много дешевых серверов и объединяете их в кластер.
- Плюсы:
- Бесконечный рост: Нужно больше мощности? Добавь еще 10 машин.
- Отказоустойчивость: Сгорел один сервер? Остальные 9 подхватят нагрузку.
- Минусы:
- Сложность: Нужно управлять множеством машин.
- Распределенность: Данные теперь лежат в разных местах (проблема консистентности).
В System Design мы почти всегда стремимся к Горизонтальному масштабированию.
Чтобы горизонтальное масштабирование работало, ваши сервера приложений должны быть Stateless (без сохранения состояния).
- Stateful (Плохо для скейлинга): Сервер А хранит сессию пользователя (логин, корзину) у себя в оперативной памяти. Если следующий запрос пользователя придет на Сервер Б, тот не узнает пользователя.
- Stateless (Хорошо): Сервер не хранит данные о клиенте между запросами.
- Вариант 1: Данные передаются в каждом запросе (например, JWT токен).
- Вариант 2: Данные хранятся во внешнем хранилище (Shared State), например, в Redis или Базе Данных. Любой сервер может обратиться к Redis и узнать состояние пользователя.
Когда у вас 10 серверов, кто-то должен решать, на какой из них отправить пришедшего пользователя. Этим занимается Load Balancer (LB).
Он стоит на входе в вашу систему (как регулировщик) и распределяет трафик.
- Software (Программные): Nginx, HAProxy. Дешево, гибко, ставится на обычные сервера. Самый частый выбор.
- Hardware (Аппаратные): F5, Citrix. Специальные "железные коробки". Невероятно быстрые, но очень дорогие.
- Cloud: AWS ELB/ALB, Google Cloud Load Balancing. Облака делают всё за вас.
Как LB выбирает сервер?
-
Round Robin (Карусель):
- Запрос 1 -> Сервер А
- Запрос 2 -> Сервер Б
- Запрос 3 -> Сервер В
- Запрос 4 -> Сервер А
- Плюс: Простота. Минус: Не учитывает нагрузку (может отправить тяжелый запрос на уже загруженный сервер).
-
Least Connections (Наименьшее число соединений):
- LB смотрит, у кого сейчас меньше всего активных соединений, и шлет туда.
- Идеально для: Долгих сессий (WebSocket, стриминг).
-
IP Hash:
- Берется IP пользователя, хэшируется, и результат привязывается к серверу.
- $$ ServerIndex=hash\left(ClientIP\right)%N $$
- Эффект: Пользователь Вася (IP 1.2.3.4) всегда попадает на Сервер А. Это называется Sticky Session.
Балансировщик должен быть умным. Если Сервер Б "умер" (завис, сгорел диск), LB должен перестать слать туда трафик.
- Passive Check: Если LB отправил запрос на сервер, а тот ответил ошибкой (500) или не ответил (Timeout), LB помечает его как "больной".
- Active Check: LB сам периодически (раз в 5 сек) пингует специальный endpoint (
/health), чтобы убедиться, что сервер жив.
В реальной жизни вы редко пишете балансировщик на Python, вы настраиваете Nginx. Но чтобы понять логику, давайте посмотрим на оба варианта.
Представьте, что это код внутри балансировщика.
import itertools
class LoadBalancer:
def __init__(self, servers):
self.servers = servers
# itertools.cycle создает бесконечный итератор: [A, B, C, A, B, C...]
self._cycle = itertools.cycle(servers)
def get_server(self):
# next() берет следующий элемент и сдвигает указатель
return next(self._cycle)
# Список IP наших бэкендов
backend_servers = ["10.0.0.1", "10.0.0.2", "10.0.0.3"]
lb = LoadBalancer(backend_servers)
# Симуляция прихода 5 запросов
for request_id in range(1, 6):
server = lb.get_server()
print(f"Request {request_id} -> sent to {server}")
Это то, что вы увидите в продакшене.
http {
# Группа серверов (наши бэкенды)
upstream my_backend_app {
# По умолчанию используется Round Robin
# Можно добавить weight=2, чтобы сервер получал в 2 раза больше запросов (если он мощнее)
server 10.0.0.1:8080;
server 10.0.0.2:8080;
# Если сервер не отвечает 3 раза подряд (max_fails) в течение 30 сек,
# он считается мертвым.
server 10.0.0.3:8080 max_fails=3 fail_timeout=30s;
}
server {
listen 80;
server_name example.com;
location / {
# Проксируем запрос на группу 'my_backend_app'
proxy_pass http://my_backend_app;
# Передаем реальный IP пользователя, иначе бэкенд будет видеть IP балансировщика
proxy_set_header X-Real-IP $remote_addr;
}
}
}
Важный нюанс: Когда вы ставите балансировщик, ваши сервера приложений перестают видеть реальный IP пользователя (они видят IP балансировщика). Поэтому критически важно передавать заголовок
X-Forwarded-ForилиX-Real-IP, как в примере выше.
Теперь наша архитектура выглядит так:
- Пользователи ➔ DNS ➔ Load Balancer (Nginx).
- Load Balancer распределяет нагрузку между Кластером серверов приложений (Stateless).
- Сервера приложений сохраняют данные в Кластер Баз Данных (Primary/Replica) и кэш в Redis.
=============================
Кэширование — это способ обменять емкость памяти на скорость. Базы данных (MySQL/PostgreSQL) хранят данные на диске (SSD/HDD). Диск — это надежно, но медленно. Кэш (Redis/Memcached) хранит данные в оперативной памяти (RAM). RAM — это ненадежно (выключил свет — данные пропали), но экстремально быстро.
- Чтение с диска (SSD): ~1-4 миллисекунды.
- Чтение из RAM: ~100-200 наносекунд.
- Разница: В тысячи раз.
В System Design интервью важно уточнять, где именно вы кэшируете.
- Client-Side (Браузер): HTTP заголовки (
Cache-Control: max-age=3600). Браузер даже не отправляет запрос на сервер, если картинка закэширована. - CDN (Content Delivery Network): Кэширование статики (картинки, JS, видео) на серверах, географически близких к пользователю.
- Load Balancer / Reverse Proxy: Nginx может кэшировать ответы бэкенда на короткое время.
- Distributed Cache (Распределенный кэш): Redis или Memcached. Именно здесь происходит основная магия бэкенда.
- Database Caching: У базы данных есть свой внутренний буфер (Buffer Pool), куда она складывает "горячие" данные.
Как именно приложение взаимодействует с кэшем и БД? Это ключевой вопрос архитектуры.
Приложение "лениво" загружает данные в кэш.
- Приложение получает запрос на данные (например,
user_id=1). - Приложение идет в Кэш.
- Hit: Данные есть? Отлично, возвращаем пользователю.
- Miss: Данных нет? Приложение идет в Базу Данных.
- Приложение читает из БД, записывает результат в Кэш и возвращает пользователю.
- Плюсы: В кэше лежит только то, что реально запрашивают. Если Redis упадет, система продолжит работать (просто медленнее, напрямую с БД).
- Минусы: Первый запрос всегда медленный (нужно сходить в БД). Данные в кэше могут устареть, если их изменили в БД напрямую.
Приложение пишет данные в кэш, а кэш синхронно пишет их в БД.
- Приложение сохраняет данные в Кэш.
- Кэш сохраняет данные в БД.
- Возвращаем "ОК" только когда записалось и туда, и туда.
- Плюсы: В кэше всегда свежие данные (Strong Consistency). Нет риска потери данных.
- Минусы: Запись медленная (ждем самую медленную часть — БД).
Приложение пишет только в кэш и сразу говорит "ОК". Кэш асинхронно (в фоне) сбрасывает данные в БД.
- Плюсы: Мгновенная запись.
- Минусы: Риск потери данных. Если кэш упадет до того, как успеет сохранить данные на диск, они исчезнут навсегда. Используется для некритичных данных (например, счетчик лайков).
Кэш (RAM) дорог и ограничен. Когда память заканчивается, нужно что-то удалить, чтобы записать новое. Как выбрать жертву?
- LRU (Least Recently Used): Удаляем то, к чему дольше всего не обращались. Самый популярный алгоритм. Основан на гипотезе: если данные нужны были недавно, они понадобятся снова.
- LFU (Least Frequently Used): Удаляем то, к чему обращаются реже всего (счетчик популярности).
- FIFO (First In, First Out): Удаляем самое старое, неважно, насколько оно популярно.
Как сделать так, чтобы данные в кэше не протухали? Пример: Мы поменяли цену товара в БД с $10 на $20, а в кэше все еще лежит $10. Пользователь видит старую цену.
Методы решения:
- TTL (Time To Live): Данные автоматически удаляются через N секунд (например, 5 минут). Это "Eventual Consistency".
- Explicit Deletion (Явное удаление): При обновлении данных в БД приложение обязано удалить ключ из кэша.
- Совет: Лучше удалять ключ (
delete), чем обновлять его (set). Это избавляет от гонок (Race Conditions).
- Совет: Лучше удалять ключ (
В Python для работы с кэшем часто используют паттерн Декоратор. Это позволяет добавить кэширование к любой функции, не меняя её код.
Представим, что у нас есть медленная функция get_user_profile_from_db.
import redis
import time
import json
from functools import wraps
# Подключение к Redis (локально или в Docker)
# decode_responses=True позволяет получать строки вместо байтов
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
def cache_response(ttl_seconds=60):
"""
Декоратор для Cache-Aside паттерна.
:param ttl_seconds: Время жизни кэша (Time To Live)
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# 1. Генерируем уникальный ключ для кэша на основе аргументов функции
# Например: "user_profile:123"
cache_key = f"{func.__name__}:{args[0]}"
# 2. Пытаемся прочитать из Redis (HIT)
cached_value = r.get(cache_key)
if cached_value:
print(f"[CACHE HIT] Читаем из памяти: {cache_key}")
# Десериализуем JSON обратно в словарь
return json.loads(cached_value)
# 3. Если в кэше нет (MISS), выполняем реальную функцию (идем в БД)
print(f"[CACHE MISS] Идем в медленную БД...")
result = func(*args, **kwargs)
# 4. Сохраняем результат в Redis с TTL
# Сериализуем словарь в JSON строку
r.setex(
name=cache_key,
time=ttl_seconds,
value=json.dumps(result)
)
return result
return wrapper
return decorator
# --- Имитация работы приложения ---
# Симуляция БД
database = {
1: {"name": "Alice", "balance": 100},
2: {"name": "Bob", "balance": 500}
}
@cache_response(ttl_seconds=5) # Кэш живет 5 секунд
def get_user_from_db(user_id):
# Имитация долгого запроса
time.sleep(2)
return database.get(user_id)
# --- Тест ---
print("--- Запрос 1 (Кэш пуст) ---")
start = time.time()
print(get_user_from_db(1))
print(f"Время: {time.time() - start:.2f} сек\n")
print("--- Запрос 2 (Данные в кэше) ---")
start = time.time()
print(get_user_from_db(1))
print(f"Время: {time.time() - start:.2f} сек (Мгновенно!)\n")
print("--- Ждем 6 секунд (TTL истекает)... ---")
time.sleep(6)
print("--- Запрос 3 (Кэш протух) ---")
start = time.time()
print(get_user_from_db(1))
print(f"Время: {time.time() - start:.2f} сек")
- Сериализация: Redis хранит строки или байты. Сложные объекты (dict, list) нужно превращать в JSON или Pickle.
- Генерация ключа: Ключ должен быть уникальным для каждого набора аргументов.
user:1иuser:2— это разные записи. setex: Атомарная операция "записать + поставить таймер". Очень важно использовать её, а не две команды (setпотомexpire), чтобы не случилось так, что запись создалась, а таймер не поставился (и запись осталась навечно).
На собеседованиях Senior уровня спрашивают про эти сценарии сбоев:
- Cache Penetration (Пробой кэша): Злоумышленники запрашивают ключи, которых не существует ни в кэше, ни в БД (например,
id=-1). Запросы постоянно пролетают сквозь кэш и бьют по БД.- Решение: Кэшировать "пустой ответ" (null) с коротким TTL или использовать Bloom Filter.
- Cache Avalanche (Лавина кэша): Множество ключей протухают одновременно (например, вы поставили всем TTL ровно 1 час). В 12:00 кэш пустеет, и БД падает от нагрузки.
- Решение: Добавлять "Jitter" (случайный разброс) к TTL. Не 60 минут, а 60 минут ± 5 минут.
- Thundering Herd (Эффект толпы): Один очень популярный ключ (например, главная новость) протухает. 10,000 пользователей одновременно видят
MISSи одновременно идут в БД за одной и той же записью.- Решение: Механизм блокировок (Mutex). Только один процесс идет обновлять кэш, остальные ждут.
Кэш — это лучший друг производительности.
- Мы используем Cache-Aside для большинства задач чтения.
- Мы используем Redis.
- Мы помним про TTL и стратегию LRU.
- Мы боимся "Лавины" и добавляем случайность во время жизни кэша.
- Синхронно (Request-Response): Я звоню вам по телефону. Я жду на линии, пока вы не поднимете трубку. Если вы заняты, я вишу и жду.
- Пример: REST API запрос
POST /buy.
- Пример: REST API запрос
- Асинхронно (Fire-and-Forget): Я отправляю вам email. Я нажимаю "Отправить" и иду заниматься своими делами. Вы прочитаете, когда сможете.
- Пример: Очереди сообщений.
В System Design мы используем очереди, чтобы развязать (decouple) компоненты. Фронтенд не должен зависеть от скорости работы бэкенда по обработке видео.
- Producer (Производитель): Тот, кто создает задачу (например, Веб-сервер).
- Message Broker (Брокер): "Почтовое отделение". Сервер, который принимает сообщения, хранит их и отдает. Самые популярные: RabbitMQ, Kafka, AWS SQS.
- Queue (Очередь): Буфер внутри брокера, где лежат сообщения.
- Consumer (Потребитель / Воркер): Тот, кто разгребает завалы. Фоновый процесс, который берет задачу из очереди и выполняет её.
- Сценарий: У нас 100 задач и 5 воркеров.
- Логика: Одно сообщение доставляется ровно одному потребителю. Воркеры конкурируют за задачи. Это способ балансировки нагрузки для тяжелых задач.
- Пример: RabbitMQ (Direct exchange). Обработка заказов, ресайз картинок.
- Сценарий: Пользователь загрузил видео. Нужно: 1) Сжать видео, 2) Обновить поиск, 3) Отправить пуш подписчикам.
- Логика: Одно сообщение копируется во все подписанные очереди. Producer не знает, кто его слушает.
- Пример: RabbitMQ (Fanout exchange), Kafka (Consumer Groups).
На собеседовании это обязательный вопрос. Это совершенно разные звери.
| Характеристика | RabbitMQ (Traditional Queue) | Apache Kafka (Event Streaming) |
|---|---|---|
| Модель | Smart Broker, Dumb Consumer. Брокер сам следит, кому отдал сообщение, и удаляет его после обработки. | Dumb Broker, Smart Consumer. Брокер — это просто лог (журнал), который хранит всё подряд. Потребитель сам помнит, на какой строчке он остановился (Offset). |
| Хранение данных | Сообщение удаляется сразу после подтверждения (ACK). Очередь должна быть пустой. | Сообщения хранятся днями или неделями (Retention Policy). Можно "перемотать" время назад и обработать заново. |
| Производительность | 10к - 50к сообщений/сек. | Миллионы сообщений/сек (Высокая пропускная способность). |
| Сценарий | Сложная маршрутизация, задачи "сделай и удали" (email, PDF). | Сбор логов, аналитика кликов, Event Sourcing, стриминг данных. |
Правило большого пальца:
- Нужна сложная логика маршрутизации или задача должна исчезнуть после выполнения? -> RabbitMQ.
- Нужно переваривать огромный поток данных (Big Data) или хранить историю событий? -> Kafka.
В очередях, как и в сети, всё может сломаться. Воркер может упасть прямо во время обработки задачи.
Как брокер узнает, что воркер не просто взял задачу, а успешно выполнил её?
- Брокер выдает задачу воркеру.
- Воркер делает работу.
- Воркер отправляет ACK брокеру.
- Только тогда брокер удаляет сообщение из очереди. Если ACK не пришел (таймаут или разрыв соединения), брокер отдает задачу другому воркеру.
Что если задача "битая"? Воркер берет задачу, падает с ошибкой, задача возвращается в очередь. Другой воркер берет её и тоже падает. Это "Poison Message" (ядовитое сообщение), которое может зациклить всю систему. Решение: После N неудачных попыток сообщение перекладывается в отдельную очередь Dead Letter Queue. Инженеры потом разбирают эту очередь вручную.
Из-за механизма Retry (повторов) одно и то же сообщение может быть доставлено дважды. Пример: Воркер списал деньги, но перед отправкой ACK упал интернет. Брокер думает, что задача не сделана, и отдает её второму воркеру. Второй воркер снова списывает деньги. Решение: Операции должны быть идемпотентны.
- Плохо:
UPDATE accounts SET balance = balance - 100(при повторе спишет 200). - Хорошо: Проверить
transaction_idв БД перед списанием. Если такой ID уже обработан — просто вернуть OK.
Мы реализуем классическую модель Worker Queue.
Вам понадобится запущенный RabbitMQ (обычно через Docker: docker run -p 5672:5672 rabbitmq).
Веб-сервер, который принимает заказ.
import pika
import sys
# 1. Подключение к брокеру
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
# 2. Объявление очереди (durable=True значит, что очередь переживет перезагрузку RabbitMQ)
channel.queue_declare(queue='task_queue', durable=True)
message = "Process Order #12345 (Resize Image)"
# 3. Публикация сообщения
channel.basic_publish(
exchange='', # Дефолтный exchange
routing_key='task_queue',
body=message,
properties=pika.BasicProperties(
delivery_mode=2, # Сделать сообщение персистентным (сохранить на диск)
))
print(f" [x] Sent '{message}'")
connection.close()
Фоновый скрипт. Можно запустить 5 копий этого скрипта в разных терминалах.
import pika
import time
def main():
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
channel.queue_declare(queue='task_queue', durable=True)
print(' [*] Waiting for messages. To exit press CTRL+C')
# Логика обработки
def callback(ch, method, properties, body):
print(f" [x] Received {body.decode()}")
# Симуляция тяжелой работы (resize, email sending)
time.sleep(5)
print(" [x] Done")
# ВАЖНО: Ручной ACK.
# Подтверждаем выполнение ТОЛЬКО после завершения работы.
ch.basic_ack(delivery_tag=method.delivery_tag)
# Fair Dispatch: Не давать воркеру больше 1 задачи за раз, пока он не сделал ACK.
# Это предотвращает перегрузку одного воркера, пока другие простаивают.
channel.basic_qos(prefetch_count=1)
channel.basic_consume(queue='task_queue', on_message_callback=callback)
channel.start_consuming()
if __name__ == '__main__':
main()
Что здесь происходит:
- Если вы запустите два воркера и отправите 5 сообщений, RabbitMQ распределит их по кругу (Round Robin).
- Если вы убьете воркер (
Ctrl+C) во время выполненияtime.sleep, вы увидите, что сообщение не пропало! RabbitMQ заметит разрыв соединения (так как ACK не пришел) и отдаст задачу другому живому воркеру.
Очереди — это кровеносная система асинхронной архитектуры.
- RabbitMQ для задач (Task Queue), Kafka для потоков (Streaming).
- Всегда используйте ACK, чтобы не терять задачи.
- Всегда думайте об Идемпотентности, чтобы не списать деньги дважды.
- Очереди сглаживают пиковые нагрузки (Traffic Spikes), позволяя воркерам работать в своем темпе.
В System Design часто путают Forward Proxy и Reverse Proxy. Разница фундаментальна.
Действует от имени Клиента.
- Сценарий: Вы в офисе. Корпоративный админ закрыл доступ к YouTube. Весь трафик из офиса идет через один сервер (Proxy), который фильтрует запросы.
- Для сервера: Сервер (например, Google) не знает, кто вы. Он видит только IP офисного прокси.
- Задачи: Обход блокировок (VPN), кэширование трафика организации, скрытие IP клиента.
Действует от имени Сервера. Это наш случай.
- Сценарий: Пользователь заходит на
google.com. Он попадает не на конкретный сервер с кодом, а на огромный входной шлюз. - Для клиента: Клиент не знает, что за этим прокси скрывается 10,000 серверов. Он думает, что общается с одним.
- Примеры: Nginx, HAProxy, AWS CloudFront.
Даже если у вас всего один сервер с приложением (например, Node.js или Python), перед ним всегда ставят Nginx. Зачем?
- SSL Termination: Расшифровка HTTPS требует много CPU. Пусть Nginx делает это быстро на C++, а бэкенд получает уже чистый HTTP и занимается бизнес-логикой.
- Security: Бэкенд никогда не "торчит" в интернет напрямую. Прокси скрывает топологию внутренней сети.
- Compression: Сжатие ответов (Gzip/Brotli) делает прокси, разгружая приложение.
- Static Content: Отдавать картинки/CSS через Python/Java — это преступление против производительности. Nginx делает это через системный вызов
sendfile()почти мгновенно.
CDN — это географически распределенная сеть серверов.
- Origin (Источник): Ваш основной сервер, где лежат оригиналы файлов.
- Edge Servers (Граничные сервера): Тысячи серверов, разбросанных по всему миру (у провайдеров, в точках обмена трафиком).
- Пользователь из Австралии заходит на ваш сайт.
- Он запрашивает картинку
logo.png. - DNS направляет его не на ваш сервер в Москве, а на ближайший Edge-сервер в Сиднее.
- Cache Miss: Если в Сиднее картинки нет, Edge скачивает её у вас (Origin), сохраняет себе и отдает пользователю.
- Cache Hit: Следующий пользователь из Австралии получит картинку мгновенно из памяти сервера в Сиднее.
Это важный вопрос при проектировании. Как контент попадает на CDN?
| Стратегия | Pull (Reactive) | Push (Proactive) |
|---|---|---|
| Принцип | Edge скачивает файл с Origin только когда первый пользователь его запросил. | Вы загружаете файлы на CDN заранее, до того как кто-то их попросит. |
| Плюсы | Простота. Хранится только то, что реально нужно (экономия места). | Мгновенная доступность для первого пользователя. |
| Минусы | Первый пользователь ждет дольше (Latnecy). Возможен всплеск трафика на Origin при вирусном контенте. | Нужно самому следить за загрузкой. Дороже хранить "мусор", который никто не смотрит. |
| Когда использовать | Маленькие блоги, сайты с большим архивом старых картинок. | Netflix, релиз патча для игры, ПО (когда точно известно, что качать будут все). |
Давайте посмотрим на "золотой стандарт" конфигурации для высоконагруженного сервиса. Это файл nginx.conf.
Обратите внимание на комментарии — в них вся суть оптимизации.
worker_processes auto; # Использовать все ядра CPU
events {
worker_connections 1024; # Сколько соединений держит один процесс
}
http {
# Включаем sendfile. Это позволяет копировать данные с диска в сеть
# прямо внутри ядра OS, не копируя их в память приложения.
# Критично для отдачи статики!
sendfile on;
# Сжатие Gzip (экономим трафик клиента)
gzip on;
gzip_types text/plain application/json text/css;
# Определяем наш бэкенд (Load Balancing)
upstream my_app {
server 127.0.0.1:8000;
server 127.0.0.1:8001;
}
server {
listen 443 ssl; # Слушаем HTTPS
server_name example.com;
# SSL сертификаты (SSL Termination)
ssl_certificate /etc/nginx/cert.pem;
ssl_certificate_key /etc/nginx/key.pem;
# 1. Отдача статики (CDN для бедных)
# Nginx сам отдаст картинки с диска, не трогая Python/Java
location /static/ {
root /var/www/html;
expires 30d; # Заголовок для браузера: "кэшируй это на 30 дней"
add_header Cache-Control "public, no-transform";
}
# 2. Проксирование на приложение
location / {
proxy_pass http://my_app;
# Передача заголовков (чтобы бэкенд знал реальный IP)
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Таймауты (если бэкенд завис, Nginx не будет ждать вечно)
proxy_read_timeout 10s;
}
}
}
Раньше CDN кэшировали только статику (картинки, JS). Сейчас современные CDN (Cloudflare, AWS CloudFront) умеют кэшировать и динамику (HTML страницы), и даже выполнять код (Edge Computing).
- Edge Functions (Lambda @ Edge): Вы можете написать код, который выполняется прямо на CDN сервере.
- Пример: Проверка авторизации пользователя. Если токен протух, CDN сам отправит на логин, даже не пуская запрос к вашему основному серверу. Это колоссально снижает нагрузку.
=======================================
Автор: Эрик Брюер (2000 год).
Теорема гласит, что распределенная система может гарантировать только два из трех свойств одновременно:
- C — Consistency (Согласованность):
- Определение: Все узлы видят одни и те же данные в одно и то же время.
- Пример: Вы положили 100 рублей на счет. Если вы тут же запросите баланс с другого сервера в другом городе, вы должны увидеть эти 100 рублей. Если сервер еще не знает об этом — он должен вернуть ошибку или ждать, но не показывать старый баланс.
- A — Availability (Доступность):
- Определение: Каждый запрос получает успешный ответ (без ошибок), но без гарантии, что данные самые свежие.
- Пример: Лента новостей. Если один сервер не синхронизировался, лучше показать старые посты, чем ошибку 500.
- P — Partition Tolerance (Устойчивость к разделению):
- Определение: Система продолжает работать, даже если пропала связь между узлами (обрыв кабеля, падение свича).
В реальности выбора "любые два" нет. В распределенной системе (где серверов больше одного) сеть ненадежна. Кабель может быть перерезан. P (Partition Tolerance) — это данность, от которой нельзя отказаться.
Поэтому реальный выбор всегда стоит так: В случае аварии сети (P), что вы выберете: C или A?
- Логика: "Лучше упасть, чем соврать".
- Поведение при сбое: Если сервер А потерял связь с сервером Б, он блокирует возможность записи, чтобы не создать конфликт данных.
- Применение: Банки, платежные системы, бронирование билетов (нельзя продать одно место двоим).
- БД: MongoDB (по умолчанию), HBase, Redis (в некоторых конфигурациях).
- Логика: "Show must go on".
- Поведение при сбое: Если связи нет, пишем локально. Потом, когда сеть починят, попробуем разрешить конфликты (merge).
- Применение: Соцсети (лента), счетчики просмотров, корзина в Amazon (лучше дать добавить товар, чем потерять клиента).
- БД: Cassandra, DynamoDB, CouchDB.
Автор: Даниэль Абади.
CAP описывает только ситуацию аварии (Partition). Но 99% времени система работает нормально. Как она ведет себя тогда?
PACELC формулируется так:
- Если есть P (Partition), то выбираем A или C (как в CAP).
- E (Else) — иначе (когда сеть работает нормально):
- Выбираем L (Latency — скорость) или C (Consistency — согласованность).
Суть: Если вы хотите мгновенную синхронизацию данных между Нью-Йорком и Лондоном (Strong Consistency), вам придется пожертвовать скоростью (Latency), так как свету нужно время, чтобы долететь. Если нужна скорость — жертвуем мгновенной согласованностью.
- DynamoDB / Cassandra: PA/EL — При аварии выбирают доступность (A), в мирное время — низкую задержку (L).
- BigTable / HBase: PC/EC — Всегда выбирают согласованность (C), даже ценой скорости.
Как менеджеру объяснить инженеру, насколько надежной должна быть система?
- SLI (Service Level Indicator): Что именно мы меряем?
- Пример: Количество ошибок 500 деленное на общее число запросов.
- SLO (Service Level Objective): Цель, к которой мы стремимся (внутренняя метрика).
- Пример: "99.9% запросов должны быть успешными".
- SLA (Service Level Agreement): Юридический контракт с клиентом (со штрафами).
- Пример: "Если доступность упадет ниже 99.9%, мы вернем вам 10% оплаты".
Выучите эти цифры наизусть. На интервью часто просят оценить допустимое время простоя (Downtime).
| Доступность | Простоев в год | Простоев в день | Тип системы |
|---|---|---|---|
| 99% (2 девятки) | 3 дня 15 часов | 14.4 мин | MVP, внутренние тулзы. |
| 99.9% (3 девятки) | 8.76 часов | 1.44 мин | Стандартный веб-сервис. |
| 99.99% (4 девятки) | 52.6 мин | 8.64 сек | Критические системы (Банкинг, E-commerce). |
| 99.999% (5 девяток) | 5.26 мин | 0.86 сек | Телеком, Медицина, Авиация. |
Формула расчета доступности для последовательных компонентов (если упал любой — упало всё):
Пример: Если у вас API (99.9%) зависит от БД (99.9%), общая надежность:
Чтобы достичь этих "девяток", сервера дублируют.
- Схема: Один сервер (Active) принимает трафик, второй (Passive) просто смотрит и получает репликацию данных.
- Heartbeat: Пассивный сервер шлет пинг ("Ты жив?"). Если Активный не ответил 3 раза — Пассивный объявляет себя главным и забирает Виртуальный IP.
- Плюс: Простота, данные не расходятся.
- Минус: Простой ресурсов (второй сервер простаивает). Переключение занимает время (downtime 10-60 сек).
- Схема: Оба сервера принимают трафик.
- Плюс: Двойная производительность. Если один упал, второй просто берет на себя 100% нагрузки (мгновенно).
- Минус: Кошмар синхронизации. Если юзер А изменил запись на сервере 1, а юзер Б ту же запись на сервере 2 — возникает конфликт. Нужны сложные алгоритмы слияния.
Одной из главных проблем распределенных систем является Split Brain (Раздвоение личности). Это когда два сервера теряют связь и оба решают, что они — Мастер.
Давайте напишем простую симуляцию того, как кворум решает эту проблему.
import random
import time
class Node:
def __init__(self, node_id, total_nodes):
self.node_id = node_id
self.total_nodes = total_nodes
self.is_leader = False
def vote_for_leader(self, available_nodes):
"""
Алгоритм кворума.
Лидером можно стать, только если тебя видят > 50% узлов (N/2 + 1).
"""
quorum = (self.total_nodes // 2) + 1
# Симуляция: кого видит этот узел в данный момент
visible_count = len(available_nodes)
print(f"[Node {self.node_id}] Вижу узлов: {visible_count}. Нужен кворум: {quorum}")
if visible_count >= quorum:
# Упрощение: Лидером становится тот, у кого меньший ID
potential_leader = min(n.node_id for n in available_nodes)
if potential_leader == self.node_id:
self.is_leader = True
print(f" -> [Node {self.node_id}] Я ЛИДЕР! (Есть кворум)")
else:
self.is_leader = False
print(f" -> [Node {self.node_id}] Я фолловер. Лидер -> {potential_leader}")
else:
self.is_leader = False
# Если кворума нет, узел переходит в режим Read-Only или останавливается
print(f" -> [Node {self.node_id}] ! НЕТ КВОРУМА ! Останавливаю запись (CP-system).")
# --- Симуляция ---
# Создаем кластер из 5 узлов
nodes = [Node(i, 5) for i in range(1, 6)]
print("\n--- Сценарий 1: Сеть работает нормально ---")
# Все видят всех
for node in nodes:
node.vote_for_leader(nodes)
print("\n--- Сценарий 2: Сеть разорвалась (Partition) ---")
# Кластер раскололся: Узлы 1,2 в одной комнате. Узлы 3,4,5 в другой.
partition_A = [nodes[0], nodes[1]] # 2 узла
partition_B = [nodes[2], nodes[3], nodes[4]] # 3 узла
print("Группа А (Узлы 1, 2):")
for node in partition_A:
node.vote_for_leader(partition_A)
print("\nГруппа Б (Узлы 3, 4, 5):")
for node in partition_B:
node.vote_for_leader(partition_B)
Результат работы кода:
- В Группе А (2 узла из 5) кворум собрать нельзя (2 < 3). Узлы понимают это и отказываются выбирать лидера. Система переходит в Read-Only. Это предотвращает запись противоречивых данных.
- В Группе Б (3 узла из 5) кворум есть (3 >= 3). Они выбирают нового лидера (Node 3) и продолжают работать.
Это классический пример CP-системы.
Мы коснулись философии распределенных систем.
- CAP: Нельзя получить всё сразу. При аварии выбирай: тормозить (CP) или врать (AP).
- SLA: 99.99% — это всего 50 минут простоя в год.
- Active-Active: Круто, но больно.
- Split Brain: Лечится кворумом (голосами > 50%).
Согласованность — это не просто "да" или "нет". Это шкала. Чем строже модель, тем медленнее система.
- Strong Consistency (Linearizability):
- Как это выглядит: Как будто система — это один компьютер. После записи данных любой следующий запрос на чтение (откуда угодно) вернет новое значение.
- Цена: Огромные задержки (Latency). Требует блокировок и консенсуса (Paxos/Raft).
- Sequential Consistency:
- Порядок операций важен. Если я запостил "Всем привет", а потом комментарий "Как дела", все должны видеть их именно в таком порядке.
- Causal Consistency (Причинная):
- Связанные события (вопрос-ответ) должны быть упорядочены. Несвязанные события могут приходить в разнобой.
- Eventual Consistency (В конечном счете):
- Как это выглядит: Я лайкнул фото. Мой друг увидит лайк через 10 секунд.
- Цена: Очень быстро. Но данные могут на время расходиться.
Сценарий: Покупка билета.
- Service A (Order): Создать заказ.
- Service B (Payment): Списать деньги.
Если шаг 1 прошел успешно, а на шаге 2 упала сеть или не хватило денег — у нас проблема. Заказ висит как "созданный", но не оплаченный. В монолите мы бы откатили транзакцию. В распределенной системе нам нужно отменить изменения в Сервисе А вручную.
Классический, но устаревший метод.
Вводится Координатор транзакций. Процесс идет в две фазы:
- Фаза 1 (Prepare): Координатор спрашивает у всех сервисов (БД): "Вы сможете сделать коммит? Заблокируйте ресурсы, но пока не сохраняйте".
- Сервис А: "Да, могу".
- Сервис Б: "Да, могу".
- Фаза 2 (Commit): Если все ответили "Да", Координатор командует: "Всем COMMIT!".
- Проблема (Blocking): Это блокирующий протокол. Если Координатор упадет после фазы 1, все базы данных будут держать блокировки на строках и ждать его воскрешения. Система встанет колом.
- Вывод: Не используйте 2PC в высоконагруженных микросервисах.
Современный стандарт.
Сага — это последовательность локальных транзакций. Каждый сервис делает свою работу и публикует событие. Если на каком-то шаге происходит ошибка, запускаются Компенсирующие транзакции (Compensation), чтобы отменить изменения предыдущих шагов.
- Шаг 1: Order Service создает заказ (Status: PENDING). -> Успех.
- Шаг 2: Payment Service пытается списать деньги. -> Ошибка (нет средств).
- Компенсация: Payment Service сообщает об ошибке. Order Service ловит событие и меняет статус заказа на FAILED (или удаляет его).
Саги бывают двух типов:
Сервисы общаются через события (RabbitMQ/Kafka).
- Сервис А сделал дело -> кинул ивент
OrderCreated. - Сервис Б слушает
OrderCreated, делает дело -> кидаетPaymentProcessed. - Сервис В слушает
PaymentProcessed... - Плюс: Простая архитектура, слабая связность.
- Минус: Сложно понять бизнес-процесс ("Кто вообще слушает этот ивент?"). Риск циклических зависимостей.
Есть отдельный сервис (Orchestrator), который говорит всем, что делать.
- Оркестратор: "Сервис А, создай заказ".
- Сервис А: "Готово".
- Оркестратор: "Сервис Б, спиши деньги".
- Сервис Б: "Ошибка!".
- Оркестратор: "Сервис А, отменяй заказ!".
- Плюс: Весь процесс виден в одном месте (в коде оркестратора). Легче искать ошибки.
- Минус: Оркестратор становится узким местом.
- Инструменты: Temporal.io, Camunda, AWS Step Functions.
Давайте напишем простую реализацию Оркестрации на Python.
import time
class DatabaseError(Exception):
pass
# --- Сервисы ---
class FlightService:
def book_flight(self):
print("[Flight] Бронирую билет...")
# Логика записи в БД
return "flight_123" # ID брони
def cancel_flight(self, flight_id):
print(f"[Flight] ! Компенсация: Отменяю билет {flight_id}")
class HotelService:
def book_hotel(self):
print("[Hotel] Бронирую отель...")
# Симуляция ошибки: мест нет или база упала
raise DatabaseError("Нет мест в отеле!")
# return "hotel_456"
def cancel_hotel(self, hotel_id):
print(f"[Hotel] ! Компенсация: Отменяю отель {hotel_id}")
class PaymentService:
def charge(self):
print("[Payment] Списываю деньги...")
return "tx_789"
def refund(self, tx_id):
print(f"[Payment] ! Компенсация: Возвращаю деньги {tx_id}")
# --- Оркестратор Саги ---
class TravelSagaOrchestrator:
def __init__(self):
self.flight_svc = FlightService()
self.hotel_svc = HotelService()
self.payment_svc = PaymentService()
# Журнал успешных операций для отката
self.history = []
def book_trip(self):
try:
# Шаг 1: Перелет
flight_id = self.flight_svc.book_flight()
self.history.append({"step": "flight", "id": flight_id})
# Шаг 2: Оплата (допустим, списываем до отеля)
tx_id = self.payment_svc.charge()
self.history.append({"step": "payment", "id": tx_id})
# Шаг 3: Отель (ТУТ БУДЕТ ОШИБКА)
hotel_id = self.hotel_svc.book_hotel()
self.history.append({"step": "hotel", "id": hotel_id})
print(">>> Успех! Путешествие забронировано.")
except Exception as e:
print(f"\n[SAGA ERROR] Произошел сбой: {e}")
print("Начинаем откат транзакций (Rollback)...\n")
self.compensate()
def compensate(self):
# Идем по истории в обратном порядке (LIFO)
for operation in reversed(self.history):
step = operation["step"]
resource_id = operation["id"]
if step == "hotel":
self.hotel_svc.cancel_hotel(resource_id)
elif step == "payment":
self.payment_svc.refund(resource_id)
elif step == "flight":
self.flight_svc.cancel_flight(resource_id)
print("\n>>> Сага отменена. Система вернулась в исходное состояние.")
# --- Запуск ---
saga = TravelSagaOrchestrator()
saga.book_trip()
Результат выполнения кода:
- Билет забронирован.
- Деньги списаны.
- Отель выдал ошибку.
- Компенсация: Деньги возвращены -> Билет отменен.
В реальной жизни (например, используя Temporal или Cadence) этот код выполнялся бы надежно, даже если бы сам скрипт оркестратора перезагрузился посередине процесса. Он бы запомнил состояние и продолжил с момента сбоя.
- ACID работает только локально. В микросервисах забудьте про глобальный COMMIT.
- 2PC — зло для HighLoad систем (блокировки).
- Saga Pattern — наш выбор. Разбиваем процесс на шаги.
- Всегда пишите компенсацию. На каждый
createдолжен быть кодundo(илиdelete). Это называется "Forward Recovery" (идти до конца) или "Backward Recovery" (откатить все назад).
Консенсус позволяет набору ненадежных машин работать как один надежный компьютер.
Главная абстракция здесь — Replicated State Machine (Реплицируемый автомат).
- Идея: Если у всех узлов одинаковое начальное состояние и мы применим к ним одни и те же команды в одном и том же порядке, то их конечное состояние тоже будет одинаковым.
- Задача консенсуса: Гарантировать, что "Лог команд" (Log Replication) одинаков на всех узлах.
Автор: Лесли Лэмпорт (1989).
Paxos был первым математически доказанным алгоритмом консенсуса.
- Суть: Очень сложный. Даже в Google инженеры шутят, что "в мире есть только 5 человек, которые понимают Paxos, и они не согласны друг с другом".
- Проблема: Paxos сложен в реализации. На практике (в Zookeeper, например) использовали модифицированный Zab, потому что чистый Paxos трудно запрограммировать без багов.
Авторы: Диего Онгаро и Джон Оустерхаут (2014).
Raft был создан специально, чтобы быть понятным. Сегодня это стандарт индустрии (используется в etcd, Consul, Kubernetes, CockroachDB).
В любой момент времени узел может быть в одном из 3 состояний:
- Leader (Лидер): "Диктатор". Все запросы на запись идут только к нему. Он рассылает приказы остальным.
- Follower (Последователь): Пассивно слушает Лидера и выполняет приказы.
- Candidate (Кандидат): Если Лидер замолчал, Последователь выдвигает себя в Кандидаты, чтобы стать новым Лидером.
- Лидер должен постоянно слать пустые пакеты (Heartbeats) каждые 50-100 мс: "Я жив, я тут".
- У каждого Фолловера есть таймер ("Терпение"). Если он не слышит Лидера, например, 300 мс, он психует: "Лидер умер!".
- Фолловер переходит в статус Candidate, голосует сам за себя и шлет всем просьбу: "Голосуйте за меня! (Term N+1)".
- Если он получает большинство голосов (
$Quorum=N/2+1$ ), он становится новым Лидером.
- Клиент шлет команду
SET x = 5Лидеру. - Лидер записывает это в свой лог, но не комитит (не применяет к состоянию).
- Лидер шлет эту запись всем Фолловерам: "Запишите у себя".
- Фолловеры пишут и отвечают "ОК".
- Как только Лидер получил "ОК" от большинства (Кворума), он делает COMMIT (значение реально меняется) и отвечает клиенту "Успех".
- Лидер сообщает Фолловерам: "Я закоммитил, вы тоже можете".
Вам вряд ли придется писать Raft с нуля (это сложно). Вы будете использовать готовые хранилища, которые его реализуют.
- ZooKeeper (Zab): Старый, надежный, написан на Java. Используется в Kafka, Hadoop для хранения конфигураций и выбора контроллера.
- etcd (Raft): Написан на Go. "Мозги" Kubernetes. Именно в etcd хранится все состояние вашего кластера (поды, сервисы).
- Consul (Raft): Service Discovery + KV хранилище от HashiCorp.
Самый частый кейс использования etcd/Zookeeper в коде — это блокировки.
Задача: У вас есть 5 серверов, на каждом крутится Cron-скрипт отправки рассылки. Нужно, чтобы рассылка ушла ровно один раз. Если запустить на всех 5 — пользователи получат 5 писем.
Решение: Первый, кто захватит "ключ" (Lock) в etcd, делает работу. Остальные видят, что ключ занят, и пропускают запуск.
# pip install etcd3
import etcd3
import time
import sys
# Подключение к кластеру etcd
client = etcd3.client(host='localhost', port=2379)
def run_critical_job(worker_name):
lock_name = "/locks/daily_email_job"
# 1. Создаем объект блокировки с TTL (временем жизни) 10 секунд.
# TTL нужен, чтобы если скрипт упадет (сгорит сервер),
# блокировка сама исчезла через 10 сек, и другие могли её взять.
lock = client.lock(lock_name, ttl=10)
print(f"[{worker_name}] Пытаюсь захватить лидерство...")
# 2. acquire() - атомарная операция. Внутри Raft гарантирует,
# что только один получит True.
is_locked = lock.acquire()
if is_locked:
try:
print(f"[{worker_name}] >>> Я ЛИДЕР! Блокировка захвачена.")
print(f"[{worker_name}] Выполняю сложную работу (отправка писем)...")
# Эмуляция работы
for i in range(5):
print(f"[{worker_name}] Работаю... {i}")
time.sleep(1)
# Важно: в реальной жизни тут надо обновлять TTL (refresh),
# если работа длится дольше 10 секунд.
lock.refresh()
print(f"[{worker_name}] Работа завершена успешно.")
finally:
# 3. Обязательно отпускаем блокировку
lock.release()
print(f"[{worker_name}] Блокировка снята.")
else:
print(f"[{worker_name}] Блокировка занята кем-то другим. Я пропускаю ход.")
# Симуляция запуска:
# Если запустить этот скрипт в двух терминалах одновременно,
# один выиграет, второй сразу скажет "Блокировка занята".
run_critical_job(sys.argv[1] if len(sys.argv) > 1 else "Worker-1")
Можно (через SELECT FOR UPDATE или GET_LOCK), но:
- Если мастер БД упадет, блокировки могут потеряться или зависнуть.
- etcd/Zookeeper созданы специально для этого: они держат блокировку в памяти и мгновенно (через Watchers) уведомляют других, если она освободилась.
Мы прошли самый сложный теоретический блок.
- CAP-теорема: Мы поняли, что распределенные системы — это компромисс.
- Saga: Мы научились делать транзакции без блокировок.
- Raft/Consensus: Мы поняли, как Kubernetes и Kafka выбирают главного и почему это надежно.
===============================
Главная ошибка новичков: делить сервисы по типу данных (UserService, PaymentService, ProductService) или по размеру кода ("Слишком большой класс, вынесу в сервис").
Правильный подход: DDD (Domain-Driven Design). Сервисы нужно делить по Bounded Context (Ограниченным контекстам) — бизнес-областям.
- Плохой пример: Сервис
User. Все ходят в него за данными пользователя. Если он упал — лежит всё. - Хороший пример:
- Контекст Продаж: Здесь "Пользователь" — это
Customer(есть история заказов, адрес доставки). - Контекст Поддержки: Здесь "Пользователь" — это
TicketRequester(история жалоб). - Контекст Аутентификации: Здесь "Пользователь" — это
Account(логин, пароль, роли).
- Контекст Продаж: Здесь "Пользователь" — это
Данные могут дублироваться, но сервисы становятся независимыми.
Если у вас 50 микросервисов, нельзя заставлять мобильное приложение знать адреса их всех (service-a.com, service-b.com).
API Gateway — это швейцар (Pattern Facade), который стоит на входе. Примеры: Kong, Zuul, AWS API Gateway, Nginx (с Lua скриптами).
Задачи Gateway:
- Маршрутизация: Клиент шлет запрос на
/api/v1/buy, Gateway пересылает его наorder-service:8080. - Аутентификация: Проверяет JWT токен один раз на входе. Микросервисам уже не нужно париться с криптографией, они получают чистый
User-IDв заголовке. - Rate Limiting: "Не больше 10 запросов в секунду от одного IP".
- Protocol Translation: Клиент общается по HTTP/JSON, а Gateway внутри кластера преобразует это в эффективный gRPC/Protobuf.
Иногда мобильному приложению нужны одни данные (мало, компактно), а веб-версии — другие (много деталей). Вместо одного монструозного Gateway делают несколько маленьких:
- BFF for Mobile
- BFF for Web
- BFF for Public API
В мире контейнеров (Docker/Kubernetes) сервисы не живут вечно. Они умирают, перезапускаются, переезжают на другие IP-адреса. Хардкодить IP (http://192.168.1.50) нельзя.
Как Сервис А найдет Сервис Б?
Клиент (Сервис А) идет в реестр (Consul/Eureka), получает список адресов Сервиса Б и сам выбирает, куда слать (Round Robin).
- Минус: В каждом микросервисе нужно писать логику балансировки.
Сервис А просто шлет запрос на виртуальное имя http://my-service. Инфраструктура (K8s Service / CoreDNS) сама перехватывает запрос и направляет его на живой под. Разработчик вообще не думает об IP.
Когда микросервисов становится 100+, управлять ими через код (библиотеки) становится адом.
- "Обновите библиотеку аутентификации во всех 100 сервисах!" — это задача на полгода.
Решение: Sidecar (Мотоцикл с коляской). Рядом с каждым контейнером вашего приложения (Python/Java) запускается маленький прокси-агент (например, Envoy).
- Приложение ничего не знает о сети. Оно шлет запрос на
localhost:8080. - Sidecar перехватывает запрос, находит нужный сервис, шифрует трафик (mTLS), делает повторы (Retries), собирает метрики и отправляет запрос другому Sidecar-у.
Сетка из таких Sidecar-ов называется Service Mesh (Istio, Linkerd).
Напишем примитивный Gateway, который маршрутизирует запросы и проверяет API-ключ.
import requests
from flask import Flask, request, jsonify
app = Flask(__name__)
# Реестр наших микросервисов (в реальности это был бы Consul или K8s DNS)
SERVICES = {
"users": "http://localhost:5001",
"orders": "http://localhost:5002"
}
API_KEY = "secret-token-123"
def check_auth(headers):
token = headers.get("Authorization")
if token != API_KEY:
return False
return True
@app.route('/<service_name>/<path:subpath>', methods=['GET', 'POST', 'PUT', 'DELETE'])
def gateway(service_name, subpath):
"""
Универсальный маршрутизатор.
Пример: GET /users/profile/1 -> идет в сервис 'users' на /profile/1
"""
# 1. Централизованная Аутентификация
if not check_auth(request.headers):
return jsonify({"error": "Unauthorized"}), 401
# 2. Поиск сервиса (Service Discovery)
base_url = SERVICES.get(service_name)
if not base_url:
return jsonify({"error": "Service not found"}), 404
# 3. Формирование целевого URL
target_url = f"{base_url}/{subpath}"
print(f"[Gateway] Проксирую запрос на: {target_url}")
# 4. Проксирование запроса (используем библиотеку requests)
try:
resp = requests.request(
method=request.method,
url=target_url,
headers={key: value for (key, value) in request.headers if key != 'Host'},
data=request.get_data(),
cookies=request.cookies,
allow_redirects=False
)
# 5. Возврат ответа клиенту
return (resp.content, resp.status_code, resp.headers.items())
except requests.exceptions.ConnectionError:
return jsonify({"error": "Service is down"}), 503
if __name__ == '__main__':
# Gateway запускается на порту 5000
app.run(port=5000)
Что дает этот код:
- Безопасность: Микросервисам
usersиordersне нужно проверятьAPI_KEY. Они доверяют Gateway (находятся в закрытой сети). - Абстракция: Клиент не знает, где физически находятся сервисы.
- API Gateway обязателен для любого публичного API.
- DDD помогает правильно разбить монолит.
- Service Discovery позволяет забыть про IP-адреса.
- Service Mesh выносит сетевую логику из кода в инфраструктуру (но добавляет сложности, осторожнее с ним!).
Это самый важный паттерн для защиты от каскадных сбоев. Он работает точно так же, как пробки в электрощитке вашей квартиры.
Если сервис Б начинает тормозить или сыпать ошибками, сервис А должен перестать слать туда запросы и сразу возвращать ошибку (Fail Fast) или дефолтное значение. Это дает сервису Б время "прийти в себя".
У предохранителя есть 3 состояния:
- Closed (Закрыт): Всё хорошо. Ток идет, запросы проходят. Мы считаем ошибки. Если ошибок стало больше порога (например, 50% за 10 сек) -> переходим в Open.
- Open (Открыт): Цепь разомкнута. Запросы мгновенно отбиваются с ошибкой, даже не пытаясь достучаться до удаленного сервиса. Мы ждем тайм-аут (например, 30 сек).
- Half-Open (Полуоткрыт): Тайм-аут прошел. Мы пропускаем один пробный запрос.
- Если успех -> переходим в Closed (счетчик ошибок сброшен).
- Если ошибка -> снова в Open (ждем еще 30 сек).
Если запрос не прошел, глупо сразу сдаваться. Возможно, просто моргнула сеть. Но еще глупее — долбить упавший сервер в бесконечном цикле while True. Это называется Retry Storm (шторм повторов), который добьет лежачий сервис.
Мы увеличиваем время ожидания перед каждой следующей попыткой экспоненциально.
- Попытка 1: Ждем 1 сек.
- Попытка 2: Ждем 2 сек.
- Попытка 3: Ждем 4 сек.
- Попытка 4: Ждем 8 сек.
- ...Stop.
Jitter (Дрожание): Если 1000 микросервисов упали одновременно, и они одновременно (через ровно 1 сек) попробуют переподключиться, они снова положат базу. Нужно добавить случайность: sleep = 2^retry_count + random(0, 100ms). Это "размажет" нагрузку.
Паттерн назван в честь переборок на корабле. Если пробит один отсек, вода не должна затопить весь Титаник.
В IT это означает изоляцию ресурсов.
- Сценарий: У вас один сервер Tomcat/Python с пулом в 100 потоков.
- Проблема: Сервис "Картинки" завис. Все 100 потоков заняты ожиданием ответа от него.
- Результат: Сервис "Логин" (который работает идеально) недоступен, потому что нет свободных потоков.
- Решение (Bulkhead): Выделить жесткие лимиты. 20 потоков на Картинки, 30 на Логин, 50 на остальное. Если потоки "Картинок" кончились — новые запросы на картинки отбиваются, но Логин работает.
Мы должны защищать свои сервисы не только от поломок соседей, но и от слишком активных пользователей (или DDoS).
- Есть ведро, в которое капают токены с фиксированной скоростью (например, 10 токенов в секунду). Максимальная емкость ведра ограничена (например, 100).
- Когда приходит запрос, он должен забрать 1 токен.
- Если токенов нет -> запрос отбрасывается (HTTP 429 Too Many Requests).
- Плюс: Позволяет кратковременные всплески (Bursts) нагрузки (пока есть накопленные токены).
- Запросы попадают в очередь (ведро).
- Из ведра запросы выходят на обработку с постоянной скоростью (как вода через дырку).
- Если ведро переполнилось — новые запросы выливаются через край (отбрасываются).
- Плюс: Идеально сглаживает трафик.
- Минус: Не умеет обрабатывать резкие всплески активности.
Напишем упрощенную реализацию паттерна "Предохранитель" в виде класса.
import time
import random
class CircuitBreakerOpenException(Exception):
pass
class CircuitBreaker:
def __init__(self, failure_threshold=3, recovery_timeout=5):
self.failure_threshold = failure_threshold # Сколько ошибок допустимо
self.recovery_timeout = recovery_timeout # Сколько ждать в Open state (сек)
self.state = "CLOSED"
self.failure_count = 0
self.last_failure_time = 0
def call(self, func, *args, **kwargs):
# 1. Логика OPEN состояния
if self.state == "OPEN":
# Проверяем, не прошло ли время тайм-аута
if time.time() - self.last_failure_time > self.recovery_timeout:
print(">>> Circuit Breaker перешел в HALF-OPEN. Пробуем запрос...")
self.state = "HALF_OPEN"
else:
# Мгновенный отказ без вызова реальной функции
remaining = self.recovery_timeout - (time.time() - self.last_failure_time)
raise CircuitBreakerOpenException(f"Circuit is OPEN. Wait {remaining:.1f}s")
# 2. Попытка вызова функции (для CLOSED и HALF-OPEN)
try:
result = func(*args, **kwargs)
# Если успех в HALF-OPEN -> Сбрасываем в CLOSED
if self.state == "HALF_OPEN":
print(">>> Успех! Circuit Breaker ЗАКРЫЛСЯ.")
self.state = "CLOSED"
self.failure_count = 0
return result
except Exception as e:
# Ловим ошибку реальной функции
self.failure_count += 1
self.last_failure_time = time.time()
print(f"!!! Ошибка вызова ({self.failure_count}/{self.failure_threshold})")
# Если превышен порог или мы были в HALF-OPEN -> Открываем цепь
if self.failure_count >= self.failure_threshold or self.state == "HALF_OPEN":
self.state = "OPEN"
print(">>> Circuit Breaker ОТКРЫЛСЯ!")
raise e
# --- Тестирование ---
# Нестабильная функция
def unreliable_service():
if random.random() < 0.7: # 70% шанс ошибки
raise ConnectionError("Service unavailable")
return "Success 200 OK"
cb = CircuitBreaker(failure_threshold=3, recovery_timeout=3)
print("--- Начинаем бомбардировку запросами ---")
for i in range(15):
try:
time.sleep(0.5)
print(f"Запрос {i+1}: ", end="")
res = cb.call(unreliable_service)
print(res)
except CircuitBreakerOpenException as e:
print(f"БЛОКИРОВКА: {e}")
except Exception as e:
print(f"Ошибка сервиса: {e}")
Анализ работы кода:
- Сначала запросы идут и иногда падают.
- Как только накапливается 3 ошибки, Breaker переходит в
OPEN. - Следующие запросы получают сообщение "БЛОКИРОВКА" мгновенно, даже не дергая
unreliable_service. - Через 3 секунды один запрос прорывается (Half-Open). Если повезет — система восстановится.
Вы научились защищать свою систему от самой себя.
- Circuit Breaker спасает от каскадного ожидания.
- Exponential Backoff спасает от DDoS-атаки "самого на себя" при рестарте.
- Rate Limiting спасает от внешних нагрузок.
Вопрос: "Что именно произошло?" (Событие).
Лог — это запись дискретного события: "Ошибка подключения к БД", "Пользователь вошел".
Нельзя заходить по SSH на каждый сервер читать логи. Решение: Централизованный сбор логов (Log Aggregation).
- Приложение пишет логи в
STDOUT(консоль). - Агент (Filebeat/Fluentd) читает эти потоки со всех контейнеров.
- Агент отправляет их в Elasticsearch (база данных для поиска по тексту).
- Вы открываете Kibana (веб-интерфейс) и ищете ошибку сразу по всем сервисам.
Это называется ELK Stack (Elastic, Logstash, Kibana) или EFK (Fluentd).
Забудьте про print(f"User {id} login failed"). Это текст, его сложно парсить машине. Пишите в JSON:
{
"level": "ERROR",
"timestamp": "2023-10-27T10:00:00Z",
"message": "Login failed",
"user_id": 12345,
"service": "auth-service",
"trace_id": "abc-999"
}
В Kibana вы сможете кликнуть на user_id и отфильтровать все логи по этому пользователю.
Вопрос: "Как здоровье системы?" (Числа во времени).
Метрики — это агрегированные числовые данные.
- Плохо: Писать в лог каждое обращение к процессору (терабайты текста).
- Хорошо: Раз в 15 секунд отправлять число: "CPU Load = 45%".
- Prometheus: База данных временных рядов (Time-Series DB). Она работает по модели Pull. Она сама приходит к вашим сервисам раз в N секунд и забирает метрики с эндпоинта
/metrics. - Grafana: Рисует красивые графики на основе данных из Prometheus.
Google SRE рекомендует мониторить минимум эти 4 метрики для каждого сервиса:
- Latency (Задержка): Сколько времени занял запрос? (p99, p95).
- Traffic (Трафик): Сколько запросов в секунду (RPS)?
- Errors (Ошибки): Процент 500-х ошибок.
- Saturation (Насыщение): Насколько мы загружены? (Очередь задач, память).
Вопрос: "Где именно мы потеряли время?"
Это самое сложное и самое полезное. Представьте запрос:
- Gateway (2 мс) -> Auth Service (50 мс) -> Order Service (2000 мс) -> DB (5 мс).
- В логах Auth Service всё ок. В логах DB всё ок. Почему Order Service тормозил 2 секунды? Может, он ждал ответа от другого сервиса?
Трассировка рисует Waterfall (Водопад) прохождения одного запроса через все микросервисы.
Магия держится на двух ID, которые передаются в HTTP-заголовках:
- Trace ID: Глобальный ID всего запроса. Генерируется на входе (в Gateway).
- Span ID: ID конкретной операции (шага).
Каждый сервис, принимая запрос, обязан:
- Прочитать
Trace ID. - Сделать свою работу (создать Span).
- Если он вызывает другой сервис — передать
Trace IDдальше.
Популярные инструменты: Jaeger, Zipkin, и современный стандарт OpenTelemetry.
В реальности вы возьмете библиотеку opentelemetry, и она сделает всё сама. Но чтобы понять суть, давайте сымитируем ручную передачу контекста (Context Propagation) на Python.
Представим два сервиса: Service A вызывает Service B.
import uuid
import time
import requests # pip install requests
# --- Эмуляция инфраструктуры трейсинга ---
def log_span(service_name, span_name, trace_id, duration_ms, parent_id=None):
"""
В реальности это отправлялось бы в Jaeger/Zipkin.
Мы просто печатаем в консоль.
"""
print(f"[{service_name}] Span: {span_name} | TraceID: {trace_id} | "
f"Parent: {parent_id} | Time: {duration_ms}ms")
# --- Service B (Downstream) ---
def service_b_handler(headers):
# 1. Извлекаем контекст (Trace ID) из заголовков пришедшего запроса
trace_id = headers.get('x-trace-id')
parent_span_id = headers.get('x-span-id') # Span ID вызывающего сервиса становится Parent ID
start_time = time.time()
# Работа сервиса
time.sleep(0.1)
duration = (time.time() - start_time) * 1000
log_span("Service-B", "process_payment", trace_id, duration, parent_span_id)
return "Payment OK"
# --- Service A (Upstream / Initiator) ---
def service_a_handler():
# 1. Запрос пришел от пользователя - генерируем новый Trace ID
trace_id = str(uuid.uuid4())[:8]
span_id = "A-100" # ID текущей операции в сервисе А
print(f"--- New Request Trace: {trace_id} ---")
start_time = time.time()
# 2. Нужно вызвать Сервис Б. ВНЕДРЯЕМ контекст в заголовки.
headers = {
'x-trace-id': trace_id,
'x-span-id': span_id
}
# В реальности тут был бы requests.get('http://service-b', headers=headers)
# Мы вызываем функцию напрямую для эмуляции
response = service_b_handler(headers)
# Работа самого Сервиса А
time.sleep(0.05)
duration = (time.time() - start_time) * 1000
log_span("Service-A", "buy_item", trace_id, duration, parent_id=None)
return f"Result: {response}"
# Запуск
service_a_handler()
Результат в консоли:
--- New Request Trace: a1b2c3d4 ---
[Service-B] Span: process_payment | TraceID: a1b2c3d4 | Parent: A-100 | Time: 100.0ms
[Service-A] Span: buy_item | TraceID: a1b2c3d4 | Parent: None | Time: 155.0ms
Система трассировки (Jaeger) увидит эти логи, объединит их по TraceID и построит график:
buy_item(Всего 155мс)process_payment(Внутри него, 100мс)
Если бы мы забыли передать x-trace-id, Jaeger подумал бы, что это два совершенно разных, несвязанных события, и мы бы не нашли причину тормозов.
Раньше для метрик была одна библиотека, для логов — другая, для трейсов — третья. Это был хаос.
Сейчас индустрия пришла к стандарту OpenTelemetry. Это единый набор библиотек и агентов, которые собирают ВСЁ (Logs, Metrics, Traces) и отправляют куда скажете (в Prometheus, в Jaeger, в Datadog).
Совет эксперта: В новых проектах сразу настраивайте OpenTelemetry. Не используйте проприетарные агенты.
=============================
Девиз: "Лучше поздно, но точно и много".
Это классический подход Big Data. Мы накапливаем данные за день/месяц (логи, транзакции), а потом запускаем огромную молотилку, которая переваривает их за ночь.
Google придумал концепцию MapReduce, чтобы индексировать весь интернет.
- Map (Отображение): Разбить задачу на тысячи мелких кусков и раздать тысяче серверов. Каждый сервер считает слова в своем куске текста.
- Reduce (Свертка): Собрать результаты от всех серверов и сложить их.
Spark работает по тому же принципу, но в 100 раз быстрее Hadoop MapReduce, потому что он старается держать данные в оперативной памяти (In-Memory), а не писать на диск после каждого шага.
- Применение: Генерация ежемесячных отчетов, обучение ML-моделей, расчет кредитного скоринга за историю в 10 лет.
Девиз: "Данные теряют ценность каждую секунду".
Если мошенник крадет деньги с карты, нам не нужен отчет "завтра утром". Нам нужно заблокировать транзакцию прямо сейчас (до 200 мс). Здесь данные не лежат на диске, они летят через трубу (Kafka), и мы пытаемся анализировать их на лету.
В потоке нет "начала" и "конца". Как посчитать "среднее количество кликов"? Мы используем Окна:
- Tumbling Window (Кувыркающееся окно): Каждые 10 секунд. (00:00-00:10, 00:10-00:20). Окна не пересекаются.
- Sliding Window (Скользящее окно): Окно размером 10 секунд, которое сдвигается каждую секунду. (Данные за 00:00-00:10, потом 00:01-00:11).
- Session Window: Пока пользователь активен + 5 минут тишины.
- Инструменты: Apache Flink (самый мощный), Kafka Streams, Spark Structured Streaming.
Как объединить точность Batch и скорость Stream?
Классика 2010-х. Мы строим две параллельные системы:
- Batch Layer (Hadoop/Spark): Хранит "Master Dataset" (абсолютно все сырые данные). Пересчитывает всё раз в сутки. Это "Source of Truth".
- Speed Layer (Flink/Storm): Считает приблизительные данные за сегодня в реальном времени.
- Serving Layer: Объединяет результаты (Вчерашние точные + Сегодняшние быстрые).
- Плюс: Если в коде Stream-обработки был баг, мы просто исправим код и перезапустим Batch ночью. Данные восстановятся.
- Минус: Нужно поддерживать две кодовые базы (одна на Java для Hadoop, другая на Scala для Flink). Это дорого и сложно.
Современный подход (предложен LinkedIn). Зачем нам Batch, если Stream движок (Flink/Kafka) стал надежным? У нас только Speed Layer. Всё есть поток. Даже история за 5 лет — это просто очень длинный поток, который можно "проиграть" заново из Kafka (увеличив скорость чтения).
- Плюс: Одинаковая логика обработки и для реал-тайма, и для истории.
Напишем простейший пример подсчета слов (Word Count), который имитирует логику Apache Spark.
from functools import reduce
from collections import defaultdict
# Огромный массив данных (в реальности - терабайты текста, разбитые на файлы)
raw_data = [
"hello world",
"hello spark",
"big data is cool",
"spark is fast",
"hello big data"
]
# --- 1. MAP (Распараллеливание) ---
# Превращаем строки в пары (Слово, 1)
# В реальности это делали бы 100 разных серверов параллельно
mapped_data = []
for sentence in raw_data:
for word in sentence.split():
mapped_data.append((word, 1))
print(f"Mapped: {mapped_data[:5]}...")
# [('hello', 1), ('world', 1), ('hello', 1), ('spark', 1), ('big', 1)...]
# --- 2. SHUFFLE (Перемешивание) ---
# Самая дорогая операция. Нужно собрать все единички для слова "hello" на один сервер,
# а для слова "spark" - на другой.
shuffled_data = defaultdict(list)
for word, count in mapped_data:
shuffled_data[word].append(count)
# --- 3. REDUCE (Свертка) ---
# Суммируем список [1, 1, 1] в одно число 3
reduced_data = {}
for word, counts in shuffled_data.items():
reduced_data[word] = sum(counts)
# Результат
# Сортируем для красоты
sorted_result = sorted(reduced_data.items(), key=lambda x: x[1], reverse=True)
print("\n--- Top Words ---")
for word, count in sorted_result:
print(f"{word}: {count}")
В чем магия Spark? Этот код на Python работает на одном ядре. Если вы запустите это в PySpark:
- Массив
raw_dataбудет называться RDD (Resilient Distributed Dataset). - Spark сам разобьет его на партиции.
- Функция
mapулетит на 50 разных серверов. - Если один сервер сгорит посередине работы, Spark заметит это и перезапустит обработку этого кусочка на другом сервере. Вам не нужно писать ни строчки кода для этого.
- Batch (Spark) нужен для сложных, тяжелых отчетов и обучения AI.
- Stream (Flink/Kafka) нужен для мгновенной реакции.
- Kappa архитектура побеждает Lambda, потому что проще поддерживать одну систему вместо двух.
Задача: Хранить метрики с серверов, котировки акций, данные с датчиков IoT. Характер данных:
- Write-Heavy: Запись происходит постоянно (тысячи вставок в секунду).
- Append-Only: Данные почти никогда не меняются задним числом.
- Time-Centric: Запросы всегда идут за интервал времени ("дай среднюю температуру за прошлый час").
В обычном B-Tree индексе, если вы пишете случайные данные, диск "мечется" туда-сюда. TSDB оптимизированы для последовательной записи. Кроме того, TSDB умеют делать Downsampling (прореживание): хранить данные за сегодня посекундно, за месяц — поминутно, за год — почасово. Это экономит терабайты места.
- Примеры: InfluxDB, Prometheus (стандарт для мониторинга), TimescaleDB (надстройка над PostgreSQL).
Задача: Найти 5 ближайших водителей Uber вокруг меня. Проблема: Базы данных хранят данные линейно (1D). А карта — двумерная (2D). Обычный индекс "отсортировать по X, потом по Y" работает плохо.
Мы превращаем 2D координаты (широту и долготу) в одну строку (1D). Весь мир делится на прямоугольники. Каждый прямоугольник делится еще на 32.
u— Европа.u4— Центральная Европа.u4p— Чехия.u4pru— Прага.
Свойство: У близких точек — общий префикс хэша. Поиск "кто рядом со мной" превращается в быстрый поиск по строке: WHERE geohash LIKE 'u4pru%'.
Вся карта — это квадрат.
- Если в квадрате слишком много точек, делим его на 4 квадрата поменьше.
- Повторяем рекурсивно. Получается дерево, где густонаселенные районы (центр Москвы) имеют глубокую детализацию, а тайга — один большой квадрат.
- Примеры: PostGIS (расширение для Postgres), Redis Geo, Google S2.
Задача: Найти документы, где встречается слово "System Design", даже если там написано "Systems Designs" (с ошибкой или в другом числе). Проблема: SELECT * FROM table WHERE text LIKE '%word%' делает Full Scan (читает каждую строку). Это непозволительно медленно.
Это структура, на которой построен весь Google, Elasticsearch и Lucene.
Вместо того чтобы хранить "Документ -> Слова", мы храним "Слово -> Список Документов".
- Tokenization: Разбиваем текст на слова.
- Normalization (Stemming/Lemmatization): Приводим слова к корню (
running->run,cats->cat). - Indexing:
| Term (Слово) | Document IDs (Где встречается) |
|---|---|
| apple | 1, 5, 14 |
| banana | 2 |
| design | 1, 3, 99 |
Теперь, чтобы найти документы про "apple design", нам не нужно читать тексты. Мы берем списки [1, 5, 14] и [1, 3, 99] и ищем их пересечение. Результат: Документ 1. Это мгновенно.
Реализуем простейший обратный индекс, который умеет делать поиск AND (пересечение).
import re
class SimpleSearchEngine:
def __init__(self):
# Обратный индекс: слово -> set(id документов)
self.index = {}
# Хранилище самих документов
self.documents = {}
def _tokenize(self, text):
# 1. Приводим к нижнему регистру
# 2. Выкидываем знаки препинания
# 3. Разбиваем на слова
text = text.lower()
words = re.findall(r'\w+', text)
return set(words) # Убираем дубликаты слов внутри одного документа
def add_document(self, doc_id, text):
self.documents[doc_id] = text
tokens = self._tokenize(text)
for token in tokens:
if token not in self.index:
self.index[token] = set()
self.index[token].add(doc_id)
def search(self, query):
tokens = self._tokenize(query)
if not tokens:
return []
# Начинаем с множества документов первого слова
# Например, ищем "fast python". Берем документы, где есть "fast".
# Потом оставляем только те, где ЕЩЕ есть и "python" (пересечение).
# Сортируем токены, чтобы начать с самого редкого слова (оптимизация)
sorted_tokens = sorted(list(tokens), key=lambda t: len(self.index.get(t, set())))
first_word = sorted_tokens[0]
result_ids = self.index.get(first_word, set()).copy()
for token in sorted_tokens[1:]:
docs_with_token = self.index.get(token, set())
# Пересечение множеств (Intersection)
result_ids &= docs_with_token
# Если пересечение пустое - дальше искать нет смысла
if not result_ids:
break
return [self.documents[doc_id] for doc_id in result_ids]
# --- Тест ---
engine = SimpleSearchEngine()
engine.add_document(1, "Python is great for System Design")
engine.add_document(2, "Java is great for Enterprise")
engine.add_document(3, "System Design interview preparation")
engine.add_document(4, "Python and Java comparison")
print("Query: 'System Design'")
print(engine.search("System Design"))
# Должен найти doc 1 и 3
print("\nQuery: 'Python'")
print(engine.search("Python"))
# Должен найти doc 1 и 4
print("\nQuery: 'Java Design'")
print(engine.search("Java Design"))
# Пусто (нет документа, где есть И Java, И Design)
Нюансы кода:
- Пересечение множеств (
&): Это самая главная операция в поиске. - Размер индекса: Обратный индекс может занимать много места, но он все равно меньше, чем сами данные.
Вы теперь знаете, что SQL — не панацея.
- Geohash помогает найти такси.
- Inverted Index помогает найти твит.
- TSDB помогает узнать нагрузку на CPU за прошлый год.
Первое, что спросят на собеседовании по безопасности.
- Authentication (Аутентификация — AuthN): Кто ты?
- Проверка личности (Логин + Пароль, Биометрия, SMS-код).
- Результат: "Это Дмитрий".
- Authorization (Авторизация — AuthZ): Что тебе можно?
- Проверка прав доступа.
- Результат: "Дмитрию можно читать новости, но нельзя удалять базу данных".
Почему мы не просим пользователя ввести пароль от Google в нашем приложении? Потому что пользователь не должен давать нам свой пароль. Для этого придумали OAuth 2.0.
Это протокол делегирования доступа. "Я (пользователь) разрешаю этому Приложению сходить в Google и взять только мое Имя и Email".
OpenID Connect (OIDC) — это надстройка над OAuth 2.0, которая стандартизирует процесс Аутентификации (добавляет ID Token).
- Resource Owner: Пользователь.
- Client: Ваше приложение.
- Authorization Server: Google / Facebook / Ваш Identity Server (Keycloak).
- Resource Server: API, где лежат данные (Google Drive API).
Так работает кнопка "Войти через Google":
- Клиент перенаправляет браузер пользователя на Сервер Авторизации.
- Пользователь вводит пароль на сайте Google.
- Google спрашивает: "Разрешить приложению доступ?" Пользователь жмет "Да".
- Google редиректит пользователя назад в ваше приложение с коротким Authorization Code.
- Ваш бэкенд (не браузер!) берет этот Code, добавляет свой секретный ключ (Client Secret) и идет к Google напрямую.
- Google проверяет код и отдает Access Token (ключ от двери) и ID Token (паспорт пользователя).
Где хранить состояние "Пользователь залогинен"?
- Механизм: Сервер создает сессию, кладет её в Redis (
session_id_123 -> {user: dmitry}), а пользователю отдает только ID в Cookies. - Плюс: Полный контроль. Можно в любой момент "разлогинить" пользователя (удалить ключ из Redis).
- Минус: Stateful. При каждом запросе нужно ходить в Redis. Плохо для микросервисов (лишняя задержка).
- Механизм: Сервер упаковывает данные пользователя в JSON, подписывает своей криптографической подписью и отдает пользователю. Сервер ничего не хранит.
- Плюс: Stateless. Микросервис получает токен, проверяет подпись (математика, без похода в базу) и верит ему. Отлично масштабируется.
- Минус: Нельзя отозвать. Если хакер украл JWT, он имеет доступ, пока не истечет срок жизни токена (TTL).
- Решение: Делать Access Token короткоживущим (5-15 мин) и использовать Refresh Token (который хранится в БД и который можно отозвать) для обновления.
Никогда, ни при каких обстоятельствах не храните пароли в открытом виде. И даже в MD5.
Если хакер украдет базу, он увидит:
user: adminpass: 5f4dcc3b5aa765d61d8327deb882cf99(Гуглится как "password" за 1 секунду через Rainbow Tables).
- Salt (Соль): Случайная строка, добавляемая к паролю. У каждого юзера — своя уникальная соль. Это убивает Rainbow Tables (хакеру придется ломать каждый пароль отдельно).
- Slow Hash: Алгоритм должен быть медленным. Если MD5 считается за 1 наносекунду, то хакер переберет миллиарды паролей. Нам нужен алгоритм, который работает 100 мс. Для пользователя это незаметно, для хакера — вечность.
Стандарты индустрии:
- Argon2 (Победитель конкурса хэширования).
- bcrypt (Старый, надежный стандарт).
- scrypt.
Вам понадобятся pyjwt и bcrypt.
import jwt
import datetime
import bcrypt
import time
# --- 1. Хэширование паролей (bcrypt) ---
def hash_password(plain_password):
# Генерация соли и хэширование. Work factor (rounds) по умолчанию 12.
# Это займет время (ощутимую долю секунды)!
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(plain_password.encode('utf-8'), salt)
return hashed
def check_password(plain_password, hashed_password):
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password)
# Демонстрация
user_pass = "superSecret123"
db_hash = hash_password(user_pass)
print(f"Пароль: {user_pass}")
print(f"В базе лежит: {db_hash}") # Выглядит как $2b$12$....
print(f"Проверка верного: {check_password('superSecret123', db_hash)}") # True
print(f"Проверка неверного: {check_password('wrong', db_hash)}") # False
print("-" * 20)
# --- 2. Работа с JWT ---
SECRET_KEY = "my_super_secret_key_never_share_this"
def create_jwt(user_id):
payload = {
"sub": user_id, # Subject (кто)
"role": "admin",
# Обязательно ставим срок жизни (Expiration Time)
"exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=15),
"iat": datetime.datetime.utcnow() # Issued At
}
token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
return token
def verify_jwt(token):
try:
# Библиотека сама проверит подпись и exp (время жизни)
decoded = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
return decoded
except jwt.ExpiredSignatureError:
return "Error: Token expired"
except jwt.InvalidTokenError:
return "Error: Invalid token"
# Демонстрация
token = create_jwt(123)
print(f"JWT Token для клиента:\n{token}")
# Клиент шлет токен назад...
decoded_data = verify_jwt(token)
print(f"Данные из токена: {decoded_data}")
Если вы возьмете строку token из кода выше и вставите на сайт jwt.io, вы увидите три части, разделенные точками:
- Header: Алгоритм шифрования (
HS256). - Payload: Данные (
{"sub": 123, "role": "admin"}). Внимание: Это Base64, оно не зашифровано! Любой может прочитать содержимое токена. Не кладите туда секретные данные. - Signature: Хэш от (Header + Payload + SecretKey). Это гарантия того, что данные не подделаны.
- In Transit (В пути): Всегда используйте HTTPS (TLS 1.3). Это шифрует канал между клиентом и сервером. Внутри кластера (между микросервисами) используйте mTLS (Mutual TLS) — когда сервисы проверяют сертификаты друг друга.
- At Rest (В покое):
- Шифрование диска (AWS EBS Encryption, LUKS).
- Шифрование колонок в БД (для паспортных данных, карт).
- Управление ключами: Никогда не храните ключи шифрования рядом с данными. Используйте KMS (Key Management Service) или Vault.
==================================
Чтобы спроектировать сложную систему и не сойти с ума, нужно двигаться по строгому алгоритму. Это "боевой устав" архитектора.
Самая частая ошибка: Кандидат слышит "Спроектируй Twitter" и бросается рисовать схему. Интервьюер думает: "Он даже не спросил, какой именно Твиттер. Может, это внутренний чат для 5 человек?"
Никогда не делайте предположений. Задавайте вопросы, чтобы сузить задачу (Scope).
Вопросы, которые нужно задать:
- Функциональные требования (Что система делает?):
- Мы постим твиты?
- Мы подписываемся на людей?
- Есть ли поиск?
- Нужны ли личные сообщения? (Интервьюер может сказать: "Нет, давай сосредоточимся только на Ленте новостей").
- Нефункциональные требования (Как система работает?):
- DAU (Daily Active Users): 1 тысяча или 100 миллионов? (Это определяет архитектуру).
- CAP-теорема: Нам важнее Consistency (банкинг) или Availability (соцсеть)?
- Read/Write Ratio: Мы больше читаем или пишем? (В Twitter читают в 100 раз чаще, чем пишут).
Вам нужно прикинуть цифры "на салфетке", чтобы понять масштаб бедствия.
- Секунд в сутках: ~86 400 (для простоты берите 100 000 или
$10^{5}$ ). - Запросов в день на 1 миллион DAU: Если каждый юзер делает 10 запросов -> 10 млн запросов в сутки.
-
RPS (Requests Per Second):
$10^{7}/10^{5}=100$ RPS. Это смешно, выдержит один ноутбук. - А если 100 млн DAU? Это уже 10 000 RPS (на среднем) и 50 000 RPS (в пике). Тут нужен кластер.
-
RPS (Requests Per Second):
- Юзеров: 100 млн.
- Каждый грузит 1 фото в день.
- Среднее фото: 2 МБ.
-
Трафик на запись:
$100M\times 2MB/100k sec=200\times 10^{6}/10^{5}=2000MB/s$ (2 ГБ/сек). -
Место за год:
$100M\times 2MB\times 365=73PB$ (Петабайта). - Вывод: Нам точно нужен шардинг и объектное хранилище (S3), одна БД не вывезет.
Нарисуйте "вид с вертолета". Не вдавайтесь в детали. Покажите основные потоки данных.
Стандартная схема для веб-сервиса:
- Client (Mobile/Web).
- Load Balancer (на входе).
- Web Servers (API Gateway + Application Service).
- Database (хранение метаданных).
- Object Storage (для картинок/видео).
На этом этапе спросите интервьюера: "Выглядит разумно? На какой части вы хотите, чтобы я сосредоточился?"
Интервьюер укажет на узкое место. Например: "Как ты будешь генерировать ленту новостей для Джастина Бибера, у которого 100 млн подписчиков?"
Здесь вы достаете свой арсенал (Уровни 1-5):
- Базы данных: SQL или NoSQL? (Для ленты лучше Cassandra/DynamoDB).
- Кэширование: Где поставить Redis? (Cache-Aside для профилей).
- Масштабирование: Как шардировать базу? (По
user_idилиtweet_id?). - Асинхронность: Используем Kafka для обработки загрузки видео.
- Специфика: Geo-sharding для поиска такси.
"Если я буду делать
SELECT * FROM tweets WHERE user_id IN (my_friends)— это убьет базу. Поэтому я применю Fan-out on Write. Когда Джастин постит твит, я асинхронно положу ID этого твита в готовые списки (Redis List) кэшей всех его подписчиков. Чтение будет мгновенным ( $O\left(1\right)$ ). Но для знаменитостей это слишком дорого, для них применим гибридный подход..."
В конце окиньте взглядом свою схему и честно скажите, где она сломается. Идеальных систем нет, и интервьюер это знает. Ценится умение видеть риски.
- "Единая точка отказа — Load Balancer (надо добавить второй в Active-Passive)".
- "Если отключат электричество, мы потеряем данные в Redis (надо включить AOF/Snapshotting)".
- "Latency может быть высокой для юзеров из Азии (надо добавить CDN)".
- Read-Heavy (Twitter, Instagram, News):
- Кэш везде (Redis + CDN).
- Денормализация данных (храним готовые view).
- Eventual Consistency допустима.
- Write-Heavy (IoT, Chat logs, Metrics):
- Message Queue (Kafka) как буфер на входе.
- NoSQL (Cassandra/HBase) или Time-Series DB.
- Batch processing для аналитики.
- Transactional (Payment, Booking):
- ACID (PostgreSQL/Oracle).
- Strong Consistency.
- Idempotency keys (защита от повторов).
- Search (Google, E-commerce catalog):
- Elasticsearch (Inverted Index).
- Crawler -> Parser -> Indexer.