Я бэкенд-разработчик: пишу на Rust сервис аналитики для продавцов маркетплейсов. Одна из его частей — автоматический биддер, который превращает цель «ДРР не выше 12%» в непрерывное управление ставками. Внутри: контроллер с мёртвой зоной и клампами, per-token бакеты против 429 «Limited by global limiter, per seller», возобновляемые фоновые джобы и борьба с дедлоками параллельных писателей. Читать далее
Продавцы на маркетплейсах управляют рекламой руками: утром посмотрел ставки, днём подкрутил, вечером обнаружил, что бюджет слит. При этом сама цель продавца формулируется одной цифрой: «хочу, чтобы реклама съедала не больше 12% выручки» (ДРР — доля рекламных расходов) или «каждый рубль рекламы должен приносить три» (ROMI). Кабинет же оперирует только ставками за тысячу показов.
Я бэкенд-разработчик: пишу на Rust сервис аналитики для продавцов маркетплейсов, и одна из его частей — автоматический биддер, который превращает цель-цифру в непрерывное управление ставками. Всё описанное ниже — мой личный опыт эксплуатации этого кода на реальных кабинетах продавцов, а не пересказ документации. Под катом — архитектура контроллера, грабли WB Advert API (rate limit «на продавца», а не на приложение), и почему половина кода — это не про ставки, а про выживание под HTTP 429.
Стек: Rust, tokio, sqlx, PostgreSQL. Всё в статье воспроизводимо на любом языке — специфики Rust тут мало, зато много специфики API маркетплейсов.
Почему «ставка» — плохой интерфейсСтавка — инструмент, а не цель. Между «ставкой 350 ₽ за тысячу показов» и «ДРР 12%» лежит цепочка: показы → клики (CTR) → корзины → заказы (CR) → выручка. Все коэффициенты плавают по дням недели, сезону и конкурентам. Продавец, который держит цель руками, фактически выполняет роль П-регулятора с частотой дискретизации «раз в день» и огромным лагом. Автоматизировать это — классическая задача управления с обратной связью.
Контроллер: мёртвая зона, клампы, автостопКаждые 30 минут воркер делает три вещи: собирает факт, сравнивает с целью, двигает ставки. Ключевые решения:
Мёртвая зона ±7%. Если фактический ДРР в пределах ±7% от цели — не трогаем ничего. Без неё контроллер «дёргается»: статистика за полчаса шумная (десятки кликов), и реакция на шум раскачивает систему сильнее, чем сам шум.
Ядро шага контроллера умещается в экран (Rust, но легко читается как псевдокод):
fn next_bid(cur_bid: i64, fact_drr: f64, target_drr: f64, cfg: &Cfg) -> Option<i64> {
let err = (fact_drr - target_drr) / target_drr; // относительная ошибка
if err.abs() < cfg.dead_zone { // мёртвая зона ±7%
return None; // ничего не трогаем
}
// ДРР выше цели → ставку вниз, ниже цели → вверх; шаг пропорционален ошибке
let raw = cur_bid as f64 * (1.0 - cfg.gain * err);
let max_step = cur_bid as f64 * cfg.max_step; // кламп шага ±25%
let clamped = raw.clamp(cur_bid as f64 - max_step, cur_bid as f64 + max_step);
Some((clamped as i64).clamp(cfg.min_bid, cfg.max_bid)) // абсолютные пределы
}
Ограничение шага ±25%. За одну итерацию ставка меняется не больше чем на четверть. Резкое снижение ставки на WB выбрасывает кампанию из аукциона (показы падают до нуля, статистика перестаёт поступать — и контроллер слепнет), резкое повышение — сжигает бюджет до того, как придёт обратная связь.
Дневной бюджет как жёсткий предохранитель. Если суточный лимит расходов достигнут — ставки на минимум до завтра, независимо от ДРР. Цель-процент не должна конфликтовать с абсолютным пределом «не больше N ₽ в день».
Автостоп с эскалацией на человека. Если цель проваливается N дней подряд (по умолчанию 3) — система выключает автопилот и шлёт уведомление. Ситуации «карточка умерла, никакая ставка не спасёт» алгоритмом не решаются, и честнее позвать владельца, чем бесконечно крутить ставки.
Fail-closed по списку товаров. Если не удалось прочитать список кампаний/товаров, включённых в автоматизацию, — не делаем НИЧЕГО. Любая ошибка чтения конфигурации трактуется как «прав нет». Двигать ставки на чужих кампаниях из-за недочитанного конфига — худший из возможных багов.
Журнал решений человеческим языком. Каждое действие пишется в лог вида «ДРР 14.1% выше цели 12% → ставка 320→280 ₽». Без журнала доверия к автопилоту нет: первое, что делает продавец, — проверяет, что именно система сделала ночью.
Грабли №1: rate limit считается на продавца, а не на ваше приложениеГлавное открытие при работе с WB API: лимиты привязаны к токену продавца, и их выедает ЛЮБОЙ сервис, которому продавец дал ключ. У активного продавца обычно подключены 2–3 сервиса аналитики одновременно. Ваше приложение может делать один запрос в минуту и всё равно стабильно получать 429 Too Many Requests с текстом «Limited by global limiter, per seller» — потому что квоту съел кто-то другой.
Выводы, к которым я пришёл:
Per-token токен-бакеты на клиентской стороне. У каждого хоста WB свой лимит (statistics — 1 запрос в минуту, advert — свои значения, feedbacks — свои). Я держу бакет на пару (класс хоста, хэш токена) и ждём в гейте ПЕРЕД каждой попыткой, а не после отказа:
// один бакет на (класс хоста, хэш токена): квоту делят все воркеры процесса
let key = (HostClass::Statistics, token_fingerprint(token));
buckets.entry(key)
.or_insert_with(|| TokenBucket::new(1, Duration::from_secs(60))) // 1 rps/min
.acquire() // ждём слот ДО запроса, а не ретраим после 429
.await;
Уважать Retry-After. Наивная экспоненциальная лесенка ретраев (1-2-4-8-16 с) бесполезна: все попытки сгорают внутри той же минутной квоты. WB присылает Retry-After — его и нужно ждать, вплоть до сотен секунд.
Fail-fast, если квоту едят снаружи. Если после честного ожидания всё равно 429 «per seller» — не жечь ретраи, а быстро отдать джобу планировщику: докачаем через полчаса. Шесть ретраев с ожиданием только растягивают джобу до таймаута и блокируют соседние задачи того же токена.
fullstats (статистика рекламных кампаний) принимает период не длиннее 31 дня — молча падает 400 на большем окне. Отчёт о продажах отдаёт date без таймзоны, что валит строгий RFC3339-парсер. Финансовый отчёт содержит строки без артикула (хранение, удержания) — если агрегировать «по товарам», эти деньги молча теряются.
Отдельный класс проблем — большие кабинеты. Кабинет на 200+ рекламных кампаний физически не пролезает в один прогон синка: отчёт отдаётся чанками по 10 кампаний с асинхронной генерацией (создать отчёт → поллить готовность → скачать), по 5–6 минут на чанк. Решение — курсор:
у джобы есть мягкий дедлайн (24 минуты из 30-минутного потолка сторожевого таймера);
обработали сколько успели — сохранили позицию курсора в таблицу, вышли со статусом success;
планировщик видит pending = true и через 10 минут ставит продолжение;
записи идемпотентны (upsert по ключу sku+день), поэтому частичные прогоны безопасны.
Тот же паттерн закрыл и финансы, и историческую докачку: любая тяжёлая джоба обязана уметь отдать частичный результат и продолжить с места обрыва. «Просто увеличить таймаут» не работает — всегда найдётся кабинет, который больше.
Грабли №3: параллельные писатели и дедлокиФинансы, реклама и агрегатор метрик пишут в одну таблицу дневных показателей. Пока джобы жили в разных «дорожках» и бегали параллельно, Postgres честно ловил дедлоки: два многострочных UPDATE берут блокировки строк в разном порядке. Классические решения — упорядочить захват или сериализовать писателей. Поскольку все писатели живут в одном процессе, я выбрал второе: лёгкий in-process мьютекс на пару (пользователь, маркетплейс) вокруг тяжёлых батчей. SQL не тронут, дедлоки исчезли, цена — редкое ожидание на замке вместо отката транзакции.
Что получилосьПродавец задаёт одну цифру. Система каждые 30 минут сверяет факт с целью и двигает ставки всех кампаний в пределах клампов, при исчерпании бюджета уходит на минимум, при систематическом провале — выключается и зовёт человека. Проверка гипотезы «а если ДРР 10%?» превратилась из недели ручных экспериментов в одно изменение цифры.
Из общих уроков:
API маркетплейсов проектируйте с презумпцией «квоту делят несколько потребителей»;
каждая фоновая джоба должна быть возобновляемой (курсор + идемпотентные записи), иначе большие клиенты будут вечно падать по таймауту;
контроллеру нужны мёртвая зона и ограничение шага больше, чем «умная» формула ставки;
fail-closed на чтении конфигурации — обязательное свойство систем, которые двигают чужие деньги.
Сама схема — факт из API статистики, контроллер с мёртвой зоной и клампами, журнал решений — воспроизводима на любом стеке; специфика тут не в языке, а в поведении API маркетплейса.
Буду рад вопросам про поведение контроллера на разреженной статистике и про то, как я атрибутирую продажи к рекламе — это тема на отдельную статью.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | История о том, как я написал компилятор Svelte на Rust | 0 | 11.83 | 02-08-2026 |
| 2 | Как понять, что делает Rust-компилятор: визуализация AST, MIR и LLVM IR | 0 | 10.8 | 15-07-2026 |
| 3 | От boot-кода до shell: несколько месяцев разработки ОС на Rust с ИИ-агентами | 0 | 12.89 | 30-07-2026 |
| 4 | Как на самом деле работает .await: пишем свой async-рантайм на Rust с нуля | 0 | 8.59 | 21-06-2026 |
| 5 | Какие отчеты и автоответчики нужны продавцам Ozon и WB? Создаем инструмент вместе - обсуждение | 0 | 5 | 26-11-2025 |
| 6 | Почему остатки на маркетплейсах разъезжаются, и почему Kafka вам, скорее всего не нужна? | 0 | 7 | 26-06-2026 |
| 7 | Yet another Rust VPN (TUN-QUIC-TUN) | 0 | 10.98 | 11-07-2026 |
| 8 | Как автоматизировали аналитику маркетплейсов и подготовили сервис к масштабированию | 5 | 7 | 26-06-2026 |
| 9 | [Перевод] Rust 1.97.0: манглинг символов, вывод линкера и поддержка запрета предупреждений в Cargo | 0 | 11.43 | 14-07-2026 |
| 10 | [Перевод] Разработчик 2.0. Следующий уровень абстракции | 0 | 9.9 | 01-08-2026 |