← отчёты · спецификация · очередь III

Спецификация: Очередь III — архитектура tazzz.ru

СтатусЧЕРНОВИК — на утверждении владельца
Версия1.0 · 2026-08-27
АвторClaude (агент очередей I–II)
Предшественникиdocs/plans/queue-2-perf-handoff.md (закрыт 27.08), отчёт https://salam0nn.ru/artifacts/otchet-2.html
База кодаветка full-update, коммит f781c537 (обе очереди в проде)
Стандарт требованийRFC 2119: ОБЯЗАН = MUST, НЕ ДОЛЖЕН = MUST NOT, СЛЕДУЕТ = SHOULD, МОЖЕТ = MAY. Слова строчными — обычный язык без нормативной силы.

Структура документа следует практике design docs Google (контекст → цели/не-цели → дизайн с компромиссами → рассмотренные альтернативы → сквозные вопросы) и IEEE-подходу к измеримым критериям приёмки. Каждый эпик самодостаточен: его можно вести отдельным заходом и отдельно принимать.


1. Контекст и охват

1.1 Где мы находимся

Три эпохи одного нагрузочного теста (p95 полного визита гостя, 3 запроса + пауза 20 с):

Гостейдо правокочередь I (26.08)очередь II (27.08)
100304 мс190 мс28 мс
40024 619 мс (отказ)539 мс32 мс
800—19 234 мс (перегруз)42 мс

На 800 гостях backend занят на 81 % из 200 доступных, Postgres — на 11 %. Гостевой путь и первый экран авторизованного больше не ограничивают систему.

1.2 Что осталось (и почему это очередь III)

Очереди I–II — настройка и точечные правки. Оставшиеся проблемы не чинятся правкой строки: они в форме данных и в форме конкурентности.

#ПроблемаМера сегодняПрирода
Э1Каждая выдача собирает карточку из ~150 колонок parsed_cars зановокомпакт-путь: 2 SQL и ~230 мкс сериализации на карточку; полный (модалка/агент): + распаковка TOASTформа данных
Э2Живой поиск: ≤ 5 сессий на весь сайт, слот занят всю сессиюMAX_CONCURRENT_SEARCHES=5 в .env; медианы сессий: DongCheDi 28 с, Che168 92 с (p95 526 с) → 2–10 поисков/мин на сайтконкурентность
Э3car_configurations = 4460 МБ при 23 588 строках (~190 КБ/строка) — 53 % базы (8354 МБ)pglz TOAST, PG 16.11форма данных
Э4Диск не SSD: 102 МБ/с, 329 IOPS, 3 мс случайное чтениеслучайное чтение оплачивает страничный кэш ОСинфраструктура
Э5Посторонний проект horeca делит с продом 4 vCPU / 7,75 ГБсейчас ~450 МБ RAM, CPU в простое <1 % — но неучтён в планировании ёмкостиинфраструктура

1.3 Вне охвата этого документа


2. Цели и не-цели

2.1 Цели (измеримые)

IDЦельМетрика приёмки
G1Выдача любой ленты — чтение готового, а не сборкаp95 сервисного времени страницы «Из БД» (30 карточек) ≤ 5 мс; полная карточка (модалка) ≤ 15 мс; 0 обращений к отложенным TOAST-колонкам на списочном пути (сторож test_list_query_budget.py остаётся зелёным)
G2Живой поиск не упирается в 5 сессий≥ 12 одновременных сессий на текущем железе без деградации деталей; шестой пользователь не ждёт в очереди при свободной памяти
G3База помещается в кэшcar_configurations ≤ 900 МБ (−80 %); суммарный размер БД ≤ 4,5 ГБ
G4Диск перестаёт быть штрафомслучайное чтение ≤ 0,3 мс (SSD/NVMe); random_page_cost можно честно поднять после перезамера 8 горячих планов
G5Ёмкость сервера предсказуемана машине только контейнеры auto-parser + observability; горизонт роста посчитан от 100 % железа

