awesome-everything EN
↑ Обратно к восхождению

Архитектура бэкенда

Размер пула: почему больше не значит быстрее

Суть Инстинкт — поднимать размер пула, пока база не перестанет быть узким местом, но за малым числом пропускная способность падает, потому что соединение — backend-процесс, конкурирующий за ядра, блокировки и память. Правильный размер — формула, а не догадка.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 14 min

Команда видит, что база — узкое место, поэтому поднимает пул соединений с 20 до 100, ожидая примерно впятеро больше пропускной способности. Пропускная способность падает. Задержка лезет вверх, CPU базы прыгает, и работа, которую сервер реально заканчивает в секунду, идёт вниз. Они подняли единственное число, выглядевшее дросселем, и сделали всё хуже. Пул был не мал — он стал намного больше числа запросов, которые железо базы способно выполнить разом, и каждое лишнее соединение за этим пределом — не параллелизм, а конкуренция.

Больше соединений — не больше параллелизма

Кажется очевидным, что больше соединений значит больше конкурентной работы. Это не так, и причина физична. У сервера базы фиксированное число ядер CPU и фиксированное число дисковых шпинделей (или очередей SSD). В любой миг он способен реально выполнять лишь столько запросов, сколько у него ядер; всё прочее ждёт. Соединение, держащее запрос, не магически бежит — оно конкурирует за ядро, на котором побежать.

Поэтому открыв 100 соединений на 8-ядерной коробке, ты не получаешь 100 запросов разом. Ты получаешь 8 бегущих и 92 дерущихся за те 8 ядер, плюс все накладные на переключение между ними. Размер пула задаёт, сколько запросов может быть в полёте; железо задаёт, сколько может реально продвигаться. Когда первое число сильно превышает второе, разрыв — чистая трата.

Три налога раздутого пула

Каждое соединение за полезным числом не сидит тихо. Оно стоит тебе тремя способами разом:

  • Переключение контекста. ОС нарезает много соединений по немногим ядрам по времени. Каждое переключение сбрасывает кеши CPU и стоит времени планировщика. Со 100 активными соединениями на 8 ядрах коробка тратит растущую долю CPU лишь на смену того, какой запрос бежит, вместо выполнения запросов.
  • Конкуренция за блокировки и латчи. Конкурентные транзакции дерутся за блокировки строк, а внутренние латчи базы (буферный пул, WAL, таблицы блокировок) общие. Больше конкурентных соединений значит больше конкуренции на этих общих структурах — а конкуренция сверхлинейна, поэтому хуже растёт быстрее числа соединений.
  • Память. В Postgres каждое соединение — форкнутый backend-процесс, держащий ~2–3 МБ базовой памяти, и хуже, work_mem выделяется на операцию на соединение. Запрос с тяжёлой сортировкой при work_mem = 16 МБ через 100 соединений может зарезервировать гигабайты; та же нагрузка на 10 соединениях не может.

Формула: маленькая, выведенная из железа

Известная рекомендация HikariCP превращает это в стартовую формулу:

соединения = (число_ядер × 2) + эффективное_число_шпинделей

Интуиция: пока один запрос ждёт дисковый I/O, другой может использовать освобождённое ядро CPU — поэтому хочется примерно вдвое больше ядер, чтобы держать их занятыми сквозь паузы I/O, плюс одно на каждый независимый диск, способный обслужить seek параллельно. 4-ядерный сервер с одним SSD даёт (4 × 2) + 1 = 9 соединений. Не 90. Число, максимизирующее пропускную способность, шокирующе мало, и измеренные бенчмарки это подтверждают: пул на ~10 часто бьёт пул на 100 на том же железе, заканчивая больше запросов в секунду при меньшей задержке.

Почему это работает

