MAATRIX / Блог / От 100 к 1000 пользователей: три подсистемы, которые рвутся именно на этом порядке

От 100 к 1000 пользователей: три подсистемы, которые рвутся именно на этом порядке

MAATRIX

Пока проект держался на сотне активных пользователей, инфраструктура почти не участвовала в разговоре — падали, если падали, из-за багов, а не из-за нагрузки. На переходе к тысяче картина меняется: куски кода, которые никто не трогал месяцами, вдруг начинают давать сбои под нагрузкой, хотя в них ничего не менялось. Ниже — разбор трёх конкретных подсистем, которые статистически рвутся именно в этом диапазоне, и что с ними делать до того, как это станет ночным инцидентом.

Почему трещины появляются именно на этом порядке

Между 100 и 1000 одновременных пользователей нет магической границы — это не константа из конфига, за которой сервер падает секунда в секунду. Но есть закономерность, из-за которой этот диапазон особенный. При 100 пользователях объём данных в базе, число параллельных запросов и конкуренция за общие ресурсы (файлы, сессии, воркеры) ещё настолько малы, что любая неоптимальность тонет в запасе прочности сервера. Полный скан таблицы на пару тысяч строк выполняется за доли миллисекунды даже без индекса — грубая ошибка архитектуры просто не успевает себя проявить.

К тысяче пользователей три вещи меняются одновременно. Растёт объём накопленных данных — не только потому, что пользователей в 10 раз больше, а потому что каждый из них успел оставить в системе больше записей. Растёт число параллельных запросов в единицу времени, а вместе с ним — конкуренция за общие ресурсы, которые при 100 пользователях почти никогда не пересекались одновременно. И сокращается время до следующего похожего запроса от другого пользователя — операции начинают выстраиваться в очередь, а не выполняться последовательно с запасом. Ни один из этих трёх факторов сам по себе не критичен — критичным становится их совпадение.

Позже, на 10 000 пользователей, разговор уже идёт о горизонтальном масштабировании, распределённом кэше и балансировке между инстансами приложения — общий обзор порогов роста разобран в статье про то, что отваливается на 100, 1000 и 10 000 пользователях. Здесь мы разбираем именно средний порядок — где один сервер архитектурно ещё справляется, но конкретные куски кода внутри него уже нет.

Подсистема 1: запросы без индексов, которые молчали на 100 и заговорили на 1000

Самый частый источник трещин на этом порядке — не сложные запросы с JOIN'ами, а простейшие выборки вида WHERE user_id = ? или ORDER BY created_at DESC LIMIT 20 на поле без индекса. Пока в таблице несколько тысяч строк, планировщик PostgreSQL или MySQL спокойно выбирает Seq Scan — полное сканирование — и это оказывается быстрее, чем читать индекс и ходить по указателям в саму таблицу. Разработчик видит время ответа в 3-5 мс, не замечает проблемы и переходит к следующей задаче.

Проблема в том, что рост объёма данных на этом этапе почти всегда обгоняет рост числа пользователей: тысяча активных пользователей — это не в 10 раз больше строк в таблице заказов, чем при сотне, а обычно заметно больше, потому что каждый успел провести в системе не один день, а недели или месяцы. В какой-то момент таблица переходит порог, после которого тот же Seq Scan начинает читать с диска заметно больше страниц, и время запроса растёт не линейно, а скачком, как только перестаёт помещаться в буферный кэш базы.

Найти такие места до инцидента можно заранее, не дожидаясь жалоб пользователей:

-- Топ самых медленных по суммарному времени запросов
SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

Расширение pg_stat_statements нужно включить заранее (shared_preload_libraries = 'pg_stat_statements' в postgresql.conf и перезапуск), но сделать это стоит именно на этом этапе роста, а не когда уже больно. Для конкретного подозрительного запроса дальше смотрим план:

EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 4821 ORDER BY created_at DESC LIMIT 20;

Если в плане видно Seq Scan on orders вместо Index Scan или Bitmap Heap Scan, а строка rows в описании сильно больше 20 — это кандидат на индекс:

CREATE INDEX CONCURRENTLY idx_orders_user_created
ON orders (user_id, created_at DESC);

Ключевое слово CONCURRENTLY здесь не опционально на проде — обычный CREATE INDEX блокирует таблицу на запись на время построения, и на таблице, которая уже обслуживает тысячу пользователей, это само по себе может стать инцидентом. У CONCURRENTLY есть обратная сторона: он не работает внутри транзакции и может завершиться с не полностью построенным (INVALID) индексом при сбое — такой нужно вручную удалить и пересоздать, миграционные фреймворки не всегда делают это сами. Подробнее о том, где проходит грань между «индекс помогает» и «планировщик больше его не выбирает», разобрано в статье про точку, где база выбирает полное сканирование.

