← Назад к вопросам

Что нужно учитывать при разработке, чтобы приложение могло быть горизонтально масштабируемым?

2.3 Middle🔥 121 комментариев
#DevOps и инфраструктура#Django

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Горизонтальное масштабирование: как проектировать приложение

Горизонтальное масштабирование означает добавление большего количества серверов, а не увеличение мощности одного сервера (вертикальное масштабирование). После 10+ лет опыта я разработал набор принципов, которые обеспечивают возможность масштабирования.

1. Stateless дизайн приложения

Это самое критичное правило.

# ❌ ПЛОХО: Stateful приложение
class AuthService:
    def __init__(self):
        self.logged_in_users = {}  # Состояние в памяти!
    
    def login(self, user_id):
        self.logged_in_users[user_id] = datetime.now()
    
    def is_logged_in(self, user_id):
        return user_id in self.logged_in_users

# Проблема: если у нас 3 сервера, они не знают о пользователях друг друга
# Пользователь логинится на сервер 1, запрос идёт на сервер 2 → не залогинен

# ✅ ХОРОШО: Stateless приложение
class AuthService:
    def __init__(self, redis_client):
        self.redis = redis_client
    
    def login(self, user_id):
        self.redis.set(f"user:{user_id}:logged_in", "true", ex=3600)
    
    def is_logged_in(self, user_id):
        return self.redis.exists(f"user:{user_id}:logged_in")

# Все серверы смотрят в один Redis → согласованность

Правило: Все состояние должно быть во внешних хранилищах (БД, Redis, S3).

2. Отсутствие локального кэша и сессий

# ❌ ПЛОХО: Кэш в памяти приложения
from functools import lru_cache

@lru_cache(maxsize=1000)
def get_user(user_id):
    return db.query(User).filter(User.id == user_id).first()

# Проблемы:
# - Каждый сервер имеет свой кэш → разные данные
# - Обновление на сервере 1 не видно на сервере 2
# - Память растёт неконтролируемо

# ✅ ХОРОШО: Кэш во Redis
import redis

redis_client = redis.Redis(host='localhost', port=6379)

def get_user(user_id):
    # Проверяем кэш
    cached = redis_client.get(f"user:{user_id}")
    if cached:
        return json.loads(cached)
    
    # Берём из БД
    user = db.query(User).filter(User.id == user_id).first()
    
    # Кэшируем
    redis_client.set(
        f"user:{user_id}",
        json.dumps(user.to_dict()),
        ex=3600  # 1 час TTL
    )
    return user

# Все серверы видят один кэш

3. Сессии в распределённом хранилище

# ❌ ПЛОХО: Сессии на сервере
# app.py (Flask)
from flask import session

@app.route('/login', methods=['POST'])
def login():
    user = authenticate(username, password)
    session['user_id'] = user.id  # Сохраняется на сервере
    return "Logged in"

# Проблема: load balancer отправил запрос на другой сервер
# Сессия там не существует → logout

# ✅ ХОРОШО: Сессии в Redis
from flask_session import Session
from flask_session.backends.redis import RedisSessionInterface

app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_REDIS'] = redis.from_url('redis://localhost:6379')
Session(app)

@app.route('/login', methods=['POST'])
def login():
    user = authenticate(username, password)
    session['user_id'] = user.id  # Сохраняется в Redis
    return "Logged in"

# Все серверы читают из Redis → всё работает

4. Асинхронная обработка длительных задач

# ❌ ПЛОХО: Длительная операция в request-response цикле
@app.post("/send-email")
def send_email(email: str):
    # Это займёт 5 секунд
    smtp = smtplib.SMTP('smtp.gmail.com', 587)
    smtp.send_message(create_email(email))
    smtp.quit()
    return {"status": "sent"}

# Проблемы:
# - HTTP запрос ждёт 5 секунд
# - Если ошибка → пользователь видит ошибку
# - При масштабировании: 100 запросов одновременно
#   = нужно 100 воркеров на 500 секунд = дорого

# ✅ ХОРОШО: Асинхронная задача через очередь
from celery import Celery
from celery.result import AsyncResult

app = FastAPI()
celery_app = Celery('tasks')

@celery_app.task
async def send_email_async(email: str):
    smtp = smtplib.SMTP('smtp.gmail.com', 587)
    smtp.send_message(create_email(email))
    smtp.quit()

@app.post("/send-email")
def send_email(email: str):
    # Добавляем в очередь и возвращаем immediately
    task = send_email_async.delay(email)
    return {"task_id": task.id, "status": "queued"}

# Преимущества:
# - HTTP возвращает мгновенно (< 100ms)
# - Celery workers обрабатывают асинхронно
# - 100 запросов = 100 задач в очереди
# - 10 workers обрабатывают их параллельно

5. Правильное использование БД

# ❌ ПЛОХО: N+1 запросы
users = db.query(User).all()
for user in users:
    posts = db.query(Post).filter(Post.user_id == user.id).all()  # 1000 запросов!
    print(f"{user.name}: {len(posts)} posts")

# При масштабировании: 1000 пользователей = 1001 запрос в БД
# На 3 серверах = 3003 запроса одновременно
# БД может не выдержать

# ✅ ХОРОШО: Eager loading
users = db.query(User).options(
    joinedload(User.posts)  # Один запрос вместо 1001!
).all()

for user in users:
    print(f"{user.name}: {len(user.posts)} posts")

