
Всем привет! Меня зовут Сергей Бунатян, я руководитель службы в Техплатформе Городских сервисов Яндекса.
На сегодняшний день существует довольно много брокеров сообщений. Наиболее часто используемыми в индустрии, пожалуй, будут те, которые, реализуют парадигму очереди сообщений. Самых известных представителей вы наверняка знаете, — Apache Kafka и RabbitMQ, а внутри Яндекса широко используется Logbroker. И, тем не менее, как нетрудно догадаться из этого вступления, мы зачем‑то решили написать свой брокер сообщений.
Сегодня я расскажу про нашу систему, которая называется STQ — Sharded Tasks Queue. По названию системы можно было бы подумать, что это ещё один сервер очередей, однако это будет не совсем верно. STQ — это скорее message broker.
В этой статье я постараюсь рассказать о том, какие задачи перед нами стояли и как это нас привело к решению написать что‑то своё. А заодно поделюсь опытом эксплуатации нашей системы и расскажу про влияние STQ на опыт разработчиков.
Что ж, поехали. Но начнём с простого вопроса: зачем вообще было изобретать что‑то своё?
С чего всё началось
Наша первоначальная цель выглядела очень просто: мы хотели ставить отложенные задачи. Например, это когда сервис А отправляет сообщение сервису Б, но не сразу, а с задержкой в 5 секунд, 5 минут или даже через полгода. Или тот же сервис А ставит задачу самому себе, но тоже с задержкой.
Вроде бы задача понятная и многократно решённая. Но если взглянуть на неё с точки зрения наших требований, то становится ясно, что решения не так уж очевидны.
Требования к системе
Можно выделить восемь основных требований к системе:
Произвольная точность. Хотелось, чтобы задачи можно было ставить на будущее не с шагом в минуту или час, а с максимально возможной точностью. Абсолютной точности, конечно, не будет никогда: есть ограничения по скорости CPU, сети и так далее Но чем она выше — тем лучше.
Однократное и многократное выполнение. Иногда задачу нужно выполнить один раз, а иногда — повторять, пусть с разными данными, но с тем же кодом.
Корректная работа с пропусками времени. Если задача была поставлена, но не выполнилась вовремя — из‑за перегруженной очереди или проблем с подсистемой хранения ‑, важно хотя бы не потерять её. Потом разработчик решит, как с ней быть: если актуальна — протолкнёт и она выполнится, а если нет — удалит.
Простота использования и подключения, отсутствие внешних зависимостей. Первое, что приходит голову для решения проблемы — хранить задачи в базе, а затем периодически вычитывать и исполнять их с помощью процесса. Это вполне хорошо работает для одного сервиса, но если таких сервисов сотни или тысячи, то дополнительная база данных у каждого сервиса превращается в лишнюю зависимость. Владельцы сервисов явно не будут счастливы, если вместе с функциональностью им вручат ещё и дополнительное хранилище, за которое теперь им нужно отвечать.
Долговременность. Задачи не должны теряться, поэтому хранение в памяти нам не подходит. Нужно надёжное и долговременное хранилище.
Наблюдаемость. Пока у вас всего один сервис, следить за его периодическими задачами просто: посмотрел логи или метрики — и всё понятно. Но если сервисов сотни или тысячи, хочется иметь единую точку мониторинга и управления.
Масштабируемость. В Городских сервисах Яндекса нагрузка высокая: много сервисов, много запросов, много пользователей. Масштабирование для нас — естественная необходимость.
Кроссплатформенность. У нас используются разные языки и фреймворки. Было бы неудобно решать одну и ту же задачу разными способами для Java, Python, C++, Go и других. Нужен единый подход.
Варианты решений
Исходя из всех требований, на ум приходят такие подходы:
Cron, at. Подходит для периодических задач, которые нужно выполнять регулярно. Однако его функциональность ограничена.
Thread/coroutine + timer. Запускаем корутину/поток, ставим таймер — и задача выполняется в нужный момент.
База данных + периодический процесс. Если нужно долговременное хранение, можно писать задачи в БД и периодическим процессом вычитывать их к нужному времени.
Существующий сервис очередей. Kafka, RabbitMQ, Logbroker, Redpanda и другие системы могут использоваться для накопления и отдачи задач. Это рабочий вариант, но со своими особенностями (о них ещё поговорим).
Свой сервис. Всегда есть путь «сделать самому». Это долго, дорого, но зато все делается под себя.
Мы составили сравнительную таблицу: насколько разные подходы удовлетворяют нашим требованиям. И идеального варианта не оказалось.

Если верить табличке, то ближе всех к нашим требованиям подобрался последний столбец: Queue server (Kafka, RabbitMQ, Logbroker, Redpanda). Поэтому ненадолго остановимся на нём.
Вариант решения с Queue server
Представим, что мы складываем задачи в сервер очередей. Казалось бы, всё просто: положили задачу, потом прочитали её и выполнили. Это вполне будет работать. До тех пор, пока мы не введём ещё одно требование: мы хотим иметь возможность добавить задержку по времени между моментом постановки задачи и моментом времени, когда она должна выполниться.
Где это может быть полезным? Ну, например мы хотим сделать какую‑то массовую рассылку уведомлений, или реализовать отложенную проверку выполненности какой‑то работы.
Тогда жизненный цикл задачи будет выглядеть примерно так:
сегодня кладём задачу «на завтра вечером»;
раньше или позже нашей задачи, в очередь кладутся ещё какие‑то задачи;
и вот, в момент времени T мы хотим достать ту единственную задачу и начать её выполнять.
Получается, что тут мы хотим нарушить отношение порядка между сообщения в сервере очередей: взять не следующую по FIFO, а именно ту задачу, для которой пришло время. А серверы очередей устроены так, что сохраняют порядок — это их базовый принцип. И именно этот конфликт «хотим менять порядок, а очередь это запрещает» — ключевой тезис всей истории.
Обходные пути для решения задачи
Что же можно предпринять, чтобы выкрутиться из сложившейся ситуации?
Можно поставить отдельный сервис‑читатель: он выгружает задачи из очереди, складывает их в базу и дальше периодическим процессом вычитывает по времени.
Получается рабочее решение. Но тогда встаёт вопрос: а зачем вообще был нужен сервер очередей? Проще сразу сделать HTTP‑endpoint, куда мы отправляем задачи, записывая их в БД и отдельный процесс‑исполнитель.
Другой способ: завести в очереди множество топиков (или отдельных очередей). Каждый из них отвечает за свой временной сдвиг: +1 минута, +2 минуты, +5 минут и так далее Так делают в некоторых компаниях, но решение получается, на наш взгляд, несколько неуклюжим.
Какие тут есть минусы:
Если гранулярность — минута, то передавать задачи с точностью до секунды уже не получится.
Получается огромное количество топиков/очередей при постановке задач на месяцы и годы вперёд (у нас такие задачи есть, пусть и немного).
Чтобы как‑то побороть предыдущий пункт, можно использовать нерегулярную сетку (первый час разметить поминутно, второй — по 5 минут, следующие часы по 30 минут и так далее). Это несколько уменьшит число топиков/очередей, но управлять и наблюдать это всё равно неудобно.
Возникнут коллизии по времени. Если поставить задачу на «+1 минуту», а через эту минуту какой‑то другой отправитель тоже поставит задачу на «+1 минуту», обе задачи попадут в одну очередь, отвечающую за «+1 минуту» — это придётся как‑то дополнительно разруливать.
Итого: с классическими серверами очередей всё равно приходится изобретать какую‑то нетривиальную обвязку.
Ещё можно попробовать сделать кольцевой буфер. Идея простая: читаем очередь, какие‑то задачи выполняем, другие — возвращаем в очередь, и так по кругу.
Какие тут изъяны:
Лишняя перегонка данных и расход вычислительных ресурсов.
Точность ограничена пропускной способностью сервера очередей: чтобы добраться до «своей» задачи, нужно прочитать все предыдущие. Поэтому мы никак не можем поручиться, в какой момент времени задача действительно будет извлечена и отправлена на выполнение: всё зависит от пропускной способности нашего сервера очередей в тот или иной момент.
На самом деле, можно придумать ещё какие‑то хитрые способы выкрутиться, но нужно признать: мы наткнулись на фундаментальное ограничение отношением порядка в очереди. Его не обойти — только городить костыли вокруг.
В итоге ни один из вариантов нам не подошёл, и мы пошли делать свой инструмент.
Свой сервис — простейший вариант решения
В минимальной версии такой сервис выглядел бы так:
сервис с двумя эндпоинтами: положить задачу и достать задачу;
долговременное хранилище под задачи, например в виде любой БД

