Нагрузочный аудит и выкатка · tazzz.ru
Сервер держал около 100 одновременных гостей и ломался на 200, потому что три ядра из четырёх простаивали. Первая очередь работ выкачена: теперь 400 гостей обслуживаются за полсекунды, а потолок ушёл за 500. Вторая очередь готова и измерена: самый медленный экран продукта — с 1,1 секунды до 0,6 миллисекунды.
Все числа — замеры на боевом сервере: сначала базовые, затем повторный прогон того же теста после выкатки.
Гостей одновременно
было ~100500+
При 400 отклик 0,54 с. Перелом теперь между 400 и 800.
Отклик при 400 гостях
было 24,6 с0,54 с
Раньше это был полный отказ. Сейчас — норма с запасом.
Ядер под нагрузкой
было 1 из 42 из 4
Оба воркера в потолке, Postgres при этом простаивает.
Витрина на сервере
было 693 мс70 мс
Индекс вместо полного прохода таблицы.
Ошибок под нагрузкой
0
Ни на одной ступени, включая 800 и 3200: система замедляется, но не падает.
Исходный диагноз подтвердился полностью: отказ наступал не от нехватки железа, а потому что веб-слой был заперт в одном процессе. Разведя HTTP и WebSocket по разным сервисам, мы получили два ядра вместо одного — и вместе с исправлениями в запросах это сдвинуло потолок примерно в пять раз.
Важная оговорка к цифрам: нагрузка подавалась прямо в backend, минуя nginx, поэтому микрокэш публичных ручек в этих числах не участвует вовсе. Реальный гостевой трафик идёт через nginx, где ответ отдаётся из кэша, — там запас ещё больше.
Виртуальный гость открывает главную (три запроса к API), ждёт 20 секунд и открывает снова. Обе линии — один и тот же тест на одном и том же сервере.
95-й перцентиль времени полного визита. Обе оси логарифмические — иначе провал «до» сжимает всё остальное в линию.
| Гостей | p95 до | p95 после | Визитов/с | Ошибок | Вердикт |
|---|---|---|---|---|---|
| 10 | 88 мс | 102 мс | 0,3 | 0 | запас |
| 50 | 275 мс | 196 мс | 1,6 | 0 | запас |
| 100 | 304 мс | 190 мс | 3,1 | 0 | запас |
| 200 | 1509 мс | 204 мс | 6,5 | 0 | норма |
| 400 | 24 619 мс | 539 мс | 12,5 | 0 | норма |
| 800 | — | 19 234 мс | 15,5 | 0 | перегруз |
Ошибок нет ни на одной ступени — даже там, где отклик уходит за 19 секунд. Это важное свойство: под перегрузом система замедляется, а не начинает отдавать сбои.
Форма кривой изменилась качественно. Раньше задержка росла линейно с числом гостей — подпись однопоточного обработчика. Теперь до 400 гостей она держится почти плоско, а перелом наступает вдвое-вчетверо позже.
Мониторинг показывает, во что именно упирается новый потолок: процессор backend стоит на 195–200 %, то есть оба воркера загружены полностью, при этом Postgres занят на 5–10 %, а load average — 2,7 из 4. Ограничитель остался тем же по природе, просто отодвинулся: следующий шаг вверх — это снова процессы, но уже вместе с памятью.
Восемь коммитов. Каждое изменение проверено на боевых данных до выкатки и подтверждено после.
| Изменение | Было | Стало | Подтверждено в проде |
|---|---|---|---|
| Индекс витрины под фактическую сортировку | 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 при превышении лимита | 503 | 429 | клиенты умеют отступать по 429, мониторинг не считает это аварией |
| Статистика планировщика | 62 из 81 без неё | 0 | таблица моделей числилась пустой, в ней 15 622 строки |
| Место на диске | 81 % | 57 % | освобождено 19,9 ГБ кэша сборки Docker |
Ноль ошибок в логах всех сервисов за всё время выкатки и последующей нагрузки. Память после выкатки даже снизилась: занято 3172 МБ против 3731 МБ до.
Как выкатывали. GitHub Actions отказался запускать задачу из-за биллинга аккаунта — репозиторий приватный, а минуты Actions платные. Поэтому выкатка сделана вручную с повторением всех шагов workflow, включая маркеры деплоя. Подробности и что с этим делать — в последнем разделе.
Шесть пунктов второй очереди реализованы. Числа получены прогоном изменённого кода на боевых данных — в контейнере прода, в режиме только чтения.
На сервер не выкачено. Это не результат повторного нагрузочного теста, а замеры отдельных ручек и сервисных функций до выкатки. Сама ёмкость (раздел «Ёмкость» выше) остаётся такой, какой её оставила первая очередь, — новый прогон теста будет после релиза.
Первый экран авторизованного
было 1078 мс0,6 мс
Лента собирается фоном раз в 3 минуты, ручка только отдаёт готовое.
Запросов на страницу выдачи
было 322
Тридцать из них читали поле, которого в карточке ленты нет.
Карточка в ленте
было 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 поля и полную галерею ссылок, хотя сетка рисует одну обложку и полтора десятка полей. Набор полей для ленты собран не на глаз, а обходом всех потребителей во фронтенде и в грунтовке чат-агента; он же продублирован в тесте, чтобы поле не исчезло из выдачи молча. Полную карточку модалка догружает отдельным запросом — она это и раньше делала.
В плане второй очереди значилось, что правка N+1 не меняет ответ. Проверка на боевых данных показала обратное: 19 из 30 карточек Avito несли по ~31 КБ каталожных характеристик — тех самых, ради которых и делались лишние запросы. В сетке они не рисуются, модалка догружает их сама, но из ответа они исчезают. Это улучшение, а не регресс, — но описывать его как «ничего не изменилось» было бы неверно.
Перенос показов на клиент решал проблему кэша, но создавал новую: отчёт присылает браузер, а значит прислать можно что угодно. Накрученные показы через бенчмарк портят «средний рынок» всем соседям по модели, то есть бьют по честным продавцам.
Теперь тот же фоновый процесс, что собирает снимок витрины, пишет рядом множество объявлений, которые витрина реально отдаёт, и отчёт сверяется с ним. Плюс квота отчётов на зрителя и жёсткая привязка к одной поверхности: поиск и агент как считались на сервере, так и считаются.
Классическая выдача догоняла карточки без ссылок на облачные копии фотографий — чтобы уйти с медленной прокладки на своё хранилище. Но облачный путь фото сейчас выключен флагом, ссылки не используются вовсе, и догон работал вхолостую: лишний запрос на каждую активную сессию поиска раз в полминуты, вечно. Догон загейчен тем же флагом.
Два прохода код-ревью, 14 замечаний, все устранены. Первый проход нашёл семь, второй — ещё семь по самим исправлениям. Из них два стоят отдельного упоминания: правка внешнего Developer API молча ломала контракт для интеграторов (дамп характеристик вернули батч-запросом, заодно там 24 запроса стали пятью), а одна моя же правка не делала того, что обещала, — ревью показало, что условие стало строже, а не мягче, и настоящая причина была в другом месте. Это ровно тот сценарий, ради которого первая очередь и завела правило прогонять ревью дважды.
Что не закрыто ни первой, ни второй очередью. Четыре находки отсюда (Н-04, Н-05, Н-12 и долг микрокэша) переехали в раздел выше — они сделаны.
В конфиге стоит ручной потолок в 5 одновременных поисков, и он перекрывает всю динамическую логику — по памяти разрешено было бы от 11 до 35. Шестой пользователь встаёт в очередь.
Существеннее длительность. По завершённым сессиям за 30 дней: DongCheDi — медиана 28 с, 95-й перцентиль 237 с; Che168 — медиана 92 с, 95-й перцентиль 526 с. Пять слотов при такой длительности дают порядка 2–10 поисков в минуту на весь сайт. Упирается здесь не сервер, а внешние площадки и антибот-лимиты: процессор и память во время поиска почти свободны.
Как чинить
Единственное место, где нужна работа с архитектурой, а не настройка. Порядок: сначала научиться отдавать первые карточки, не удерживая слот до конца сессии; затем раздельные квоты на площадку вместо общего числа; и только потом поднимать значение, опираясь на живой guard по свободной памяти. Параллельно расширять пул прокси — настоящий ограничитель там.
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, а не правка констант.
Первая закрыта и подтверждена замерами. Оценки для второй и третьей — прогноз.
Не выкачено: осталось прогнать сборку фронтенда (локально нет ни зависимостей, ни Docker), выкатить вручную и повторить нагрузочный тест.
Отдельная проблема, вскрывшаяся при выкатке. К коду отношения не имеет, но блокирует любой следующий релиз.
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 решит, что менялось всё.
Чтобы измерить продукт целиком, нужно снять одно методическое ограничение.
Авторизованные сценарии — поиск, чат-агент, избранное, заявки — измерены на уровне сервисных функций, но не сквозь HTTP: выдача тестовых токенов заблокирована политикой безопасности среды. Для следующего теста нужно заранее подготовить пул тестовых учётных записей с токенами.
Одна оговорка из практики: при 3200 виртуальных пользователях генератор нагрузки забивает сервер настолько, что SSH перестаёт пробиваться на несколько минут. Сайт при этом отвечает нормально — нагрузка шла мимо nginx. Выше 800 гонять только осознанно.
Критерий «держит» стоит зафиксировать в репозитории вместе со сценариями: 95-й перцентиль полного визита меньше 1,5 с и ноль ошибок. Инструменты и сырые результаты обоих прогонов лежат на сервере в /root/loadtest.
| Сценарий | Статус | Что нужно, чтобы измерить |
|---|---|---|
| Главная, гость | измерено до и после | — |
| WebSocket, удержание | 1000 клиентов | — |
| Доставка события в сокет | подтверждено в проде | — |
| Выдача «Из БД» | на уровне сервиса | Токены |
| Живой поиск | по истории сессий | Токены + окно, когда можно грузить площадки |
| Чат-агент | не измерялся | Токены + бюджет на вызовы модели |
| Заявки, диалоги, избранное | не измерялся | Токены |
Чат-агент вынесен отдельно намеренно: его стоимость определяется внешней моделью и оплачивается за вызов, поэтому нагрузочный тест по нему планируется с бюджетом.