Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Кэширование в веб‑приложении: как ускорить сайт в 3–10 раз и снизить расходы на серверы

Разработка и технологии12 декабря 2025 г.
Разбираемся, как настроить кэш на уровнях приложения, сервера и CDN, чтобы страницы и API работали быстрее, а счёт за инфраструктуру стал ниже. Даём готовые примеры кода, конфиги Nginx и понятную стратегию внедрения за 1–2 недели без риска для данных.
Кэширование в веб‑приложении: как ускорить сайт в 3–10 раз и снизить расходы на серверы

  • Оглавление
  • Введение: зачем бизнесу кэш
  • Базовые принципы кэширования и где оно живёт
  • Что кэшировать: страницы, блоки, запросы к БД и внешним API
  • TTL, инвалидация и защита от "лавины" запросов
  • Пример: кэш в Python (FastAPI + Redis) с защитой от stampede
  • Микрокэш на уровне Nginx: мгновенная отдача популярных страниц
  • Кэш через CDN: когда выносить на периметр
  • HTTP-заголовки для кэша: Cache-Control, ETag, Vary
  • Измеряем эффект: командами wrk и log-метриками
  • Безопасность: что нельзя кэшировать и как не утечь данные
  • Экономика: оценка выгоды на цифрах
  • Чек‑лист внедрения и план на 2 недели
  • Частые ошибки

Введение: зачем бизнесу кэш

Кэширование — это способ отвечать быстрее и дешевле, отдавая заранее подготовленный результат вместо повторных дорогих вычислений. Вы выигрываете сразу по трём направлениям:

  • Скорость: страницы и API отдаются за миллисекунды, метрики LCP/TTFB улучшаются, конверсия растёт.
  • Стабильность: пики трафика не валят базу и бэкенд.
  • Экономия: меньше CPU/памяти и платных запросов к внешним сервисам, реже нужно масштабироваться.

В типичном проекте кэш даёт 3–10-кратный прирост производительности на популярных страницах и снижает нагрузку на базу на 50–90%.

Базовые принципы кэширования и где оно живёт

Кэш можно включать на нескольких уровнях:

  • Браузер: пользователь повторно открывает страницу, и часть данных уже в локальном кэше.
  • CDN: отдать статичный и «почти статичный» контент ближе к пользователю.
  • Балансировщик/веб-сервер (Nginx): микрокэшировать ответы на 1–10 секунд, сглаживая всплески.
  • Приложение: кэшировать тяжёлые вычисления, запросы к базе, рендеринг шаблонов.
  • База данных: кэш планов запросов, буфер страниц; обычно трогаем косвенно — оптимизируя запросы выше.

Что кэшировать: страницы, блоки, запросы к БД и внешним API

  • Полные страницы: карточка товара, список новинок, лендинги, справочные разделы.
  • Фрагменты: «блок рекомендации», «топ продаж», «акции». Это даёт гибкость — блок меняется отдельно.
  • Запросы к БД: дорогие агрегаты (COUNT с условиями, статистика), часто запрашиваемые записи.
  • Ответы внешних API: курсы валют, геоданные, каталоги партнёров — всё, что медленное и платное.

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

TTL, инвалидация и защита от "лавины" запросов

  • TTL (время жизни): сколько секунд/минут хранить копию. Пример: курс валют — 5 минут, карточка товара — 30–120 секунд, главная — 10–30 секунд.
  • Инвалидация (сброс): ключи кэша должны быть легко сбрасываемы при изменениях. Привязывайте ключи к ID сущности и версии данных.
  • Защита от лавины (cache stampede): при истечении TTL сотни запросов одновременно идут в базу. Используйте «мягкий TTL» (stale-while-revalidate), «разогрев» и распределённые блокировки.

Пример: кэш в Python (FastAPI + Redis) с защитой от stampede

Ниже рабочий пример сервиса, который выдаёт карточку товара. Мы кэшируем готовый JSON в Redis c TTL, используем распределённый замок, чтобы один запрос обновлял кэш, а остальные ждали или получали устаревшие данные в пределах короткого окна.

Подготовка:

# Запускаем Redis локально
docker run --rm -p 6379:6379 redis:7

# Установка зависимостей
pip install fastapi uvicorn redis[async] pydantic httpx

Код приложения:

# app.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import asyncio
import json
import time
from typing import Optional

import redis.asyncio as redis

app = FastAPI()
r = redis.from_url("redis://localhost:6379/0", decode_responses=True)

CACHE_TTL = 60            # основной TTL
STALE_TTL = 15            # окно, в которое можно отдавать устаревшие данные
LOCK_TTL = 10             # сколько держать замок на пересборку

class Product(BaseModel):
    id: int
    name: str
    price: float
    stock: int
    updated_at: float

# Заглушка: якобы дорогой запрос к БД
async def fetch_product_from_db(product_id: int) -> Product:
    await asyncio.sleep(0.2)  # имитация задержки БД/логики
    return Product(
        id=product_id,
        name=f"Товар #{product_id}",
        price=1999.0 + (product_id % 7) * 50,
        stock=42,
        updated_at=time.time()
    )

async def get_with_cache(product_id: int) -> dict:
    key = f"product:{product_id}:v1"  # включаем версию схемы в ключ
    lock_key = f"lock:{key}"

    raw = await r.get(key)
    if raw:
        data = json.loads(raw)
        # Проверяем, не устарело ли полностью
        if time.time() - data["_cached_at"] < CACHE_TTL:
            return {"data": data, "cached": True, "stale": False}
        # В пределах STALE_TTL разрешаем отдать старое, пока один поток обновляет
        if time.time() - data["_cached_at"] < CACHE_TTL + STALE_TTL:
            # Пытаемся поставить замок на обновление
            got_lock = await r.set(lock_key, "1", nx=True, ex=LOCK_TTL)
            if got_lock:
                # Фоновой таск обновления (без ожидания клиента)
                asyncio.create_task(refresh_cache(key, product_id, lock_key))
            return {"data": data, "cached": True, "stale": True}
        # Иначе — кэш слишком старый, падаем на обновление ниже

    # Нет кэша или он слишком старый: ставим замок
    got_lock = await r.set(lock_key, "1", nx=True, ex=LOCK_TTL)
    if got_lock:
        # Мы ответственные за обновление
        product = await fetch_product_from_db(product_id)
        payload = product.model_dump()
        payload["_cached_at"] = time.time()
        await r.set(key, json.dumps(payload), ex=CACHE_TTL + STALE_TTL)
        await r.delete(lock_key)
        return {"data": payload, "cached": False, "stale": False}
    else:
        # Кто-то уже обновляет: коротко ждём и пробуем снова
        await asyncio.sleep(0.05)
        raw2 = await r.get(key)
        if raw2:
            data2 = json.loads(raw2)
            return {"data": data2, "cached": True, "stale": True}
        # Совсем ничего нет — ждём ещё и запрашиваем напрямую, чтобы не завис
        product = await fetch_product_from_db(product_id)
        payload = product.model_dump()
        payload["_cached_at"] = time.time()
        await r.set(key, json.dumps(payload), ex=CACHE_TTL + STALE_TTL)
        return {"data": payload, "cached": False, "stale": False}

async def refresh_cache(key: str, product_id: int, lock_key: str):
    try:
        product = await fetch_product_from_db(product_id)
        payload = product.model_dump()
        payload["_cached_at"] = time.time()
        await r.set(key, json.dumps(payload), ex=CACHE_TTL + STALE_TTL)
    finally:
        await r.delete(lock_key)

@app.get("/products/{product_id}")
async def product_endpoint(product_id: int):
    if product_id <= 0:
        raise HTTPException(status_code=400, detail="invalid id")
    result = await get_with_cache(product_id)
    headers = {
        "X-Cache": "HIT" if result["cached"] else "MISS",
        "X-Cache-Stale": "1" if result["stale"] else "0",
        # Разрешим прокси держать ответ короткое время (см. микрокэш ниже)
        "Cache-Control": "public, max-age=30, stale-while-revalidate=30"
    }
    return result["data"], headers

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=False)

Запуск:

python app.py

Что даёт пример:

  • TTL + «устаревший, но допустимый» ответ уменьшает лавину запросов к БД.
  • Распределённый замок через Redis гарантирует, что обновление выполняет один процесс.
  • Версия в ключе позволяет безболезненно мигрировать формат ответа.

Микрокэш на уровне Nginx: мгновенная отдача популярных страниц

Микрокэш полезен для публичных GET-запросов. Он держит ответ 1–10 секунд, перекрывая пики. Короткий TTL безопасен: пользователь почти не заметит, а сервер разгружается.

Пример конфига:

proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=app_cache:50m max_size=1g inactive=60m use_temp_path=off;

map $request_method $no_cache {
    default 0;
    POST 1;
    PUT 1;
    PATCH 1;
    DELETE 1;
}

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_cache app_cache;
        proxy_cache_bypass $no_cache;
        proxy_no_cache $no_cache;

        proxy_cache_valid 200 302 10s;  # микрокэш 10 секунд для успешных ответов
        proxy_cache_valid 404 1s;       # коротко кэшируем 404, чтобы не бомбили
        add_header X-Proxy-Cache $upstream_cache_status;
    }
}

Важно: для страниц, зависящих от авторизации, микрокэш нужно отключать или сегментировать ключи по куке/токену.

Кэш через CDN: когда выносить на периметр

Если аудитория географически распределена или у вас много статики и «почти статичных» страниц, CDN уменьшает задержки. Что отдавать через CDN:

  • Статические ресурсы: изображения, стили, скрипты, шрифты.
  • Квазистатические HTML-страницы: каталоги, статьи, карточки, где TTL 30–120 секунд приемлем.

Рекомендации:

  • Включайте компрессию (Gzip/Brotli), webp/avif для картинок, HTTP/2/3.
  • Управляйте инвалидцией: по тегам/папкам, а не "очистить всё".
  • Внимательно настройте критерии кэш-ключа (URL + заголовки Vary), чтобы не смешивать версии для разных языков/регионов.

HTTP-заголовки для кэша: Cache-Control, ETag, Vary

  • Cache-Control: управляет TTL на стороне браузера и прокси. Пример: Cache-Control: public, max-age=60, stale-while-revalidate=60.
  • ETag: позволяет отдавать 304 «Не изменилось», если контент не поменялся.
  • Vary: подсказывает кэшу, какие заголовки меняют ответ. Например, Vary: Accept-Encoding, Accept-Language.

Для персонализированных страниц: используйте Cache-Control: private, no-store.

Измеряем эффект: командами wrk и лог-метриками

Проверяем до и после внедрения:

# Установить wrk (Linux)
sudo apt-get install wrk -y

# Нагрузка 30 секунд, 4 потока, 200 одновременных соединений
wrk -t4 -c200 -d30s http://localhost:8000/products/123

Что смотреть:

  • Requests/sec: должно вырасти кратно.
  • Latency p50/p95: должны заметно снизиться.
  • Ошибки 5xx: должны исчезнуть при пиках.
  • X-Cache/X-Proxy-Cache в логах: доля HIT > 70% для популярных страниц — хороший ориентир.

Серверные метрики: утилизация CPU бэкенда и БД, количество активных соединений, время отклика БД.

Безопасность: что нельзя кэшировать и как не утечь данные

  • Никогда не кэшируйте персональные данные и страницы с уникальными токенами восстановления.
  • Разделяйте кэш для анонимных и авторизованных: по заголовку куки/токену, либо отключайте кэш для авторизованных.
  • Очищайте кэш при изменении критичных сущностей: цена, наличие, права доступа.
  • Следите за размером кэша и политикой выселения (LRU), чтобы не "съесть" всю память.

Экономика: оценка выгоды на цифрах

Предположим:

  • Сейчас: 200 RPS на карточке товара, средняя задержка 150 мс, CPU бэкенда ~70%, БД ~80%.
  • После кэша: 70% запросов обслуживаются из кэша (Redis/Nginx), бэкенд обрабатывает только 30% тяжёлых запросов.

Итог:

  • Нагрузка на БД падает на ~60–80%.
  • Можно уменьшить число экземпляров бэкенда на 30–50% или выдерживать x2–x3 рост трафика без расходов.
  • Если сервер стоит, условно, 200$ в месяц, экономия при сокращении двух инстансов — 400$ в месяц. За год — 4800$. Настройка кэша окупается за недели.

Чек‑лист внедрения и план на 2 недели

Неделя 1:

  • Карта горячих точек: какие эндпоинты и запросы самые дорогие (по логам и профилю SQL).
  • Включить Redis и кэш запросов к внешним API (TTL 1–5 минут).
  • Кэш фрагментов: «топ продаж», «рекомендации», списки.
  • Включить микрокэш в Nginx на 5–10 секунд для публичных GET.

Неделя 2:

  • Кэш карточек, категорий и поисковых подсказок (TTL 30–120 секунд).
  • Настроить заголовки Cache-Control/ETag/Vary, подключить CDN для статики.
  • Добавить защиту от stampede (замки/мягкий TTL) на горячих ключах.
  • Автоинвалидация при изменениях (события, вебхуки, версии ключей).
  • Измерить эффект, зафиксировать SLO: p95 < 200 мс для популярных страниц.

Частые ошибки

  • Кэшируют «всё подряд», включая персональные страницы. Решение: сегментация по типам пользователей, Vary, private.
  • Нет стратегии инвалидации: пользователи видят старые цены. Решение: версии ключей + события сброса при изменении сущности.
  • Длинные TTL без мягкого окна: лавина при истечении. Решение: stale-while-revalidate, замки, разогрев ключей.
  • Отсутствие наблюдаемости: непонятно, что даёт кэш. Решение: добавьте X-Cache, метрики hit/miss, алерты.
  • Один слой кэша противоречит другому. Решение: документируйте порядок: приложение → Nginx → CDN, согласуйте TTL и заголовки.

Резюме: правильно настроенный кэш — это быстрые страницы, более довольные пользователи и меньшие счета за инфраструктуру. Начиная с горячих точек и коротких TTL, вы в течение 1–2 недель можете добиться ощутимого прироста скорости без больших переработок кода.


кэшированиеRedisпроизводительность