Отдельная ловушка — не отсутствие индекса, а лишний. На этом же этапе нередко решают «на всякий случай проиндексировать всё» — и получают обратный эффект: каждая запись теперь обновляет пять индексов вместо одного, вставки замедляются, а часть индексов планировщик вообще не использует. Прежде чем добавлять индекс, стоит убедиться через EXPLAIN ANALYZE, что он нужен именно для этого запроса, а не строить их по интуиции.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Подсистема 2: тяжёлые операции внутри HTTP-запроса

Вторая типичная трещина — синхронная обработка операций, которые сами по себе не быстрые: отправка письма через SMTP или API, генерация PDF-отчёта, обработка загруженного изображения, запрос к стороннему платёжному шлюзу. При 100 пользователях, если такая операция занимает условные несколько сотен миллисекунд — секунду, это почти незаметно: воркер занят чуть дольше обычного, но следующий запрос приходит не сразу, и очередь не успевает накопиться.

На тысяче пользователей арифметика меняется. Если у приложения, скажем, 8 воркеров (типичная настройка для Gunicorn, Puma или PHP-FPM на среднем VPS), а операция регистрации с отправкой приветственного письма занимает секунду из-за медленного ответа SMTP-сервера, то при 10 одновременных регистрациях часть запросов неизбежно ждёт в очереди, пока освободится воркер — и это ещё до того, как база или CPU оказались перегружены. Внешне это выглядит как «сайт тормозит без видимой причины»: CPU не под нагрузкой, память в порядке, а время ответа скачет, потому что воркеры физически заняты ожиданием ответа от чужого сервера, а не вычислениями.

Симптом легко перепутать с проблемой самого приложения, потому что таймауты появляются не в коде, вызвавшем операцию, а у случайных других пользователей, которым не хватило свободного воркера. Проверить гипотезу можно по времени ответа по эндпоинтам: если /register или /checkout стабильно занимают секунды, а остальные — миллисекунды, но весь сайт периодически «подвисает» на пике — дело почти наверняка в исчерпании пула воркеров одним медленным местом.

Решение — вынести операцию из потока запроса в фоновую очередь задач: пользователь получает мгновенный ответ («заявка принята»), а письмо, отчёт или обработка файла выполняются отдельным процессом-воркером, не блокируя обработку HTTP-запросов. На Python это чаще всего Celery или RQ поверх Redis, на Ruby — Sidekiq, на Node.js — BullMQ. Минимальный пример на Celery:

# tasks.py
from celery import Celery

app = Celery('tasks', broker='redis://localhost:6379/0')

@app.task
def send_welcome_email(user_id):
    user = User.objects.get(id=user_id)
    send_email(user.email, template='welcome')

# views.py — было
def register(request):
    user = create_user(request.data)
    send_email(user.email, template='welcome')  # блокирует воркер
    return JsonResponse({'status': 'ok'})

# views.py — стало
def register(request):
    user = create_user(request.data)
    send_welcome_email.delay(user.id)  # мгновенный возврат
    return JsonResponse({'status': 'ok'})

Важный нюанс: очередь задач — не панацея, а новая подсистема со своими отказами. Если воркеры очереди упадут или отстанут, задачи будут копиться, а не выполняться, и пользователь не узнает об этом, пока не заметит, что письмо так и не пришло. Стоит сразу настроить мониторинг глубины очереди и алерт на её аномальный рост — про устройство очередей сообщений и зачем они нужны, если уже есть база, разобрано в статье про то, как устроена очередь сообщений. Годится сюда не любая задача: для небольшого объёма иногда проще и надёжнее синхронный cron раз в минуту, чем полноценный брокер сообщений.

Подсистема 3: файловое и сессионное хранилище без учёта конкурентного доступа

Третья трещина менее очевидна, потому что не связана ни с базой, ни с внешними вызовами — это простое файловое хранилище на локальном диске: сессии PHP по умолчанию (session.save_path), файлы блокировок для кэша страниц, локальные файлы загрузок, текстовые логи или счётчики, которые приложение переписывает при каждом запросе. При 100 пользователях одновременная запись двумя процессами в один файл — редкое событие: вероятность, что два запроса попадут в одну миллисекунду на один и тот же файл, статистически мала.

На тысяче пользователей эта вероятность перестаёт быть пренебрежимой, особенно если несколько операций пользователя пересекаются на одном и том же файле сессии или кэша. Классический сценарий — файловые сессии PHP: по умолчанию PHP блокирует файл сессии на всё время обработки запроса, и если пользователь открывает несколько вкладок или делает параллельные AJAX-запросы, они выстраиваются в очередь друг за другом на уровне файловой блокировки, даже если само приложение готово обработать их параллельно. Симптом — необъяснимые задержки именно у активных пользователей с несколькими открытыми вкладками при в целом невысокой нагрузке на сервер.

Более опасный вариант — гонка при записи вовсе без блокировки: два процесса читают файл, оба вносят изменение в своей копии и оба перезаписывают файл целиком — сохраняется только одно из двух изменений, второе теряется без единой ошибки в логах. Это коварно именно потому, что не падает и не логируется — данные просто иногда «теряются», а разработчик тратит часы, пытаясь воспроизвести баг, который проявляется только под конкурентной нагрузкой.

