Нагрузочный аудит · очереди I и II · tazzz.ru

Потолки tazzz.ru

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

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

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

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

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

было ~100

800+

На 800 сервер занят меньше чем наполовину — потолок не достигнут.

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

после очереди I — 19,2 с

42 мс

До правок сервер не доживал и до 400. Ошибок — ноль.

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

было 1 из 4

2 из 4

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

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

было 1078 мс

1,1 мс

Замер на живом проде после выкатки: лента читается из снимка.

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

0

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

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

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

01 · Ёмкость

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

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

Три эпохи одной кривой

95-й перцентиль времени полного визита. Обе оси логарифмические — без этого нижняя линия слилась бы с осью: между «до» и «после второй очереди» на 800 гостях четыре с половиной сотни раз.

до правок после очереди I после очереди II порог приемлемого отклика, 1,5 с
Задержка визита: до правок, после очереди I и после очереди II До правок: 100 гостей — 304 мс, 200 — 1509 мс, 400 — 24 619 мс. После очереди I: 400 — 539 мс, 800 — 19 234 мс. После очереди II: 100 — 28 мс, 200 — 29 мс, 400 — 32 мс, 800 — 42 мс. 10 мс 100 мс 1 с 10 с 1,5 с 24,6 с 19,2 с 0,54 с 28 мс 42 мс 10 50 100 200 400 800 одновременных гостей на главной
Гостейp95 допосле Iпосле IIВизитов/сОшибокВердикт
100304 мс190 мс28 мс3,50запас
2001509 мс204 мс29 мс7,00запас
40024 619 мс539 мс32 мс13,60запас
800—19 234 мс42 мс27,60запас

Визитов/с — по прогону после очереди II. Ошибок нет ни на одной ступени ни в одном из трёх прогонов. На 800 гостях после второй очереди перегруза больше нет: 27,6 визита в секунду при p95 в 42 мс.

Первая очередь изменила форму кривой — задержка перестала расти линейно с числом гостей. Вторая опустила саму кривую на порядок: то, что сервер считал на каждый запрос, теперь читается готовым из снимков, и визит стоит миллисекунды процессора вместо десятков.

Главное — потолок больше не виден в этом тесте. После первой очереди на 800 гостях оба воркера стояли в упоре (195–200 % CPU) и отклик уходил за 19 секунд; теперь на тех же 800 backend занят на 81 % из доступных 200, Postgres — на 11 %, load average 0,45 из 4. Дальше 800 не гнали намеренно: генератор на таких объёмах глушит SSH на сервере, а запас и так очевиден.

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

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

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

Выкачено 27 августа, коммит f781c537. CI по-прежнему не стартует из-за биллинга — выкатка ручная, с повторением всех шагов workflow: селф-тесты (включая проверку shared на Node 22), rsync из git-архива, пересборка образов, пересоздание сервисов и nginx, маркеры деплоя. Ноль ошибок в логах всех сервисов после выкатки; таблица ниже — замеры до релиза, а строка «Ёмкость» выше — повторный нагрузочный тест уже по живому проду.

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

было 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 КБ каталожных характеристик — тех самых, ради которых и делались лишние запросы. В сетке они не рисуются, модалка догружает их сама, но из ответа они исчезают. Это улучшение, а не регресс, — но описывать его как «ничего не изменилось» было бы неверно.

Сверх плана

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

устранено

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

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

Третий круг ревью нашёл в самом гарде три дыры и добил их: «на витрине ноль лотов» выключало проверку ровно тогда, когда ни один показ не легитимен (теперь в множестве живёт член-часовой «посчитано»); свежая публикация две минуты до тика фоновой задачи считалась «невидимой» и её первая волна показов отбраковывалась (лот досыпается в множество прямо при публикации); а проверка из двух команд Redis могла потерять честный батч на истечении ключа между ними (стала одной атомарной командой). Квота на зрителя поднята с 20 до 60 в минуту — ключ гостя это суточный хеш адреса, и за одним NAT мобильного оператора её делят сотни людей.

Сверх плана

Кэш витрины можно было отравить одним запросом

устранено

Дыра оказалась старше второй очереди — она жила с первой, в микрокэше nginx. Ключ кэша схлопывал запрос «без параметра» и ?limit=1 в один ключ, а тела у них разные: 40 карточек против одной. Любой прохожий, дёрнув публичную ручку с ?limit=1, на 30 секунд подменял витрину лендинга для всех гостей однокарточным ответом — без авторизации, одним curl.

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

Сверх плана

Холодная сборка снимка шла табуном — у витрины с первой очереди

устранено

Пока снимка нет в Redis (рестарт, вытеснение), каждый одновременный запрос запускал СВОЮ сборку: у витрины это до десяти секунд ORDER BY random по всем площадкам — параллельно и в каждом запросе. Замок «строит один, остальные ждут» существовал только у «популярного», и третий круг нашёл в нём два обхода: ждали не тот ключ (Redis вытесняет ключи независимо) и после таймаута строили всей толпой без замка.

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

Сверх плана

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

устранено

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

Три прохода код-ревью, 24 замечания, все отработаны. Первый проход нашёл семь, второй — семь по самим исправлениям, третий — ещё десять, из них восемь исправлены, а два приняты как осознанные решения и записаны в код (снятая с продажи машина живёт в снимке до трёх минут — это и есть цена ускорения с 1,1 с до долей миллисекунды). Из находок два примера, почему проходов именно несколько: правка внешнего 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 · Точечные правки кода

выкачена 27 августа на 800 гостях p95 42 мс, подтверждено
  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-запросов. Проверено, что на откате правки он падает. Рядом — тесты набора полей ленты и снимка «популярного».

Выкачено вручную 27 августа (CI лежит из-за биллинга); повторный нагрузочный тест подтвердил: 100–800 гостей, p95 28–42 мс, ошибок ноль.

Очередь 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 клиентов—
Доставка события в сокетподтверждено в проде—
Выдача «Из БД»на уровне сервисаТокены
Живой поискпо истории сессийТокены + окно, когда можно грузить площадки
Чат-агентне измерялсяТокены + бюджет на вызовы модели
Заявки, диалоги, избранноене измерялсяТокены

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