tazzz.ru · сводный отчёт · 26–28 августа 2026
Сервер держал около 100 одновременных гостей и на четырёхстах отказывал; живой поиск упирался в 5 сессий, один мёртвый прокси и одну площадку без запасного канала. За три дня прошли четыре волны работ — две очереди веб-производительности, «карточка как проекция» и программа «ёмкость без закупок» — плюс сутки живых проверок с починкой пяти площадок. Теперь 1600 гостей обслуживаются за 130 мс поверх непрерывной записи, а контрольный прогон отдал 133 карточки из 133 — без единой ошибки.
Потолок одновременных гостей
≈100→1600–2400
Верхняя ступень пройдена уже под непрерывной записью проекций (500 пересборок/мин).
800 гостей, p95 визита
19,2 с→42 мс
После очереди II; под записью — 45 мс. Ошибок 0 на всех ступенях.
Первый экран пользователя
1078 мс→0,6 мс
Лента «популярного» собирается фоном раз в 3 минуты, ручка отдаёт готовый снимок.
Карточка в ленте
7–38 КБ→1,4 КБ
50 полей вместо 133, одна обложка вместо галереи; страница выдачи — 2 SQL-запроса вместо 32.
Экономия детальных фетчей
44–62%
Дельта-«Свежие»: деталь тянется только новым машинам, остальное отдаётся из базы по сегодняшним курсам.
Контрольный прогон 28.08
133/133
Пять площадок через пользовательское API, все карточки с «под ключ», ноль ошибок в шести контейнерах.
26.08
Очередь I
Индекс витрины, Socket.IO отдельным процессом, микрокэш nginx, настройка Postgres. Потолок ≈100 → ≈500 гостей.
27.08 утро
Очередь II + справочник
Снимки вместо сборки на запрос, ленточная карточка. 800 гостей = 42 мс. Плюс 23 файла архитектурного справочника.
27.08 день
Очередь III: Ф1 + Э3
Спецификация → код → два ревью → выкатка → стресс: 1600 гостей OK поверх записи. Карточка стала проекцией.
27.08 вечер
Ёмкость без закупок
Аудит → спека → W0–W6 → четыре прохода ревью → Ф0 и выкатка на бой. Дельта заработала в первый же вечер.
28.08
Проверка боем
Семь итераций через пользовательское API: починены auto.ru, drom, che168, celery и перевод; один ложный след откачен.
01 · Ёмкость веба — очереди I и II
Методика неизменна все три дня: виртуальный гость открывает главную (три запроса к API), ждёт 20 секунд и открывает снова; нагрузка подаётся с самого сервера прямо в backend, минуя nginx и его кэш. Критерий «держит» — p95 полного визита меньше 1,5 с и ноль ошибок.
Кривая ёмкости: p95 визита по ступеням нагрузки
Обе шкалы логарифмические. Четвёртая линия — отдельный, более жёсткий тест: те же гости поверх непрерывной записи проекций (дренаж 500 пересборок/мин).
| гостей | до правок | очередь I | очередь II | очередь III, под записью |
|---|---|---|---|---|
| 100 | 304 мс | 190 мс | 28 мс | — |
| 200 | 1509 мс | 204 мс | 29 мс | — |
| 400 | 24 619 мс · отказ | 539 мс | 32 мс | — |
| 800 | — | 19 234 мс · перегруз | 42 мс | 45 мс |
| 1600 | — | — | — | 130 мс · ошибок 0 |
| 2400 | — | — | — | 3050 мс · перегруз, ошибок 0 |
Диагноз подтвердился полностью: отказ наступал не от нехватки железа, а потому что весь веб-слой был заперт в одном процессе. Postgres при перегрузе простаивал на 5–10%.
| Правка | Было | Стало |
|---|---|---|
| Индекс витрины под фактическую сортировку | 693 мс | 70 мс (Index Only Scan 0,19 мс) |
| Socket.IO — отдельный сервис, HTTP — два воркера | 1 ядро | 2 ядра |
| Микрокэш публичных ручек в nginx | нет | 30 с, мусорные параметры схлопнуты |
| Общий пул соединений с Redis | 2,35 мс | 0,53 мс на чтение снимка |
| Счётчик фонда — из снимка вместо COUNT | 44,6 мс | из снимка |
| Postgres: буферы, autovacuum горячих таблиц, advisory-лок на стартовый create_all | 512 МБ | 1 ГБ; статистика планировщика собрана (62 из 81 таблицы жили без неё) |
| Троттлинг подбора пароля — общий на процессы | 10/мин на воркер | 5/мин на машину, 429 вместо 503 |
| Диск: снят кэш сборки Docker | занято 81% | 57% (−19,9 ГБ) |
Главная идея: ничего не собирать в момент запроса. Всё, что одинаково для всех, собирается фоном и лежит готовыми байтами.
| Что мерили | Было | Стало | Чем починено |
|---|---|---|---|
| «Популярное», первый экран, 40 карточек | 1078 мс · 17 SQL · 711 КБ | 0,6 мс · 0 SQL · 67 КБ | снимок в Redis, сборка ушла в beat-задачу |
| Персональные рекомендации | ≈1,1 с | 73 мс · 0 SQL | ранжирование того же снимка в памяти |
| Снимок витрины для первой отрисовки | 14,2 мс · 846 КБ | 0,4 мс · 61 КБ | готовые байты вместо двойной перекодировки |
| Запросов SQL на страницу выдачи | 32 | 2 | убран безусловный N+1 по raw_json; тест стережёт бюджет запросов |
| Карточка в ленте | 7,0–38,3 КБ · 133 поля | 1,4 КБ · 50 полей | компактная форма: одна обложка вместо галереи |
| Визит гостя, работа Python | 79 мс | 14 мс | сумма правок; через nginx большинство запросов до Python не доходит |
| Показы лотов | в GET-ручках | отдельный эндпоинт | показ считается по факту отрисовки — честнее и не теряется в кэше |
02 · Очередь III — карточка как проекция
Каждая карточка исторически собиралась в момент запроса из тяжёлой строки parsed_cars (143 колонки, TOAST-поля по сотням килобайт). Очередь III переворачивает модель: карточка — это материализованная проекция, которую пересобирает единственный писатель при каждом изменении машины, а чтение — это отдача готового.
У DongCheDi в properties_json хранился сырец API: 286–303 КБ на строку, где карточке нужны три ключа. Белый список читаемого множества (проверен полным grep потребителей и снапшот-оракулом «до/после байт-в-байт») сжимает строку в 13,5 раза. Новые парсы уже пишут диету; пережатие истории + lz4 + VACUUM FULL ждут окна владельца: таблица 4460 МБ → прогноз 0,7–0,9 ГБ, и база целиком помещается в кэш ОС.
Попутная находка: lz4 сам по себе цели не берёт — на этих JSON он даёт 3,3×, столько же, сколько текущий pglz. Выигрыш только в связке с диетой.
Конвейер сохранения прогнан реплеем 150 боевых payload-ов на стенде; для справки — пик прода за 14 дней всего 42 сейва/мин.
| Фаза (3 писателя параллельно) | rps | p50 | p95 | Ошибок |
|---|---|---|---|---|
| insert, проекции выключены | 214 | 12,7 мс | 19,9 мс | 0 |
| insert, проекции включены | 146 | 18,3 мс | 35,1 мс | 0 |
| update, проекции включены | 212 | 13,3 мс | 21,3 мс | 0 |
| update + конкурентный дренаж outbox | 172 | 16,6 мс | 27,2 мс | 0 |
Цена синхронной сборки — +6–8 мс на сейв (~+6% к боевому базлайну), запас над пиком кампаний ≥200×. В «грязных» прогонах дренаж успел сделать 7500–10 500 пересборок против сейвов тех же машин: 0 дедлоков, консистентность 150/150. Под гостевым стрессом дренаж держал темп 500 сборок за 5,5–8,7 с на прогон, outbox 5000 → 0 за 10 минут.
Из пяти эпиков очереди III два в проде (Э1 Ф1, Э3 для новых парсов), по трём собраны данные и решения за владельцем: SSD вместо HDD (диск — QEMU HARDDISK, 102 МБ/с), окно пережатия истории, вынос соседнего проекта horeca (~450 МБ RAM, данных меньше 200 МБ — переезд на минуты).
03 · Программа «ёмкость без закупок» — W0–W6
Аудит 27.08 (все пункты проверены живьём) показал: комфортная ёмкость ~150 поисков/час, жёсткий потолок ~240/час — и упирается он не в железо. Уникальных платных IP всего пять, из них один мёртв (а система этого не видела — счётчики были нулями); che168 ходил напрямую с IP сервера — единая точка отказа; детальный слот на 50–65% занят внутренней работой: записью в БД, LLM-расчётом мощности, лишними запросами. При этом 39–54% машин живого поиска уже лежали в базе — и парсились заново.
Ответ — семь потоков, все против внутренних потерь: W0 однострочники (мёртвый прокси выключен, che168 переведён на 3 живых маршрута, потолок сессий 5→8, wal_compression); W1 асинхронный коммит + дифф-апсерт (повторный сейв не переписывает нетронутые TOAST-колонки и индексы); W2 LLM-расчёт мощности вынесен из детального слота в фоновое дообогащение; W3 диета запросов детали encar (осмотр — параллельно карточке, DIRECT только на 407); W4 флот прокси — реестр здоровья, AIMD-темп, маршрутный предохранитель; W5 дельта-«Свежие» — деталь только новым машинам, знакомым пересчитывается «под ключ» по сегодняшним курсам; W6 канарейка threads-пула воркера деталей.
Дельта-«Свежие»: сколько детальных фетчей уходит
SQL по боевым сессиям за 60 дней. Контур — доля машин поиска, уже лежащих в базе; заливка — чистая экономия фетчей с учётом порога ревалидации 14 дней.
Спецификация выводила W1 из посылки «сейв 1,8–4,8 с, потому что HDD и fsync». Живой замер посылку опроверг: коммит стоит 2,3 мс — это 0,2% сейва. Настоящая причина оказалась в другом: --max-memory-per-child=150 МБ добивает процесс-ребёнок практически после каждой задачи (87 из 95 процессов прожили ровно одну), и каждый сейв новой машины заново платит ~1,1 с за холодный справочник каталога.
Из чего состоит медиана сейва — 1425 мс
Ф0 выполнен на бою 27.08 с явного разрешения владельца: мёртвый прокси выключен, che168 получил три живых маршрута вставкой в proxies (креды не покидали сервер), потолок сессий 8, wal_compression включён без рестарта. Смоук сразу: che168 — 24 карточки с «под ключ» (раньше площадка зависела от прямого канала), два поиска одновременно без очереди.
Код W1–W6 выкачен вечером 27.08. Проверка живыми запросами в тот же вечер: encar 50/50 карточек с ценой, che168 23/24 — из них 23 отданы дельтой без детального фетча, dongchedi 19/20; ошибок 0, пауз площадок 0. Счётчики здоровья прокси впервые перестали быть нулями и сразу показали правду о маршрутах.
04 · Транспорты площадок — 28.08
Весь день 28.08 — цикл «прогон через пользовательское API → разбор → починка → прогон»: токены не покидали сервер, запросы шли изнутри контейнера. Итог на боевом коде 09ab5b04:
| Площадка | Карточек | С «под ключ» | Примечание |
|---|---|---|---|
| encar | 50/50 | 50 | 426 машин за два часа — ни одного корейского иероглифа в карточках |
| che168 | 24/24 | 24 | держится на дельте; сессию мерить не короче 7 минут |
| dongchedi | 20/20 | 20 | 1 incomplete — настоящий пробел источника |
| drom | 20/20 | 20 | 2 incomplete — в объявлении нет топлива/коробки |
| auto.ru | 19/19 | 19 | первый успешный живой парс с 19.08 |
| итого | 133/133 | 133 | ошибок в шести контейнерах — ноль |
05 · Как это делалось — ревью и проверка боем
Рабочий цикл каждой волны: спецификация → код → многопроходное ревью (включая multi-agent на максимальной строгости) → тесты-оракулы → выкатка → смоук живым API → стресс. Урок очереди I, задавший тон: три «очевидных улучшения» оказались ухудшениями — поймали их только измерением.
Где ловились дефекты
Самые дорогие находки: дельта-пересчёт сохранялся фронтовым payload-ом и записал бы русские значения в нативные колонки (порча данных); дельта-карточка эмитилась через канал, который глушат фильтры, — машина исчезала из выдачи; encar-скрапер делил один curl-хендл между потоками — причём, как выяснилось, делал это ещё до программы; «под ключ» дельта-карточки считался по курсам двухнедельной давности и соседствовал бы в одной выдаче со свежепересчитанными.
Под всё это написано 19 новых тестов-оракулов (паритет проекции по 8 площадкам, карта писателей, четыре ветки дельты, AIMD флота, диета encar…) — только по программе ёмкости 176 проверок; регрессы гонялись и на sqlite, и на настоящем PostgreSQL 16.
06 · От чего отказались
Переключатели потоков W1–W6 решение владельца
Спецификация требовала флаг на каждый поток и фазовое включение. Владелец отказался держать в коде две версии одного и того же — все шесть выключателей и старые ветки удалены, остался один путь. Цена: выкатка сразу меняет поведение по всем потокам, откат — только откатом кода. Попутно вскрылось, что «случайное перемешивание прокси», которое сносили за ненадобностью, было защитой от очереди сессий к первому адресу — вернули как разрыв ничьей.
Прямой канал che168 последней ступенью откачено после боя
Выглядело спасением: HTTP 200 за 0,7 с. Оказалось, эти 29 181 байт — страница JS-челленджа, а не JSON, и ровно столько же отдают «живые» маршруты. Урок: у che168 о живости нельзя судить по коду и размеру ответа — только по тому, распарсился ли JSON.
Прогрев каталога в worker_process_init убрано за 2 минуты
24 одновременно стартующих ребёнка выходили за таймаут «UP» — пул убивал их по кругу, детали вставали. Правильное лечение — прогрев в родителе до форка (copy-on-write); предупреждение оставлено в коде на месте удалённого блока.
md5-гард raw_json из спецификации W1 заменено лучшим
Вместо сравнения хэша одной колонки — дифф-апсерт всех: не пишется ни одна колонка, чьё значение не изменилось. Сравнение бесплатное (строка уже загружена), а выигрыш шире — TOAST и 20 индексов не трогаются при повторных сейвах.
«Эмитить карточку без „под ключ“ через 60 с» из спеки W2 противоречило инварианту
Та же спецификация инвариантом И1 запрещает карточки без цены. На отказе дообогащения машина получает честный сегодняшний карантин; «ждёт дольше 60 с» осталось сигналом в колокольчик админки — с троттлингом, чтобы не заливать ленту событий.
Строгое сравнение цены в дельте не работало на dongchedi
Листинг отдаёт цену в 万 с шагом 100 ¥, деталь — точное число в фэнях: строгое равенство почти всегда ложно и вернуло бы в детальный пул ровно то, что экономим. Стало три состояния: совпало / округление ≤0,5% / изменилось — и коридор доверия против разъехавшихся единиц.
lz4 как самостоятельное решение для конфигураций не даёт цели
Замер на боевых строках: 3,3× — столько же, сколько текущий pglz. В план вошёл только в связке с диетой белого списка (13,5×).
W5b — коалесценция дублирующихся поисков отложено владельцем
Вместе с ней отложен и подъём потолка сессий 8 → 12: сначала неделя наблюдения за Ф1.
Поднять random_page_cost под «логику» опровергнуто замером
Диск — HDD, поднять стоимость случайного чтения «логично». Проверка на 8 горячих запросах: один план переворачивается в худшую сторону, 9,5 мс → 47 мс — случайное чтение оплачивает страничный кэш ОС, а не диск.
Бэкфилл проекций на весь фонд отложено до SSD
480 тысяч строк по HDD — не сейчас. Код готов и выключен; включается одной переменной после переезда на SSD (вопрос владельцу B).
07 · Объём, инструменты, бюджет
Объём за три дня: 47 коммитов, 121 файл, +20 432 строки
Сутки живых проверок стоили $0,35: 1148 вызовов пакетного перевода (gemini-3-flash, 431 тыс. токенов) и 49 расчётов мощности (deepseek). Остаток на OpenRouter — $0,03, то есть один-два китайских поиска.
Поэтому проверки транспорта переведены на бесплатные площадки (drom и auto.ru — русский текст, перевод не нужен), а оракулы гоняются внутри контейнера без сети. Кончившиеся деньги пайплайн переживает сам — проверено по коду: 402 → брейкер → строки добирает Google; карточки не теряются, падает только качество китайских комплектаций и корейского осмотра. Килсвитч: TRANSLATION_BATCH_LLM=0.
08 · Что дальше
| Вопрос | Что даст |
|---|---|
| A. Окно на пережатие истории конфигураций (lz4 + бэкфилл диеты ~2 ч + VACUUM FULL) | таблица 4460 МБ → 0,7–0,9 ГБ; база помещается в кэш ОС |
| B. SSD на месте или новый сервер | случайное чтение 3 мс → ~0,3 мс; открывает бэкфилл проекций на весь фонд |
| C. Куда выносить horeca (450 МБ RAM на боевой машине) | весь сервер — под tazzz; переезд на минуты копирования |
| D. Пул тестовых учёток для приёмки сквозь HTTP | приёмка G1/G2 на живых ручках, а не сервисных функциях |
| E. Биллинг GitHub Actions или self-hosted runner | CI вместо ручных выкаток (сборка и так идёт на этом же сервере) |
| Поднять CELERY_MAX_MEM_KB 150 → ~300 МБ | самый дешёвый рычаг на слот-время: дети перестанут умирать после каждой задачи |
| Пополнить OpenRouter; мёртвый прокси .165 — провайдеру; европейский residential для mobile.de | вернуть китайский перевод в полный ярус; +1 маршрут флоту; разблокировать mobile.de |
| Через неделю наблюдения — потолок сессий 8 → 12 и окно канарейки threads-пула | следующий шаг к расчётным 600+ поискам/час |
Наблюдение первых суток идёт по добавленной телеметрии: лаг outbox проекций (алерт при >5 мин), доля дельты в сессиях «Свежих», очередь дообогащения, кулдауны маршрутов che168, MemAvailable в пиках.