$ sudo teach IT

Урок №7. Приоритетная обработка задач: HIGH, MEDIUM, LOW, starvation и aging

В предыдущих уроках мы научились безопасно резервировать фоновые задачи, восстанавливать их после падения воркера и предотвращать дублирование внешних операций.

Теперь добавим ещё одно требование: не все задачи одинаково важны.

Например, проверка данных клиента должна выполняться раньше обновления статистики. Но что произойдёт, если срочные задачи поступают постоянно? Получат ли менее важные задачи возможность выполниться?

В этом уроке разберём две стратегии выбора задач: строгий приоритет и aging — повышение эффективного приоритета по мере ожидания.

Чему ты научишься

  • Выбирать задачи по приоритетам HIGH, MEDIUM и LOW.
  • Объяснять, как ORDER BY и FOR UPDATE SKIP LOCKED работают вместе.
  • Понимать, почему высокоприоритетные задачи не всегда обрабатываются первыми.
  • Распознавать starvation — голодание низкоприоритетных задач.
  • Разбирать и объяснять SQL-формулу aging.
  • Оценивать ограничения приоритетной очереди в production.

1. Почему обычной очереди уже недостаточно?

До этого момента наши воркеры резервировали доступные задачи без разделения по важности.

Теперь представим, что в сервис поступают три категории задач:

Приоритет Значение Пример
HIGH 3 Проверка данных клиента
MEDIUM 2 Генерация отчёта
LOW 1 Обновление статистики

У нас есть четыре задачи:

ID Приоритет Возраст задачи
#42 LOW 40 минут
#43 HIGH 30 минут
#44 MEDIUM 20 минут
#45 HIGH 10 минут

Какую задачу следует взять первой?

Если мы учитываем только время создания, первой окажется LOW #42. Она самая старая.

Но если задача HIGH важнее LOW, мы хотим получить другой порядок:

#43 → #45 → #44 → #42.

Сначала обе HIGH-задачи, затем MEDIUM и только потом LOW. Среди задач одинакового приоритета выбираем ту, которая создана раньше.

Это и есть строгий приоритет.

2. Подготовка лабораторной

Продолжаем работать с уже созданной базой данных:

  • База: backend_training_lab
  • Схема: backend_training
  • Новая таблица: lesson07_tasks

Скачай прикреплённый к уроку ZIP с лабораторной №7, распакуй его и перейди в папку lesson07_priority_queue.

Используй существующее Python-окружение. Если нужные зависимости ещё не установлены:

pip install -r requirements.txt

Создай таблицу урока в уже существующей базе:

psql -d backend_training_lab -f schema.sql

Используй прежний DATABASE_URL. Если переменная не задана, укажи строку подключения к своей учебной БД:

export DATABASE_URL='postgresql+psycopg://USER:PASSWORD@localhost:5432/backend_training_lab'

Заменяй USER и PASSWORD своими данными. Новую БД или отдельного пользователя создавать не требуется.

В этом уроке четыре независимых эксперимента:

python -m lesson07.demo priority
python -m lesson07.demo skip-locked
python -m lesson07.demo starvation
python -m lesson07.demo aging

Каждый эксперимент самостоятельно подготавливает тестовые данные. Он удаляет строки только из backend_training.lesson07_tasks, поэтому не запускай несколько экспериментов одновременно.

После каждого запуска в терминале будет показано, что программа делает, какой результат ожидается и что фактически вернул PostgreSQL.


3. Эксперимент №1. Строгий приоритет

Начнём с самого простого решения.

Если HIGH имеет значение 3, MEDIUM — 2, а LOW — 1, достаточно отсортировать задачи по убыванию приоритета:

SELECT id, priority
FROM backend_training.lesson07_tasks
WHERE status = 'pending'
ORDER BY
    priority DESC,
    created_at ASC,
    id ASC
FOR UPDATE SKIP LOCKED
LIMIT 1;

Разберём сортировку:

  • priority DESC — сначала наибольший приоритет.
  • created_at ASC — среди задач одного приоритета сначала самая старая.
  • id ASC — если приоритет и время создания совпадают, используем ID для определения порядка.

FOR UPDATE SKIP LOCKED нам уже знаком: запрос пытается заблокировать выбранную строку и пропускает те строки, которые сейчас заблокированы другими транзакциями.

В лабораторной это часть атомарного SQL-запроса: PostgreSQL находит задачу, блокирует строку и обновляет её статус в одной транзакции.

Как выбор задачи объединён с UPDATE?

В repository.py используется конструкция WITH next_task AS (...):

WITH next_task AS (
    SELECT id
    FROM backend_training.lesson07_tasks
    WHERE status = 'pending'
    ORDER BY priority DESC, created_at ASC, id ASC
    FOR UPDATE SKIP LOCKED
    LIMIT 1
)
UPDATE backend_training.lesson07_tasks AS task
SET status = 'processing',
    attempts = task.attempts + 1,
    lease_until = clock_timestamp()
        + (:lease_seconds * INTERVAL '1 second'),
    claimed_by = :worker_name
FROM next_task
WHERE task.id = next_task.id
RETURNING task.id, task.priority, task.attempts;

Сначала next_task выбирает и блокирует одну подходящую строку. Затем UPDATE резервирует именно её: устанавливает processing, увеличивает attempts и записывает имя воркера.

До COMMIT другой воркер не сможет получить ту же заблокированную строку через этот механизм.

Гипотеза: PostgreSQL будет резервировать задачи в порядке HIGH → MEDIUM → LOW, даже если LOW-задача создана раньше всех.

Запусти:

python -m lesson07.demo priority

Найди в терминале последовательность захватов. Ожидаемый порядок:

#43 HIGH
#45 HIGH
#44 MEDIUM
#42 LOW

Обрати внимание: здесь Worker A последовательно резервирует и завершает задачи, поэтому мы можем легко проверить порядок.

Что мы выяснили? Для простой приоритетной очереди достаточно сортировки по priority. При одинаковом приоритете мы сохраняем порядок по времени создания.

Но это ещё не гарантия порядка выполнения в конкурентной системе.

4. Эксперимент №2. Что произойдёт с двумя воркерами?

Представим, что Worker A уже выбрал HIGH-задачу #43, но ещё не завершил свою транзакцию.

В этот момент Worker B тоже хочет получить задачу.

Что ему делать: ждать освобождения #43 или взять другую доступную задачу?

В нашей очереди используется SKIP LOCKED, поэтому Worker B может продолжить работу, не ожидая освобождения заблокированной строки #43.

Гипотеза: Worker A заблокирует HIGH #43, а Worker B пропустит её и зарезервирует следующую HIGH #45.

python -m lesson07.demo skip-locked

Эксперимент проходит следующим образом:

  1. Worker A открывает транзакцию и резервирует #43, но пока не выполняет COMMIT.
  2. Worker B в другом подключении выполняет резервирование и получает #45.
  3. Worker B фиксирует свою транзакцию.
  4. Worker A выполняет COMMIT.

В итоге обе задачи имеют статус processing, но принадлежат разным воркерам.

Здесь важно различать два момента:

  • До COMMIT Worker A: его изменение ещё не зафиксировано, но строка #43 уже заблокирована.
  • После COMMIT: изменение становится зафиксированным, а блокировка строки освобождается.

Именно поэтому мы используем два подключения и две пересекающиеся транзакции. Если бы Worker A сразу выполнил COMMIT, мы не смогли бы показать пропуск строки, которую удерживает другой воркер.

Инженерный вывод: приоритет определяет желаемый порядок выбора, но SKIP LOCKED позволяет обходить уже занятые задачи.

В реальной системе самая приоритетная строка может быть временно недоступна. Тогда воркер выбирает следующую доступную строку — возможно, даже с более низким приоритетом.

Значит ли SKIP LOCKED, что запрос вообще никогда не ждёт?

Нет. SKIP LOCKED относится к блокировкам строк. PostgreSQL по-прежнему получает необходимые блокировки уровня таблицы, и при некоторых конфликтах ожидание возможно.

Также SKIP LOCKED не гарантирует единый глобальный порядок резервирования для всех конкурентных воркеров. Это механизм уменьшения конкуренции за строки, а не полноценный планировщик с гарантированной очередностью.


5. Эксперимент №3. Starvation — когда LOW никогда не получает очередь

Мы решили первую задачу: HIGH обрабатываются раньше LOW.

Но представь следующую ситуацию.