# Итого: 1 запрос вместо 1001 = 1000x быстрее

6. Кэширование на всех уровнях

# На разных уровнях:

# 1. HTTP кэширование (браузер)
@app.get("/api/products")
def get_products():
    return {
        "products": products_list,
        "cache-control": "public, max-age=3600"
    }

# 2. Redis кэш (приложение)
redis_client.set("products:list", json.dumps(products), ex=3600)

# 3. Database indices
db.execute("""
    CREATE INDEX idx_products_category 
    ON products(category)
""")

# 4. Query результаты
results = db.query(Product).filter(...).all()
redis_client.set("query:products:electronics", results, ex=1800)

# 5. CDN для статики
static_url = "https://cdn.example.com/images/product-1.jpg"

7. Database sharding (разбиение данных)

# На 10 млн пользователей: одна БД не может
# Решение: sharding — разбить данные по серверам

def get_shard_number(user_id: int) -> int:
    """Определяет, на каком shard'е находится пользователь"""
    return user_id % NUM_SHARDS

def get_user(user_id: int):
    shard_num = get_shard_number(user_id)
    db_connection = get_db_connection(f"shard_{shard_num}")
    
    user = db_connection.query(User).filter(
        User.id == user_id
    ).first()
    return user

# Результат:
# - 10 млн пользователей распределены на 5 БД
# - Каждая БД имеет 2 млн записей
# - Запросы параллельны на разных серверах

8. Connection pooling

# ❌ ПЛОХО: Новое подключение для каждого запроса
for i in range(100):
    conn = psycopg2.connect(dbname="mydb", user="user", password="pass")
    # Execute query
    conn.close()

# Проблема: подключение к БД требует 100-500ms
# 100 запросов = 10-50 секунд только на подключение!

# ✅ ХОРОШО: Connection pool
from sqlalchemy import create_engine

engine = create_engine(
    "postgresql://user:password@localhost/mydb",
    pool_size=20,           # 20 постоянных подключений
    max_overflow=10,        # ещё до 10 временных
    pool_recycle=3600       # переподключаться каждый час
)

session = Session(engine)
for i in range(100):
    # Берёт подключение из пула (мгновенно)
    user = session.query(User).first()

# Результат: мгновенно, без задержек

9. Логирование структурировано

# ❌ ПЛОХО: Обычное логирование
import logging
logger = logging.getLogger()
logger.info(f"User {user_id} logged in")

# Проблемы:
# - Логи в разных форматах
# - Нельзя искать по полям
# - При масштабировании: логи на 5 серверах
#   как их все смотреть?

# ✅ ХОРОШО: Структурированное логирование
import structlog

logger = structlog.get_logger()
logger.info(
    "user_login",
    user_id=user_id,
    ip_address=request.remote_addr,
    timestamp=datetime.now(),
    server=os.getenv("HOSTNAME")
)

# В ELK Stack (Elasticsearch):
# POST /logs/_doc
# {
#   "event": "user_login",
#   "user_id": 123,
#   "ip_address": "192.168.1.1",
#   "server": "app-server-1"
# }

# Теперь можно: Kibana → поиск всех логинов на 2025-03-23

10. Health checks и graceful shutdown

# ✅ Kubernetes/Docker должны знать когда сервер здоров

@app.get("/health")
def health_check():
    # Проверяем зависимости
    if not redis_client.ping():
        return {"status": "unhealthy"}, 503
    if not db.ping():
        return {"status": "unhealthy"}, 503
    
    return {"status": "healthy", "uptime": time.time() - start_time}

# Graceful shutdown: закончить текущие запросы, потом выключиться
import signal

def signal_handler(signum, frame):
    logger.info("Shutting down gracefully...")
    # Не принимаем новые запросы
    # Ждём текущие завершиться (timeout=30s)
    # Закрываем БД и Redis
    exit(0)

signal.signal(signal.SIGTERM, signal_handler)

Чек-лист для горизонтального масштабирования

✅ Приложение stateless (всё состояние во внешних хранилищах)
✅ Нет локального кэша (используется Redis)
✅ Сессии в распределённом хранилище
✅ Длительные задачи асинхронны (Celery, RabbitMQ)
✅ Database N+1 решены (eager loading, indices)
✅ Connection pooling настроен
✅ Логирование структурированное и централизованное
✅ Health checks работают
✅ Graceful shutdown реализован
✅ Запросы идемпотентны (повторный запрос = тот же результат)
✅ Нет file uploads на локальный диск (используется S3)
✅ Версионирование API (легче обновлять)

Архитектура с 1000+ одновременными пользователями

Load Balancer (nginx)
    ↓
  ├─ App Server 1
  ├─ App Server 2
  ├─ App Server 3
  └─ App Server N
    ↓
  ├─ PostgreSQL (master-slave replication)
  ├─ Redis Cluster (кэш и сессии)
  └─ RabbitMQ (очередь задач)
    ↓
  ├─ Celery Worker 1
  ├─ Celery Worker 2
  └─ Celery Worker N

Заключение

Горизонтальное масштабирование — это не добавление серверов в конце. Это архитектурные решения, которые нужно принять в самом начале проекта. Главное правило: приложение должно быть stateless, и все её зависимости (БД, кэш, сессии, логи) должны быть распределённые и масштабируемые.

Что нужно учитывать при разработке, чтобы приложение могло быть горизонтально масштабируемым? | PrepBro