Если файловая блокировка всё же нужна (например, для локального кэша генерируемых отчётов), она должна быть явной и с разумным таймаутом, а не полагаться на удачу:

import fcntl

def write_report_cache(path, data):
    with open(path, 'w') as f:
        fcntl.flock(f, fcntl.LOCK_EX)  # эксклюзивная блокировка на запись
        f.write(data)
        fcntl.flock(f, fcntl.LOCK_UN)

Но на этом порядке роста правильный путь чаще не «блокировать файл аккуратнее», а убрать состояние с локального диска сервера. Сессии стоит перенести во внешнее хранилище — Redis или базу данных, — рассчитанное на конкурентный доступ по своей природе: атомарные операции, TTL, без блокировки всего файла ради одного поля:

; php.ini
session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379?auth=your_password"

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

Как три трещины складываются в один инцидент

По отдельности каждая из трёх подсистем деградирует постепенно — время ответа растёт на десятки, потом на сотни миллисекунд, и это можно заметить по метрикам заранее. Опасность в том, что на растущей нагрузке они усиливают друг друга. Отсутствие индекса делает один запрос медленнее и увеличивает время, которое воркер держит открытым HTTP-соединение. Если этот же воркер параллельно занят синхронной отправкой письма из другого запроса, пул воркеров исчерпывается быстрее, чем при любой из проблем по отдельности. А если запрос ещё и упирается в заблокированный файл сессии — общее время ответа складывается из всех трёх задержек последовательно, хотя каждая по отдельности выглядела бы терпимой.

Типичная картина инцидента: несколько недель время ответа медленно растёт, метрики CPU и памяти остаются в норме, это списывают на «сезонную нагрузку», а в случайный пиковый час (рассылка, рекламная кампания, начало рабочего дня) три эффекта совпадают — и сервис на несколько минут перестаёт отвечать, хотя формально ни один ресурс не исчерпан на 100%. Узкое место здесь — не ресурс сервера, а конкретная строчка кода или отсутствующий индекс, и по стандартным дашбордам его сразу не видно.

Что мониторить и проверять на этом этапе

Профилактика дешевле разбора инцидента, и у всех трёх подсистем есть конкретные, измеримые сигналы, которые стоит отслеживать заранее, а не постфактум:

ПодсистемаЧто проверитьИнструмент
Запросы без индексовТоп медленных запросов по суммарному времени, EXPLAIN ANALYZE на подозрительныхpg_stat_statements, лог медленных запросов (log_min_duration_statement)
Синхронные тяжёлые операцииВремя ответа по эндпоинтам, число занятых воркеров в моментеAPM (New Relic, Sentry Performance) или простое логирование длительности хендлеров
Файлы и сессии без блокировокЧисло одновременных сессий на пользователя, ошибки чтения/записи в логахЛоги приложения, lsof на файлы блокировок, мониторинг очереди Redis

Практический порядок действий: включить логирование запросов дольше 100-200 мс и раз в неделю просматривать топ по pg_stat_statements; вынести из синхронного пути хотя бы email и генерацию отчётов — это обычно самые заметные по времени операции и самые простые для переноса в очередь; провести ревизию, что хранится на локальном диске сервера приложения и есть ли у этого хранилища атомарность записи при конкурентном доступе. Ни одна из этих проверок не требует апгрейда сервера — всё чинится в коде и конфигурации, и цена ревизии на порядок ниже, чем разбор инцидента на проде с недовольными пользователями.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Как понять, что причина именно в отсутствии индекса, а не в общей нагрузке на базу?

Смотрите план запроса через EXPLAIN ANALYZE: если видите Seq Scan на таблице с заметным числом строк вместо Index Scan, а CPU и память сервера базы при этом не на пределе — дело в конкретном запросе, а не в железе.

Обязательно ли сразу ставить Celery или Sidekiq?

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

Если сессии уже хранятся в Redis, а не в файлах — эта подсистема не моя проблема?

Файловая гонка возможна с любым общим ресурсом, не только сессиями — кэшем на диске, счётчиками в текстовом файле, временными файлами загрузок. Стоит проверить всё, что приложение пишет на локальный диск сервера при обработке запроса.

Можно ли добавить индексы на все поля, по которым идёт фильтрация, и не разбираться дальше?

Не стоит: каждый лишний индекс замедляет вставку и обновление строк, а часть индексов планировщик вообще не будет использовать. Добавляйте индекс под конкретный медленный запрос, подтверждённый EXPLAIN ANALYZE, а не по принципу «на всякий случай».

Эти три проблемы проявятся ровно на 1000 пользователей?

Порог условный: приложение с тяжёлыми запросами упрётся раньше, лёгкий CRUD с малым числом операций на пользователя — позже. Ориентируйтесь не на число пользователей, а на объём данных в таблицах и число параллельных запросов в пиковые часы.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →