Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Кейс: проектирование системы с нуля
case_study

Кейс: проектирование системы с нуля

Полный цикл проектирования: от требований до архитектуры и масштабирования. Практическое задание.

Кейс: Проектирование системы с нуля — URL Shortener (аналог bit.ly)

Полный цикл проектирования распределённой системы: от сбора требований до архитектуры, масштабирования и обеспечения надёжности. Этот кейс объединяет все концепции курса в практическом примере.


#Введение

Проектирование системы с нуля — одна из самых сложных задач для разработчика. Нужно учесть требования, оценить нагрузку, выбрать технологии, спроектировать архитектуру и предусмотреть масштабирование.

В этом кейсе мы спроектируем URL shortener — сервис для сокращения длинных ссылок (аналог bit.ly, tinyurl.com). Это классическая задача системного дизайна, которая охватывает:

  • Сбор и анализ требований
  • Расчёт нагрузки (capacity estimation)
  • Проектирование API
  • Моделирование данных
  • Архитектурные решения
  • Масштабирование и кэширование
  • Обеспечение надёжности

#1. Сбор требований (Requirements Gathering)

Первый шаг — понять, что мы строим и для кого. Требования делятся на функциональные и нефункциональные.

#1.1 Функциональные требования (Functional Requirements)

Описывают, что система должна делать:

IDТребованиеПриоритет
FR1Пользователь может отправить длинный URL и получить короткий алиасMust have
FR2При переходе по короткой ссылке происходит редирект на оригинальный URLMust have
FR3Пользователь может зарегистрироваться и управлять своими ссылкамиShould have
FR4Система собирает статистику переходов (количество, страна, устройство)Should have
FR5Пользователь может задать кастомный алиас (например, short.link/my-promo)Could have
FR6Ссылки имеют срок жизни (TTL) и автоматически удаляютсяWon't have (v1)
FR7API для интеграции с другими сервисамиShould have

MVP (Minimum Viable Product) включает FR1 и FR2 — базовая функциональность сокращения и редиректа.

#1.2 Нефункциональные требования (Non-Functional Requirements)

Описывают, как система должна работать:

IDТребованиеЗначениеОбоснование
NFR1Доступность (Availability)99.9%Пользователи должны иметь доступ к сервису почти всегда
NFR2Задержка (Latency)< 100ms для редиректаРедирект должен быть мгновенным, иначе пользователи уйдут
NFR3Пропускная способность1000 запросов/сек (создание), 100 000 запросов/сек (редирект)Редиректов намного больше, чем созданий
NFR4КонсистентностьEventual consistency допустима для статистикиСтатистика может обновляться с задержкой
NFR5МасштабируемостьГоризонтальное масштабированиеОжидаем рост нагрузки в 10x за год
NFR6Надёжность (Durability)Данные не должны терятьсяСсылки должны работать годами

#1.3 Ограничения (Constraints)

  • Бюджет: ограниченный на старте, поэтому используем open-source решения
  • Команда: 3 разработчика, поэтому выбираем простые и знакомые технологии
  • Время: MVP за 2 месяца

#2. Оценка нагрузки (Capacity Estimation)

Делаем back-of-the-envelope calculations — приблизительные расчёты на салфетке. Точность не важна, важен порядок величин.

#2.1 Входные данные (Assumptions)

ПараметрЗначениеОбоснование
Пользователей в день1 000 000Целевая аудитория
% создающих ссылки1%Типичная конверсия
Созданий ссылок в день10 0001M × 1%
Редиректов на 1 ссылку100Средняя популярность
Редиректов в день1 000 00010 000 × 100
Срок хранения данных5 летБизнес-требование

#2.2 Расчёт пропускной способности (Throughput)

Создание ссылок:
  10 000 запросов / 86 400 секунд ≈ 0.12 запроса/сек
  Пиковая нагрузка (×10): ~1.2 запроса/сек

Редиректы:
  1 000 000 запросов / 86 400 секунд ≈ 11.6 запросов/сек
  Пиковая нагрузка (×10): ~116 запросов/сек

Вывод: Нагрузка невысокая, один сервер справится. Но проектируем с запасом на рост.

#2.3 Расчёт хранилища (Storage)

Для каждой ссылки храним:

ПолеРазмерТип
short_code8 байтVARCHAR(8)
long_url2048 байтVARCHAR(2048)
user_id8 байтBIGINT
created_at8 байтTIMESTAMP
expires_at8 байтTIMESTAMP (nullable)
click_count4 байтаINT
Итого~2084 байта
Объём данных в день:
  10 000 ссылок × 2084 байта ≈ 20 MB/день

Объём данных за 5 лет:
  20 MB × 365 × 5 ≈ 36.5 GB

Индексы (×2 от данных):
  36.5 GB × 2 ≈ 73 GB

Итого с запасом (×3):
  73 GB × 3 ≈ 220 GB

Вывод: 220 GB за 5 лет — помещается на одном диске. Но для отказоустойчивости нужна репликация.

#2.4 Расчёт полосы пропускания (Bandwidth)

Входящий трафик (создание ссылок):
  10 000 запросов × 1 KB ≈ 10 MB/день

Исходящий трафик (редиректы):
  1 000 000 запросов × 500 байт (HTTP 301 + заголовки) ≈ 500 MB/день

Пиковая полоса пропускания:
  500 MB / 86 400 сек × 8 бит/байт × 10 (пик) ≈ 460 Kbit/сек

Вывод: Полоса пропускания не является узким местом.


#3. Определение интерфейсов (System Interface Definition)

Проектируем API до реализации — contract-first подход.

#3.1 REST API для создания ссылок

POST /api/v1/links Content-Type: application/json Authorization: Bearer <token> { "long_url": "https://example.com/very/long/path?param1=value1&param2=value2", "custom_alias": "my-promo", // optional "expires_in_days": 30 // optional }

Ответ 201 Created:

{ "id": "lnk_7x8y9z", "short_code": "abc123", "short_url": "https://short.link/abc123", "long_url": "https://example.com/very/long/path?param1=value1&param2=value2", "created_at": "2026-03-03T10:00:00Z", "expires_at": "2026-04-02T10:00:00Z", "click_count": 0 }

Ответ 400 Bad Request (валидация):

{ "error": { "code": "INVALID_URL", "message": "URL должен быть валидным и начинаться с http:// или https://", "request_id": "req_abc123", "timestamp": "2026-03-03T10:00:00Z" } }

Ответ 409 Conflict (алиас занят):

{ "error": { "code": "ALIAS_TAKEN", "message": "Кастомный алиас 'my-promo' уже занят", "request_id": "req_def456", "timestamp": "2026-03-03T10:00:00Z" } }

#3.2 REST API для редиректа

GET /{short_code} Host: short.link

Ответ 301 Moved Permanently:

HTTP/1.1 301 Moved Permanently Location: https://example.com/very/long/path?param1=value1&param2=value2 Cache-Control: public, max-age=31536000 X-Request-ID: req_xyz789

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

#3.3 REST API для статистики

GET /api/v1/links/{short_code}/stats Authorization: Bearer <token>

Ответ 200 OK:

{ "short_code": "abc123", "total_clicks": 1542, "clicks_by_date": [ {"date": "2026-03-01", "clicks": 234}, {"date": "2026-03-02", "clicks": 456}, {"date": "2026-03-03", "clicks": 852} ], "clicks_by_country": [ {"country": "US", "clicks": 600}, {"country": "GB", "clicks": 400}, {"country": "DE", "clicks": 542} ], "clicks_by_device": [ {"device": "mobile", "clicks": 900}, {"device": "desktop", "clicks": 642} ] }

#3.4 Python-реализация API (FastAPI)

from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel, HttpUrl, Field from datetime import datetime, timedelta import uuid app = FastAPI(title="URL Shortener API", version="1.0.0") class LinkCreateRequest(BaseModel): long_url: HttpUrl custom_alias: str | None = Field(None, min_length=3, max_length=20) expires_in_days: int | None = Field(None, ge=1, le=365) class LinkResponse(BaseModel): id: str short_code: str short_url: str long_url: str created_at: datetime expires_at: datetime | None click_count: int class ErrorResponse(BaseModel): code: str message: str request_id: str timestamp: datetime @app.post("/api/v1/links", response_model=LinkResponse, status_code=201) async def create_link(request: Request, payload: LinkCreateRequest): request_id = str(uuid.uuid4()) # Проверка кастомного алиаса на уникальность if payload.custom_alias: existing = await db.links.find_one({"short_code": payload.custom_alias}) if existing: raise HTTPException( status_code=409, detail=ErrorResponse( code="ALIAS_TAKEN", message=f"Алиас '{payload.custom_alias}' уже занят", request_id=request_id, timestamp=datetime.utcnow() ).dict() ) # Генерация короткого кода short_code = payload.custom_alias or await generate_short_code() # Сохранение в БД link = { "id": f"lnk_{uuid.uuid4().hex[:8]}", "short_code": short_code, "long_url": str(payload.long_url), "user_id": request.state.user_id, "created_at": datetime.utcnow(), "expires_at": datetime.utcnow() + timedelta(days=payload.expires_in_days) if payload.expires_in_days else None, "click_count": 0 } await db.links.insert_one(link) return LinkResponse( id=link["id"], short_code=short_code, short_url=f"https://short.link/{short_code}", long_url=link["long_url"], created_at=link["created_at"], expires_at=link["expires_at"], click_count=0 ) @app.get("/{short_code}") async def redirect(short_code: str, request: Request): link = await db.links.find_one({"short_code": short_code}) if not link: raise HTTPException(status_code=404, detail="Ссылка не найдена") # Проверка срока действия if link.get("expires_at") and link["expires_at"] < datetime.utcnow(): raise HTTPException(status_code=410, detail="Ссылка истекла") # Асинхронное обновление статистики (не блокируем редирект) app.state.task_queue.put(("increment_click", short_code, request)) return RedirectResponse(url=link["long_url"], status_code=301)

#4. Модель данных (Data Model Design)

#4.1 Схема базы данных (PostgreSQL)

-- Таблица ссылок CREATE TABLE links ( id VARCHAR(20) PRIMARY KEY, short_code VARCHAR(20) NOT NULL UNIQUE, long_url VARCHAR(2048) NOT NULL, user_id BIGINT, created_at TIMESTAMP NOT NULL DEFAULT NOW(), expires_at TIMESTAMP, click_count INT NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT TRUE ); -- Индексы для ускорения поиска CREATE INDEX idx_links_short_code ON links(short_code); CREATE INDEX idx_links_user_id ON links(user_id); CREATE INDEX idx_links_created_at ON links(created_at); CREATE INDEX idx_links_expires_at ON links(expires_at) WHERE expires_at IS NOT NULL; -- Таблица кликов (для детальной статистики) CREATE TABLE link_clicks ( id BIGSERIAL PRIMARY KEY, link_id VARCHAR(20) NOT NULL REFERENCES links(id), clicked_at TIMESTAMP NOT NULL DEFAULT NOW(), country_code VARCHAR(2), device_type VARCHAR(20), user_agent TEXT, ip_address INET, referer VARCHAR(2048) ); -- Индексы для аналитики CREATE INDEX idx_clicks_link_id ON link_clicks(link_id); CREATE INDEX idx_clicks_date ON link_clicks(clicked_at); CREATE INDEX idx_clicks_country ON link_clicks(country_code);

#4.2 Альтернатива: NoSQL (MongoDB)

Для URL shortener хорошо подходит документная БД — простая схема, горизонтальное масштабирование:

# MongoDB схема { "_id": "lnk_7x8y9z", "short_code": "abc123", "long_url": "https://example.com/long/path", "user_id": 12345, "created_at": ISODate("2026-03-03T10:00:00Z"), "expires_at": ISODate("2026-04-02T10:00:00Z"), "click_count": 1542, "is_active": True, "metadata": { "title": "Example Page", "description": "Page description" } } # Индексы db.links.createIndex({ "short_code": 1 }, { unique: true }) db.links.createIndex({ "user_id": 1 }) db.links.createIndex({ "expires_at": 1 }, { expireAfterSeconds: 0 }) # TTL индекс

Преимущества MongoDB:

  • Гибкая схема (легко добавлять поля)
  • Встроенный TTL для автоудаления
  • Горизонтальное масштабирование через шардирование

Недостатки:

  • Нет транзакций (в старых версиях)
  • Сложнее делать JOIN-подобные запросы

#4.3 Стратегия шардирования (Sharding Strategy)

Когда одна база не справляется, используем шардирование.

Ключ шардирования: short_code (равномерное распределение)

Шард 1: short_code A-F
Шард 2: short_code G-L
Шард 3: short_code M-R
Шард 4: short_code S-Z

Проблема: Неравномерное распределение (буквы используются с разной частотой).

Решение: Хэш-шардирование:

def get_shard_id(short_code: str) -> int: """Определяет номер шарда по короткому коду.""" hash_value = hash(short_code) & 0xFFFFFFFF return hash_value % NUM_SHARDS # NUM_SHARDS = 4

Альтернатива: Шардирование по user_id (если ссылки привязаны к пользователям).


#5. Архитектурный дизайн (Architecture Design)

#5.1 Высокоуровневая архитектура (High-Level Design)

┌─────────────────────────────────────────────────────────────────────────┐
│                              Clients                                     │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐                              │
│  │   Web    │  │  Mobile  │  │   API    │                              │
│  │  Browser │  │   App    │  │  Clients │                              │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘                              │
└───────┼─────────────┼─────────────┼─────────────────────────────────────┘
        │             │             │
        └─────────────┴─────────────┘
                      │
                      ▼
        ┌─────────────────────────┐
        │      Load Balancer      │  ← Nginx / HAProxy / AWS ALB
        │   (распределение load)  │
        └────────────┬────────────┘
                     │
        ┌────────────┼────────────┐
        │            │            │
        ▼            ▼            ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│   Web App    │ │   Web App    │ │   Web App    │  ← API Servers (FastAPI)
│   Server 1   │ │   Server 2   │ │   Server N   │     (stateless, горизонтально)
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
       │                │                │
       └────────────────┼────────────────┘
                        │
        ┌───────────────┼───────────────┐
        │               │               │
        ▼               ▼               ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│    Redis     │ │  PostgreSQL  │ │    Kafka     │
│    Cache     │ │   Primary    │ │   (events)   │
│  (кэш URL)   │ │   + Replica  │ │  (аналитика) │
└──────────────┘ └──────────────┘ └──────┬───────┘
                                         │
                                         ▼
                                  ┌──────────────┐
                                  │  Analytics   │
                                  │   Worker     │  ← Обработка кликов
                                  └──────────────┘

#5.2 Компоненты системы

КомпонентТехнологияНазначение
Load BalancerNginx / AWS ALBРаспределение запросов между серверами
API ServersFastAPI (Python)Обработка HTTP-запросов, бизнес-логика
CacheRedisКэширование популярных URL (снижает нагрузку на БД)
DatabasePostgreSQLХранение ссылок и метаданных
Message QueueKafka / RabbitMQАсинхронная обработка статистики кликов
Analytics WorkerPython + CeleryАгрегация статистики, обновление счётчиков

#5.3 Поток данных (Data Flow)

#Создание ссылки

┌────────┐     POST /links      ┌──────────────┐     ┌─────────┐
│ Client │ ────────────────────> │ Load Balancer │ ──> │  API    │
└────────┘                      └──────────────┘     │ Server  │
                                                    └────┬────┘
                                                         │
                    ┌────────────────────────────────────┼────────────────────────────────────┐
                    │                                    │                                    │
                    ▼                                    ▼                                    ▼
            ┌───────────────┐                   ┌───────────────┐                   ┌───────────────┐
            │  Validate URL │                   │ Generate Code │                   │  Check Alias  │
            └───────────────┘                   └───────────────┘                   └───────────────┘
                                                         │
                                                         ▼
                                                  ┌───────────────┐
                                                  │  INSERT into  │
                                                  │  PostgreSQL   │
                                                  └───────────────┘
                                                         │
                                                         ▼
            ┌────────────────────────────────────────────────────────────────────────────────────┐
            │                                    Response                                       │
            │  { "short_code": "abc123", "short_url": "https://short.link/abc123", ... }        │
            └────────────────────────────────────────────────────────────────────────────────────┘

#Редирект (критичный путь)

┌────────┐    GET /abc123     ┌──────────────┐     ┌─────────┐
│ Client │ ─────────────────> │ Load Balancer │ ──> │  API    │
└────────┘                    └──────────────┘     │ Server  │
                                                  └────┬────┘
                                                       │
                          ┌────────────────────────────┼────────────────────────────┐
                          │ 1. Check Redis Cache      │                            │
                          ▼                            │                            │
                  ┌───────────────┐                    │                            │
                  │     Redis     │ ── HIT ──────────> │                            │
                  │  (кэш URL)    │                    │                            │
                  └───────────────┘                    │                            │
                          │                            │                            │
                          │ MISS                       │                            │
                          ▼                            │                            │
                  ┌───────────────┐                    │                            │
                  │  PostgreSQL   │ ──────────────────> │                            │
                  │  (основное    │                    │                            │
                  │   хранилище)  │                    │                            │
                  └───────────────┘                    │                            │
                                                       │                            │
                                                       ▼                            ▼
                                              ┌───────────────┐            ┌───────────────┐
                                              │  301 Redirect │            │  Kafka Event  │
                                              │  Location:    │            │  (click)      │
                                              │  long_url     │            └───────────────┘
                                              └───────────────┘

#5.4 Python-реализация сервиса с кэшированием

import redis import json from typing import Optional from datetime import timedelta class URLShortenerService: def __init__( self, db_connection, redis_client: redis.Redis, cache_ttl: timedelta = timedelta(hours=24) ): self.db = db_connection self.redis = redis_client self.cache_ttl = cache_ttl async def get_long_url(self, short_code: str) -> Optional[str]: """ Получение длинного URL по короткому коду. Приоритет: Redis кэш → PostgreSQL. """ # 1. Проверка кэша cache_key = f"url:{short_code}" cached = self.redis.get(cache_key) if cached: # Кэш-хит — возвращаем сразу return cached.decode('utf-8') # 2. Поиск в БД link = await self.db.links.find_one( {"short_code": short_code, "is_active": True} ) if not link: return None # Проверка срока действия if link.get("expires_at") and link["expires_at"] < datetime.utcnow(): return None # 3. Запись в кэш long_url = link["long_url"] self.redis.setex(cache_key, self.cache_ttl, long_url) return long_url async def create_link( self, long_url: str, user_id: int, custom_alias: Optional[str] = None, expires_in_days: Optional[int] = None ) -> dict: """Создание новой короткой ссылки.""" # Генерация или проверка кастомного алиаса if custom_alias: # Проверка уникальности existing = await self.db.links.find_one({"short_code": custom_alias}) if existing: raise AliasTakenError(f"Алиас '{custom_alias}' уже занят") short_code = custom_alias else: short_code = await self._generate_unique_code() # Сохранение в БД link = { "id": f"lnk_{uuid.uuid4().hex[:8]}", "short_code": short_code, "long_url": long_url, "user_id": user_id, "created_at": datetime.utcnow(), "expires_at": datetime.utcnow() + timedelta(days=expires_in_days) if expires_in_days else None, "click_count": 0, "is_active": True } await self.db.links.insert_one(link) # Пре-популяция кэша (оптимизация) cache_key = f"url:{short_code}" self.redis.setex(cache_key, self.cache_ttl, long_url) return { "short_code": short_code, "short_url": f"https://short.link/{short_code}", "long_url": long_url } async def _generate_unique_code(self, length: int = 6) -> str: """Генерация уникального короткого кода.""" import random import string alphabet = string.ascii_letters + string.digits # a-zA-Z0-9 while True: code = ''.join(random.choices(alphabet, k=length)) existing = await self.db.links.find_one({"short_code": code}) if not existing: return code async def record_click(self, short_code: str, request_info: dict): """Асинхронная запись статистики клика.""" # Отправка события в Kafka для асинхронной обработки event = { "short_code": short_code, "timestamp": datetime.utcnow().isoformat(), "country": request_info.get("country"), "device": request_info.get("device"), "user_agent": request_info.get("user_agent"), "ip": request_info.get("ip") } self.kafka_producer.send("link_clicks", value=event)

#6. Масштабирование (Scaling Considerations)

#6.1 Узкие места (Bottlenecks)

КомпонентПотенциальное узкое местоРешение
База данныхЗапись при создании ссылокРепликация, шардирование
База данныхЧтение при редиректеКэширование в Redis
КэшCache stampede (одновременная инвалидация)Cache locking, probabilistic early expiration
API серверыCPU при генерации кодовГоризонтальное масштабирование
СетьПолоса пропускания для редиректовCDN для статики, сжатие

#6.2 Стратегия кэширования (Caching Strategy)

Что кэшировать:

  • Популярные URL (80/20 правило: 20% ссылок дают 80% трафика)
  • TTL кэша: 24 часа (баланс между актуальностью и нагрузкой)

Проблема Cache Stampede: Когда популярная ссылка истекает в кэше, тысячи запросов одновременно идут в БД.

Решение 1: Cache Locking

import redis.lock async def get_long_url_with_lock(self, short_code: str) -> Optional[str]: cache_key = f"url:{short_code}" lock_key = f"lock:{short_code}" # Попытка получить кэш cached = self.redis.get(cache_key) if cached: return cached.decode('utf-8') # Попытка захватить блокировку lock = self.redis.lock(lock_key, timeout=10, blocking_timeout=1) if lock.acquire(blocking=False): try: # Двойная проверка после захвата блокировки cached = self.redis.get(cache_key) if cached: return cached.decode('utf-8') # Загрузка из БД link = await self._load_from_db(short_code) if link: self.redis.setex(cache_key, self.cache_ttl, link["long_url"]) return link["long_url"] finally: lock.release() else: # Ждём пока другой процесс загрузит данные await asyncio.sleep(0.1) return await self.get_long_url_with_lock(short_code) return None

Решение 2: Probabilistic Early Expiration

import random async def get_long_url_with_early_expiration(self, short_code: str) -> Optional[str]: cache_key = f"url:{short_code}" cached = self.redis.get(cache_key) if cached: # Проверка TTL ttl = self.redis.ttl(cache_key) if ttl > 0 and ttl < 300: # Последние 5 минут жизни кэша # С вероятностью 10% обновляем кэш асинхронно if random.random() < 0.1: asyncio.create_task(self._refresh_cache_async(short_code)) return cached.decode('utf-8') # Кэш пуст — загружаем из БД return await self._load_and_cache(short_code)

#6.3 Репликация базы данных (Database Replication)

┌─────────────────┐
│   Primary DB    │  ← Запись (CREATE, UPDATE)
│   (read-write)  │
└────────┬────────┘
         │
    ┌────┴────┐
    │         │
    ▼         ▼
┌─────────┐ ┌─────────┐
│ Replica │ │ Replica │  ← Чтение (SELECT для редиректов)
│   1     │ │   2     │
│ (read)  │ │ (read)  │
└─────────┘ └─────────┘

Конфигурация PostgreSQL:

-- Настройка репликации ALTER SYSTEM SET wal_level = replica; ALTER SYSTEM SET max_wal_senders = 10; ALTER SYSTEM SET max_replication_slots = 10; -- Создание реплики pg_basebackup -h primary_host -D /var/lib/postgresql/data -U replicator -P -R

Чтение из реплик в Python:

from psycopg2 import pool class DatabaseRouter: def __init__(self): self.primary_pool = pool.SimpleConnectionPool(1, 10, host="primary.db") self.replica_pools = [ pool.SimpleConnectionPool(1, 20, host="replica1.db"), pool.SimpleConnectionPool(1, 20, host="replica2.db"), ] self.replica_index = 0 def get_read_connection(self): """Round-robin выбор реплики для чтения.""" pool = self.replica_pools[self.replica_index] self.replica_index = (self.replica_index + 1) % len(self.replica_pools) return pool.getconn() def get_write_connection(self): """Всегда пишем в primary.""" return self.primary_pool.getconn()

#6.4 Горизонтальное масштабирование API

# docker-compose.yml для масштабирования version: '3.8' services: api: build: . deploy: replicas: 4 # 4 экземпляра API resources: limits: cpus: '1' memory: 512M environment: - REDIS_URL=redis://redis:6379 - DB_URL=postgresql://user:pass@primary.db/shortener depends_on: - redis - postgres redis: image: redis:7-alpine command: redis-server --appendonly yes postgres: image: postgres:15 environment: POSTGRES_DB: shortener POSTGRES_USER: user POSTGRES_PASSWORD: pass volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata:

#7. Надёжность (Reliability Patterns)

#7.1 Резервирование (Redundancy)

┌─────────────────────────────────────────────────────────────────┐
│                        Region: us-east-1                        │
│                                                                 │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐             │
│  │   API       │  │   API       │  │   API       │             │
│  │  Server 1   │  │  Server 2   │  │  Server 3   │             │
│  └─────────────┘  └─────────────┘  └─────────────┘             │
│                                                                 │
│  ┌─────────────┐  ┌─────────────┐                              │
│  │   Redis     │  │   Redis     │                              │
│  │  Primary    │──│  Replica    │  (Sentinel для failover)     │
│  └─────────────┘  └─────────────┘                              │
│                                                                 │
│  ┌─────────────┐  ┌─────────────┐                              │
│  │  Postgres   │──│  Postgres   │  (Streaming Replication)     │
│  │  Primary    │  │  Replica    │                              │
│  └─────────────┘  └─────────────┘                              │
└─────────────────────────────────────────────────────────────────┘

#7.2 Failover для Redis (Sentinel)

import redis.sentinel # Подключение через Sentinel sentinel = redis.sentinel.Sentinel([ ('sentinel1', 26379), ('sentinel2', 26379), ('sentinel3', 26379), ]) # Получение master для записи master = sentinel.master_for('mymaster', socket_timeout=0.1) # Получение slave для чтения slave = sentinel.slave_for('mymaster', socket_timeout=0.1) # При падении master Sentinel автоматически выберет нового

#7.3 Circuit Breaker для внешних зависимостей

from circuitbreaker import circuit class URLShortenerService: @circuit(failure_threshold=5, recovery_timeout=30) async def call_external_api(self, url: str) -> dict: """Вызов внешнего API с circuit breaker.""" async with aiohttp.ClientSession() as session: async with session.get(url) as response: return await response.json() # При 5 последовательных ошибках circuit "размыкается" # и следующие вызовы сразу возвращают ошибку без попытки # Через 30 секунд circuit переходит в "полуоткрытое" состояние # и пробует один запрос

#7.4 Graceful Degradation

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

async def redirect_with_fallback(self, short_code: str, request: Request) -> Response: """Редирект с деградацией при недоступности компонентов.""" # Попытка получить из кэша try: long_url = await self.get_long_url(short_code) if long_url: # Асинхронная запись статистики (не критично) asyncio.create_task(self.record_click_safe(short_code, request)) return RedirectResponse(url=long_url, status_code=301) except redis.RedisError: # Кэш недоступен — идём сразу в БД pass # Попытка получить из БД try: link = await self.db.links.find_one({"short_code": short_code}) if link: # Статистику не пишем (Kafka недоступна) return RedirectResponse(url=link["long_url"], status_code=301) except Exception as e: logger.error(f"Database error: {e}") # Полный фейл — возвращаем ошибку raise HTTPException(status_code=503, detail="Сервис временно недоступен")

#8. Генерация коротких кодов (Short Code Generation)

#8.1 Стратегии генерации

СтратегияПлюсыМинусы
UUID (сокращённый)Уникальность гарантированаДлинный (8+ символов)
Хэш (MD5/SHA, base62)ДетерминированныйВозможны коллизии
Sequence + base62Короткий, уникальныйТребует синхронизации
Random + проверкаПростойМожет потребовать несколько попыток

#8.2 Base62 кодирование

import string class Base62Encoder: """Кодирование чисел в base62 (a-zA-Z0-9).""" ALPHABET = string.ascii_letters + string.digits # 62 символа BASE = 62 @classmethod def encode(cls, num: int, min_length: int = 6) -> str: """Кодирование числа в строку base62.""" if num == 0: return cls.ALPHABET[0] * min_length chars = [] while num > 0: num, rem = divmod(num, cls.BASE) chars.append(cls.ALPHABET[rem]) result = ''.join(reversed(chars)) return result.zfill(min_length) # Дополнение до min_length @classmethod def decode(cls, s: str) -> int: """Декодирование строки base62 в число.""" num = 0 for char in s: num = num * cls.BASE + cls.ALPHABET.index(char) return num # Пример использования encoder = Base62Encoder() code = encoder.encode(12345) # "3D7" original = encoder.decode(code) # 12345

#8.3 Генерация с использованием sequence (PostgreSQL)

-- Создание sequence CREATE SEQUENCE short_code_seq START WITH 1; -- Получение следующего значения SELECT nextval('short_code_seq'); -- 1, 2, 3, ...
class SequenceCodeGenerator: def __init__(self, db_connection, encoder: Base62Encoder): self.db = db_connection self.encoder = encoder async def generate(self) -> str: """Генерация уникального кода через sequence.""" async with self.db.acquire() as conn: # Атомарное получение следующего значения result = await conn.fetchval("SELECT nextval('short_code_seq')") return self.encoder.encode(result, min_length=6)

#9. Полный пример: развёртывание системы

#9.1 Docker Compose для локальной разработки

# docker-compose.yml version: '3.8' services: api: build: context: . dockerfile: Dockerfile ports: - "8000:8000" environment: - REDIS_URL=redis://redis:6379 - DATABASE_URL=postgresql://shortener:secret@postgres:5432/shortener - KAFKA_BOOTSTRAP_SERVERS=kafka:9092 depends_on: redis: condition: service_healthy postgres: condition: service_healthy volumes: - ./app:/app command: uvicorn app.main:app --host 0.0.0.0 --reload redis: image: redis:7-alpine ports: - "6379:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5 postgres: image: postgres:15-alpine environment: POSTGRES_DB: shortener POSTGRES_USER: shortener POSTGRES_PASSWORD: secret ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data - ./init.sql:/docker-entrypoint-initdb.d/init.sql healthcheck: test: ["CMD-SHELL", "pg_isready -U shortener"] interval: 5s timeout: 3s retries: 5 kafka: image: confluentinc/cp-kafka:7.4.0 environment: KAFKA_BROKER_ID: 1 KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 depends_on: - zookeeper zookeeper: image: confluentinc/cp-zookeeper:7.4.0 environment: ZOOKEEPER_CLIENT_PORT: 2181 analytics-worker: build: . environment: - DATABASE_URL=postgresql://shortener:secret@postgres:5432/shortener - KAFKA_BOOTSTRAP_SERVERS=kafka:9092 depends_on: - kafka - postgres command: python -m app.workers.analytics volumes: pgdata:

#9.2 Init SQL для PostgreSQL

-- init.sql CREATE DATABASE shortener; \c shortener; CREATE TABLE links ( id VARCHAR(20) PRIMARY KEY, short_code VARCHAR(20) NOT NULL UNIQUE, long_url VARCHAR(2048) NOT NULL, user_id BIGINT, created_at TIMESTAMP NOT NULL DEFAULT NOW(), expires_at TIMESTAMP, click_count INT NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT TRUE ); CREATE TABLE link_clicks ( id BIGSERIAL PRIMARY KEY, link_id VARCHAR(20) NOT NULL REFERENCES links(id), clicked_at TIMESTAMP NOT NULL DEFAULT NOW(), country_code VARCHAR(2), device_type VARCHAR(20), user_agent TEXT, ip_address INET ); CREATE INDEX idx_links_short_code ON links(short_code); CREATE INDEX idx_links_user_id ON links(user_id); CREATE INDEX idx_clicks_link_id ON link_clicks(link_id); CREATE INDEX idx_clicks_date ON link_clicks(clicked_at); CREATE SEQUENCE short_code_seq START WITH 1000;

#10. Итоговая архитектура

graph TB subgraph Clients Web[Web Browser] Mobile[Mobile App] API[API Clients] end subgraph Edge LB[Load Balancer<br/>Nginx/ALB] CDN[CDN<br/>CloudFront] end subgraph Application API1[API Server 1<br/>FastAPI] API2[API Server 2<br/>FastAPI] API3[API Server 3<br/>FastAPI] end subgraph Data Redis[(Redis Cache<br/>Cluster)] Primary[(PostgreSQL<br/>Primary)] Replica1[(PostgreSQL<br/>Replica 1)] Replica2[(PostgreSQL<br/>Replica 2)] end subgraph Async Kafka[Kafka Cluster<br/>Events] Worker[Analytics Worker<br/>Celery] end Web --> LB Mobile --> LB API --> LB LB --> API1 LB --> API2 LB --> API3 API1 --> Redis API2 --> Redis API3 --> Redis API1 --> Primary API2 --> Replica1 API3 --> Replica2 API1 --> Kafka API2 --> Kafka API3 --> Kafka Kafka --> Worker Worker --> Primary Primary -.Replication.-> Replica1 Primary -.Replication.-> Replica2

#11. Контрольный список (Checklist)

При проектировании системы проверьте:

  • Требования: Функциональные и нефункциональные требования задокументированы
  • Оценка нагрузки: Back-of-the-envelope calculations выполнены
  • API: Контракты определены до реализации
  • Данные: Схема БД спроектирована, индексы добавлены
  • Кэш: Стратегия кэширования определена, обработаны edge cases
  • Масштабирование: План горизонтального масштабирования готов
  • Надёжность: Redundancy, failover, circuit breaker предусмотрены
  • Мониторинг: Метрики, логи, алерты настроены

#12. Дополнительные ресурсы

  • System Design Primer (GitHub)
  • Designing Data-Intensive Applications (книга)
  • High Scalability (блог)
  • AWS Architecture Center

Ключевой вывод: Проектирование системы — это баланс между требованиями, сложностью и стоимостью. Начинайте с простого, измеряйте, масштабируйте по мере роста.