Настройка пула соединений — то, в чём разработчики часто ошибаются. При конфигурировании пула есть несколько принципов, которые некоторым могут показаться неочевидными, и их нужно понимать. Подробнее в новом переводе от команды Spring АйО.
10 000 одновременных пользователей фронтенда
Представьте, что у вас есть веб-сайт, который, возможно, и не масштаба Facebook, но всё же нередко имеет 10 000 пользователей, одновременно отправляющих запросы к базе данных — в сумме примерно 20 000 транзакций в секунду. Каким должен быть размер пула соединений? Вы можете удивиться: вопрос не в том, насколько большим он должен быть, а скорее в том, насколько маленьким!
Посмотрите это короткое видео от группы Oracle Real-World Performance — наглядная демонстрация, способная открыть глаза.
Из видео видно, что одно лишь уменьшение размера пула соединений — при отсутствии любых других изменений — снизило времена отклика приложения примерно со ~100 мс до ~2 мс, то есть улучшение более чем в 50 раз.
Но почему?
Кажется, в других областях вычислений мы уже недавно поняли, что «меньше — значит больше». Почему nginx-веб-сервер всего с 4 потоками способен существенно обойти Apache со 100 процессами? Разве это не очевидно, если вспомнить Computer Science 101?
Даже компьютер с одним ядром CPU может «одновременно» поддерживать десятки или сотни потоков. Но всем нам следовало бы понимать, что это лишь трюк операционной системы. В реальности это единственное ядро может исполнять только один поток за раз; затем ОС переключает контекст, и это ядро выполняет код другого потока, и так далее. Это базовый закон вычислений: при наличии одного ресурса CPU последовательное выполнение A и B всегда будет быстрее, чем «одновременное» выполнение A и B на одном ядре. Как только число потоков превышает число ядер CPU, вы начинаете работать медленнее, добавляя потоки, а не быстрее.
Это почти верно…
Ограниченные ресурсы
Всё не так просто, как сказано выше, но близко. Есть ещё несколько факторов. Если посмотреть, какие основные узкие места бывают у базы данных, их можно свести к трём базовым категориям: CPU, диск, сеть. Можно добавить сюда память, но по сравнению с диском и сетью разница в пропускной способности составляет несколько порядков.
Если бы мы игнорировали диск и сеть, всё было бы просто. На сервере с 8 вычислительными ядрами установка числа соединений в 8 дала бы оптимальную производительность, а всё сверх этого начало бы замедлять работу из-за накладных расходов на переключение контекста. Но мы не можем игнорировать диск и сеть. Базы данных обычно хранят данные на диске, который традиционно состоит из вращающихся металлических пластин с головками чтения/записи, закреплёнными на рычаге с шаговым приводом.
Комментарий от Михаила Поливаха
Статья старая, поэтому тут автор описывает идоматическое представление HDD. В большинстве production систем уже давно SSD, который впрочем все равно страдает от тех же проблем, что автор пишет ниже.
Комментарий от Павла Кислова
SSD (solid-state drive) хранит данные в микросхемах флеш-памяти, а не на вращающихся дисках, как HDD.
Основной элемент — это ячейки NAND-памяти, которые могут удерживать заряд и тем самым хранить биты информации.
Данные записываются и читаются электрически, без механических движущихся частей. Контроллер SSD управляет всеми операциями: распределяет данные, обрабатывает команды и оптимизирует работу памяти.Запись происходит не по отдельным байтам, а блоками, что влияет на скорость и поведение накопителя.
Перед перезаписью данных ячейки нужно очистить, поэтому используется механизм “garbage collection”. Для равномерного износа применяется wear leveling — алгоритм, распределяющий записи по всем ячейкам.
SSD также использует кеш (DRAM или SLC-кеш), чтобы ускорить запись и чтение. Интерфейсы (SATA или NVMe через PCIe) определяют максимальную скорость передачи данных. В итоге SSD работает быстро за счёт параллелизма, отсутствия механики и умного управления памятью контроллером.
Эти головки могут находиться только в одном месте в один момент времени (читая/записывая данные для одного запроса) и должны «перемещаться» к новой позиции, чтобы читать/писать данные для другого запроса. Поэтому есть стоимость времени позиционирования (seek time), а также вращательная задержка, когда диску приходится ждать, пока нужные данные снова «подъедут» на пластине, чтобы их можно было прочитать/записать. Кэширование, конечно, помогает, но принцип остаётся.
На протяжении этого времени («ожидание ввода-вывода», I/O wait) соединение/запрос/поток просто «заблокирован», ожидая диск. И именно в это время ОС могла бы эффективнее использовать ресурс CPU, выполняя больше кода для другого потока.
Комментарий от Михаила Поливаха
Собственно, это и происходит - ОС просто переводит Task в uninterruptible sleep и свитчает контекст на ядре для другой Task
Поэтому, поскольку потоки блокируются на I/O, мы действительно можем сделать больше работы, имея число соединений/потоков больше, чем число физических вычислительных ядер.
Насколько больше? Сейчас увидим. Вопрос «насколько больше» также зависит от дисковой подсистемы, потому что у новых SSD-накопителей нет стоимости «позиционирования» и вращательных факторов. Не обманывайте себя мыслью: «SSD быстрее, значит я могу иметь больше потоков». Это ровно на 180 градусов наоборот. Быстрее, без seek, без вращательных задержек означает меньше блокировок, и поэтому меньше потоков (т.е. ближе к числу ядер) будет работать лучше, чем больше потоков. Большее число потоков работает лучше только тогда, когда блокировки создают возможности для выполнения.
Сеть похожа на диск. Запись данных «по проводу» через ethernet-интерфейс тоже может вносить блокировки, когда буферы отправки/приёма заполняются и всё останавливается. Интерфейс 10 GbE будет «тормозить» меньше, чем гигабитный ethernet, а тот — меньше, чем 100-мегабитный. Но сеть — третий по значимости фактор с точки зрения блокировок ресурсов, и некоторые часто исключают его из расчётов.
Вот ещё одна диаграмма, чтобы разбавить стену текста.

