Как реализовать равномерное распределение заявок между менеджерами
Что проверяют
Умеете ли вы уточнить, что значит «равномерно», и видите ли гонку, когда заявки приходят одновременно. Следом почти всегда спрашивают: «А если экземпляров сервиса несколько?»
Как отвечать
Сначала критерий: равномерно по числу заявок за день, по текущей открытой нагрузке или с учётом веса заявки? Кто участвует в раздаче: только менеджеры на смене, без отпускников?
Стратегии по возрастанию сложности:
- round-robin: по кругу,
itertools.cycleв памяти илиINCRв Redis с остатком от деления на число менеджеров. Просто, но не видит, что у одного десять открытых заявок, а у другого ноль; - наименее загруженный: минимум по открытым заявкам. В памяти удобна куча
heapq, но в реальной системе состояние живёт в базе; - взвешенный: менеджеру на полставки половина потока.
Главное в ответе атомарность. Если два воркера gunicorn одновременно прочитают «у Анны 3 заявки, это минимум», обе заявки уйдут Анне. Поэтому выбор и увеличение счётчика делают одной операцией в базе:
UPDATE managers SET open_count = open_count + 1, last_assigned_at = now() WHERE id = (SELECT id FROM managers WHERE on_shift ORDER BY open_count, last_assigned_at LIMIT 1 FOR UPDATE SKIP LOCKED) RETURNING id
В Django это select_for_update(skip_locked=True) внутри transaction.atomic(), в SQLAlchemy with_for_update(skip_locked=True). Сортировка по last_assigned_at вторым ключом решает ничьи, иначе при равной нагрузке первым всегда оказывается один и тот же менеджер.
Счётчик уменьшается при закрытии заявки, а периодически его сверяют с фактическим числом открытых заявок.
Где спотыкаются
Держат счётчики в памяти процесса. Под gunicorn с четырьмя воркерами у каждого своя копия, и равномерность пропадает сразу.
Никогда не уменьшают счётчик. Через месяц «наименее загруженным» оказывается новичок, у которого просто меньше истории.
Не учитывают доступность: заявка уходит менеджеру в отпуске и висит без ответа.