Полный цикл проектирования: от требований до архитектуры и масштабирования. Практическое задание.
Полный цикл проектирования распределённой системы: от сбора требований до архитектуры, масштабирования и обеспечения надёжности. Этот кейс объединяет все концепции курса в практическом примере.
Проектирование системы с нуля — одна из самых сложных задач для разработчика. Нужно учесть требования, оценить нагрузку, выбрать технологии, спроектировать архитектуру и предусмотреть масштабирование.
В этом кейсе мы спроектируем URL shortener — сервис для сокращения длинных ссылок (аналог bit.ly, tinyurl.com). Это классическая задача системного дизайна, которая охватывает:
Первый шаг — понять, что мы строим и для кого. Требования делятся на функциональные и нефункциональные.
Описывают, что система должна делать:
| ID | Требование | Приоритет |
|---|---|---|
| FR1 | Пользователь может отправить длинный URL и получить короткий алиас | Must have |
| FR2 | При переходе по короткой ссылке происходит редирект на оригинальный URL | Must have |
| FR3 | Пользователь может зарегистрироваться и управлять своими ссылками | Should have |
| FR4 | Система собирает статистику переходов (количество, страна, устройство) | Should have |
| FR5 | Пользователь может задать кастомный алиас (например, short.link/my-promo) | Could have |
| FR6 | Ссылки имеют срок жизни (TTL) и автоматически удаляются | Won't have (v1) |
| FR7 | API для интеграции с другими сервисами | Should have |
MVP (Minimum Viable Product) включает FR1 и FR2 — базовая функциональность сокращения и редиректа.
Описывают, как система должна работать:
| ID | Требование | Значение | Обоснование |
|---|---|---|---|
| NFR1 | Доступность (Availability) | 99.9% | Пользователи должны иметь доступ к сервису почти всегда |
| NFR2 | Задержка (Latency) | < 100ms для редиректа | Редирект должен быть мгновенным, иначе пользователи уйдут |
| NFR3 | Пропускная способность | 1000 запросов/сек (создание), 100 000 запросов/сек (редирект) | Редиректов намного больше, чем созданий |
| NFR4 | Консистентность | Eventual consistency допустима для статистики | Статистика может обновляться с задержкой |
| NFR5 | Масштабируемость | Горизонтальное масштабирование | Ожидаем рост нагрузки в 10x за год |
| NFR6 | Надёжность (Durability) | Данные не должны теряться | Ссылки должны работать годами |
Делаем back-of-the-envelope calculations — приблизительные расчёты на салфетке. Точность не важна, важен порядок величин.
| Параметр | Значение | Обоснование |
|---|---|---|
| Пользователей в день | 1 000 000 | Целевая аудитория |
| % создающих ссылки | 1% | Типичная конверсия |
| Созданий ссылок в день | 10 000 | 1M × 1% |
| Редиректов на 1 ссылку | 100 | Средняя популярность |
| Редиректов в день | 1 000 000 | 10 000 × 100 |
| Срок хранения данных | 5 лет | Бизнес-требование |
Создание ссылок:
10 000 запросов / 86 400 секунд ≈ 0.12 запроса/сек
Пиковая нагрузка (×10): ~1.2 запроса/сек
Редиректы:
1 000 000 запросов / 86 400 секунд ≈ 11.6 запросов/сек
Пиковая нагрузка (×10): ~116 запросов/сек
Вывод: Нагрузка невысокая, один сервер справится. Но проектируем с запасом на рост.
Для каждой ссылки храним:
| Поле | Размер | Тип |
|---|---|---|
| short_code | 8 байт | VARCHAR(8) |
| long_url | 2048 байт | VARCHAR(2048) |
| user_id | 8 байт | BIGINT |
| created_at | 8 байт | TIMESTAMP |
| expires_at | 8 байт | TIMESTAMP (nullable) |
| click_count | 4 байта | 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 лет — помещается на одном диске. Но для отказоустойчивости нужна репликация.
Входящий трафик (создание ссылок):
10 000 запросов × 1 KB ≈ 10 MB/день
Исходящий трафик (редиректы):
1 000 000 запросов × 500 байт (HTTP 301 + заголовки) ≈ 500 MB/день
Пиковая полоса пропускания:
500 MB / 86 400 сек × 8 бит/байт × 10 (пик) ≈ 460 Kbit/сек
Вывод: Полоса пропускания не является узким местом.
Проектируем API до реализации — contract-first подход.
POST /api/v1/links
Content-Type: application/json
Authorization: Bearer <token>
{
"long_url": "https://example.com/very/long/path?param1=value1¶m2=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¶m2=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"
}
}GET /{short_code}
Host: short.linkОтвет 301 Moved Permanently:
HTTP/1.1 301 Moved Permanently
Location: https://example.com/very/long/path?param1=value1¶m2=value2
Cache-Control: public, max-age=31536000
X-Request-ID: req_xyz789Важно: Используем 301 (перманентный редирект), а не 302. Браузеры и CDN кэшируют 301, что снижает нагрузку на сервер.
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}
]
}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)-- Таблица ссылок
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);Для 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:
Недостатки:
Когда одна база не справляется, используем шардирование.
Ключ шардирования: 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 (если ссылки привязаны к пользователям).
┌─────────────────────────────────────────────────────────────────────────┐
│ 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 │ ← Обработка кликов
└──────────────┘
| Компонент | Технология | Назначение |
|---|---|---|
| Load Balancer | Nginx / AWS ALB | Распределение запросов между серверами |
| API Servers | FastAPI (Python) | Обработка HTTP-запросов, бизнес-логика |
| Cache | Redis | Кэширование популярных URL (снижает нагрузку на БД) |
| Database | PostgreSQL | Хранение ссылок и метаданных |
| Message Queue | Kafka / RabbitMQ | Асинхронная обработка статистики кликов |
| Analytics Worker | Python + Celery | Агрегация статистики, обновление счётчиков |
┌────────┐ 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 │ └───────────────┘
└───────────────┘
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)| Компонент | Потенциальное узкое место | Решение |
|---|---|---|
| База данных | Запись при создании ссылок | Репликация, шардирование |
| База данных | Чтение при редиректе | Кэширование в Redis |
| Кэш | Cache stampede (одновременная инвалидация) | Cache locking, probabilistic early expiration |
| API серверы | CPU при генерации кодов | Горизонтальное масштабирование |
| Сеть | Полоса пропускания для редиректов | CDN для статики, сжатие |
Что кэшировать:
Проблема 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)┌─────────────────┐
│ 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()# 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:┌─────────────────────────────────────────────────────────────────┐
│ 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 │ │
│ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
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 автоматически выберет нового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 переходит в "полуоткрытое" состояние
# и пробует один запросКогда часть системы недоступна, продолжаем работать в ограниченном режиме:
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="Сервис временно недоступен")| Стратегия | Плюсы | Минусы |
|---|---|---|
| UUID (сокращённый) | Уникальность гарантирована | Длинный (8+ символов) |
| Хэш (MD5/SHA, base62) | Детерминированный | Возможны коллизии |
| Sequence + base62 | Короткий, уникальный | Требует синхронизации |
| Random + проверка | Простой | Может потребовать несколько попыток |
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-- Создание 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)# 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:-- 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;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При проектировании системы проверьте:
Ключевой вывод: Проектирование системы — это баланс между требованиями, сложностью и стоимостью. Начинайте с простого, измеряйте, масштабируйте по мере роста.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.