Далеко не каждой системе необходим отдельный Kafka, RabbitMQ или специализированный message-брокер: для многих backend-продуктов очередь на базе PostgreSQL проще и надежнее в эксплуатации. Используя FOR UPDATE SKIP LOCKED, advisory locks, transactional outbox и корректную модель повторной обработки, можно связать изменение бизнес-данных и постановку фоновой задачи в одной транзакции.
В статье разбираю реализацию пула обработчиков на Go, конкуренцию между потребителями и нарастающую задержку перед повтором. Показываю, как работать с очередью необработанных сообщений, корректной остановкой и очисткой накопившихся записей. А ещё определяю границу, после которой очередь поверх Postgres перестаёт быть прагматичным решением и проигрывает специализированному брокеру сообщений — по пропускной способности, времени отклика и управляемости.
Проблема двух систем
Почти в каждом backend-продукте пользователь оформляет заказ. Системе нужно отправить письмо, сформировать отчёт и вызвать внешние API. Выполнять такую задачу прямо в HTTP-запросе не всегда разумно. Увеличивается время ответа, а пользовательский сценарий становится зависимым от внешних сервисов. Команда обычно пользуется привычным набором: Kafka, RabbitMQ или Redis.
Появляется риск рассинхронизации. Заказ уже сохранён в базе, но сообщение в брокер не отправлено. Процесс завершился между двумя операциями. Или обратная ситуация: задача стала доступна воркеру до того, как связанные с ней данные были зафиксированы в базе. Это частный случай проблемы двойной записи. Изменение бизнес-данных и постановка фоновой задачи происходят в разных системах и не образуют одну транзакцию.
Классическим ответом на эту проблему является использование паттерна «transactional outbox». Приложение в одной транзакции меняет бизнес-данные и пишет строку в служебную таблицу исходящих сообщений, а отдельный процесс читает её и доставляет сообщение в брокер. Атомарность восстановлена, свойства Kafka или RabbitMQ сохранены. Но в системе появляется ещё один компонент, который нужно писать, разворачивать и мониторить, а брокер по-прежнему остаётся в эксплуатации. Получается, что таблица в PostgreSQL всё равно нужна — вопрос лишь в том, служит она перевалочным пунктом или самой очередью.
Можно пойти дальше и убрать брокер совсем. Если очередь живёт в том же PostgreSQL, что и бизнес-данные, то изменение заказа и постановка фоновой задачи — одна атомарная операция. В рамках операции либо произошло и то, и другое, либо ничего. Никакого зазора, в который может провалиться работа.
Одна строчка SQL вместо брокера
Вся конструкция опирается на обычную таблицу — назовём её «jobs». В ней хранится минимум: тип задачи, полезная нагрузка в JSON, статус, время, раньше которого задачу запускать не нужно, а также счётчик попыток. Постановка задачи — это простой INSERT (его можно выполнить в той же транзакции, что и изменение бизнес-данных). Такой процесс закрывает проблему двух систем из предыдущего раздела.
Однако остается вопрос, как несколько параллельных Go-воркеров будут разбирать задачи, не выстраиваясь в очередь друг за другом? Ответ — конструкция FOR UPDATE SKIP LOCKED, появившаяся в PostgreSQL ещё в версии 9.5:
SELECT id FROM jobs
WHERE status = ’ready’ AND run_at <= now()
ORDER BY run_at
LIMIT 10
FOR UPDATE SKIP LOCKED;
Запрос выбирает готовые к запуску задачи и временно блокирует выбранные строки. Если другую задачу уже обрабатывает параллельный воркер, SKIP LOCKED не ждёт освобождения блокировки, а пропускает эту строку и переходит к следующей. Благодаря этому несколько воркеров могут работать одновременно, при этом они не забирают задачу дважды и не создают общую очередь ожидания.
Далее воркер в короткой транзакции переводит задачи в статус обработки и фиксирует изменение. Затем он уже выполняет основную работу. Поэтому длительные операции не удерживают блокировки в базе и не мешают другим воркерам получать новые задачи.
Правила работы с очередью
Таблица задач и механизм SKIP LOCKED позволяют нескольким воркерам безопасно забирать работу без дублирования. Чтобы такую очередь можно было использовать в продакшене, нужно предусмотреть обработку сбоев. Если задача завершилась ошибкой, её не стоит сразу удалять. Обычно её возвращают в очередь с задержкой, увеличивая интервал между попытками. Это снижает нагрузку на временно недоступный внешний сервис. После заданного числа неудачных попыток задачу переводят в отдельный статус или хранилище для ручного разбора.
Также необходимо учитывать и сбои самих воркеров. Если процесс получил задачу и завершился до окончания работы, она не должна остаться в статусе обработки навсегда. Для этого задаче дают ограниченное время обработки: когда оно истекает, другой воркер может взять её повторно. При штатной остановке воркер, наоборот, перестаёт брать новые задачи и завершает уже начатые.
При такой модели возможны повторные запуски одной и той же задачи. Например, внешний API мог получить запрос, а воркер — завершиться до сохранения результата. Поэтому обработчики должны корректно переносить повторное выполнение. Например, не отправлять пуш-сообщение дважды или не создавать повторный платёж.
Готовые библиотеки избавляют от необходимости реализовывать эти механизмы с нуля. Для Go можно рассмотреть библиотеки River, gue и другие решения поверх PostgreSQL. Выбор зависит от требований к автоматическим повторам и планированию задач, а также наблюдаемости и модели обработки.
Работает и на серьёзном масштабе
Очередь в PostgreSQL не ограничивается небольшими сервисами. Ее используют и высоконагруженные системы, если очередь проектируют и обслуживают как отдельный компонент.
Например, для конкурентного получения задач в Я.Диске используется механизм FOR UPDATE SKIP LOCKED. Масштаб: десятки тысяч задач в секунду и сотни типов задач. Один из крупнейших сервисов рунета выбрал SQL-очередь, при том, что Kafka в компании тоже есть и используется там, где её свойства подходят лучше.
Второй кейс — компания 37signals, создатели фреймворка Ruby on Rails, Basecamp и почтового сервиса HEY. В HEY отказались от Redis и перевели фоновые системы на очередь Solid Queue. Она обрабатывает около 20 млн. задач в сутки. Для этого используются 800 воркеров, четыре диспетчера и два планировщика на 74 виртуальных машинах. Очередь работает в отдельной базе данных, для которой выделены 32 CPU, 64 Гб памяти и 350 Гб диска. Solid Queue стал очередью по умолчанию в Ruby on Rails 8, а подход официально признан стандартом целой экосистемы.
Где начинаются проблемы
Очередь в PostgreSQL удобна, пока нагрузка на неё остаётся умеренной и предсказуемой. Но у подхода есть особенности, которые нужно учесть до запуска в продакшен.
Первая особенность связана с частым изменением записей. Задача обычно создаётся, затем меняет статус, а после выполнения удаляется или архивируется. В PostgreSQL старые версии строк не исчезают сразу, их очищает механизм vacuum. Если он не успевает за потоком обновлений, таблица и индексы разрастаются, а запросы к очереди начинают работать медленнее. Поэтому для таблицы задач обычно настраивают отдельные параметры autovacuum, следят за размером таблицы и не хранят завершённые задачи.
Вторая особенность касается механизма LISTEN/NOTIFY. Его суть в мгновенных уведомлениях, которыми удобно будить воркеров вместо периодического опроса таблицы. Компания Recall.ai в марте 2025 года получила из-за него три простоя: уведомления выполнялись в транзакциях, а на этапе COMMIT возникала конкуренция за глобальную блокировку, из-за чего коммиты фактически выстраивались в очередь. В PostgreSQL 19 обещают внедрить улучшения, которые уменьшают лишние пробуждения backend-процессов при NOTIFY, но это не отменит необходимости измерять поведение конкретной системы под нагрузкой.
Где проходит граница очередей
Интуитивно хочется провести границу и разделить применение очередей по реальной нагрузке. Но с большим опытом приходит понимание, что универсального порога по нагрузке нет. На практике значение имеют размер задач, число воркеров, индексы, частота повторных попыток и ресурсы самой базы.
Гораздо важнее оценивать стоимость эксплуатации. Очередь на PostgreSQL удобна, пока использует существующую базу: те же бэкапы, мониторинг, инструменты диагностики и компетенции команды. Для многих прикладных задач этого достаточно — особенно, если очередь обслуживает фоновые операции одного приложения.
Очереди «Яндекс Диска» и 37signals показывают, что PostgreSQL способен обрабатывать очень большой поток задач, но для этого очереди выделяют собственную базу, ресурсы, настройки обслуживания, мониторинг и т. д. У Диска очередь работает на шардированной базе с отдельной обвязкой, а в 37signals для Solid Queue используется выделенная база данных.
Не менее важна семантика. Очередь в PostgreSQL — когда одному приложению нужно выполнить фоновую задачу. Если же одно событие должны независимо получать несколько сервисов, а историю нужно хранить и перечитывать — лучше подходит Kafka. Она рассчитана на хранение потока событий и независимое чтение разными группами потребителей.
RabbitMQ уместнее, когда нужны готовые механизмы маршрутизации, подтверждения обработки и управления потоком сообщений. Эти возможности можно частично реализовать поверх таблицы в PostgreSQL, но тогда очередь постепенно превращается в собственный брокер, который нужно проектировать и сопровождать. RabbitMQ же предоставляет подтверждения обработки сообщений как встроенный механизм.
Очередь на PostgreSQL не заменяет Kafka или RabbitMQ, но хорошо подходит для фоновых задач. Главный критерий выбора — не популярность или абстрактный предел по числу задач. В приоритете — какие свойства требуются системе и сколько будет стоить их эксплуатация.






