На приведённом выше бенчмарке PostgreSQL видно, что показатели TPS начинают выходить на плато примерно на уровне 50 соединений. А в видео Oracle выше показали снижение числа соединений с 2048 до всего лишь 96. Мы бы сказали, что даже 96 — вероятно, слишком много, если только у вас не машина с 16 или 32 ядрами.
Формула
Формула ниже приводится проектом PostgreSQL как отправная точка, но мы считаем, что в целом она будет применима для разных СУБД. Вам следует протестировать своё приложение — то есть смоделировать ожидаемую нагрузку — и попробовать разные настройки пула вокруг этой стартовой точки:
соединения = ((число_ядер 2) + эффективное_число_шпинделей)
Формула, которая довольно хорошо подтверждается во множестве бенчмарков уже много лет, заключается в том, что для оптимальной пропускной способности число активных соединений должно быть где-то около ((число_ядер 2) + эффективное_число_шпинделей). Число ядер не должно включать HT-потоки, даже если включён hyperthreading. Эффективное число шпинделей равно нулю, если активный набор данных полностью кэшируется, и приближается к фактическому числу шпинделей по мере падения доли попаданий в кэш. ... Пока не было проведено анализа того, насколько хорошо формула работает с SSD.
Комментарий от редакции. Шпиндели =)
Наверное, не совсем широкое понятие, и мы его, пожалуй, ни разу и не слышали. Но. В данном контексте шпиндели – это HDD с которыми работает БД.
И что, по-вашему, эт�� означает? Ваш маленький 4-ядерный сервер i7 с одним жёстким диском должен работать с пулом соединений: 9 = ((4 * 2) + 1). Округлим до 10 — для красивого числа. Кажется мало? Попробуйте — мы готовы поспорить, что на такой конфигурации вы без труда сможете обслуживать 3000 пользователей фронтенда, выполняющих простые запросы, при 6000 TPS. Если вы проведёте нагрузочные тесты, то, вероятно, увидите, что показатели TPS начинают падать, а времена отклика на фронтенде — расти, когда вы увеличиваете пул соединений заметно выше 10 (на данном «железе»).
Аксиома: вам нужен небольшой пул, насыщенный потоками, ожидающими соединения.
Если у вас 10 000 пользователей фронтенда, иметь пул соединений на 10 000 было бы чистым безумием. 1000 — всё ещё ужасно. Даже 100 соединений — перебор. Вам нужен небольшой пул максимум на несколько десятков соединений, а остальные потоки приложения должны быть заблокированы на пуле в ожидании соединений. Если пул настроен правильно, он выставлен ровно на границе числа запросов, которые база данных способна обрабатывать одновременно, — а это редко бывает сильно больше, чем (число ядер CPU * 2), как отмечено выше.
Мы не перестаём удивляться внутренним веб-приложениям, с которыми сталкивались: несколько десятков пользователей фронтенда выполняют периодическую активность — и при этом пул соединений на 100 соединений. Не переизбыточно выделяйте ресурсы под базу данных.
«Блокировка пула» (Pool-locking)
Перспектива «блокировки пула» поднималась в связи с одиночными исполнителями, которые захватывают много соединений. В основном это проблема уровня приложения. Да, увеличение размера пула может смягчить зависания в таких сценариях, но мы настоятельно рекомендуем сначала посмотреть, что можно сделать на уровне приложения, прежде чем расширять пул.
Расчёт размера пула, чтобы избежать взаимоблокировки (deadlock), — это довольно простая формула распределения ресурсов:
, где Tn — максимальное число потоков, а Cm — максимальное число одновременных соединений, удерживаемых одним потоком.
Например, представьте три потока (Tn=3), каждый из которых требует четыре соединения для выполнения некоторой задачи (Cm=4). Размер пула, необходимый, чтобы гарантировать невозможность взаимоблокировки:
Другой пример: у вас максимум восемь потоков (Tn=8), и каждому требуется три соединения для выполнения некоторой задачи (Cm=3). Размер пула, необходимый, чтобы гарантировать невозможность взаимоблокировки:
Это не обязательно оптимальный размер пула, а минимальный, необходимый для предотвращения взаимоблокировки.
В некоторых средах использование JTA (Java Transaction Manager) может радикально снизить требуемое число соединений, возвращая один и тот же объект Connection из getConnection() потоку, который уже удерживает Connection в текущей транзакции.
Предостережение читателю
Подбор размера пула в конечном счёте сильно зависит от конкретного случая.
Например, системы со смесью долгоживущих транзакций и очень коротких транзакций, как правило, сложнее всего настраивать с любым пулом соединений. В таких случаях хорошо может работать создание двух экземпляров пула (например, одного — для долгих задач, другого — для «реалтайм»-запросов).
В системах, где преобладают долгоживущие транзакции, часто существует «внешнее» ограничение на необходимое число соединений — например, очередь выполнения заданий, которая допускает одновременный запуск лишь определённого числа задач. В этих случаях размер очереди заданий следует «подогнать по размеру» под пул (а не наоборот).

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.
