Квота, которая предупреждает, а не роняет сервис
Классическая квота работает как стена: пока место есть — всё нормально, как только лимит достигнут — запись обрывается посреди дела. Пользователь узнаёт об ограничении не из письма или баннера в личном кабинете, а из ошибки 500 в момент, когда пытался сохранить важный файл. Для него это выглядит как поломка сервиса, а не как ожидаемое и объяснимое ограничение. Разберём, как спроектировать квоту так, чтобы она предупреждала заранее — а не превращалась в неприятный сюрприз в самый неподходящий момент.
Содержание
Почему тихая квота — это плохой дизайн
Жёсткий лимит без предупреждений технически работает: он не даёт разрастись одному тенанту за счёт остальных, бережёт диск сервера и держит систему предсказуемой по ресурсам. Проблема не в самом лимите, а в том, что о его существовании пользователь узнаёт последним — уже после того, как операция фактически провалилась.
С точки зрения пользователя это выглядит как случайный сбой: загрузка файла зависла и упала с ошибкой, форма не сохранилась, импорт данных оборвался на середине. Он не видит текста «квота исчерпана» — он видит непонятную ошибку, открывает тикет в поддержку, теряет время, и первое впечатление от продукта — «у них тут всё ломается». Разбор такого тикета тоже недёшев: нужно поднять логи, понять, что причина не в баге, а в лимите, и объяснить это пользователю постфактум — хотя переписки можно было избежать одним заблаговременным уведомлением.
Есть и вторая цена тихой квоты — для самой системы. Когда лимит становится известен только в момент отказа операции, у администратора или у скрипта автоматизации нет запаса времени на реакцию: докупить место, почистить старые данные, связаться с клиентом о повышении тарифа. Решение принимается в панике, а не по плану. Похожая история разбиралась и с алертами мониторинга — порог алерта стоял на девяноста, а сервис умирал на семидесяти: дело не в наличии порога как такового, а в том, предупреждает он заранее или сообщает о проблеме постфактум.
Правильная квота решает не только задачу «не пустить тенанта за границу лимита», но и задачу «дать всем заинтересованным сторонам время среагировать до того, как граница будет достигнута». Это два разных требования, и жёсткий лимит без порогов закрывает только первое.
Несколько порогов вместо одной границы
Вместо одной точки «лимит достигнут» правильный дизайн квоты — это шкала с несколькими уровнями, каждый из которых что-то запускает.
| Уровень | Типичный порог | Что происходит |
|---|---|---|
| Норма | 0-79% | Ничего, фоновый учёт использования |
| Предупреждение | 80% | Уведомление пользователю/админу, статус в кабинете меняется на «жёлтый» |
| Критично | 95% | Повторное, более настойчивое уведомление; при наличии — автоматическое масштабирование или блокировка неприоритетных операций |
| Лимит | 100% | Запись новых данных блокируется, чтение и удаление остаются доступны |
Конкретные проценты (80/95) — это ориентир, а не догма: они хорошо работают при плавном росте использования (типичный веб-сервис, файловое хранилище пользователей). Если бывают резкие скачки — например, ночной батч-импорт может за час съесть 30% квоты — пороги стоит сдвинуть ниже или добавить промежуточный уровень, чтобы между предупреждением и лимитом оставалось время на реакцию именно при вашей скорости роста, а не абстрактной.
Важное архитектурное решение — не слать уведомление на каждый повторный запрос, пока использование держится выше порога. Без этого пользователь, который завис на 82% квоты неделю, будет получать письмо при каждой операции. Нужна либо отметка «уведомление по этому порогу уже отправлено» в хранилище состояния тенанта, либо гистерезис: повторное уведомление шлётся только если использование сначала опустилось ниже порога, а потом снова его превысило. Второй подход честнее отражает реальность — тенант либо продолжает расти (тогда сработает уже следующий, более высокий порог), либо ситуация не меняется и повторное письмо не несёт новой информации.
-- состояние уведомлений храним рядом с самим тенантом,
-- а не пересчитываем "отправляли или нет" на лету
CREATE TABLE tenant_quota_state (
tenant_id bigint PRIMARY KEY REFERENCES tenants(id),
quota_bytes bigint NOT NULL,
used_bytes bigint NOT NULL DEFAULT 0,
last_notified_threshold smallint NOT NULL DEFAULT 0, -- 0, 80 или 95
updated_at timestamptz NOT NULL DEFAULT now()
);
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверКак считать использование, чтобы порогам можно было доверять
Пороги в 80% и 95% бессмысленны, если само число «используется X из Y» неточное или устаревшее. Есть три типичных источника данных об использовании, и у каждого свои компромиссы.
Учёт на уровне файловой системы — самый точный способ для дискового пространства. Классическая quota-подсистема Linux (edquota, setquota, repquota) или встроенные квоты ZFS/Btrfs считают реальное занятое место на блочном уровне, без риска рассинхронизации с тем, что физически лежит на диске. Подробно про настройку такой квоты на уровне ОС и контейнеров — в статье дисковые квоты для пользователей и контейнеров. Минус подхода — он даёт только факт использования диска, но ничего не знает о бизнес-смысле данных: сколько это файлов, чьи они, какие можно безопасно предложить пользователю удалить.
Учёт на уровне приложения — сумма размеров файлов или строк, которые сервис сам ведёт в базе для каждого тенанта. Это менее точно (легко разойтись с реальностью, если данные были удалены в обход приложения — вручную на сервере), зато даёт человекочитаемую детализацию: «у вас 3.2 ГБ из 5 ГБ, из них 1.8 ГБ — вложения в чатах старше года».
-- пересчёт использования тенанта раз в N минут фоновой задачей,
-- а не на каждый запрос — иначе агрегация станет узким местом
UPDATE tenant_quota_state t
SET used_bytes = sub.total_bytes,
updated_at = now()
FROM (
SELECT tenant_id, COALESCE(SUM(size_bytes), 0) AS total_bytes
FROM files
WHERE deleted_at IS NULL
GROUP BY tenant_id
) sub
WHERE t.tenant_id = sub.tenant_id;
Периодическая сверка (du, df, listing бакета) — самый грубый, но и самый надёжный способ поймать расхождение между тем, что думает приложение, и тем, что реально занято на диске или в объектном хранилище. Раз в сутки стоит сверять цифры и логировать расхождение больше нескольких процентов — это чаще всего признак либо бага в подсчёте, либо данных, оставшихся в обход приложения (временные файлы, ручная выгрузка бэкапа рядом).
На практике разумно держать оба слоя: точный OS-уровень как источник истины для жёсткого лимита, и приложенческий учёт — для понятной детализации в личном кабинете. Пересчитывать использование на каждую запись дорого при высокой нагрузке, поэтому обычно берут фоновую задачу раз в несколько минут либо инкрементальный счётчик, обновляемый атомарно при каждой записи/удалении и изредка сверяемый с полным пересчётом.
Куда и как отправлять предупреждения
Мало посчитать процент — нужно, чтобы предупреждение реально дошло до того, кто может на него отреагировать, и не потерялось в шуме.
Практический список каналов по убыванию надёжности для срочных вещей:
- Баннер в личном кабинете — минимальная планка, видна при каждом визите, но бесполезна, если пользователь не заходит неделями.
- Email — привычен, но легко теряется в потоке писем; годится как основной канал для менее срочного порога (80%).
- Webhook / мессенджер — для критичного порога (95%) заметно надёжнее письма: уведомление в Telegram-чат обычно замечают за минуты, а не часы.
- In-app блокировка неприоритетных операций — на уровне 95% можно отключить необязательные фоновые задачи (генерацию превью, дублирование данных для аналитики), оставив основную функциональность рабочей чуть дольше.
# упрощённый пример проверки порогов фоновой задачей
THRESHOLDS = [(80, "warning"), (95, "critical")]
def check_quota(tenant):
pct = tenant.used_bytes / tenant.quota_bytes * 100
for threshold, level in sorted(THRESHOLDS, reverse=True):
if pct >= threshold and tenant.last_notified_threshold < threshold:
send_notification(tenant, level, pct)
tenant.last_notified_threshold = threshold
break
if pct < 80:
tenant.last_notified_threshold = 0 # гистерезис: ушли ниже — сбросили отметку
Если у вас уже есть Prometheus/Grafana для инфраструктурного мониторинга, разумно завести квоты тенантов туда же отдельными правилами, а не городить параллельный механизм алертов только для них — меньше мест, где логика уведомлений может незаметно разойтись. Как поднять такой мониторинг с нуля, разобрано в статье мониторинг диска на VPS: установка и настройка, а про то, как подобрать пороги алертов, чтобы они не превратились в фоновый шум, который все игнорируют, — в статье алерты, которые не бесят: пороги.
Что показать, когда лимит всё-таки достигнут
Даже при хорошо настроенных предупреждениях лимит рано или поздно будет достигнут — кто-то не отреагировал вовремя, кто-то намеренно тянет до последнего. В этот момент важно не то, что запись заблокирована (это ожидаемо), а то, насколько понятно об этом сообщается.
Плохой вариант — общая ошибка 500 Internal Server Error или Something went wrong, из которой пользователь не может понять, в чём дело и что делать. Хороший вариант — специфичная, машиночитаемая ошибка с понятным текстом для человека:
{
"error": {
"code": "QUOTA_EXCEEDED",
"message": "Хранилище заполнено: использовано 5.0 из 5.0 ГБ. Освободите место или увеличьте лимит в настройках тарифа.",
"used_bytes": 5368709120,
"quota_bytes": 5368709120,
"upgrade_url": "/billing/plans"
}
}
На уровне HTTP ближе всего по смыслу код 507 Insufficient Storage (из расширения WebDAV, но допустимо использовать и вне WebDAV) или 413 Payload Too Large, если ограничение относится к конкретной загружаемой сущности. Часть API используют 403 Forbidden с телом ошибки, как в примере выше, — это тоже нормально, если фронтенд читает error.code и показывает осмысленный экран, а не системную заглушку.
Ключевое требование к сообщению — оно должно объяснять причину и предлагать выход одним и тем же экраном: сколько занято, сколько доступно, что можно сделать (удалить старые данные, перейти на тариф выше, обратиться к администратору). Абстрактное «превышена квота» без цифр почти так же бесполезно, как generic-ошибка — пользователь всё равно не понимает, на сколько именно он превысил лимит и что конкретно удалить, чтобы вписаться обратно.
Отдельно стоит убедиться, что операции чтения и удаления остаются доступны даже при 100% использования — иначе тенант физически не сможет освободить место, чтобы выйти из блокировки, и единственным выходом останется обращение в поддержку. Это частая, но легко избегаемая ошибка реализации: блокировать по коду не только запись, но заодно и DELETE.
Пример: минимальный сервис квот поверх Postgres и cron
Для сервиса среднего размера не обязательно городить отдельный микросервис квот — достаточно таблицы состояния, фоновой задачи и middleware перед операциями записи.
Схема из трёх частей:
- Таблица состояния (
tenant_quota_stateиз примера выше) — источник истины о текущем использовании и последнем отправленном уведомлении. - Фоновая задача пересчёта, запускаемая по cron раз в 5-15 минут — пересчитывает
used_bytesиз первичных данных (или забирает готовое значение из quota-подсистемы ОС, если квота диска ведётся там) и вызывает проверку порогов.
# /etc/cron.d/quota-check — раз в 10 минут
*/10 * * * * appuser /usr/bin/python3 /opt/app/scripts/check_quotas.py >> /var/log/quota-check.log 2>&1
- Middleware перед записью, который делает быструю проверку
used_bytes >= quota_bytesпрямо из уже посчитанного состояния (без пересчёта на лету — это дорого при большом потоке запросов) и возвращает структурированную ошибкуQUOTA_EXCEEDED, если лимит достигнут:
def before_write(tenant_id, incoming_size):
state = get_quota_state(tenant_id) # читаем закешированное состояние
if state.used_bytes + incoming_size > state.quota_bytes:
raise QuotaExceeded(
used=state.used_bytes,
quota=state.quota_bytes,
)
# запись продолжается штатно
Важный нюанс: middleware проверяет по последнему пересчитанному значению, а не по абсолютно свежему — между пересчётами возможна небольшая рассинхронизация (тенант формально может на короткое время перейти за 100%, если несколько крупных записей прошли одновременно между циклами cron). Для большинства сценариев (файлы, документы, вложения) это допустимый компромисс — цена точности «до байта в реальном времени» обычно не стоит сложности распределённой блокировки на каждую запись. Если превышение критично (например, тарификация по объёму данных), проверку стоит делать атомарно в той же транзакции, что и саму запись.
Для дискового пространства на уровне VPS этот прикладной слой разумно комбинировать с классической ОС-квотой как жёстким предохранителем — приложенческая логика даёт красивые пороги и понятные ошибки, а ОС-квота гарантирует, что даже при баге в приложенческом коде тенант физически не сможет занять больше выделенного места и не уронит соседей. Разбор похожего сценария — как диск, заполнившийся до 100%, роняет не только виновника, а всех подряд, — в статье диск заполнился на 100% — что отвалилось первым.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Какие пороги ставить, если не 80/95?
Ориентируйтесь на скорость роста использования у конкретного тенанта, а не берите числа из статьи как догму. При плавном росте 80/95 достаточно. При резких скачках (батч-импорт, массовая загрузка) добавьте промежуточный порог или сдвиньте первый ниже, чтобы оставалось реальное время на реакцию.
Нужно ли слать уведомление и пользователю, и администратору?
Обычно да, но с разным содержанием: пользователю — про его квоту с предложением почистить данные или сменить тариф, администратору — про агрегированное состояние диска сервера, потому что превышение у одного тенанта — это симптом, а не вся картина.
Что делать, если у сервиса нет квот по тенантам, а есть общий диск на всех?
Тот же принцип применим на уровне сервера целиком: пороги 80/95% от общей ёмкости, уведомление заранее, а не в момент, когда df показывает 100%. Как посчитать запас места заранее — в статье сколько дискового пространства закладывать с запасом.
Можно ли автоматически увеличивать квоту вместо блокировки?
Можно, но тогда порог 95% должен запускать не только уведомление, а платное или согласованное расширение лимита — иначе автоматика превращается в способ незаметно вырастить расходы клиента без его ведома.
Как быть с квотой на объектное хранилище (S3-совместимое)?
Механизм тот же: listing бакета или встроенные метрики хранилища вместо du, те же пороги и та же понятная ошибка при отказе загрузки. Разница в том, как считать использование — через API хранилища, а не через файловую систему ОС.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →