Расхожее мнение гласит, что очереди на Postgres не масштабируются — для серьёзных нагрузок нужны выделенные системы вроде RabbitMQ + Celery или Redis + BullMQ. В DBOS с этим не согласились и показали, как при правильных оптимизациях выжать из Postgres 30 тысяч выполнений workflow в секунду на тысячах серверов.
Первая проблема — несколько воркеров одновременно пытаются забрать одни и те же самые старые задачи. Без защиты они колотят по таблице, создавая конкуренцию, и большинство запросов возвращается пустыми. Спасение — конструкция FOR UPDATE SKIP LOCKED. Она блокирует выбранные строки и заставляет следующий запрос пропускать уже залоченные, забирая следующие по старшинству. Без SKIP LOCKED дальше ~100 workflow в секунду не уйти, а с ним масштаб вырастает кардинально.
Следующим узким горлом стали ошибки сериализации на уровне изоляции транзакций. Изначально очередь работала в режиме REPEATABLE READ, чтобы честно соблюдать глобальные лимиты «не больше N параллельных выполнений по всем воркерам». Но при высокой конкуренции Postgres откатывал большинство транзакций с исключением Serialization Failure, и воркеры тратили время на повторы, а не на работу. Выяснилось, что крупные очереди почти никогда не используют глобальный контроль — пользователям хватает локальных ограничений вроде «10 workflow на воркер». Поэтому уровень изоляции сделали условным: если глобальные лимиты нужны, остаётся REPEATABLE READ, для всех остальных — READ COMMITTED, который полностью убирает ошибки сериализации и резко поднимает пропускную способность.
Когда конкуренцию победили, при нагрузке выше 8 тысяч выполнений в секунду упёрлись в CPU. Виновниками оказались неэффективные индексы. Индекс для dequeuer’а находил все записи со статусом ENQUEUED по имени очереди, но не сортировал их, заставляя Postgres тратить процессор на сортировку по времени. Дополнительные индексы для наблюдаемости, вроде поиска по идентификатору родительского workflow, обновлялись при каждом изменении статуса задачи, а autovacuum потом подчищал горы устаревших записей.
Решение — сделать индексы избирательными. Главный dequeue-индекс переделали в частичный: он покрывает только строки со статусом ENQUEUED и сразу возвращает их отсортированными по приоритету и времени. Когда workflow уходит из состояния ENQUEUED, запись из индекса просто удаляется, а не поддерживается весь жизненный цикл. Та же логика легла на индексы для наблюдаемости — например, индекс по родительскому workflow создаётся только для тех записей, у которых родитель действительно есть. В сумме оптимизации обрушили нагрузку на CPU и позволили стабильно обрабатывать свыше 30 тысяч workflow в секунду, или 80 миллиардов в месяц.