LOW-задача #42 ожидает уже 30 минут. Worker A готов взять следующую задачу. В этот момент поступает новая HIGH-задача.

Worker A выбирает HIGH — это соответствует нашему правилу.

После её завершения появляется ещё одна HIGH. Затем ещё одна.

Наша LOW-задача по-прежнему ждёт.

Так может продолжаться очень долго.

Starvation (голодание) — ситуация, когда задача не получает возможность выполниться, потому что другие задачи постоянно получают преимущество при выборе.

Важная деталь: LOW-задача не заблокирована и не находится в состоянии processing. Она исправно остаётся pending, но планировщик каждый раз предпочитает другую задачу.

Гипотеза: если перед каждым резервированием добавлять новую HIGH-задачу, LOW #42 останется в очереди.

python -m lesson07.demo starvation

Лабораторная выполняет пять раундов:

Раунд Новая HIGH Состояние LOW #42
1#101pending
2#102pending
3#103pending
4#104pending
5#105pending

В каждом раунде воркер резервирует и завершает новую HIGH-задачу.

А LOW #42 остаётся pending.

Мы не доказали, что она будет ждать бесконечно: эксперимент ограничен пятью раундами. Но мы увидели механизм, который может приводить к starvation при постоянном поступлении высокоприоритетных задач.

Почему это плохо для production?

Даже низкоприоритетные операции часто должны завершаться за разумное время. Например, обновление статистики можно отложить на несколько минут, но если оно не выполняется несколько часов, отчёты и мониторинг могут начать показывать устаревшие данные.

Возникает инженерный вопрос: как сохранить преимущество HIGH, но при этом дать давно ожидающей LOW-задаче шанс?


6. Aging — когда время ожидания влияет на приоритет

Теперь подойдём к самой важной части урока.

Представим обычную очередь обслуживания.

Клиенты с высоким приоритетом проходят раньше остальных. Но человек с низким приоритетом стоит уже полчаса, а перед ним постоянно появляются новые посетители.

Можно ввести правило: за каждые пять минут ожидания добавляем один балл к приоритету.

Это и есть идея aging — старения задач.

Задача не становится другой по своей важности. Но чем дольше она ждёт, тем больше оснований дать ей возможность выполниться.

6.1. Базовый и эффективный приоритет

Нам понадобятся два понятия.

Базовый приоритет (priority) — значение, с которым задача была создана. LOW = 1, MEDIUM = 2, HIGH = 3.

Эффективный приоритет (effective_priority) — временный балл, который планировщик рассчитывает при выборе задачи с учётом её возраста.

Формула простая:

Эффективный приоритет =
    Базовый приоритет
    + Количество полных интервалов ожидания

В нашем эксперименте один интервал — 5 минут.

Посмотрим, как растёт балл одной LOW-задачи:

Возраст Прибавка Эффективный приоритет
0 минут01
4 минуты01
5 минут12
10 минут23
15 минут34
30 минут67

Обрати внимание на первые две строки.

Если задача ждёт четыре минуты, дополнительного балла ещё нет. Мы начисляем его только за полный пятиминутный интервал.

Через пять минут появляется первый дополнительный балл. Через десять — второй.

А через тридцать минут исходный LOW с базовым приоритетом 1 получает эффективный приоритет 7.

Это не означает, что он навсегда стал HIGH. Его priority в таблице по-прежнему равен 1. Значение 7 существует только при расчёте порядка выбора.

6.2. Как LOW может обогнать HIGH?

Теперь сравним три задачи из лабораторной:

Задача Возраст Расчёт
#42 LOW 30 мин 1 + 6 = 7
#43 HIGH 1 мин 3 + 0 = 3
#44 MEDIUM 2 мин 2 + 0 = 2

Без aging порядок был бы таким:

HIGH #43 → MEDIUM #44 → LOW #42

С aging порядок меняется:

LOW #42 (7) → HIGH #43 (3) → MEDIUM #44 (2)

Почему?

Потому что теперь мы сортируем не по исходному priority, а по вычисляемому effective_priority.

Главная идея aging: высокая важность даёт задаче преимущество в начале ожидания, но длительное ожидание может постепенно изменить порядок выбора.

Причём баллы за ожидание получают все pending-задачи, не только LOW. Просто старая LOW в нашем примере накопила больше баллов, чем недавно поступившая HIGH.


7. Как работает SQL-формула aging?

Идею мы поняли. Теперь посмотрим, как PostgreSQL рассчитывает её в SQL.

В лабораторной используется такое выражение:

(
    priority
    + GREATEST(
        0,
        FLOOR(
            EXTRACT(EPOCH FROM (
                statement_timestamp() - created_at
            ))
            / (:aging_minutes * 60)
        )
    )
)::integer AS effective_priority

На первый взгляд формула выглядит сложнее, чем наша идея «прибавить балл за каждые пять минут».

Но она делает ровно это. Разберём её по частям.

7.1. Сколько времени прошло с создания задачи?

statement_timestamp() - created_at

created_at — момент создания задачи.

statement_timestamp() — время начала текущего SQL-запроса.

Разность показывает возраст задачи.

Например:

Сейчас:       12:30
Создана:      12:00
Прошло:       30 минут

Почему используем statement_timestamp(), а не now()?

В PostgreSQL now() возвращает время начала текущей транзакции. А нам нужен момент, когда выполняется конкретный запрос выбора задачи. statement_timestamp() возвращает время начала именно текущего SQL-оператора.

Почему не используем clock_timestamp()?

clock_timestamp() возвращает фактическое текущее время, которое может изменяться даже во время выполнения одного SQL-запроса.

Для нашего расчёта удобно иметь единую точку времени на весь оператор. Поэтому используем statement_timestamp().

7.2. Переводим время ожидания в секунды

EXTRACT(EPOCH FROM (
    statement_timestamp() - created_at
))

Вычитание временных значений возвращает интервал. EXTRACT(EPOCH FROM ...) переводит его в количество секунд.

Для тридцати минут получаем:

30 × 60 = 1800 секунд

Зачем это нужно? Чтобы сравнить время ожидания с длительностью нашего пятиминутного интервала.

7.3. Считаем количество интервалов

EXTRACT(EPOCH FROM (
    statement_timestamp() - created_at
))
/ (:aging_minutes * 60)

:aging_minutes — параметр запроса. В нашем эксперименте он равен 5.

Умножаем 5 на 60, чтобы получить 300 секунд, и делим возраст задачи на размер интервала:

1800 / (5 × 60)
= 1800 / 300
= 6

То есть задача прожила шесть полных пятиминутных интервалов.

7.4. Почему нужен FLOOR?

FLOOR(
    EXTRACT(EPOCH FROM (
        statement_timestamp() - created_at
    ))
    / (:aging_minutes * 60)
)

FLOOR округляет число вниз до ближайшего целого.

Представим, что задача ожидает 12 минут:

12 / 5 = 2.4

FLOOR(2.4) = 2

Значит, мы начисляем два балла, а не три.

Задача уже прошла два полных пятиминутных интервала. Третий ещё не завершился.

Именно поэтому в таблице выше задача с ожиданием 4 минуты не получала дополнительного балла.

7.5. Зачем нужен GREATEST(0, ...)?

GREATEST(
    0,
    FLOOR(...)
)

GREATEST выбирает наибольшее из переданных значений.

Здесь она не позволяет прибавке стать отрицательной.

Например, если created_at по ошибке оказался в будущем, разность времени будет отрицательной. Без защиты мы могли бы получить отрицательную прибавку и уменьшить эффективный приоритет.

В нашей учебной стратегии минимальная прибавка равна нулю.

7.6. Собираем результат

Осталось прибавить число полных интервалов к исходному приоритету:

priority + GREATEST(0, FLOOR(...))

Для LOW-задачи, которая ждёт 30 минут:

priority = 1

Прошло секунд = 1800

Размер интервала = 300 секунд

Полных интервалов = FLOOR(1800 / 300) = 6

Прибавка = GREATEST(0, 6) = 6

effective_priority = 1 + 6 = 7

В конце выражения:

::integer AS effective_priority

::integer приводит полученное значение к целочисленному типу, а AS effective_priority задаёт имя вычисляемому столбцу в результате SELECT.

Итого: всё большое SQL-выражение нужно лишь для того, чтобы рассчитать возраст задачи, определить количество полных интервалов и прибавить их к исходному приоритету.


8. Эксперимент №4. Проверяем aging в PostgreSQL

Теперь проверим, что расчёт действительно меняет порядок резервирования.

Гипотеза: старая LOW-задача #42 с эффективным приоритетом 7 будет выбрана раньше новой HIGH-задачи #43 с эффективным приоритетом 3.

Запусти:

python -m lesson07.demo aging

Программа выполняет четыре шага.

Шаг 1. Создаёт три pending-задачи с разным приоритетом и возрастом.

Шаг 2. Проверяет порядок без aging. Ожидаемый результат — HIGH #43.

Шаг 3. Выполняет SELECT с расчётом effective_priority. Ожидаемый лидер — LOW #42 с баллом не меньше 7.

Шаг 4. Worker A действительно резервирует задачу через SQL с aging.

Ожидаем:

task_id=42
priority=LOW
attempts=1
worker=Worker A

После этого посмотри состояние таблицы.

У задачи #42 статус изменился с pending на processing, но её базовый priority остался равен 1.

Это принципиально.

Мы не обновляли приоритет задачи в базе. Мы только рассчитали новый балл и использовали его для выбора.

8.1. Где именно aging влияет на резервирование?

В обычном варианте мы сортировали задачи по полю:

ORDER BY priority DESC, created_at ASC, id ASC

В варианте с aging вместо простого поля используем вычисляемое выражение:

ORDER BY (
    priority
    + GREATEST(
        0,
        FLOOR(
            EXTRACT(EPOCH FROM (
                statement_timestamp() - created_at
            ))
            / (:aging_minutes * 60)
        )
    )
) DESC,
created_at ASC,
id ASC
FOR UPDATE SKIP LOCKED
LIMIT 1

Всё остальное остаётся знакомым: PostgreSQL выбирает одну подходящую pending-задачу, блокирует её и резервирует в транзакции.

Новая часть здесь только одна: способ вычисления порядка выбора.

Зачем в лабораторной отдельно нужны AGING_PREVIEW_SQL и AGING_CLAIM_SQL?

AGING_PREVIEW_SQL показывает, какая задача окажется первой при заданном расчёте. Он ничего не резервирует.

AGING_CLAIM_SQL применяет тот же расчёт при реальном резервировании задачи: использует FOR UPDATE SKIP LOCKED и затем UPDATE.

Разделение нужно для обучения: сначала можно увидеть порядок, не изменяя данные, а потом проверить реальное поведение воркера.

В конкурентной системе результат предварительного SELECT не гарантирует, что при следующем запросе будет выбрана та же строка: между запросами состояние очереди может измениться.


9. Что выбрать: строгий приоритет или aging?

Оба подхода решают задачу выбора, но делают это по-разному.

Критерий Строгий приоритет Aging
Главное правило HIGH всегда предпочтительнее LOW среди доступных задач Долгое ожидание может изменить порядок
Преимущество Простота и предсказуемое правило Уменьшает риск голодания старых задач
Недостаток Возможен starvation Сложнее выбор и SQL-сортировка
Подходит, когда Высокий приоритет действительно должен доминировать Важно учитывать не только срочность, но и время ожидания

Какой подход лучше?

Зависит от требований продукта.

Если LOW-задачи действительно могут ожидать сколь угодно долго, строгий приоритет может оказаться достаточным.

Если любая задача должна рано или поздно получать шанс на обработку, стоит рассматривать aging или другую политику справедливого распределения ресурсов.

Но не стоит считать, что aging автоматически гарантирует обработку каждой задачи за определённое время.

На время ожидания по-прежнему влияют скорость поступления новых задач, количество воркеров, длительность обработки и выбранная формула приоритета.

10. Ограничения и production-компромиссы

10.1. Aging не меняет саму задачу

В нашем решении поле priority остаётся прежним. Меняется только вычисляемый балл при выборе.

Это полезно, потому что исходная бизнес-важность задачи не теряется. Если задача создана как LOW, мы всегда можем отличить её от настоящей HIGH, даже если из-за длительного ожидания она временно оказалась выше в очереди.

10.2. Мы считаем возраст от created_at

В лабораторной время ожидания рассчитывается от момента создания задачи.

Для наших задач этого достаточно: они создаются сразу со статусом pending.

Но в production задача может побывать в обработке, завершиться ошибкой и снова вернуться в очередь.