Почему пул на 10 бьёт пул на 100, когда у нагрузки явно больше 10 дел? Потому что лишние 90 не делают работу — они стоят в очереди внутри базы вместо снаружи неё, а стоять внутри хуже. Когда 100 соединений бьют в 8-ядерную коробку, база принимает все 100 и нарезает их по времени, платя налог переключения и конкуренции за блокировки на каждом, поэтому каждый отдельный запрос бежит медленнее и общее заканчивается позже. Когда 10 соединений бьют в ту же коробку, база гонит их почти на полной скорости, а прочие запросы ждут в очереди пула — дёшево, в памяти приложения, ничего не стоя базе. Та же общая работа, но малый пул даёт базе работать в её эффективной точке и толкает неизбежное ожидание в самое дешёвое место. Ограничение пула — тот же урок ограниченной конкурентности из async-юнита: лишнее ставишь в очередь где-то дёшево, а не вываливаешь на дорогой ресурс.

Подбор размера — поиск точки насыщения

Формула — стартовая точка, не финальный ответ — у реальных нагрузок смесь CPU-bound и I/O-bound запросов, и правильное число находится измерением. Метод — поднимать конкурентность, следя за пропускной способностью и задержкой: пропускная способность растёт, затем выходит на плато (точка насыщения), затем падает, как конкуренция берёт верх. Лучший размер пула сидит на плоской вершине или чуть ниже — наименьший пул, достигающий пика пропускной способности. За ним ты покупаешь задержку без отдачи в пропускной способности, ровно поведение колена очереди из урока про пропускную способность, теперь движимое выбранным тобой пулом.

Размер пула (8 ядер)Что реально происходитПропускная способностьЗадержка
4 (мал)Ядра простаивают, ожидая I/OНиже пикаНизкая, но ёмкость трачена
~10–16 (в точку)Ядра заняты, минимум конкуренцииПикНаименьшая на пике
50Тяжёлое переключение + конкуренция за блокировкиНиже пикаЛезет вверх
100База молотит планирование, не выполнениеСильно ниже пикаВысокая, нестабильная
Викторина

Команда поднимает пул с 20 до 100 на 8-ядерной базе ради пропускной способности, но та падает, а задержка лезет. Почему?

Викторина

По эвристике HikariCP, какой разумный стартовый размер пула для 4-ядерной базы с одним SSD?

Викторина

Почему память на соединение — реальное ограничение при подборе пула Postgres, помимо базовой цены процесса?

Вспомните перед уходом
  1. 01
    Почему подъём размера пула за небольшим числом снижает пропускную способность вместо роста?
  2. 02
    Какова формула размера HikariCP и какое за ней рассуждение?
  3. 03
    Как реально найти правильный размер пула для реальной нагрузки и почему малый пул лучше даже при избытке работы?
Итог

Инстинкт чинить узкое место базы расширением пула — задом наперёд: соединение — реальный backend-процесс, конкурирующий за фиксированное число ядер, поэтому пул сильно больше того, что железо способно гнать, превращает лишние соединения в чистую конкуренцию — переключение контекста по слишком немногим ядрам, сверхлинейную конкуренцию за блокировки и латчи на общих внутренних структурах и память на соединение, где work_mem множится на операцию по каждому соединению. (ядра × 2) + шпиндели от HikariCP ставит 4-ядерную коробку с одним SSD на около 9 соединений, и пул на ~10 рутинно бьёт пул на 100 на идентичном железе, потому что малый пул даёт базе работать в её эффективной точке, пока излишек ждёт дёшево в очереди пула, а не молотит внутри движка. Правильный размер — наименьший пул, достигающий пика пропускной способности, найденный подъёмом конкурентности до плато. Знание размера отвечает, сколько соединений держать — следующий урок спрашивает, что случается с запросом, когда все они заняты: очередь ожидания, таймаут взятия и как этот таймаут становится намеренным дросселем задержки.

Связанные уроки
встречается в185
Продолжить восхождение ↑Взятие и таймауты: очередь ожидания — настоящий дроссель задержки
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.