2.2 Не-цели (осознанно отвергнуты)


3. Обзор решения

Общая идея очереди III — закончить сдвиг «считать при записи, читать готовое», начатый деньгами (ADR 0015) и снимками (очередь II), доведя его до единицы данных — карточки; и убрать два внешних ограничителя (диск, соседний проект), чтобы ёмкость считалась от честного железа.

       ЗАПИСЬ (парсер, переводы, фото, модерация)
            │
            ▼
   parsed_cars (150 колонок, источник истины)
            │  той же транзакцией / outbox
            ▼
   ┌─ card_projections ─────────────────────┐
   │ card_feed  JSONB  (~1,4 КБ, лента)     │   Э1: считаем один раз при записи
   │ card_full  JSONB  (~7–38 КБ, модалка)  │
   │ rev, built_at                          │
   └────────────┬───────────────────────────┘
                │ SELECT по PK / батч IN (...)
                ▼
       ЧТЕНИЕ (ленты, модалка, снимки витрины/популярного, агент)

Порядок эпиков выбран по зависимостям и риску (см. §10): Э5 → Э4 → Э3 идут первыми (инфраструктура, низкий риск кода, освобождают диск и память под Э1), затем Э2 (включение готового флага), затем Э1 (самый большой объём кода).


4. Эпик Э1 — материализованная карточка

4.1 Проблема (цифры)

serialize_car() (services/car_card.py) на каждый показ строит словарь из ~150 колонок ORM: компактный — ~230 мкс/карточку чистого CPU, полный — плюс json.loads четырёх TOAST-полей (inspection_report_ru, options_*, raw_json у Avito). Очередь II спрятала эту цену за снимки для двух лент, но каждая НЕ снимочная поверхность платит её живьём: страница «Из БД», избранное, история, алерты, similar/analogs, ленты агента, Developer API. Класс проблем Н-04/Н-05/Н-12 возвращается с каждой новой поверхностью — сторожа-тесты ловят регресс, но не убирают причину.

4.2 Требования

4.3 Дизайн

Хранение. Отдельная таблица (не колонки в parsed_cars — иначе каждый UPDATE проекции раздувает и без того широкую горячую строку и её TOAST):

CREATE TABLE card_projections (
  car_id     bigint PRIMARY KEY REFERENCES parsed_cars(id) ON DELETE CASCADE,
  rev        integer NOT NULL DEFAULT 1,
  card_feed  jsonb   NOT NULL,   -- контракт FEED_CARD_FIELDS (~1,4 КБ)
  card_full  jsonb   NOT NULL,   -- контракт serialize_car(compact=False)
  built_at   timestamptz NOT NULL DEFAULT now()
);

JSONB, а не TEXT: ленты отдают массив карточек — jsonb конкатенируется на стороне SQL (jsonb_agg) без раскодирования в Python, и остаётся путь к отдаче готовых байтов, как у bootstrap-снимка. ON DELETE CASCADE безопасен: parsed_cars не удаляется строками (инвариант 1), каскад — страховка от ручных операций.

Сборка. Новый модуль services/card_projection.py: build_for_car(session, car) -> None — вызывает боевые serialize_car(compact=False) и to_feed_card, применяет no-foreign гард (T1.6), UPSERT по car_id с rev = rev + 1. Единственная точка записи проекции (инвариант в духе «проекцию user_listings пишет только user_listing_service»).

Инвалидация. Полный список писателей parsed_cars (проверен grep-ом по коду, Приложение A) делится на два класса:

КлассПисателиМеханизм
Синхронный (той же транзакцией)parser_repo.add_parsed_car (создание/репарс), user_listing_service._project (модерация/публикация), session_persistпрямой вызов build_for_car перед commit — как уже делается с parsed_car_body_class («same-TX», инвариант 4)
Асинхронный (outbox → beat ≤ 60 с)translation batch (tasks/parser_tasks.translate_batch), horsepower_estimator, image_tasks (images_local_json), no_foreign_writeback, alias_store (комплектации), encar_region write-back города (db_results:1550), body_class_tasksписатель кладёт car_id в card_rebuild_outbox (таблица, той же TX — по образцу alert_match_outbox, инвариант 6); beat-задача drain_card_rebuild_outbox раз в минуту пересобирает батчем

Outbox, а не Redis-очередь и не триггеры БД: переживает рестарты (в отличие от Redis-списка), не прячет логику в SQL (в отличие от триггеров), уже отработан в проекте радаром.

Чтение. select_db_cars_page и списочные пути заменяют serialize_car(compact=True) на SELECT card_feed FROM card_projections WHERE car_id = ANY(...); промах по проекции (строка ещё не собрана) — фолбэк на живую сборку с постановкой в outbox (self-healing, как с фото). GET /cars/<id> отдаёт card_full + живые overlay-и (T1.5). Снимки витрины/популярного продолжают работать поверх — их сборка дешевеет с ~1–4 с до сотен миллисекунд.

Осознанные изменения ответа. Ровно два, оба уже случались в очереди II и приняты: (а) поля, которые сегодня вычисляются на лету и могут «дозреть» между сборками (свежий перевод, доехавшее фото), будут отставать до 60 с; (б) cache_age_days/data_freshness считаются при сборке — допустимая погрешность до минуты. Всё остальное ОБЯЗАНО совпадать с сегодняшним ответом байт-в-байт; контракт стерегут test_feed_card.py и новый оракул сравнения (см. §4.6).

4.4 Рассмотренные альтернативы

АльтернативаПочему нет
Колонка card_json прямо в parsed_carsкаждый пересчёт раздувает горячую широкую строку; HOT-update невозможен (меняется TOAST) → распухание таблицы, по которой ходит парсер
Redis как хранилище карточеккарточка обязана переживать рестарт и вытеснение; полный фонд ~480 К × ср. 9 КБ ≈ 4 ГБ — не влезает в RAM рядом с процессами; Redis уже занят сессиями/снимками
Триггеры Postgres на UPDATEсборка карточки требует Python (переводы, канон галереи, no-foreign) — из триггера не позвать; NOTIFY-мост хрупче outbox
Генерируемые (GENERATED) колонкито же ограничение: выражение не может звать сериализатор
Оставить как есть + расширять снимкикаждая новая поверхность = новый снимок и новый beat; класс проблемы не закрывается (уже 3 снимка)

4.5 План внедрения (фазы = отдельные выкатки)

  1. Ф1 Таблица + card_projection.py + outbox + beat-дренаж; фоновый бэкфилл всего фонда батчами по 500 с паузами (на живом проде, ~480 К строк — часы). Флаг CARD_PROJECTION=off: пишем, не читаем. Метрика: покрытие фонда (COUNT(card_projections)/COUNT(parsed_cars eligible)), лаг outbox.
  2. Ф2 Теневое чтение: CARD_PROJECTION=shadow — читаем оба пути, сравниваем (нормализованный diff, логируем расхождения), отдаём старый. Выход из фазы: ≥ 99,9 % совпадений за 48 ч, расхождения объяснены списком §4.3.
  3. Ф3 CARD_PROJECTION=on для страницы «Из БД» → избранное/алерты/similar → снимки → GET /cars/<id>. По одной поверхности за выкатку.
  4. Ф4 Снос мёртвых путей и перевод сторожей на новый контракт. Только после двух недель на on.

Откат на любой фазе — флаг в off; таблица и outbox остаются (безвредны).

4.6 Тестирование и приёмка

4.7 Риски

РискВероятностьМитигация
Пропущенный писатель → вечно чёрствая карточкасредняяПриложение A собрано grep-ом, оракул инвалидации, self-healing фолбэк на промахе + недельный reconcile-скан (built_at < parsed_cars.updated_at)
Бэкфилл давит продсредняябатчи ≤ 500, statement_timeout, пауза по load average — как у refresh_body_class
Разъезд контрактов feed/full с фронтомнизкаяканон один (T1.2), сторожа остаются
Рост диска (+~4,5 ГБ проекций)высокаявыполнять ПОСЛЕ Э3+Э4: −3,5 ГБ конфигураций и SSD с запасом покрывают

5. Эпик Э2 — ёмкость живого поиска (Н-09)

5.1 Проблема (цифры)

MAX_CONCURRENT_SEARCHES=5 в .env прода перекрывает всю динамику capacity_service.max_concurrent_count(). Причина потолка — НЕ память и не CPU (во время поиска оба почти свободны), а то, что диспетчер деталей parse_details_progressive держит 1 слот очереди detail_fetch на всю жизнь сессии: слотов — по числу ядер, минус резерв → 5. При медиане сессии 28–92 с и p95 до 526 с это 2–10 поисков/мин на весь сайт; шестой пользователь стоит в очереди.

5.2 Что уже построено (степень ограниченности — брим-филд, не грин-филд)

В коде очереди деталей уже существует невключённый режим fan-out (DETAIL_DISPATCH_MODE=fanout + GLOBAL_DETAIL_POOL=on, дефолт legacy): сессия не пиннит слот; детали идут короткими задачами-на-машину (services/session/detail_work.py: _pump_session → process_single_car_detail), admission v2 гейтит по сумме весов площадок (DEFAULT_PLATFORM_COST_MB: avito 80, encar/che168 120, dongchedi 250 МБ) через Lua + живой guard MemAvailable; скрейперы encar/mobile_de уже несут ветки под глобальный пул. При fan-out max_concurrent_count() сам становится RAM-производным (total_capacity_mb() // min(cost)). Дизайн-работа сделана; спецификация этого эпика — контролируемое включение.

5.3 Требования

5.4 Рассмотренные альтернативы

5.5 Приёмка

G2: 12 параллельных сессий на стенде из смеси che168+encar+avito, ни одна не в очереди, p95 «до первой карточки» не хуже legacy-базлайна, память в пределах guard. Отдельно подтверждено: kill воркера деталей посреди сессии → сессия доживает (насос переотправляет), инвариант «сессии отменяемы» (Redis job_id) сохранён.


6. Эпик Э3 — диета car_configurations (Н-10)

6.1 Проблема (цифры)

car_configurations (database/models/parser.py:370): 4460 МБ / 23 588 строк = ~190 КБ на строку, 53 % всей БД. Четыре TEXT-поля: config_json, properties_json + их _ru-копии. Строки Che168 в той же таблице ~10 КБ — разница ×20 говорит, что для DongCheDi сохраняется сырой ответ API целиком. 23 116 из 23 588 строк живые (parsed_cars.config_car_id) — удалить нельзя. Таблица конкурирует за страничный кэш с горячей parsed_cars (3389 МБ) — прямой вклад в штраф медленного диска.

6.2 Требования

6.3 Оценка эффекта и риск

lz4 на JSON-тексте даёт типично 2–4× (замерить на копии 100 строк до решения); белый список — остальное до цели G3 (≤ 900 МБ). Главный риск — выбросить ключ, который читает редкий путь (модалка ТТХ китайцев, комплектации-канонизатор): закрывается T3.1 + оракул: снапшот ответов GET /cars/<id> для 20 эталонных dongchedi-машин до/после — побайтово равны.


7. Эпик Э4 — переезд на SSD

7.1 Проблема и ограничение

Диск прода (80 ГБ, занято 44) — 102 МБ/с, 329 IOPS, 3 мс случайное чтение. Проверено в очереди I: тюнить random_page_cost под него НЕЛЬЗЯ (план счётчика «бренд+бюджет» переворачивается 9,5 → 47 мс) — спасает только страничный кэш. SSD убирает штраф, а не маскирует его. Это единственный эпик, требующий действий владельца в панели хостинга (Timeweb Cloud, судя по registry-зеркалу) — сам перенос данных автоматизируем.

7.2 Требования

7.3 Альтернатива

Новый сервер целиком (SSD + больше RAM) с миграцией DNS — рассмотреть, если хостинг не умеет менять диск на месте: цена сопоставима, бонусом решается Э5 (horeca просто не переезжает). Решение за владельцем — см. §11, вопрос B.


8. Эпик Э5 — вынос horeca

8.1 Состояние

/root/horeca, 6 контейнеров (web, backend, worker, postgres, redis, minio) на машине прода tazzz. Сейчас скромен (~450 МБ RAM, CPU < 1 % в простое), но: не учтён в admission-бюджете capacity_service (ёмкость считается от MemAvailable — всплеск horeca «съедает» слоты поиска незаметно), диск/IOPS общие, и любой его инцидент — инцидент прода tazzz.

8.2 Требования


9. Сквозные вопросы


10. Последовательность, оценки, зависимости

Э5 (вынос horeca / решение «новый сервер»)
   └─► Э4 (SSD)  ──► Э3 (lz4 + диета)  ──► Э1 (проекция карточки, Ф1–Ф4)
Э2 (fan-out) — независим от цепочки данных; стартует параллельно после Э5
ЭпикОценка чистой работыКалендарно (с наблюдением)Ключевой риск
Э5 horeca0,5–1 деньпо окну владельцазабытые тома/кроны
Э4 SSD1 день + репетиция2–3 дняокно записи
Э3 конфигурации1–2 дня3–4 дняпотерянный ключ JSON
Э2 fan-out1 день правок + прогоны1,5–2 недели (канарейки)антибот при росте параллелизма
Э1 проекция4–6 дней3–4 недели (теневая фаза 48 ч, пофазный rollout)пропущенный писатель

Оценки — прогноз; правило очередей I–II сохраняется: каждое утверждение о выигрыше подтверждается замером до/после, «очевидные» улучшения без замера не выкатываются (в очереди I три таких оказались ухудшениями).


11. Открытые вопросы (нужно решение владельца до старта)

#ВопросБлокирует
AОкно на VACUUM FULL car_configurations и стоп-запись Э4 (ночь?)Э3, Э4
BМенять диск на месте или новый сервер (тогда Э5 отпадает)? Тариф?Э4, Э5
CКуда переносить horeca, кто его пользователи?Э5 (если не B-новый-сервер)
DПул тестовых учёток с JWT для приёмки G1/G2 сквозь HTTPприёмка Э1, Э2
EБиллинг GitHub / self-hosted runner — чинить до Э1 (много выкаток)?комфорт всех эпиков

12. Приложение A — писатели parsed_cars (карта инвалидации Э1)

Собрано grep-ом по full-update@f781c537; новый писатель обязан попасть сюда и в оракул инвалидации.

ПисательЧто меняетКласс (§4.3)
database/repositories/parser_repo.py (add_parsed_car)создание, репарс: цены, parsed_at, статусысинхронный
services/user_listing_service.py (_project, _unpublish)проекция объявлений tazzz, статусысинхронный
services/session/session_persist.pylive-детали → БДсинхронный
tasks/parser_tasks.translate_batch → translation_service*_ru-поляoutbox
services/horsepower_estimator.pyengine_power_hpoutbox
tasks/image_tasks.pyimages_local_jsonoutbox
services/text/no_foreign_writeback.pyпереводы write-backoutbox
services/complectation_canon/alias_store.pycomplectation_canon*outbox
services/agent/db_results.py:1556 (ленивая миграция города Encar)city/city_cnoutbox
tasks/body_class_tasks.py / services/body_class_sql.pyпроизводный класс кузова (side-таблица; карточку меняет через body_class)outbox
services/parser_manager.py, tasks/distributed_parser_tasks.pyстатусы, деталисинхронный (идут через repo)
services/database_service.py, database/repositories/vehicle_repo.pyадмин/служебные правкиoutbox

13. Приложение B — источники практик