В таком случае время с created_at и время непрерывного ожидания в pending — не одно и то же.

Если бизнес-правило требует учитывать именно ожидание в очереди после последнего возврата, понадобится отдельное поле, например pending_since, и определённое правило его обновления.

10.3. Вычисляемая сортировка может стоить дорого

Строгий приоритет проще оптимизировать: порядок зависит от постоянных полей priority, created_at и id.

При aging PostgreSQL должен учитывать вычисление, зависящее от текущего времени.

На большой таблице это может потребовать оценки множества ожидающих задач и отдельной сортировки. Поэтому для production потребуется смотреть реальный план выполнения через EXPLAIN (ANALYZE, BUFFERS) на репрезентативных данных.

Не стоит заранее утверждать, что aging всегда медленный. Но и считать вычисляемый ORDER BY бесплатным нельзя.

Может ли FOR UPDATE SKIP LOCKED нарушить ожидаемый порядок?

При конкурентной работе воркеров — да. Уже заблокированная задача может быть пропущена. Кроме того, PostgreSQL документирует особенности сочетания ORDER BY с блокирующими операторами на уровне READ COMMITTED, когда сортируемые значения могут конкурентно изменяться.

В нашей лабораторной проверяется конкретное поведение на небольшой таблице. Это не гарантия строгого глобального порядка в системе с множеством воркеров.

10.4. Приоритет — это не гарантия порядка завершения

Даже если Worker A первым зарезервировал HIGH, а Worker B позже взял MEDIUM, MEDIUM может завершиться раньше.

Причина простая: задачи выполняются разное время.

Наш планировщик управляет порядком резервирования, но не принуждает воркеры завершать задачи в том же порядке.


11. Проверь понимание

Ответь своими словами, не запуская код повторно.

  1. Почему в первом эксперименте LOW #42 выбирается последней, хотя она самая старая?
  2. Почему Worker B смог получить #45, когда Worker A ещё не выполнил COMMIT для #43?
  3. Как постоянное появление HIGH-задач может приводить к starvation?
  4. LOW-задача имеет priority=1 и ожидает 17 минут. При aging_minutes=5 чему равен её эффективный приоритет? Объясни расчёт.
  5. Почему после резервирования через aging в таблице остаётся исходное значение priority?
  6. Почему aging не стоит считать безусловной гарантией максимального времени ожидания?

Вопросы специально требуют объяснять причины. Для собеседования уровня Middle+/Senior важно не только знать SQL, но и уметь обосновать выбранную политику обработки задач.


Итог урока

Мы добавили в нашу систему приоритеты и изучили два способа выбора задач.

Строгий приоритет позволяет обрабатывать HIGH раньше MEDIUM и LOW. Реализуется простой сортировкой, но при постоянном притоке HIGH может приводить к голоданию менее приоритетных задач.

Aging учитывает не только исходную важность, но и возраст задачи. В нашей реализации за каждые пять полных минут добавляется один балл. Это позволяет давно ожидающей LOW-задаче обогнать недавно поступившую HIGH.

При этом PostgreSQL не предоставляет отдельного встроенного «режима aging». Формулу и правила начисления баллов определяет наше приложение, а PostgreSQL выполняет соответствующий SQL-запрос.

Главный инженерный вывод: приоритетная очередь — это не только сортировка задач. Это выбор между срочностью, справедливостью, производительностью и предсказуемостью обработки.

Источники и дополнительное изучение

Е. В. Рогов. «PostgreSQL 16 изнутри».

  • Глава 2. «Изоляция», стр. 47–74. Видимость изменений и поведение транзакций, в том числе Read Committed.
  • Глава 8, стр. 174–175. Пример пакетной обработки строк с FOR UPDATE SKIP LOCKED.
  • Глава 13. «Блокировки строк», стр. 258–282. Устройство блокировок строк, конкурентное обновление и очереди ожидания.
  • Глава 23. «Сортировка и слияние», начиная со стр. 469. Механизмы сортировки и их стоимость при выполнении запросов.

Официальная документация PostgreSQL 16:

Механизмы блокировок, видимости данных и сортировки рассматриваются в книге Рогова и документации PostgreSQL. Алгоритм aging, выбор пятиминутного интервала и формула эффективного приоритета — учебные инженерные решения, реализованные в лабораторной этого урока.