Привет, Хабр! С вами Александр Константинов, технический эксперт по облачным технологиям из 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 с облачным?Делитесь, как бы вы подошли к оптимизации работы «пациента» с таким анамнезом.
Keirichs
Хорошая статья, спасибо