Привет, Хабр! С вами Александр Константинов, технический эксперт по облачным технологиям из Cloud.ru. Сегодня хочу показать вам наглядно, как можно оптимизировать производительность веб-приложения. Рассмотрим это на усредненном примере кейса, который типичен для многих наших клиентов, пройдем весь путь настройки, выполним нагрузочное тестирование и сравним до/после.

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

Исходник и проблемы

Все совпадения, особенно с кейсами под NDA случайны. Итак, дано: B2C ритейлер со 150 тыс. товарных позиций в каталоге и гипермаркетами в нескольких регионах. Аудитория собственного веб-приложения за последние годы выросла до 2 млн. активных пользователей в месяц, но нагрузка неравномерная: в обычные дни поток не превышает 200 000 запросов в минуту, зато перед большими праздниками и в периоды сезонных распродаж случается чуть ли не десятикратный всплеск активности.

Текущий стек приложения выстроен следующим образом. Сначала так исторически сложилось, а потом было слишком сложно что-то менять:

  • Python + Fast API отвечали за общую бизнес‑логику;

  • PostgreSQL выступал в качестве хранилки данных;

  • Микросервисы + Kubernetes делили между собой ответственность за разные части магазина

Казалось бы, пациент скорее жив, чем мертв, по крайней мере он уже на кубере и его хотя бы можно масштабировать. И в принципе, в типичный вторник, вся эта машинерия кое-как держалась на плаву, но время шло, количество пользователей и товарных позиций росло, и приложение уже не могло похвастаться ни скоростями, ни отзывчивостью.

  • При пиковом трафике база данных начинали «запыхиваться», выдавая нагрузку CPU ≈ 90 %.

  • Поскольку сессии пользователей хранились в базе, это приводило к долгим запросам при обращении к SKU: пользователю проще было плюнуть и закрыть приложение, чем ждать пока оно покажет актуальную цену и параметры товара.

  • В моменты пиковых нагрузок стали случаться ошибки при чтении базы.

Чтобы понять, насколько все плохо, давайте посмотрим на бенчмарки нашего гипотетического микросервиса для генерации коротких ссылок. Создаем директорию и файл теста:

mkdir loadtest

cd loadtest

nano short-links.test.js

Вставляем содержимое теста, заменив <IP-ADDRESS> на публичный IP-адрес своей виртуальной машины short-links-service:

import http from 'k6/http';

import { check, sleep } from 'k6';

 

export const options = {

  scenarios: {

    shortener_flow: {

      executor: 'constant-vus',

      vus: 10,

      duration: '1m'

    },

  },

};

 

const BASE = 'https://<IP-ADDRESS>.nip.io';

 

export default function () {

  const createPayload = JSON.stringify({ original_url: 'https://cloud.ru' });

  const params = { headers: { 'Content-Type': 'application/json' } };

 

  const createRes = http.post(${BASE}/shorten, createPayload, params);

 

  check(createRes, {

    'create - status 201/200': r => r.status === 201 || r.status === 200,

    'create - has short_code': r => !!r.json('short_code'),

  });

 

  const shortCode = createRes.json('short_code');

  const targetURL = ${BASE}/${shortCode};

 

  for (let i = 0; i < 20; i++) {

    const res = http.get(targetURL, { redirects: 0 });

    check(res, {

      'redirect status 302/301': r => r.status === 302 || r.status === 301,

    });

  }

 

  sleep(1);

}

Данный нагрузочный тест моделирует работу 10 виртуальных пользователей, которые в течение одной минуты создают короткие ссылки через POST-запрос и затем по 20 раз запрашивают каждую полученную короткую ссылку, проверяя корректность редиректа.

Теперь запускаем нагрузочный тест командой:

k6 run short-links.test.js

Получаем результат. У нас вышло примерно следующее:

█ TOTAL RESULTS

 

   checks_total.......................: 1584    24.456932/s

   checks_succeeded...................: 100.00% 1584 out of 1584

   checks_failed......................: 0.00%   0 out of 1584

 

   ✓ create - status 201/200

   ✓ create - has short_code

   ✓ redirect status 302/301

 

   HTTP

   http_req_duration.......................................................: avg=370.01ms min=19.25ms med=387.07ms max=622.41ms p(90)=453.49ms p(95)=483.84ms

      { expected_response:true }............................................: avg=370.01ms min=19.25ms med=387.07ms max=622.41ms p(90)=453.49ms p(95)=483.84ms

   http_req_failed.........................................................: 0.00%  0 out of 1512

   http_reqs...............................................................: 1512   23.345253/s

 

   EXECUTION

   iteration_duration......................................................: avg=8.78s    min=5.8s    med=8.86s    max=10.19s   p(90)=9.85s    p(95)=10.01s

   iterations..............................................................: 72     1.111679/s

   vus.....................................................................: 4      min=4         max=10

   vus_max.................................................................: 10     min=10        max=10

 

   NETWORK

   data_received...........................................................: 341 kB 5.3 kB/s

   data_sent...............................................................: 172 kB 2.7 kB/s

 

running (1m04.8s), 00/10 VUs, 72 complete and 0 interrupted iterations

shortener_flow ✓ [======================================] 10 VUs  1m0s

Видим, что в нашем кейсе средняя задержка находится на уровне 370.01 мс, медиана на 387.07 мс, а 95-й перцентиль (p(95)) на уровне 483.84 мс. Такой уровень приемлем для какого-нибудь внутреннего сервиса, но никак не для публичного приложения. Помнится, Amazon как-то подсчитал, что каждая 100 мс задержка стоит им 1% продаж. Целевые значения для страниц или ключевых API-эндпоинтов в ритейле должны быть в районе 100-200 мс.

На этом месте архитекторы крепко призадумалась: как бы это всё решить так, чтобы и не слишком сильно менять текущую инфраструктуру, и при этом не добавить себе лишней работы как при разовой настройке всего и вся, так и в долгосрочной перспективе?

Варианты решений: от набора пластырей до маленькой революции

Что можно было бы сделать малой кровью? На первый взгляд, с каким-никаким кешем ситуация должна быть лучше, чем вообще без оного, так ведь? Почему бы не прикрутить какой-нибудь локальный типа fastapi-cache?

Если вы достаточно наивны, этот вариант выглядит как минимально энергозатратный. Но на практике локальный кэш в сочетании с микросервисами и кубером даст гремучую смесь. Во-первых, кеш будет жить на каждом поде отдельно, что неизбежно приведет к ситуации, когда, разные пользователи будут видеть разную цену из-за того, что данные были закешированы из базы в разное время. Во-вторых, если под перезагрузится, весь кеш пропадет, его нужно будет «прогреть», что вызовет огромную нагрузку на базу и может положить ее полностью. Короче, без централизованного кеша согласованность параметров у всех пользователей не гарантирована, и чтобы она появилась нам понадобится еще один «пластырь» в виде бэк-кеша, что уже намекает на то, что усилия уже не такие уж и минимальные.

Думаем дальше: что можно точечно сделать с высокой нагрузкой на CPU и долгими запросами, не меняя всё остальное? Можно увеличить мощность БД: насоздавать реплик и заставить мастера работать только на запись, а реплики на чтение. Таким образом можно у нас будет только пересылать всем репликам обновления раз в какой-то период, а сами реплики уже будут обрабатывать чтение. Но база требует намного больше ресурсов, чем кеш, а в некоторых компаниях вопрос «где взять новую мощность» еще более болезненный, чем всё остальное, и наш пример не исключение. По всему выходило, что все наименее болезненные и трудозатратные дороги ведут в Redis.

Решаем проблемы производительности с Redis

С таким анамнезом клиент и попадает на операционный стол. Redis — это высокопроизводительное хранилище данных в оперативной памяти, построенное по принципу «ключ–значение», которое мгновенно возвращает часто используемую информацию без обращения к основной базе. Его можно развернуть как self‑hosted (на собственных серверах, с ручным управлением и настройкой отказоустойчивости), или использовать в виде managed‑сервиса, где обновления, мониторинг и масштабирование берет на себя облачный провайдер. Ниже я опишу как всё настраивается в облаке Cloud.ru, но та же логика в общих чертах применима к любому другому провайдеру. Суть примерно в следующем: обращения к популярным данным (например, для коротких ссылок) сначала идут в Redis, а в PostgreSQL уходят только при необходимости (например, если данных в кеше нет).

  • При поступлении запроса на получение короткой ссылки приложение сначала пробует прочитать сведения из Redis по ключу, завязанному на код ссылки.

  • Если данные найдены — мгновенно происходит редирект, и счетчик переходов инкрементируется прямо в Redis.

  • Если данных нет — приложение обращается к базе PostgreSQL (в данном случае не важно это локальный PostgreSQL или облачный), загружает и кэширует результат в Redis.

  • Параметр кеширования TTL (например, 3600 секунд) задает срок жизни ключа в Redis.

  • Отдельная фоновая задача периодически синхронизирует накопленные счетчики переходов (clicks), обновляя значения в PostgreSQL и очищая временные ключи в Redis, что обеспечивает консистентность.

Как это делается:

1. Разворачиваем необходимые ресурсы в облаке.

Создаем кластер Managed Redis со следующими параметрами:

Название: short-links-cache.

Версия Redis: v7.0.5.

vCPU: 2.

RAM: 4.

Подсеть: short-link-service-subnet.

2. Настраиваем кеширование для веб-приложения. Пользоваться будем паттерном Cache Aside, суть которого проста как всё гениальное: приложение никогда не ходит напрямую в базу, а всегда смотрит сначала в кеш. Если в кеше нет, забирает из базы, сохраняет в кеш и читает данные оттуда.

Вот код нашего сервиса до оптимизации, работаем напрямую с базой. Затем переписываем его на работу с Redis, используем паттерн Cache Aside для кеширования и посмотрим, что же будет с производительностью. Подробнее в документации.

3. Запускаем нагрузочный тест с кешированием.

Запускаем тест

k6 run short-links.test.js

Проверяем результат:

У нас он получился такой:

█ TOTAL RESULTS

 

   checks_total.......................: 8690    141.794978/s

   checks_succeeded...................: 100.00% 8690 out of 8690

   checks_failed......................: 0.00%   0 out of 8690

 

   ✓ create - status 201/200

   ✓ create - has short_code

   ✓ redirect status 302/301

 

   HTTP

   http_req_duration.......................................................: avg=24.59ms min=10.53ms med=17.39ms max=3.04s p(90)=23.72ms p(95)=61.7ms

      { expected_response:true }............................................: avg=24.59ms min=10.53ms med=17.39ms max=3.04s p(90)=23.72ms p(95)=61.7ms

   http_req_failed.........................................................: 0.00%  0 out of 8295

   http_reqs...............................................................: 8295   135.349752/s

 

   EXECUTION

   iteration_duration......................................................: avg=1.52s   min=1.25s   med=1.48s   max=5.44s p(90)=1.52s   p(95)=1.55s

   iterations..............................................................: 395    6.445226/s

   vus.....................................................................: 1      min=1         max=10

   vus_max.................................................................: 10     min=10        max=10

 

   NETWORK

   data_received...........................................................: 1.8 MB 30 kB/s

   data_sent...............................................................: 946 kB 15 kB/s

 

running (1m01.3s), 00/10 VUs, 395 complete and 0 interrupted iterations

shortener_flow ✓ [======================================] 10 VUs  1m0s

Self или Managed: вот в чем вопрос

Тут всё зависит от того, что вы можете себе позволить. В упомянутом нами обезличенном примере, как вы уже, наверное, догадываетесь, бюджет на покупку нового сервера для Redis не был предусмотрен. Поэтому рассматривались либо аренда с развертыванием self-hosted решения, либо Managed Redis в облаке.

― А чего тут думать, когда и так понятно, что self-hosted лучше? ― спросит читатель.

― А кто это всё будет патчить, настраивать резервные копии и планы восстановления, когда у нас уже и так дефицит кадров ― ответит плачущий DevOps. ― Опять я и опять в выходные-праздники?

На первый взгляд кажется, что платить за облачный сервис дороже, чем арендовать несколько t3.medium‑инстансов. Однако, если учесть часы инженеров (проектирование, внедрение, поддержка, инциденты) и риски (простой, потеря данных), совокупная стоимость у управляемого решения за год по факту может оказаться в 2‑3 раза ниже, чем у self‑hosted‑кластера. И пока ваша рука не пустилась писать гневный комментарий про «реклама-дизлайк-отписка», падажжите! Мы тут не рекламируем свой Managed Redis, хотя бы потому что self-hosted тоже можем продать. Просто призываем смотреть на свой исходник без розовых очков. Мы чаще чем хотелось бы видим клиентов, которые вовремя не осознали, что в случае с self-hosted кеш не приходит один: вместе с ним приходят:

  • расходы на обеспечение безопасности и соответствие требованиям регуляторов;

  • необходимость управлять резервным копированием и восстановлением после аварий;

  • сложности масштабирования и нагрузки, приводящие к дополнительным затратам на оборудование и лицензии.

Результаты оптимизации и случаи, когда Managed Redis не нужен

Давайте подведем итог, что мы имеем до Managed Redis и после по результатам нагрузочного тестирования.

Время отклика сервера:

  • До кеширования: 370.01 мс (среднее)

  • После кеширования: 24.59 мс (среднее)

Улучшение в 15 раз.

Время итерации:

  • До кеширования: 8.78 с (среднее)

  • После кеширования: 1.52 с (среднее)

Улучшение почти в 6 раз.

Пропускная способность:

  • До кеширования: 23.3 запроса/сек

  • После кеширования: 135.3 запроса/сек

Улучшение почти в 6 раз.

В обоих случаях достигнут 100% успех всех проверок без ошибок, но с Redis система обрабатывает значительно больший объем запросов при той же нагрузке. Однако, как и любое техническое решение, кеширование с Redis имеет свои области оптимального применения и ограничения.

Кому лучше не связываться с Managed Redis:

  • Проектам с минимальной нагрузкой. Если у вас трафик менее 1000 запросов в час и нет выраженных проблем производительности (CPU < 70%, нормальное время отклика) «пластыри» могут быть более оправданными.

  • Командам со специфическими требованиями к безопасности. Если у вас уже есть глубокий опыт управления собственной Redis-инфраструктурой и потребность сохранять данные в контуре компании из-за требований ИБ и регуляторки, вам никуда не деться от self-hosted Redis, сочувствуем.

  • Проектам, где задержки недопустимы вообще. Приложения с критическими требованиями к латентности (< 1ms). Но если у вас такие требования, вы и так не используете ни базы, ни кеши.

  • Проектам с жесткими бюджетными ограничениями. Если у вас вообще не предусмотрен никакой бюджет на операционные затраты или потенциальная прибыль от проекта несопоставима с затратами на облачный сервис, возможно вообще стоит рассмотреть альтернативные способы типа docker-compose.

Расскажите в комментариях, был ли у вас опыт сравнения своего Redis с облачным?Делитесь, как бы вы подошли к оптимизации работы «пациента» с таким анамнезом.

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


  1. Keirichs
    17.10.2025 14:51

    Хорошая статья, спасибо