Каждый порядок ломает своё: что отваливается на 100, 1000 и 10 000 пользователях
Проект растёт, и в какой-то момент то, что вчера летало, начинает подтормаживать или падать в самый неподходящий момент. Первая реакция — «нужен сервер помощнее». Иногда это правда, но чаще дело не в железе, а в том, что архитектура упёрлась в свой потолок именно на этом порядке нагрузки. У 100, 1000 и 10 000 одновременных пользователей — разные узкие места, и лечатся они по-разному. Разберём, что типично ломается на каждом рубеже и что проверять, прежде чем платить за апгрейд.
Содержание
- Почему пороги — это не просто цифры
- 100 пользователей: сервер не виноват — виноват код
- 1000 пользователей: очередь на соединения с базой
- Connection pooling: что настроить в первую очередь
- 10 000 пользователей: без кэша и масштабирования по горизонтали не обойтись
- Статика, CDN и на что смотреть, когда всё выше уже настроено
Почему пороги — это не просто цифры
Когда говорят «100 пользователей» или «10 000 пользователей», обычно имеют в виду не общее число зарегистрированных, а количество одновременно активных сессий — тех, кто прямо сейчас держит открытое соединение или дёргает базу. Разница огромная: у сайта с 50 000 регистраций может быть 30 одновременных пользователей в будний день, и он прекрасно живёт на одном небольшом VPS. А у стримингового сервиса с 5 000 подписчиков в момент премьеры одновременная нагрузка может быть в разы выше «паспортной».
Порядки роста — 100, 1000, 10 000 — условные ориентиры, а не жёсткие границы, за которыми что-то обязательно ломается ровно в эту секунду. Смысл в другом: на каждом порядке доминирует свой тип узкого места, и это довольно предсказуемо, потому что упирается в конкретные лимиты — число процессов, соединений, файловых дескрипторов, ядер CPU. Зная заранее, что посыплется следующим, вы чините проблему до того, как она станет инцидентом в проде.
Общая логика такая: на малых числах один сервер справляется практически с любой архитектурой, и все проблемы — это баги и неэффективный код. На средних узким местом становится база данных и то, как приложение с ней работает. На больших — упираетесь в физические пределы одного сервера, и без распределения нагрузки, кэша и вынесенной статики уже не обойтись.
100 пользователей: сервер не виноват — виноват код
На сотне одновременных пользователей практически любой современный VPS с 2-4 ядрами и 4-8 ГБ памяти справляется без напряжения — если приложение написано без грубых ошибок. Проблема в том, что мелкие проекты на этом этапе почти никогда не смотрят на производительность: код писался, чтобы работало, а не чтобы работало быстро. И тут всплывают классические вещи.
Самая частая беда — N+1 запросы к базе. Страница со списком из 50 заказов делает один запрос на список и ещё 50 отдельных запросов, чтобы подтянуть данные покупателя к каждому. При одном пользователе это незаметно: полсотни лишних запросов к локальной базе выполняются за миллисекунды. При десяти одновременных пользователях суммарная нагрузка на БД вырастает кратно, и время ответа начинает плавать. Чинится это JOIN'ом или eager loading — в зависимости от ORM это select_related/prefetch_related в Django, includes в Rails, with() в Laravel.
Вторая типичная проблема — отсутствие индексов на полях, по которым идёт фильтрация или сортировка. На таблице в пару тысяч строк это незаметно, EXPLAIN покажет полный скан за миллисекунды. Но стоит таблице разрасти до сотен тысяч строк — а на 100 активных пользователей общая база вполне может быть такого размера, если проект живёт не первый месяц — и та же выборка начинает занимать заметное время под каждым запросом.
Третье — синхронные внешние вызовы в обработчике запроса. Отправка письма через SMTP, запрос к API платёжной системы, генерация PDF — если всё это делается прямо во время HTTP-запроса, каждый такой вызов держит воркер приложения занятым на секунды. Если пять пользователей одновременно оформляют заказ, а воркеров всего 4 — остальные просто ждут в очереди, даже если база и код в остальном быстрые.
Практический ориентир: посмотрите на медленные запросы через EXPLAIN ANALYZE, включите логирование запросов дольше условных 100-200 мс, проверьте, что тяжёлые операции вынесены в очередь задач, а не выполняются синхронно. Апгрейд сервера тут почти всегда не решает проблему — он просто отодвигает её на следующий порядок роста.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать сервер1000 пользователей: очередь на соединения с базой
Здесь начинается первый по-настоящему архитектурный лимит: соединения с базой данных. У PostgreSQL по умолчанию max_connections равен 100, у MySQL исторически чуть больше, но тоже конечен. Каждое активное соединение с PostgreSQL — это отдельный процесс на сервере БД, который занимает память вне зависимости от того, выполняет он запрос или простаивает. Если приложение при каждом запросе открывает собственное соединение, а у вас 1000 одновременных пользователей, вы упрётесь в лимит задолго до того, как закончится процессорное время или память.
Симптом узнаваем: в логах появляются ошибки вида FATAL: too many connections for role или remaining connection slots are reserved у PostgreSQL, у MySQL — Too many connections. Часть запросов при этом зависает или падает с таймаутом, хотя сам сервер БД по CPU и памяти загружен слабо — упор именно в число слотов, а не в вычислительные ресурсы.
Наивное решение — поднять max_connections до 500 или 1000 — работает плохо. Каждое соединение резервирует память (несколько мегабайт на процесс плюс буферы), и при большом их числе сервер тратит память на обслуживание соединений вместо кэша данных, а планировщик ОС — время на переключение контекста между простаивающими бэкендами. На практике после определённого порога пропускная способность базы не растёт, а падает — точные цифры зависят от версии СУБД и профиля нагрузки, их стоит проверять на своём стенде, а не брать из чужих бенчмарков.
Правильный путь на 1000 пользователей — не увеличивать лимит соединений, а сократить их число через пул соединений (connection pooling). Подробно о том, что это такое и как выбрать размер пула, разобрано в статье про connection pooling — здесь остановимся на практическом чек-листе.
Второй момент на этом этапе — сессии пользователей. Если сессии хранятся в памяти процесса приложения (типичная настройка «из коробки» у многих фреймворков), при масштабировании до нескольких инстансов пользователь будет терять сессию при переключении между ними. Стоит заранее вынести сессии во внешнее хранилище (тот же Redis), чтобы не переделывать это в панике позже.
Connection pooling: что настроить в первую очередь
Пул соединений решает конкретную задачу: вместо того чтобы каждый запрос от приложения открывал новое соединение к базе, пул держит небольшое фиксированное число уже открытых соединений и раздаёт их запросам по очереди. Запрос занимает соединение на время выполнения и тут же возвращает обратно — благодаря этому 1000 одновременных пользователей вполне могут обслуживаться пулом из 20-50 соединений к базе.
Для PostgreSQL стандартный внешний пулер — PgBouncer. Устанавливается отдельным процессом (можно на том же сервере, что и приложение, или рядом с базой) и работает в одном из трёх режимов: session, transaction или statement. Для веб-приложений почти всегда нужен transaction pooling — соединение с базой закрепляется за клиентом только на время одной транзакции, а не на всю сессию:
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb
[pgbouncer]
listen_port = 6432
listen_addr = 127.0.0.1
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
Здесь max_client_conn — сколько клиентов пул принимает одновременно, а default_pool_size — сколько реальных соединений к PostgreSQL держится под каждую базу. Именно второе число должно оставаться разумным относительно max_connections самого PostgreSQL, а первое может быть на порядок больше — в этом и смысл пулинга.
Важный нюанс transaction pooling: он ломает некоторые возможности сессионного уровня — prepared statements, SET для сессии, advisory locks, которые рассчитывают на закреплённое соединение. Если приложение активно использует такие функции, придётся либо переключаться на session pooling (меньше эффекта от пулинга), либо проверить совместимость ORM с транзакционным режимом — у части ORM есть готовые флаги для этого.
Многие фреймворки и ORM умеют пулить соединения и на своей стороне (например, встроенный пул в SQLAlchemy или Prisma), и тогда внешний PgBouncer избыточен для одного инстанса приложения — но становится нужен снова, когда инстансов несколько и каждый держит свой пул: суммарно они всё равно могут упереться в лимит базы. Прежде чем ставить PgBouncer, посчитайте: число инстансов × размер пула на инстанс — и сравните с max_connections базы.
10 000 пользователей: без кэша и масштабирования по горизонтали не обойтись
На этом порядке одиночный сервер приложения и оптимизированный доступ к базе уже не спасают — вы упираетесь в физические ограничения одной машины: число ядер CPU, объём памяти, пропускную способность сети и диска. Узкое место смещается с «неэффективных запросов» на «слишком много одинаковой работы, которую можно не делать заново».
Кэш-прослойка — первое, что решает эту проблему дешевле всего. Идея простая: результаты дорогих или часто повторяющихся операций (запрос к базе, рендер страницы, ответ внешнего API) сохраняются в быстром хранилище в памяти, и следующий такой же запрос отдаётся из кэша без похода в базу. Стандартный инструмент — Redis: хранит данные в памяти, поддерживает TTL и структуры данных сложнее простого key-value, что удобно для счётчиков, очередей и списков лидеров.
Практический паттерн кэширования на уровне приложения — cache-aside: приложение сначала проверяет кэш, при промахе идёт в базу, а результат кладёт в кэш с разумным TTL:
def get_product(product_id):
key = f"product:{product_id}"
cached = redis_client.get(key)
if cached:
return json.loads(cached)
product = db.query(Product).get(product_id)
redis_client.setex(key, 300, json.dumps(product.to_dict()))
return product
Здесь важна не столько сама техника, сколько дисциплина инвалидации: если товар обновился, а кэш живёт ещё 5 минут — часть пользователей увидит старые данные. Для одних сущностей (каталог, статичный контент) это некритично, для других (остаток товара, баланс счёта) — уже проблема, и там нужна явная инвалидация ключа при изменении данных, а не просто ожидание истечения TTL. Если раньше не работали с Redis — есть отдельная статья про установку и настройку Redis на VPS.
Второй элемент этого порядка — горизонтальное масштабирование самого приложения. Один процесс с ограниченным числом воркеров физически не обслужит 10 000 одновременных соединений, сколько бы памяти вы ни докидывали в вертикаль. Решение — несколько инстансов приложения за балансировщиком нагрузки. Ключевое условие — приложение должно быть stateless: не хранить состояние (сессии, файлы, кэш в памяти процесса) локально на инстансе, а выносить его в Redis, объектное хранилище или базу. Тогда балансировщик направляет запрос на любой инстанс без разницы для пользователя.
Пример upstream-блока для распределения нагрузки между тремя инстансами приложения через nginx:
upstream app_backend {
least_conn;
server 10.0.0.11:8000;
server 10.0.0.12:8000;
server 10.0.0.13:8000;
}
server {
listen 80;
location / {
proxy_pass http://app_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Директива least_conn направляет новый запрос на инстанс с наименьшим числом активных соединений — обычно справедливее, чем round robin, когда запросы отличаются по времени обработки. Если нужна более гибкая балансировка нескольких серверов с проверкой здоровья, стоит посмотреть в сторону HAProxy — есть отдельная статья про установку и настройку балансировки нагрузки на VPS с нуля.
Статика, CDN и на что смотреть, когда всё выше уже настроено
Даже с кэшем и несколькими инстансами приложения остаётся категория трафика, которую вообще не стоит гонять через бэкенд, — статические файлы: изображения, CSS, JS, видео, файлы для скачивания. На 10 000 пользователей раздача статики напрямую с сервера приложения начинает конкурировать за пропускную способность сети и диска с динамическими запросами, которые реально требуют вычислений.
CDN решает эту проблему, вынося статику на сеть географически распределённых узлов, ближайших к пользователю: файл один раз попадает на edge-сервер CDN и дальше отдаётся оттуда, не долетая до вашего сервера вовсе. Origin-сервер видит только запросы на файлы, которых ещё нет в кэше CDN или у которых истёк TTL. Настройка обычно сводится к тому, чтобы направить DNS-запись статического поддомена на CDN и выставить корректные заголовки кэширования (Cache-Control, ETag) на самих файлах — подробности в статье про настройку CDN для сайта.
Здесь же стоит проговорить эффект «стада» (thundering herd): если TTL у популярного ресурса истекает одновременно у всех узлов кэша, множество запросов долетают до origin-сервера разом и на пике могут его положить — тот случай, когда кэш вроде бы есть, а не спасает. Смягчается это небольшим случайным разбросом TTL у разных ключей или staleness-паттерном, когда старое значение продолжает отдаваться, пока фоново обновляется новое.
Практический ориентир: посчитайте долю статики в общем трафике (видно в логах nginx или в панели CDN), убедитесь, что у приложения нет собственного состояния на инстансе, и держите под рукой метрики по каждому слою — hit rate кэша, число активных соединений к базе через пул, время ответа по инстансам. Без этих метрик масштабирование превращается в добавление серверов вслепую — дороже, чем нужно, и не решает первопричину, если она осталась в коде.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли просто взять сервер помощнее и не разбираться в архитектуре?
На уровне 100 пользователей — почти всегда да, если проблема не в грубом баге вроде бесконечного цикла. На 1000+ вертикальный апгрейд помогает слабее: лимит соединений с базой и число воркеров упираются не столько в мощность CPU, сколько в архитектурные ограничения. На 10 000 один сервер, даже самый мощный, физически не заменит распределение нагрузки.
Как понять, что причина именно в лимите соединений к БД?
Смотрите логи на ошибки вида too many connections или remaining connection slots are reserved, а также загрузку CPU и памяти на сервере БД — если она невысокая, а запросы всё равно тормозят, дело почти наверняка в числе слотов.
Нужен ли Redis, если проект пока на 500-1000 пользователей?
Не обязательно как кэш данных, но стоит заранее вынести туда сессии и очереди фоновых задач — дешёвая страховка перед добавлением второго инстанса приложения.
С чего начать масштабирование, если бюджет ограничен?
С измерения, а не с покупки серверов: включите логирование медленных запросов, посчитайте hit rate кэша и посмотрите, сколько времени уходит на базу против остального кода. Часто один индекс или включённый пул соединений откладывает необходимость в новом сервере на месяцы.
Одинаковы ли эти пороги для любого приложения?
Нет, это ориентиры. Тяжёлое по вычислениям приложение (генерация отчётов, обработка изображений) упрётся в CPU гораздо раньше 1000 пользователей, а лёгкий CRUD-сервис с хорошими индексами проживёт на одном сервере дольше условных 1000. Смотрите на свои метрики, а не на чужие цифры.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →