Часть 1. Анатомия проблемы: откуда берётся окно оверселлинга
Остаток разъезжается не потому, что «софт плохой». Он разъезжается потому, что между моментом «товар физически продан» и моментом «покупатель на другой площадке больше не может его купить» проходит время. Это окно я и называю окном оверселлинга. Внутри него вы продаёте то, чего уже нет.
Окно складывается из трёх независимых задержек, и это первое, что нужно понять. Большинство обсуждений видит только первую.
Задержка 1. Ваша синхронизация. Учётная система (1С, МойСклад, CRM) узнала о продаже, но ещё не донесла новый остаток до маркетплейса. Если синхронизация работает по расписанию «раз в N минут», то в среднем половину этого интервала вы живёте с устаревшими данными.
Задержка 2. Лимиты API маркетплейса. Даже если вы готовы слать обновления хоть каждую секунду, площадка вам не даст. Ozon: 80 запросов в минуту, до 100 позиций в запросе, плюс отдельное правило — одну пару «товар-склад» нельзя обновлять чаще раза в 30 секунд. Wildberries: порядка 300 запросов в минуту на marketplace-методы (с весны 2026 лимиты различаются по типам токенов, цифра — ориентир для personal/service). При каталоге в несколько тысяч SKU это жёсткий потолок: 8000 SKU в минуту на Ozon в идеале, а в реальности меньше.
Задержка 3. Витрина самого маркетплейса. Вот про это не пишет почти никто. Даже когда ваш запрос принят, маркетплейс обновляет витрину не мгновенно. Wildberries и Яндекс Маркет официально пишут про обновление витрины в течение 15 минут. У Ozon публичного SLA на отражение остатка нет, но и там это не мгновенно — счёт идёт на десятки минут.
Сложите слагаемые. Типичная установка «коннектор раз в 5 минут» плюс 15 минут витрины — это уже окно около 20 минут, а с учётом лимитов API и больше. Связка «1С по расписанию 30 минут плюс витрина» — под час. А базовая 1С, которая выгружает остатки «несколько раз в сутки», открывает окно в часы.
Главный вывод этой части, который перевернёт всё дальнейшее: задержка 3 от вас не зависит вообще. Сколько бы вы ни ускоряли свою сторону, ниже потолка витрины маркетплейса вы не опуститесь. Запомните это — к этому мы вернёмся, когда будем решать, нужна ли вам Kafka.
Часть 2. Уровень 1 — коробка
Коробочное решение (нативная интеграция МойСклад, модуль для 1С, SaaS-агрегатор вроде SelSup) работает по простой модели: по расписанию выгрузить остатки из учётки и разложить по площадкам.
Реальная периодичность из документации вендоров:
|
Решение |
Периодичность остатков |
Цена |
|---|---|---|
|
МойСклад (пакетная интеграция) |
каждые 10 минут |
от 3 500 ₽/мес |
|
MPsklad (коннектор) |
от 1-5 минут, зависит от нагрузки и лимитов API |
от 3 000 ₽/мес |
|
SelSup |
по событию (продажа / изменение остатка) + ночной прогон |
от 1 800 ₽/мес |
|
1С-модуль (Виктория) |
по отзывам интеграторов — 15-30 мин |
от 33к разово + ~18к/год |
Цифры по периодичности, ценам и лимитам API актуальны на момент написания (середина 2026); и тарифы вендоров, и лимиты площадок меняются — сверяйтесь с первоисточником.
Отдельно отмечу честность подачи. На главной у многих написано «мгновенная синхронизация», но «мгновенно» обычно относится к резервированию товара внутри самой системы, а не к скорости доставки данных на витрину площадки — а её всё равно ограничивают и лимит API, и задержка самой витрины из части 1. Это не обман, это разные вещи, которые маркетинг склеивает в одну.
Приятное исключение — RDV Маркет: они прямо признают риск оверселлинга при плановом обновлении и предлагают рабочий приём — буфер минимального остатка. Товар уходит в ноль на витрине раньше, чем физически кончился на складе. Грубо, но работает, и этот приём стоит держать в голове на любом уровне решения.
Когда коробки достаточно. Если у вас одна площадка, спокойный оборот и каталог в сотни позиций, окно в 15-30 минут вы можете просто не замечать. Не стройте ничего. Коробка за 1800 рублей в месяц закроет вопрос, а инженер вам не нужен.
Где коробка ломается. Проблемы начинаются при росте: несколько площадок, несколько складов, тысячи SKU, всплески спроса в акции. Тут вылезает то, о чём вендоры молчат. Во-первых, фиксированное расписание: в спокойный день 5 минут избыточны, в день распродажи — катастрофически медленно. Во-вторых, при большом каталоге коробка физически не успевает прокачать все SKU через лимиты API за свой интервал, и очередь обновлений начинает отставать сама от себя. Вы платите за «каждую минуту», а получаете «когда успеется».
Часть 3. Уровень 2 — свой поллинг на Go
Логичный следующий шаг: раз коробка медленная и негибкая, напишем свой сервис, который опрашивает учётку чаще и шлёт обновления умнее. Это уже инженерное решение, и на Go оно пишется быстро.
Наивная первая версия выглядит так:
// Каждые N секунд тащим остатки из учётки и пушим в маркетплейс.func (s *Syncer) Run(ctx context.Context) { ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: stocks, err := s.accounting.FetchStocks(ctx) if err != nil { log.Printf("fetch stocks: %v", err) continue } for _, st := range stocks { if err := s.marketplace.UpdateStock(ctx, st.SKU, st.Qty); err != nil { log.Printf("update %s: %v", st.SKU, err) } } } }}
Запускаем, и через пару минут получаем 429 Too Many Requests. Мы уперлись в задержку 2 из первой части. Лечится это лимитером на стороне клиента, например токен-бакетом:
import "golang.org/x/time/rate"// Ozon: лимит ~80 запросов в минуту. Держим 70/мин устойчиво + всплеск до 10.// ВАЖНО: лимитер должен быть ОДИН на площадку, общий для всех воркеров.// Если у каждого воркера свой лимитер, пять воркеров суммарно дадут 5×70 и поймают бан.limiter := rate.NewLimiter(rate.Every(time.Minute/70), 10)func (s *Syncer) push(ctx context.Context, st Stock) error { if err := limiter.Wait(ctx); err != nil { return err } return s.marketplace.UpdateStock(ctx, st.SKU, st.Qty)}
Стало лучше: мы больше не ловим баны. Но мы решили одну проблему и тут же родили две новые.
Проблема А: мы всё ещё опрашиваем. Каждые 10 секунд мы дёргаем учётку и гоним полный список остатков, даже если не изменилось ничего. Это лишняя нагрузка на 1С (а 1С под нагрузкой — отдельная боль) и куча бессмысленных запросов к API, которые съедают наш лимит вхолостую.
Проблема Б: при лимите мы начинаем отставать. Если каталог большой, а лимитер режет скорость, то один проход по всем SKU не укладывается в интервал. Мы тратим лимит на товары, которые не менялись, а реально изменившийся остаток ждёт своей очереди. Чем больше каталог, тем хуже. Это та же болезнь, что у коробки, только теперь она наша.
Поллинг — это про «спросить, не изменилось ли». Но нам не нужно спрашивать. Нам нужно реагировать на изменение.
Часть 4. Разворот первый: остаток — это состояние, а не поток событий
Вот ключевая мысль, ради которой стоит читать эту статью.
Почти все, включая большинство вендоров, неявно думают об остатках как о потоке событий: «продали один — шлём минус один, поступило десять — шлём плюс десять». Кажется логичным. На самом деле это источник гонок и дрейфа.
Маркетплейсу не нужна история ваших изменений. Ему нужно ровно одно: актуальное число прямо сейчас. API остатков и Ozon, и Wildberries принимает абсолютное значение (поставить остаток в 5), а не дельту (изменить на минус один). Это меняет всё.
Из этого следуют два важнейших свойства.
Идемпотентность на нашей стороне достаётся бесплатно. Раз API принимает абсолютное значение, отправить «поставь 5» два раза подряд абсолютно безопасно. Не нужен сложный механизм дедупликации, не нужны гарантии exactly-once. Повтор при ошибке — это норма, а не проблема.
Промежуточные значения можно и нужно схлопывать. Если за десять секунд остаток товара дёрнулся пять раз и пришёл к значению 3, маркетплейсу надо отправить один раз тройку, а не проигрывать пять переходов. Это называется компакцией по ключу, и для остатков она работает идеально, потому что важно только последнее значение. Побеждает последняя запись — last-write-wins.
Это переворачивает дизайн. Мы перестаём думать «как доставить все события по порядку» и начинаем думать «как держать желаемое состояние маркетплейса в соответствии с учёткой». Это задача синхронизации состояний, а не доставки событий. И решается она радикально проще.
Часть 5. Уровень 3a — event-driven для одного селлера
Собираем правильную архитектуру. Она проще, чем кажется, и не требует ни Kafka, ни RabbitMQ, ни вообще внешней очереди.
Модель данных — желаемое состояние, а не журнал событий. Одна таблица, которая хранит, какой остаток должен быть на площадке и какой мы туда уже доставили:
create table stock_state ( marketplace text not null, sku text not null, qty int not null, -- желаемое (из учётки) version bigint not null, -- монотонная версия источника synced_version bigint not null default 0, -- что реально доставлено в МП dirty bool generated always as (version > synced_version) stored, primary key (marketplace, sku));-- "грязная" строка = та, где доставленное отстаёт от желаемогоcreate index idx_stock_dirty on stock_state (marketplace, version) where dirty;
Строка «грязная», если version > synced_version: желаемое значение изменилось, а до площадки мы его ещё не донесли. Это и есть наша очередь на отправку, прямо в таблице состояния. Небольшая, но важная деталь для тех, кто полезет повторять: флаг вынесен в generated-колонку dirty не для красоты. Если написать частичный индекс с предикатом where version > synced_version, PostgreSQL такой индекс создаст, но планировщик не станет его использовать для запроса (он не доказывает совпадение предиката запроса с предикатом индекса, когда сравниваются две колонки). С отдельной булевой колонкой предикаты совпадают текстуально, и индекс реально работает, а пара (marketplace, version) заодно отдаёт сортировку без лишнего шага.
Источник изменений — outbox или CDC. Тут две ситуации.
Если вы контролируете учётку (МойСклад с вебхуками, своя CRM, доработанная конфигурация 1С с подпиской на изменение регистра остатков) — пишите новое значение и инкремент version в эту таблицу прямо в момент изменения. Это паттерн outbox, атомарно и чисто.
Если учётку трогать нельзя (legacy 1С, чужая конфигурация) — читайте изменения из журнала транзакций базы через CDC. И сразу важный нюанс, на котором ловятся авторы статей про «просто поставьте Debezium»: 1С чаще всего живёт на MS SQL Server, а не на PostgreSQL. Значит, это не Debezium с WAL по умолчанию, а механизм MS SQL Change Data Capture. Деталь, которая решает, заведётся у вас CDC за день или вы будете неделю воевать с инфраструктурой.
Очередь, которой нет: PostgreSQL SKIP LOCKED. Для одного селлера внешний брокер не нужен. Конкурентную выборку задач даёт сам PostgreSQL:
// Воркер забирает пачку "грязных" SKU, не мешая другим воркерам.const pickBatch = ` select sku, qty, version from stock_state where marketplace = $1 and dirty order by version limit $2 for update skip locked`
FOR UPDATE SKIP LOCKED означает: каждый воркер берёт свою пачку строк, заблокированные другим воркером строки молча пропускаются. Можно запустить несколько воркеров параллельно, и они не подерутся за одни и те же SKU. Это не полноценный брокер сообщений (нет FIFO-гарантий, нет встроенного мониторинга глубины), но для одного селлера это вполне достаточная замена очереди, без единой строчки инфраструктуры сверх той базы, что у вас уже есть.
Отправка — батч, идемпотентно, с компакцией. Здесь есть важная тонкость, на которой спотыкаются даже опытные. Соблазн — открыть транзакцию, выбрать строки под SKIP LOCKED, сходить в API площадки и тут же записать результат, всё в одной транзакции. Так делать нельзя: транзакция будет держать блокировки строк и соединение из пула всё время сетевого вызова к Ozon или WB, а это секунды, а при 429 и ретраях — десятки секунд. Держать открытую транзакцию БД во время внешнего I/O — классический способ выжечь пул соединений. Поэтому разносим на две короткие транзакции, а между ними — сеть:
type Worker struct { db *pgxpool.Pool mp string // имя площадки ("ozon", "wb") - дискриминатор в таблице client MarketplaceClient // клиент API площадки limiter *rate.Limiter // один на площадку, общий для всех воркеров}func (w *Worker) syncBatch(ctx context.Context) error { // Транзакция 1 (короткая): забрать пачку под SKIP LOCKED и сразу закрыть. tx, err := w.db.Begin(ctx) if err != nil { return fmt.Errorf("begin pick tx: %w", err) } rows, err := tx.Query(ctx, pickBatch, w.mp, 100) if err != nil { _ = tx.Rollback(ctx) return fmt.Errorf("pick batch: %w", err) } items, err := pgx.CollectRows(rows, func(r pgx.CollectableRow) (StockItem, error) { var it StockItem err := r.Scan(&it.SKU, &it.Qty, &it.Version) return it, err }) if err != nil { _ = tx.Rollback(ctx) return fmt.Errorf("collect rows: %w", err) } if err := tx.Commit(ctx); err != nil { // блокировки SKIP LOCKED сняты return fmt.Errorf("commit pick tx: %w", err) } if len(items) == 0 { return nil } // Внешний вызов - ВНЕ транзакции. Один батч-запрос вместо N. // Значение абсолютное => отправка идемпотентна, повтор безопасен. if err := w.limiter.Wait(ctx); err != nil { return fmt.Errorf("limiter wait: %w", err) } if err := w.client.UpdateStocksBatch(ctx, items); err != nil { return fmt.Errorf("update batch: %w", err) // строки остались "грязными", повторим } // Транзакция 2: подтвердить доставку. Условие version=$1 защищает от гонки. batch := &pgx.Batch{} for _, it := range items { batch.Queue( `update stock_state set synced_version = $1 where marketplace = $2 and sku = $3 and version = $1`, it.Version, w.mp, it.SKU) } if err := w.db.SendBatch(ctx, batch).Close(); err != nil { return fmt.Errorf("commit synced_version: %w", err) } return nil}
Обратите внимание на условие version = $1 в финальном update. Если между выборкой и подтверждением остаток успел измениться снова (version вырос), мы НЕ пометим строку как синхронизированную, и на следующем проходе отправим уже новое значение. Это и есть last-write-wins в чистом виде. Честная оговорка: мы можем доставить на витрину промежуточное значение (то, что прочитали в момент выборки), но система самосходится — финальное значение остатка будет доставлено гарантированно. Для остатков это именно то, что нужно.
Раз внешний вызов вынесен из транзакции, SKIP LOCKED здесь работает не ради корректности — её обеспечивают идемпотентность и условие version = $1. И будем честны про его пределы: блокировка снимается на коммите первой транзакции, то есть SKIP LOCKED разводит воркеры только в момент выборки. В окне между коммитом и записью synced_version строка снова видна как грязная, и второй воркер может подхватить тот же SKU и отправить его повторно. Корректность от этого не страдает (повторная отправка абсолютного значения безопасна), но дублирующий вызов тратит лимит API — тот самый дефицитный ресурс из части 1. Поэтому на одного селлера держите по одному воркеру на площадку: параллелить есть смысл между площадками (свой лимитер на каждую), а не внутри одной, где общий лимитер всё равно сериализует отправку. Здесь нас страхует идемпотентность, а не блокировка — и это нормально.
И главная красота этой модели проявляется ровно тогда, когда тяжело. Поймали 429 и затормозили? Грязные строки просто копятся, а когда лимитер отпустит, мы отправим только последнее актуальное значение каждого SKU, одной пачкой. Поллинг без компакции в этой ситуации захлебнулся бы штормом устаревших запросов. Здесь backpressure встроен в саму модель данных: отставание не порождает лавину, оно порождает компакцию.
Что с ретраями. Отдельная dead-letter-очередь тут не нужна. При семантике «поставить значение» неудавшаяся отправка не требует разбора: строка осталась грязной, следующее значение всё равно перезапишет неудавшееся. После N неуспешных попыток — алертим, но ничего не дропаем и никуда не перекладываем.
Часть 6. Разворот второй: а где же Kafka?
Если вы читали про event-driven и CDC, вы ждёте, что в финале я достану Kafka. Это же канонический стек: Debezium читает базу, пишет в Kafka, консьюмеры разбирают. Так вот: для одного селлера Kafka почти всегда оверкилл. И вот честная арифметика, почему.
Аргумент масштаба. Kafka проектировалась под сотни тысяч и миллионы сообщений в секунду. У одного селлера, даже крупного, поток изменений остатков — это единицы, ну десятки событий в секунду в пике. Брать Kafka ради такого потока — это держать кластер, KRaft или ZooKeeper, партиции, консьюмер-группы, мониторинг и эксплуатацию всего этого ради нагрузки, которую PostgreSQL переваривает не замечая. Вы будете обслуживать Kafka вместо того, чтобы продавать.
Аргумент потолка. Вспомните задержку 3 из первой части: даже у Wildberries и Яндекс Маркета витрина официально обновляется до 15 минут, а у Ozon — и того дольше, и от вас это не зависит. Какой смысл в near-real-time доставке за миллисекунды через Kafka, если ниже по течению стоит шлюз, который всё равно подержит ваше обновление на десятки минут? Вы оптимизируете участок, который не является узким местом. Оговорюсь честно: этот аргумент бьёт именно по «Kafka ради скорости». Если Kafka берут ради журнала событий или нескольких независимых потребителей (об этом ниже), потолок витрины ни при чём — там у неё другая роль.
Аргумент «CDC не равно Kafka». Эти две технологии постоянно склеивают, хотя это разные вещи. Ценность CDC — ловить изменения из базы, не трогая саму учётку. Эту ценность вы получаете и без Kafka: можно читать поток изменений и складывать прямо в stock_state. Kafka здесь — не CDC, а транспорт, и для одного селлера этот транспорт избыточен.
Когда Kafka действительно нужна. Не «никогда», а «не на этом масштабе». Граница проходит здесь:
-
Вы строите не интеграцию для себя, а платформу-агрегатор для сотен селлеров. Тогда суммарный поток — уже тысячи событий в секунду, и горизонтальное масштабирование консьюмеров оправдано.
-
Вам нужен журнал событий как источник истины для аналитики, аудита и переигрывания истории, а не только текущее состояние.
-
Вам нужно несколько независимых потребителей одного потока: синхронизация остатков, синхронизация цен, аналитика, ML — каждый в своём темпе.
-
Любопытный момент: Kafka log compaction по ключу делает ровно то, что мы руками сделали через
versionи компакцию. На масштабе платформы это встроенное свойство становится аргументом за. На масштабе одного селлера — то же самое пишется одним SQL-запросом.
Иными словами, Kafka — это решение для уровня платформы и продукта, а не для интеграции одного селлера. Если вам продают Kafka под задачу «синхронизировать остатки моего магазина» — вам продают сложность, которую придётся кормить.
Часть 7. Пояс безопасности, который забывают все
Любая event-driven система со временем дрейфует. Рано или поздно событие потеряется: CDC моргнул при переподключении, площадка молча отклонила обновление, случился редкий баг. Если вы полагаетесь только на поток событий, расхождение будет медленно накапливаться, и однажды вы снова окажетесь там, откуда начали.
Лечится это просто и обязательно: периодическая полная сверка (reconciliation). Раз в час или раз в сутки тянем фактические остатки из API площадки, сравниваем с тем, что мы считаем доставленным (synced), и при расхождении просто помечаем строку грязной — дальше отработает обычный механизм синхронизации.
// Раз в час: где наше желаемое значение разошлось с витриной площадки -// бампаем version, дальше отработает обычная синхронизация.// Один батч-запрос, а не N: статья про батчинг, держим слово.func (w *Worker) reconcile(ctx context.Context) error { remote, err := w.client.FetchAllStocks(ctx) if err != nil { return fmt.Errorf("fetch remote stocks: %w", err) } skus := make([]string, len(remote)) qtys := make([]int32, len(remote)) for i, r := range remote { skus[i], qtys[i] = r.SKU, int32(r.Qty) } _, err = w.db.Exec(ctx, ` update stock_state s set version = s.version + 1 from unnest($2::text[], $3::int[]) as r(sku, qty) where s.marketplace = $1 and s.sku = r.sku and s.qty <> r.qty`, w.mp, skus, qtys) if err != nil { return fmt.Errorf("reconcile update: %w", err) } return nil}
Здесь мы сравниваем наше желаемое значение (s.qty) с тем, что фактически вернула витрина (r.qty), и при расхождении бампаем version — то есть форсим передоставку текущего желаемого значения. Сам qty при этом не трогаем: его задаёт учётка, а не площадка.
Формула простая: event-driven даёт скорость, reconciliation даёт надёжность. Только событиями — быстро, но со временем дрейфует. Только сверкой — надёжно, но медленно (это, по сути, уровень коробки). Вместе — быстро и надёжно. Этот пояс безопасности вендоры почти никогда не упоминают, а именно он отличает систему, которая работает на демо, от системы, которая работает через полгода.
Итог: какой уровень выбрать
Не стройте космолёт, если вам нужно доехать до соседнего двора. Честная карта выбора:
|
Ваша ситуация |
Решение |
|---|---|
|
1 площадка, спокойный оборот, сотни SKU |
Коробка. Не нанимайте инженера. |
|
2-3 площадки, есть деньги в обороте, окно в полчаса уже больно |
Свой event-driven сервис: outbox/CDC + PostgreSQL + воркеры (уровень 3a) |
|
Платформа на сотни селлеров, нужен replay и аналитика |
Вот теперь Kafka/Debezium и горизонтальный консьюминг |
И три вещи, которые стоит унести из статьи, на каком бы уровне вы ни были:
-
Остаток — это состояние, а не поток событий. Синхронизируйте желаемое значение с компакцией по SKU, а не дельты по порядку. Это убирает целый класс гонок.
-
Окно оверселлинга состоит из трёх задержек, и нижняя граница задана витриной маркетплейса, а не вашим кодом. Не оптимизируйте то, что не является узким местом.
-
Event-driven без сверки дрейфует. Периодический reconciliation обязателен.
Самое дорогое в этой задаче — не код. Код, как видите, помещается в одну статью. Самое дорогое — понять, какой уровень сложности задаче действительно соответствует, и не построить платформу там, где хватало таблицы в PostgreSQL.
Я проектирую интеграционные системы 16 лет: банки, ритейл, аэропорт. Из недавнего — ускорил мобильное приложение топ-10 банка в 60+ раз (разбор был здесь, на Хабре). Если у вас остатки разъезжаются между площадками и учёткой, и вы хотите понять, какой уровень решения нужен именно вам, — напишите в телеграм @alleku007, разберём вашу схему за полчаса без обязательств.
ссылка на оригинал статьи https://habr.com/ru/articles/1052408/