Для маленькой компании и без учёта наших требований (наблюдаемость, масштабирование, кроссплатформенность и так далее) на этом можно было бы остановиться, и статья закончилась бы здесь. Но у нас требования шире, поэтому продолжаем.
Для нас всё оказалось не так просто, как на картинке выше. Если хочется получить систему по‑настоящему масштабируемую, с наблюдаемостью, с поддержкой квот и лимитов, с нормальным control plane — выходит уже не игрушечное решение, а что‑то с довольно нетривиальной архитектурой.

Теперь, пожалуй, перейдём от вводной части к более содержательной. При этом хочу оговориться, что разбор внутреннего устройства STQ — это тема для отдельной большой статьи (может даже не одной), поэтому сильно углубляться в детали я пока не буду.
Sharded Tasks Queue (STQ)
Главное отличие STQ от привычных серверов очередей в том, что в ней нет отношения порядка. И именно это свойство и определяет всю систему. Здесь мы используем произвольный доступ к данным (задачам или сообщениям — в данном случае это синонимы). Чтение идёт не в порядке записи, не по смещению и не по последовательному номеру, а в произвольном порядке — по ключу.
Но у этого подхода есть минусы. Раз нет линейного доступа по offset, значит, придётся работать со случайным доступом. А случайный доступ требует индекса. Индекс нужно поддерживать: держать его в актуальном состоянии, шардировать, реплицировать, следить за локальностью данных, на которые он ссылается. Все эти накладные расходы делают операции записи и чтения дороже и снижают пропускную способность. Классическая очередь работает быстрее: положил пачку байт — вычитал пачку байт. С индексом всё превращается, скорее, в работу по модели базы данных, где есть первичный ключ и обращения идут уже через него.
Но у этой конструкции есть и положительная сторона. Если пожертвовать частью производительности, появляется гибкость и удобство. И вот именно эта гибкость стала для нас ключевым преимуществом.
Вернёмся на шаг назад, к возможности ставить задачи в будущее. Оказывается, отсутствие отношения порядка открывает довольно неожиданные возможности — не только ставить отложенные задачи, но и двигать задачи по времени. Что это значит? Всё просто: пока задача ещё не начала исполняться, вы имеете право передвинуть её вперёд или назад.
И это бывает действительно полезно. Например мы запланировали задачу «на завтра», а потом выяснили, что можно выполнить её прямо сейчас — пожалуйста, просто обновляем ей время и она отправляется на исполнение немедленно. Или наоборот: понимаете, что к назначенному моменту как‑то другая работа может не быть ещё выполнена и задачу брать ещё рано — спокойно откладываете задачу на более поздний срок.

Другая особенность: через STQ можно заранее подготовить целую пачку заданий и разослать их в нужный момент. Классический пример — массовая рассылка. Хотите, чтобы в полночь первого января всем пользователям пришло поздравление с Новым годом — заранее ставите все эти задачи на будущее, и они исполняются именно в нужное время, без зависимости от того, какие задачи запланированы перед ними.
Из этого же свойства естественным образом вытекает переназначение задач. Если можно сдвигать задачу в будущее, значит, она вполне может переназначать сама себя. Выполнилась один раз — и тут же поставила себя ещё на одно исполнение, потом ещё и ещё. Это уже зависит от бизнес‑логики: кто‑то ограничит количество повторов, кто‑то сделает вечный цикл.
Чтобы это не звучало слишком теоретически, приведу несколько примеров, где мы используем STQ. Самое очевидное — уведомления. Тут параллельность критична: вы точно не хотите, чтобы отправка пуша одному пользователю зависела от того, успели ли вы отослать уведомление другому. STQ решает эту задачу естественным образом.

Другая область — массовые рассылки. Кроме того, у нас довольно часто STQ используется как замена Cron. Никто не задумывал систему такой специально, но, как это обычно бывает, когда у вас есть удобный инструмент, он начинает подменять собой другие.
Есть и совсем неожиданные сценарии: например, замена корутин. Да, в коде может быть отдельный поток или event loop для асинхронного выполнения задач, но можно и просто закинуть задачу в STQ. Если нагрузка не слишком высокая и лишние сетевые походы некритичны, то разница невелика. Зато используя STQ как центральный узел, вы получаете «из коробки» метрики, алерты, логи и инструменты вмешательства при нештатной ситуации. Поэтому многие наши разработчики выбирают именно этот путь, даже когда формально можно было бы обойтись средствами языка программирования или фреймворка.
Другой пример — проверка результата. Допустим, есть служба поддержки с SLA: оператор должен ответить пользователю за пять минут. Вы просто ставите задачу в STQ — через пять минут проверить, ответил ли оператор. Или, например, нужно убедиться, что платёж прошёл успешно, и, если нет, запустить свою обработку такой ситуации. Сценариев здесь масса.
И, наконец, есть просто отложенные работы: сгенерировать отчёт, завершить пользовательскую сессию, выполнить тяжёлую задачу чуть позже. Всё это реальные примеры, которые у нас крутятся на STQ.
Когда начинаешь работать с STQ, быстро понимаешь: отложенные задачи — это не просто удобно, это ещё и удивительно мощно. Иногда достаточно поставить задачу «закрыть сессию через пять минут» — и всё, проблема решена. Но ещё важнее свойство, которое вытекает из отсутствия отношения порядка: независимость.
В классических очередях порядок всегда играет роль: пока не обработаешь предыдущее сообщение, дальше не продвинешься. А это значит, что одна зависшая задача может застопорить всю очередь. Да, в каких‑то системах, можно бить топики/очереди на партиции, но эти партиции всё равно внутри себя поддерживают отношение порядка. В транзакционном бизнесе — с заказами, платежами, возвратами — это становится критичным. Никто не хочет, чтобы сбой в обработке одного заказа, парализовал обработку сотни других заказов.
В теории, конечно, есть пути, как это сделать на системах типа Kafka. Вот, например, статья от Uber на эту тему. Однако для нас такое решение выглядит избыточно громоздким и ведущим к протеканию абстракций механики обмена сообщениями на уровень клиентского кода.
STQ избавляет от необходимости прибегать к подобного рода трюкам. Каждая задача в STQ обрабатывается сама по себе, независимо. Проблемные задачи, конечно, никуда не деваются, но в STQ они не создают затора. Это открывает интересные возможности для разработчиков. Обработчики задач теперь могут быть «медленными»: ходить во внешние сервисы, делать тяжёлые вычисления, ждать долгого ответа. Могут быть ненадёжными: если упала обработка одной задачи, это не значит, что упадут остальные. Тут, конечно, многое зависит от причины падения: если есть баг в коде, то он, конечно, затронет все задачи, а вот ошибка в данных заденет только обработку конкретной задачи. Простой пример: чтобы обработать задачу, вам нужно достать какие‑то данные из БД или другого сервиса. Если их там не оказалось, то да, текущую задачу вы не сможете обработать. Но ведь для остальных задач данные могут быть на месте и это не повод блокировать их обработку.
Ещё один вид проблем, с которыми вы можете столкнуться — Metastable Failure.

Давайте снова вернёмся к нашим очередям и вспомним, что в них лежат сотни тысяч задач, которые необходимо достать и передать на исполнение обработчику. Задачи обрабатываются независимо, но на самом деле, остаётся ещё косвенное влияние задач друг на друга — через вычислительные ресурсы.
Представим ситуацию, когда часть обработчиков задач легла. Очередь растёт, задачи поступают, но разгребаются они слишком медленно. Вы обнаруживаете проблему, поднимаете упавшие воркеры, и, казалось бы, всё начинает приходить в норму, задачи начинают разгребаться с прежней скоростью. Однако в реальности мы сталкиваемся с явлением, которое называется metastable failure: формально задачи вроде бы разгребаются, да только ваш бизнес почему‑то стоит.
Тут проблема тоже довольна понятна: у вас случается конкуренция между старыми задачами, которые, возможно, уже неактуальны, и новыми, которые вы точно не хотите потерять. И решение, которое мы поддержали в STQ, оказалось довольно простым: задачи, которые несколько раз падали, получают более низкий приоритет (это, конечно, настраивается). Когда обработчики возвращаются, они в первую очередь берут свежие заказы, а «хвосты» разгребают по мере сил. Главное — не потерять актуальное. И всё это возможно благодаря отсутствию отношения порядка.
Следующее важное свойство — идемпотентность. Представьте себе сервис, который в своём HTTP‑обработчике делает три шага: пишет в базу, кладёт задачу в очередь и вызывает другой сервис. И вот на последнем шаге он падает. Что сделает в такой ситуации клиент? Клиент, конечно же, сделает повторный запрос, и в классической очереди вы получите дубликат сообщения.


Хорошая борьба с дублями — дело неблагодарное: надо предусматривать защиту и на клиенте, и на консьюмере. В STQ эта проблема решена за счёт внешних идентификаторов: мы можем получать ключ задачи от клиента и дальше можно спокойно дедуплицировать задачи, так как внутри STQ обращение к данным происходит по произвольному ключу. Для разработчиков это огромный выигрыш: не нужно думать о дублях вообще. Он просто ставит задачу и может быть уверен, что доставка at least once ему гарантирована.
Ну и последнее, о чём стоит сказать — про модели доставки. Большинство систем выбирают одну — push или pull.
Push, как в RabbitMQ, хорош для быстрого отклика — сообщения сразу летят в консьюмер, не нужно ждать, пока их придут и прочитают. Довольно легко делать наивную балансировку, равномерно распределяя задачи, между обработчиками. Но в случае, если обработчики не равны, или не равна стоимость обработки задач, придётся бороться с неравномерностью нагрузки, выдумывать backpressure механизм, писать SDK.
Pull‑модель, как в Kafka, даёт естественную балансировку: каждый потребитель сам забирает столько, сколько может, да ещё и батчами, если нужно, однако приходится мириться с задержками.
В STQ мы поддержали обе модели, и это оказалось трудозатратным, но правильным решением: разные сценарии требуют разных подходов.

STQ в эксплуатации
Сегодня на STQ работают Такси, Доставка, Лавка, Еда, Маркет, Банк и платёжный шлюз — всего более 700 микросервисов и порядка 3500 очередей. Средняя нагрузка — 100 тысяч задач в секунду, в пике — до 150 тысяч. На STQ построены некоторые другие системы: например, процессинг для долгих бизнес‑транзакций.
Пользователей у нас тоже много: в нашей бизнес‑группе больше 1800 разработчиков, и многие из них используют STQ как инструмент по умолчанию для межсервисного взаимодействия. Мы проводим регулярные NPS‑опросы, и по последнему результату STQ у нас заняла второе место — уступив лишь на один балл userver, самой любимой нашей технологии.
Почему разработчики так любят систему? Потому что с ней просто работать. STQ многое прощает: не нужно думать про ретраи, дубли, застопорившиеся очереди. Вы пишете продуктовый код, а коммуникация между сервисами работает «магически правильно».
Выводы и мораль истории
Мораль всей этой истории проста: мы сознательно сделали выбор в пользу удобства разработчиков, а не максимальной вычислительной эффективности. STQ — это не конкурент Kafka, Logbroker, Redpanda или RabbitMQ. Она решает другую задачу: обеспечивает надёжное и простое взаимодействие микросервисов. Под гигабайты и терабайты пересылаемых по сети данных она не заточена, но для рантайма и продуктовой логики подходит идеально.
Когда я ещё работал в Яндекс Еде и пользовался STQ как разработчик, я о многом, что написано в статье, даже не задумывался — всё просто работало. И лишь перейдя в команду Техплатформы Городских сервисов и разобравшись, как это устроено изнутри, понял, насколько сложна эта простота. Сделать удобно — это всегда труднее всего.
Какую мораль из всего этого может почерпнуть читатель? Не всегда важны попугаи, набиваемые в бенчмарках. Систем, которые перемалывают большие потоки данных, уже создано достаточно, и они отлично решают свою задачу. Но иногда стоит сосредоточиться на улучшении пользовательского опыта. Это помогает разработчикам делать свою работу быстрее и приятнее.
Комментарии (3)

savostin
10.12.2025 07:18Не согласен со всеми минусами для DB в таблице. Я бы минус поставил в строку «произвольная точность» ибо она зависит от periodic, но это можно решить подпиской на изменения, в том же Postgres

fo_otman
10.12.2025 07:18Выполнилась один раз — и тут же поставила себя ещё на одно исполнение, потом ещё и ещё
Вы изобрели агенты Битрикса)
VladimirFarshatov
Чем не подошли river или airflow?