Нагрузочный аудит · очереди I и II · tazzz.ru
Сервер держал около 100 одновременных гостей и ломался на 200. После двух очередей работ — обе выкачены и перемерены — 800 гостей обслуживаются за 42 миллисекунды, и это не потолок: сервер на них загружен меньше чем наполовину.
Все числа — один и тот же тест на боевом сервере, прогнанный трижды: до правок, после первой очереди и после второй.
Гостей одновременно
было ~100800+
На 800 сервер занят меньше чем наполовину — потолок не достигнут.
Отклик при 800 гостях
после очереди I — 19,2 с42 мс
До правок сервер не доживал и до 400. Ошибок — ноль.
Ядер под нагрузкой
было 1 из 42 из 4
Оба воркера в потолке, Postgres при этом простаивает.
Первый экран авторизованного
было 1078 мс1,1 мс
Замер на живом проде после выкатки: лента читается из снимка.
Ошибок под нагрузкой
0
Ни на одной ступени, включая 800 и 3200: система замедляется, но не падает.
Исходный диагноз подтвердился полностью: отказ наступал не от нехватки железа, а потому что веб-слой был заперт в одном процессе. Разведя HTTP и WebSocket по разным сервисам, мы получили два ядра вместо одного — и вместе с исправлениями в запросах это сдвинуло потолок примерно в пять раз.
Важная оговорка к цифрам: нагрузка подавалась прямо в backend, минуя nginx, поэтому микрокэш публичных ручек в этих числах не участвует вовсе. Реальный гостевой трафик идёт через nginx, где ответ отдаётся из кэша, — там запас ещё больше.
Виртуальный гость открывает главную (три запроса к API), ждёт 20 секунд и открывает снова. Три линии — один и тот же тест на одном и том же сервере: до правок, после первой очереди, после второй.
95-й перцентиль времени полного визита. Обе оси логарифмические — без этого нижняя линия слилась бы с осью: между «до» и «после второй очереди» на 800 гостях четыре с половиной сотни раз.
| Гостей | p95 до | после I | после II | Визитов/с | Ошибок | Вердикт |
|---|---|---|---|---|---|---|
| 100 | 304 мс | 190 мс | 28 мс | 3,5 | 0 | запас |
| 200 | 1509 мс | 204 мс | 29 мс | 7,0 | 0 | запас |
| 400 | 24 619 мс | 539 мс | 32 мс | 13,6 | 0 | запас |
| 800 | — | 19 234 мс | 42 мс | 27,6 | 0 | запас |
Визитов/с — по прогону после очереди II. Ошибок нет ни на одной ступени ни в одном из трёх прогонов. На 800 гостях после второй очереди перегруза больше нет: 27,6 визита в секунду при p95 в 42 мс.
Первая очередь изменила форму кривой — задержка перестала расти линейно с числом гостей. Вторая опустила саму кривую на порядок: то, что сервер считал на каждый запрос, теперь читается готовым из снимков, и визит стоит миллисекунды процессора вместо десятков.
Главное — потолок больше не виден в этом тесте. После первой очереди на 800 гостях оба воркера стояли в упоре (195–200 % CPU) и отклик уходил за 19 секунд; теперь на тех же 800 backend занят на 81 % из доступных 200, Postgres — на 11 %, load average 0,45 из 4. Дальше 800 не гнали намеренно: генератор на таких объёмах глушит SSH на сервере, а запас и так очевиден.
Восемь коммитов. Каждое изменение проверено на боевых данных до выкатки и подтверждено после.
| Изменение | Было | Стало | Подтверждено в проде |
|---|---|---|---|
| Индекс витрины под фактическую сортировку | 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, включая маркеры деплоя. Подробности и что с этим делать — в последнем разделе.
Шесть пунктов второй очереди реализованы и выкачены. Числа в таблице получены до релиза прогоном изменённого кода на боевых данных; после релиза выборочно подтверждены на живых ручках (первый экран — 1,1 мс на проде).
Выкачено 27 августа, коммит f781c537. CI по-прежнему не стартует из-за биллинга — выкатка ручная, с повторением всех шагов workflow: селф-тесты (включая проверку shared на Node 22), rsync из git-архива, пересборка образов, пересоздание сервисов и nginx, маркеры деплоя. Ноль ошибок в логах всех сервисов после выкатки; таблица ниже — замеры до релиза, а строка «Ёмкость» выше — повторный нагрузочный тест уже по живому проду.
Первый экран авторизованного
было 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 КБ каталожных характеристик — тех самых, ради которых и делались лишние запросы. В сетке они не рисуются, модалка догружает их сама, но из ответа они исчезают. Это улучшение, а не регресс, — но описывать его как «ничего не изменилось» было бы неверно.
Перенос показов на клиент решал проблему кэша, но создавал новую: отчёт присылает браузер, а значит прислать можно что угодно. Накрученные показы через бенчмарк портят «средний рынок» всем соседям по модели, то есть бьют по честным продавцам.
Теперь тот же фоновый процесс, что собирает снимок витрины, пишет рядом множество объявлений, которые витрина реально отдаёт, и отчёт сверяется с ним. Плюс квота отчётов на зрителя и жёсткая привязка к одной поверхности: поиск и агент как считались на сервере, так и считаются.
Третий круг ревью нашёл в самом гарде три дыры и добил их: «на витрине ноль лотов» выключало проверку ровно тогда, когда ни один показ не легитимен (теперь в множестве живёт член-часовой «посчитано»); свежая публикация две минуты до тика фоновой задачи считалась «невидимой» и её первая волна показов отбраковывалась (лот досыпается в множество прямо при публикации); а проверка из двух команд Redis могла потерять честный батч на истечении ключа между ними (стала одной атомарной командой). Квота на зрителя поднята с 20 до 60 в минуту — ключ гостя это суточный хеш адреса, и за одним NAT мобильного оператора её делят сотни людей.
Дыра оказалась старше второй очереди — она жила с первой, в микрокэше nginx. Ключ кэша схлопывал запрос «без параметра» и ?limit=1 в один ключ, а тела у них разные: 40 карточек против одной. Любой прохожий, дёрнув публичную ручку с ?limit=1, на 30 секунд подменял витрину лендинга для всех гостей однокарточным ответом — без авторизации, одним curl.
Починено с двух сторон: у бэкенда теперь один строгий разбор limit для всех трёх лент (только цифры, всё прочее — дефолт маршрута), у nginx — отдельный ключ-сентинел для «не-цифр», и оба контракта закреплены проверкой на пяти краевых случаях, включая ведущие нули.
Пока снимка нет в Redis (рестарт, вытеснение), каждый одновременный запрос запускал СВОЮ сборку: у витрины это до десяти секунд ORDER BY random по всем площадкам — параллельно и в каждом запросе. Замок «строит один, остальные ждут» существовал только у «популярного», и третий круг нашёл в нём два обхода: ждали не тот ключ (Redis вытесняет ключи независимо) и после таймаута строили всей толпой без замка.
Логика вынесена в один общий модуль и подключена к обоим снимкам: ждущие следят ровно за тем ключом, который им нужен, а замок умершего строителя перехватывает ровно один из ждущих. Это же закрыло замечание о том, что два снимка дублировали одну машинерию и уже успели разойтись.
Классическая выдача догоняла карточки без ссылок на облачные копии фотографий — чтобы уйти с медленной прокладки на своё хранилище. Но облачный путь фото сейчас выключен флагом, ссылки не используются вовсе, и догон работал вхолостую: лишний запрос на каждую активную сессию поиска раз в полминуты, вечно. Догон загейчен тем же флагом.
Три прохода код-ревью, 24 замечания, все отработаны. Первый проход нашёл семь, второй — семь по самим исправлениям, третий — ещё десять, из них восемь исправлены, а два приняты как осознанные решения и записаны в код (снятая с продажи машина живёт в снимке до трёх минут — это и есть цена ускорения с 1,1 с до долей миллисекунды). Из находок два примера, почему проходов именно несколько: правка внешнего 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, а не правка констант.
Первые две закрыты и подтверждены замерами на проде. Оценки для третьей — прогноз.
Выкачено вручную 27 августа (CI лежит из-за биллинга); повторный нагрузочный тест подтвердил: 100–800 гостей, p95 28–42 мс, ошибок ноль.
Отдельная проблема, вскрывшаяся при выкатке. К коду отношения не имеет, но блокирует любой следующий релиз.
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 клиентов | — |
| Доставка события в сокет | подтверждено в проде | — |
| Выдача «Из БД» | на уровне сервиса | Токены |
| Живой поиск | по истории сессий | Токены + окно, когда можно грузить площадки |
| Чат-агент | не измерялся | Токены + бюджет на вызовы модели |
| Заявки, диалоги, избранное | не измерялся | Токены |
Чат-агент вынесен отдельно намеренно: его стоимость определяется внешней моделью и оплачивается за вызов, поэтому нагрузочный тест по нему планируется с бюджетом.