Нагрузочный аудит и выкатка · tazzz.ru

Потолки tazzz.ru

Сервер держал около 100 одновременных гостей и ломался на 200, потому что три ядра из четырёх простаивали. Первая очередь работ выкачена: теперь 400 гостей обслуживаются за полсекунды, а потолок ушёл за 500. Вторая очередь готова и измерена: самый медленный экран продукта — с 1,1 секунды до 0,6 миллисекунды.

Замеры 26–27 августа 2026
Выкачено очередь I, 26 августа, коммит ec7f9d36
Хост 89.169.46.226 · 4 vCPU / 7,75 ГБ
Статус очередь I в продакшне · очередь II готова, ждёт выкатки
Итог

Что изменилось

Все числа — замеры на боевом сервере: сначала базовые, затем повторный прогон того же теста после выкатки.

Гостей одновременно

было ~100

500+

При 400 отклик 0,54 с. Перелом теперь между 400 и 800.

Отклик при 400 гостях

было 24,6 с

0,54 с

Раньше это был полный отказ. Сейчас — норма с запасом.

Ядер под нагрузкой

было 1 из 4

2 из 4

Оба воркера в потолке, Postgres при этом простаивает.

Витрина на сервере

было 693 мс

70 мс

Индекс вместо полного прохода таблицы.

Ошибок под нагрузкой

0

Ни на одной ступени, включая 800 и 3200: система замедляется, но не падает.

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

Важная оговорка к цифрам: нагрузка подавалась прямо в backend, минуя nginx, поэтому микрокэш публичных ручек в этих числах не участвует вовсе. Реальный гостевой трафик идёт через nginx, где ответ отдаётся из кэша, — там запас ещё больше.

01 · Ёмкость

Кривая до и после

Виртуальный гость открывает главную (три запроса к API), ждёт 20 секунд и открывает снова. Обе линии — один и тот же тест на одном и том же сервере.

Задержка перестала расти вместе с числом гостей

95-й перцентиль времени полного визита. Обе оси логарифмические — иначе провал «до» сжимает всё остальное в линию.

до выкатки после выкатки порог приемлемого отклика, 1,5 с
Задержка визита до и после выкатки До выкатки: 10 гостей — 88 мс, 100 — 304 мс, 200 — 1509 мс, 400 — 24 619 мс. После: 10 — 102 мс, 100 — 190 мс, 200 — 204 мс, 400 — 539 мс, 800 — 19 234 мс. 100 мс 1 с 10 с 1,5 с 24,6 с 1,5 с 0,54 с 19,2 с 0,19 с 10 50 100 200 400 800 одновременных гостей на главной
Гостейp95 доp95 послеВизитов/сОшибокВердикт
1088 мс102 мс0,30запас
50275 мс196 мс1,60запас
100304 мс190 мс3,10запас
2001509 мс204 мс6,50норма
40024 619 мс539 мс12,50норма
800—19 234 мс15,50перегруз

Ошибок нет ни на одной ступени — даже там, где отклик уходит за 19 секунд. Это важное свойство: под перегрузом система замедляется, а не начинает отдавать сбои.

Форма кривой изменилась качественно. Раньше задержка росла линейно с числом гостей — подпись однопоточного обработчика. Теперь до 400 гостей она держится почти плоско, а перелом наступает вдвое-вчетверо позже.

Мониторинг показывает, во что именно упирается новый потолок: процессор backend стоит на 195–200 %, то есть оба воркера загружены полностью, при этом Postgres занят на 5–10 %, а load average — 2,7 из 4. Ограничитель остался тем же по природе, просто отодвинулся: следующий шаг вверх — это снова процессы, но уже вместе с памятью.

02 · Выкатка

Что именно поехало в прод

Восемь коммитов. Каждое изменение проверено на боевых данных до выкатки и подтверждено после.

ИзменениеБылоСталоПодтверждено в проде
Индекс витрины под фактическую сортировку 693 мс70 мс Index Only Scan, 0,19 мс, индекс валиден, 14 МБ
Socket.IO вынесен в отдельный сервис, HTTP — в два процесса 1 ядро2 ядра backend: 2 воркера, backend-ws: 1; реальный пользователь подключился к своей комнате
Микрокэш публичных ручек в nginx нет30 с заголовок X-Api-Cache: HIT; мусорные параметры схлопываются в один ключ
Общий пул соединений с Redis 2,35 мс0,53 мс на каждое чтение снимка витрины
Счётчик фонда — из снимка вместо COUNT 44,6 мсиз снимка значение совпадает с точным подсчётом
Postgres: буферы удвоены 512 МБ1 ГБ применилось, память в норме
Троттлинг подбора пароля стал общим на процессы 10/мин5/мин блокировка на 6-й попытке независимо от воркера
429 вместо 503 при превышении лимита 503429 клиенты умеют отступать по 429, мониторинг не считает это аварией
Статистика планировщика 62 из 81 без неё0 таблица моделей числилась пустой, в ней 15 622 строки
Место на диске 81 %57 % освобождено 19,9 ГБ кэша сборки Docker

Ноль ошибок в логах всех сервисов за всё время выкатки и последующей нагрузки. Память после выкатки даже снизилась: занято 3172 МБ против 3731 МБ до.

Как выкатывали. GitHub Actions отказался запускать задачу из-за биллинга аккаунта — репозиторий приватный, а минуты Actions платные. Поэтому выкатка сделана вручную с повторением всех шагов workflow, включая маркеры деплоя. Подробности и что с этим делать — в последнем разделе.

03 · Очередь II

Правки кода: сделано и измерено

Шесть пунктов второй очереди реализованы. Числа получены прогоном изменённого кода на боевых данных — в контейнере прода, в режиме только чтения.

На сервер не выкачено. Это не результат повторного нагрузочного теста, а замеры отдельных ручек и сервисных функций до выкатки. Сама ёмкость (раздел «Ёмкость» выше) остаётся такой, какой её оставила первая очередь, — новый прогон теста будет после релиза.

Первый экран авторизованного

было 1078 мс

0,6 мс

Лента собирается фоном раз в 3 минуты, ручка только отдаёт готовое.

Запросов на страницу выдачи

было 32

2

Тридцать из них читали поле, которого в карточке ленты нет.

Карточка в ленте

было 7,0–38,3 КБ

1,4 КБ

50 полей вместо 133 и одна обложка вместо всей галереи.

Визит гостя, работа Python

было 79 мс

14 мс

Три запроса главной. Через nginx большая их часть и вовсе не доходит до Python.

Что мерилиБылоСталоЧем починено
«Популярное», 40 карточек 1078 мс · 17 SQL · 711 КБ 0,6 мс · 0 SQL · 67 КБ снимок в Redis, сборка ушла в beat
Персональные рекомендации ≈1,1 с 73 мс · 0 SQL ранжирование того же снимка в памяти
Снимок витрины для первой отрисовки 14,2 мс · 846 КБ 0,4 мс · 61 КБ готовые байты вместо двойной перекодировки
Витрина, ассорти по площадкам 57,1 мс · 273 КБ 6,7 мс · 44 КБ ленточная форма карточки
Витрина, свежие 46,8 мс · 207 КБ 12,4 мс · 59 КБ ленточная форма карточки
Страница выдачи «Из БД», Avito ×30 57 мс · 32 SQL · 1150 КБ 18 мс · 2 SQL · 48 КБ снят N+1 плюс ленточная форма
Страница выдачи «Из БД», Encar ×30 52 мс · 32 SQL · 142 КБ 19 мс · 2 SQL · 43 КБ снят N+1 плюс ленточная форма
Developer API, 20 позиций 24 SQL 5 SQL дамп характеристик — одним запросом на страницу

Сборка снимков перенесена в фоновые задачи: витрина — 3–4 с раз в 2 минуты, «популярное» — 1,1 с раз в 3 минуты. Это единственная работа, которая стала дороже, и она вне пути запроса.

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

Вторая половина — про объём. Карточка ленты несла 133 поля и полную галерею ссылок, хотя сетка рисует одну обложку и полтора десятка полей. Набор полей для ленты собран не на глаз, а обходом всех потребителей во фронтенде и в грунтовке чат-агента; он же продублирован в тесте, чтобы поле не исчезло из выдачи молча. Полную карточку модалка догружает отдельным запросом — она это и раньше делала.

Три вещи, которых в задании не было

Уточнение

Ответ страницы выдачи не «побайтово идентичен» — он на 80 % меньше

задание ошибалось

В плане второй очереди значилось, что правка N+1 не меняет ответ. Проверка на боевых данных показала обратное: 19 из 30 карточек Avito несли по ~31 КБ каталожных характеристик — тех самых, ради которых и делались лишние запросы. В сетке они не рисуются, модалка догружает их сама, но из ответа они исчезают. Это улучшение, а не регресс, — но описывать его как «ничего не изменилось» было бы неверно.

Сверх плана

Публичная ручка показов поверила бы любому номеру объявления

устранено

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

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

Сверх плана

Фронт каждые 30 секунд перезапрашивал 40 карточек впустую

устранено

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

Два прохода код-ревью, 14 замечаний, все устранены. Первый проход нашёл семь, второй — ещё семь по самим исправлениям. Из них два стоят отдельного упоминания: правка внешнего Developer API молча ломала контракт для интеграторов (дамп характеристик вернули батч-запросом, заодно там 24 запроса стали пятью), а одна моя же правка не делала того, что обещала, — ревью показало, что условие стало строже, а не мягче, и настоящая причина была в другом месте. Это ровно тот сценарий, ради которого первая очередь и завела правило прогонять ревью дважды.

04 · Осталось

Что ещё ограничивает систему

Что не закрыто ни первой, ни второй очередью. Четыре находки отсюда (Н-04, Н-05, Н-12 и долг микрокэша) переехали в раздел выше — они сделаны.

Н-09

Живой поиск ограничен пятью сессиями, а слот держится минутами

Продуктовый потолок

В конфиге стоит ручной потолок в 5 одновременных поисков, и он перекрывает всю динамическую логику — по памяти разрешено было бы от 11 до 35. Шестой пользователь встаёт в очередь.

Существеннее длительность. По завершённым сессиям за 30 дней: DongCheDi — медиана 28 с, 95-й перцентиль 237 с; Che168 — медиана 92 с, 95-й перцентиль 526 с. Пять слотов при такой длительности дают порядка 2–10 поисков в минуту на весь сайт. Упирается здесь не сервер, а внешние площадки и антибот-лимиты: процессор и память во время поиска почти свободны.

Как чинить

Единственное место, где нужна работа с архитектурой, а не настройка. Порядок: сначала научиться отдавать первые карточки, не удерживая слот до конца сессии; затем раздельные квоты на площадку вместо общего числа; и только потом поднимать значение, опираясь на живой guard по свободной памяти. Параллельно расширять пул прокси — настоящий ограничитель там.

Н-10

Таблица конфигураций — 53 % базы

Гигиена данных

4460 МБ при базе 8354 МБ, и всего 23 588 строк — около 190 КБ на строку. Данные лежат в четырёх текстовых полях: китайский оригинал и русские переводы. Для сравнения, строки Che168 в той же таблице весят по 10 КБ — разница в двадцать раз указывает на то, что для DongCheDi сохраняется весь сырой ответ API целиком.

Ссылки живые (23 116 из 23 588 используются), удалить нельзя. Но объём раздувает резервные копии и конкурирует за кэш.

Как чинить

Разобрать, что именно лежит в JSON DongCheDi, и сохранять только используемые поля. Дополнительно включить сжатие TOAST алгоритмом lz4 — он быстрее и плотнее устаревшего pglz.

Отдельно про диск. Он не SSD: 102 МБ/с последовательно, 329 операций в секунду, 3 мс на случайное чтение. Соблазн «настроить Postgres под медленный диск» проверен и отвергнут: на восьми горячих запросах один план переворачивается в худшую сторону — счётчик «бренд + бюджет» уходит с индексного доступа на битмап по 305 тыс. строк, 9,5 мс превращаются в 47 мс. Случайное чтение здесь оплачивает страничный кэш операционной системы, а не диск. Настоящее решение — переезд на SSD, а не правка констант.

05 · План

Три очереди работ

Первая закрыта и подтверждена замерами. Оценки для второй и третьей — прогноз.

Очередь I · Настройка

выкачена 26 августа 100 → 500+ гостей, подтверждено
  1. Индекс витрины. Сортировка и индекс были построены по разным полям — из-за расхождения в одном слове запрос читал 647 МБ ради 24 карточек. Ручка публичная, без авторизации: это был способ занять весь сервер с одного адреса.
  2. Статистика планировщика. 62 таблицы из 81 не имели её вовсе.
  3. Микрокэш публичных ручек. С нормализацией параметров, иначе кэш обходится прогулкой по одной цифре.
  4. Socket.IO отдельным сервисом, HTTP в два процесса. Разведение трафика вместо липких сессий сняло весь риск переподключений.
  5. Тюнинг Postgres. Буферы удвоены; константы планировщика намеренно не тронуты (см. врезку выше).
  6. 429 вместо 503 и чистка диска — освобождено 19,9 ГБ.

Очередь II · Точечные правки кода

готова, ждёт выкатки 1,1 с → 0,6 мс на первом экране
  1. Кэшировать «популярное» (Н-05). Снимок собирает фоновая задача раз в 3 минуты; ручка читает готовое. 1078 мс → 0,6 мс, ноль запросов к базе.
  2. Убрать N+1 по сырому JSON (Н-04). 32 запроса → 2. Ответ, вопреки плану, меняется: из карточек Avito уходит 31 КБ характеристик, которые сетка не рисует.
  3. Показы отдельным эндпоинтом (Н-03·2). Плюс сверка отчёта клиента с множеством объявлений, которые витрина реально отдаёт. С ленты собственников снято исключение из кэша.
  4. Снимок витрины готовыми байтами. 14,2 мс → 0,4 мс. Разнообразие порядка сохранено четырьмя заранее перетасованными раскладками.
  5. Компактная форма карточки для лент (Н-12). 7,0–38,3 КБ → 1,4 КБ, 133 поля → 50.
  6. Тест на число SQL-запросов. Проверено, что на откате правки он падает. Рядом — тесты набора полей ленты и снимка «популярного».

Не выкачено: осталось прогнать сборку фронтенда (локально нет ни зависимостей, ни Docker), выкатить вручную и повторить нагрузочный тест.

Очередь III · Архитектура

недели · по мере роста снимает потолки, а не сдвигает
  • Готовая карточка вместо сборки на лету. Сейчас каждая выдача собирает карточку из 150 колонок широкой таблицы и распаковывает тяжёлые поля. Правильная форма — хранить готовый JSON карточки, собираемый один раз при сохранении машины (деньги там уже так и считаются). Тогда лента становится чтением одной колонки по индексу, и весь класс проблем Н-04, Н-05, Н-12 исчезает целиком.
  • Разделение путей чтения и записи. Одна таблица обслуживает и парсер, и выдачу, и радар, и кампании — отсюда 150 колонок. Отдельная узкая проекция под выдачу поместится в кэш целиком.
  • Переезд на SSD. Самое дешёвое действие с наибольшим эффектом на базу: снимает штраф за промах мимо кэша, а не маскирует его.
  • Ёмкость живого поиска (Н-09). Раздельные квоты на площадку, слот, не удерживаемый до конца сессии, расширение пула прокси.
  • Вынести посторонний проект. Контейнеры стороннего приложения делят с продом четыре ядра и память — при планировании ёмкости это неучтённая переменная.
06 · CI

Почему деплой пришлось делать руками

Отдельная проблема, вскрывшаяся при выкатке. К коду отношения не имеет, но блокирует любой следующий релиз.

GitHub Actions отказался запускать задачу: «recent account payments have failed or your spending limit needs to be increased». Ни сборки, ни доставки кода, ни рестарта не было — сервер остался нетронутым, а код спокойно лежал в ветке.

Репозиторий приватный, значит минуты Actions платные. Но есть и структурная причина, почему они кончаются так быстро: деплой делает всю тяжёлую работу на вашем же сервере по SSH, а раннер GitHub всё это время просто ждёт — и минуты тикают. То есть вы платите за ожидание собственного железа. При 276–480 коммитах в месяц и прогоне в 10–25 минут квота уходит за считаные дни.

Отсюда решение, которое стоит рассмотреть отдельно от текущего платежа: self-hosted runner на этом же сервере. Он бесплатен, а сборка и так идёт там же — исчезнет посредник, которому вы платите за простой.

Ручная выкатка воспроизводима. Порядок шагов записан и проверен. Две вещи в нём легко упустить, и обе ломают выкатку молча: nginx.conf смонтирован отдельным файлом и резолвится по inode, поэтому nginx нужно пересоздавать, а не перезапускать; и маркеры деплоя нужно проставить вручную, иначе следующий прогон CI решит, что менялось всё.

07 · Дальше

Что осталось непроверенным

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

Авторизованные сценарии — поиск, чат-агент, избранное, заявки — измерены на уровне сервисных функций, но не сквозь HTTP: выдача тестовых токенов заблокирована политикой безопасности среды. Для следующего теста нужно заранее подготовить пул тестовых учётных записей с токенами.

Одна оговорка из практики: при 3200 виртуальных пользователях генератор нагрузки забивает сервер настолько, что SSH перестаёт пробиваться на несколько минут. Сайт при этом отвечает нормально — нагрузка шла мимо nginx. Выше 800 гонять только осознанно.

Критерий «держит» стоит зафиксировать в репозитории вместе со сценариями: 95-й перцентиль полного визита меньше 1,5 с и ноль ошибок. Инструменты и сырые результаты обоих прогонов лежат на сервере в /root/loadtest.

СценарийСтатусЧто нужно, чтобы измерить
Главная, гостьизмерено до и после—
WebSocket, удержание1000 клиентов—
Доставка события в сокетподтверждено в проде—
Выдача «Из БД»на уровне сервисаТокены
Живой поискпо истории сессийТокены + окно, когда можно грузить площадки
Чат-агентне измерялсяТокены + бюджет на вызовы модели
Заявки, диалоги, избранноене измерялсяТокены

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