Прим. перев.: автор этой статьи — технический архитектор Sudhir Jonathan — рассказывает об одном из тех базовых механизмов, с которым сталкивается каждый пользователь, разработчик и системный администратор. Однако до возникновения определённых (и иногда довольно специфичных) проблем многие не задумываются о том, как всё работает «под капотом». Автор устраняет этот пробел, используя популярные фреймворки, серверы БД и приложений в качестве понятных примеров.
Соединения — это скрытый механизм, который компьютерные системы используют для общения друг с другом. Они стали настолько неотъемлемой частью нашей жизни, что мы часто забываем, насколько они важны, не замечаем, как они работают и терпят неудачу. Часто мы забываем о них до тех пор, пока не возникает проблема. При этом обычно она проявляется массовым отказом именно в то время, когда системы загружены сильнее всего. Поскольку соединения встречаются повсюду и они важны практически для каждой системы, стоит потратить немного времени на их изучение.
Соединения — что это?
Соединение — это связующее звено между двумя системами, позволяющее им обмениваться информацией в виде последовательности нулей и единиц: посылать и принимать байты.
В зависимости от того, как расположены системы по отношению друг к другу, комбинация нижележащего программного и аппаратного обеспечения активно работает, чтобы обеспечить физическое перемещение информации, абстрагируя ее. Например, при взаимодействии двух Unix-процессов за выделение памяти для обмена данными и приём/доставку байтов с обеих сторон отвечает система межпроцессного взаимодействия (IPC). Если системы расположены на разных компьютерах, они скорее всего будут взаимодействовать по протоколу TCP, который и обеспечит перемещение данных по проводной или беспроводной системе связи между компьютерами. Детали совместной работы компьютеров для надёжной обработки, передачи и приёма данных скорее относятся к проблеме стандартизации, и большинство систем используют базовые блоки, предоставляемые протоколами UDP и TCP. То, как эти соединения обрабатываются на каждом из концов, является более актуальной проблемой для разработки приложений. О ней мы и поговорим сейчас.
Где используются соединения?
Соединения используются прямо сейчас. Ваш браузер установил соединение с веб-сервером, на котором размещен этот блог, и по нему получил байты, составляющие HTML, CSS, JavaScript и изображения, на которые вы сейчас смотрите. При работе по протоколу HTTP/1.1 браузер устанавливал множество соединений с сервером — по одному для каждого файла. Протокол HTTP/2 позволил получить все файлы по одному соединению (с помощью мультиплексирования). Во всех этих случаях браузер выступал клиентом, а сервер блога, собственно, был сервером.
Но сервер, в свою очередь, также устанавливал соединения, чтобы передать эту страницу. Так, он подключился к базе данных и отправил ей запрос, содержащий URL страницы. В ответ он получил её содержимое. В данном сценарии сервер приложений выступал клиентом, а база данных — сервером. Кроме того, сервер приложений мог устанавливать соединения с различными сторонними сервисами, такими как сервис подписки или оплаты, а также сервис определения местоположения.
Организовать «отгрузку» статических файлов, таких как JS, CSS и изображения, помогает CDN-система, расположенная между браузером и сервером блога. Браузер (клиент) установил соединение с ближайшим сервером CDN, и, если нужных файлов не оказалось в кэше CDN-сервера, тот (выступая как клиент) связался с сервером блога (сервер).
Если внимательно посмотреть на системы, которыми мы пользуемся или которые создаём, можно увидеть множество всевозможных соединений. Часто они скрыты от глаз, и, забывая об их невидимом существовании и ограничениях, можно столкнуться с проблемами в моменты, когда меньше всего этого ожидаешь.
Почему важна обработка соединений?
Понимание того, как именно ведется работа с соединениями, важна, поскольку их стоимость асимметрична: издержки, связанные с созданием соединения, отличаются на стороне клиента и сервера. В одноранговой (P2P) системе это не так, и соединения имеют одинаковую «стоимость» на обоих концах, но такое бывает редко. Типичное использование соединений всегда предполагает наличие клиента и сервера, при этом издержки, связанные с созданием соединения, различны на стороне клиента и сервера.
Прежде чем перейти к различным механизмам обработки соединений, необходимо освежить знания о способах запуска программ на компьютерах и об их параллельной работе.
При старте программы операционная система запускает код как один экземпляр процесса. Во время работы процесс занимает одно ядро CPU и некоторый объём памяти, и не делится своей памятью ни с какими другими процессами.
Процесс может запускать так называемые threads (потоки выполнения) — дочерние элементы процесса, способные работать параллельно. Потоки используют память совместно с процессом, который их породил (тот может выделять больше памяти для их использования).
Также процесс может использовать event loop (цикл событий), который выглядит как система с одним процессом, который отслеживает имеющиеся задания, непрерывно и бесконечно перебирая их. При этом он выполняет активные задания и пропускает заблокированные.
Другой способ предполагает использование внутренних конструкций, таких как fibers (файберы), green-threads (зелёные потоки), coroutines (сопрограммы) или actors (акторы). Каждая из этих конструкций чуть отличается от остальных (в том числе, в смысле издержек), но все они внутренне управляются процессом и его потоками.
Возвращаясь к обработке соединений, давайте сначала рассмотрим подключения к базе данных. Для сервера приложений (клиента в данном случае) установка TCP-соединения связана с выделением небольшого объёма памяти для буфера и одного порта.
Если используется PostgreSQL, на стороне сервера каждое соединение обрабатывается путём создания нового процесса, который занимается всеми запросами, поступающими по данному соединению. Такой процесс занимает ядро процессора и около 10 Мб памяти (или больше).
MySQL для обработки каждого подключения создаёт поток внутри процесса. Требования к памяти гораздо ниже в потоковой модели, но платить за это приходится постоянным переключением контекстов.
Redis обрабатывает каждое соединение как итерацию в цикле событий, что снижает требования к ресурсам, однако платить за это приходится тем, что каждое соединение дожидается своей очереди, при этом Redis обрабатывает запросы строго по одному.
Представьте себе запрос к серверу приложений. Браузер инициирует TCP-соединение в качестве клиента (это ему обходится дёшево — небольшой объём памяти для буфера и один порт). На стороне сервера ситуация совершенно иная:
Если сервер использует Ruby on Rails, каждое соединение обрабатывается одним потоком, порождённым внутри фиксированного числа запущенных процессов (в случае веб-сервера Puma), или одним процессом (Unicorn).
Если используется PHP, система CGI запускает новый PHP-процесс для каждого соединения, а более популярная реализация FastCGI поддерживает несколько активных процессов, чтобы ускорить обработку новых соединений.
В случае Go для обработки каждого соединения создается goroutine (дешёвая и легковесная потокоподобная структура, управляемая & планируемая исполняемой средой Go).
В Node.js/Deno входящие соединения обрабатываются в цикле событий путём последовательного перебора и ответа на запросы по одному за раз.
В системах вроде Erlang/Elixir каждое соединение обрабатывается актором — ещё одной легковесной и внутренне-планируемой потокоподобной конструкцией.
Архитектуры обработки соединений
Из примеров выше видно, что существуют несколько типичных стратегий обработки соединений:
Процессы. Каждое соединение обрабатывается отдельным процессом, который либо создается специально для этого соединения (CGI, PostgreSQL), либо входит в некую группу доступных процессов (Unicorn, FastCGI).
Потоки. Каждое соединение обрабатывается отдельным потоком, который либо специально создаётся, либо берётся из специального резерва. Потоки могут быть распределены по нескольким процессам, при этом все потоки эквивалентны между собой (Puma/Ruby, Tomcat/Java, MySQL).
Цикл событий. Каждое соединение включается в цикл событий в виде задачи, и соединения с данными для чтения обрабатываются последовательно (Node, Redis). Обычно подобные системы — однопроцессные и однопотоковые, но в некоторых случаях бывают многопроцессными, когда каждый процесс действует как полунезависимая система с отдельными циклами событий.
Coroutines / Green-Threads / Fibers / Actors. Каждое соединение обрабатывается легковесной конструкцией с внутренним управлением (Go, Erlang, Scala/Akka).
Представление о том, как сервер обрабатывает соединения, имеет решающее значение для понимания его ограничений и моделей масштабирования. Даже базовое использование или настройка требуют понимания того, как обрабатываются соединения: Redis и PostgreSQL, например, предлагают различную семантику транзакций и блокировок, на которую влияют их соответствующие механизмы обработки соединений. Серверы, основанные на процессах и потоках, могут «упасть» из-за исчерпания ресурсов, если их максимальное количество не задано в разумных пределах. С другой стороны, установка пределов может привести к масштабному недоиспользованию серверов из-за слишком низких лимитов. Системы, основанные на циклах событий, ничего не выигрывают от работы на 64-ядерных CPU (если, конечно, их 64 копии не настроены на совместную работу — что отлично работает в случае веб-серверов, но плохо подходит для баз данных).
Каждый из этих способов обработки соединений по-разному проявляет себя при использовании в серверах приложений и базах данных из-за распределённой или централизованной природы каждой системы. Например, серверы приложений, как правило, хорошо подходят для горизонтального масштабирования — они работают нормально и одинаково независимо от того, один ли у вас сервер, 10 или 10000. В этих случаях отказ от модели процессов/потоков обычно приводит к росту производительности, поскольку мы хотим выполнить как можно больше работы с минимальным использованием памяти и переключением контекста процессором.
Подходы на циклах событий вроде Node отлично зарекомендовали себя на одноядерных серверах, и для использования на многоядерных серверах их необходимо кластеризовать правильным образом. Системы, основанные на сопрограммах/акторах, такие как Go или Erlang, гораздо легче задействуют ядра процессора, так как разработаны специально для этого: на одной машине параллельно могут работать многие тысячи горутин и акторов.
С другой стороны, централизованные базы данных выигрывают от обработки на основе процессов/потоков/циклов событий, поскольку из-за транзакционных гарантий системы нежелательно, чтобы множество соединений работало с одними и теми же данными в одно и то же время. В случае операций, происходящих на множестве соединений, придётся использовать блокировки во время чувствительных к транзакциям этапов их работы, или использовать стратегии вроде MVCC, поэтому чем меньше число возможных обработчиков соединений, тем лучше. Эти системы поддерживают малое число соединений на одной машине.
На крупном сервере PostgreSQL может управлять несколькими сотнями соединений, в то время как MySQL способен обрабатывать пару тысяч. Redis способен обрабатывать наибольшее количество соединений (возможно, десятки тысяч), поскольку цикл событий помогает ему поддерживать согласованность данных, но платить за это приходится тем, что одновременно выполняется только одна операция.
Распределённые базы данных могут и будут пытаться отойти от модели, основанной на процессах и потоках. Поскольку данные распределяются по нескольким машинам, от блокировок обычно отказываются в пользу секционирования (partitioning). Такие базы данных способны поддерживать множество соединений между большим числом серверов. Например, AWS DynamoDB или Google Datastore, а также распределенные БД, написанные на Go, с готовностью примут миллионы или даже миллиарды одновременных подключений.
Однако все эти решения имеют последствия — они жертвуют многими операциями (join'ы, ad-hoc-запросы) и гарантиями согласованности, предоставляемыми централизованными/односерверными базами данных. Но, идя на эту жертву, они получают возможность обрабатывать соединения секционированным, горизонтально масштабированным, практически неограниченным способом, позволяя выбирать конструкцию, которая поддерживает множество соединений на множестве машин. Проблема соединений в данном случае снимается: каждый отдельный сервер должен сам заботиться об их обработке, но в совокупности, с тысячами и миллионами машин с умной маршрутизацией подключений, данные системы часто ведут себя так, словно они бесконечно масштабируемы.
Что такое пул и зачем он нужен?
«Дороговизна» соединений требует их эффективного и экономного использования. Часто бывает сложно понять, насколько дорого они обходятся. Связано это с асимметрией: с точки зрения клиента соединение стоит дёшево, и обычно от их чрезмерного числа страдает именно сервер.
В процессе работы клиент не может позволить себе роскошь использовать отдельное соединение для каждой операции. Например, сервер приложений как клиент подключается к базе данных. Устанавливая для каждого запроса новое соединение, он искусственно ограничил бы себя возможностями БД (к которой подключается) по обработке соединений. В некоторых случаях подобный способ работы абсолютно эффективен — например, если сервер приложений является прокси-сервером для базы данных. В реальности серверы приложений выполняют кучу другой работы: ожидают поступления запрашиваемых данных на сервер, анализируют его, формулируют запрос, пересылают его в БД по соответствующему соединению, ждут результатов, считывают и обрабатывают их, преобразуют выходные данные в формат HTML/JSON/RPC, посылают сетевые запросы другим сервисам, и т.д. Основную часть времени соединение простаивает — другими словами, дорогостоящий ресурс используется неэффективно. И это мы ещё не учли издержки, связанные с созданием соединения (запуск процесса, аутентификация) и завершением его работы на стороне сервера.
Чтобы повысить эффективность использования соединений, многие клиенты баз данных используют так называемые connection pools (пулы соединений). Пул — это объект, самостоятельно обслуживающий некоторый набор соединений, не предусматривающий прямого доступа или использования. Пул выдает соединения, когда необходимо связаться с базой данных. Соединения возвращаются в пул после завершения работы. Пул может быть инициализирован с заданным числом соединений, или может наполняться по необходимости. Идеальное применение пула соединений выглядит следующим образом: код запрашивает соединение у пула (checkout), когда оно ему необходимо, использует его и сразу возвращает в пул (release). Таким образом, код не удерживает соединение, пока выполняется работа, никак с ним не связанная, что существенно повышает эффективность. Это позволяет выполнять множество различных задач с использованием одного или нескольких соединений. Если все соединения в пуле заняты, когда происходит очередной запрос (checkout), запрашивающей стороне обычно приходится ждать (block), пока соединение не освободится.
Механизм пулов по-разному реализован в различных языках и фреймворках:
Ruby on Rails, например, автоматически выделяет и возвращает соединения в пул, при этом непонимание нюансов данного процесса приводит к неэффективному коду. Представьте, что совершается запрос к базе данных, за которым следует длинный сетевой запрос к другому сервису, и еще один запрос к БД. В этом случае соединение простаивает во время выполнения сетевого запроса (автоматическое управление Rails должно быть консервативным и осторожным, а потому — неэффективным).
В Go есть драйвер для работы с БД, входящий в стандартную библиотеку, который поддерживает автоматическую работу с пулом соединений. Однако неучет того, что соединения возвращаются в пул между обращениями к БД, приводит к неожиданным и трудно воспроизводимым багам. Иногда разработчики исходят из предположения, что последовательные операции в одном и том же запросе идут по тому же соединению, однако автоматический менеджер случайным образом выбирает соединения из пула (гейзенбаг в Go, связанный с Postgres advisory locks).
Транзакции усугубляют данную проблему: базы данных часто привязывают функционал транзакции к соединению (иногда это называют session — сессией). Начав транзакцию, вы можете её зафиксировать (commit) или откатить (roll back) только по тому же соединению, на котором она изначально запускалась. Автоматическое управление пулом должно учитывать этот фактор с тем, чтобы не вернуть соединение в пул во время выполнения транзакции. В зависимости от базы данных другие функции, такие как блокировки и подготовленные выражения (prepared statements), также могут иметь привязку к соединениям.
Так что, если мы хотим писать эффективный код, нам необходимо знать, как работает пулинг соединений в используемом фреймворке, какие действия выполняются автоматически, и когда автоуправление не работает или его применение контрпродуктивно. Pooling-прокси (вроде pgBouncer, Odyssey или AWS RDS Proxy) — один из инструментов, помогающий забыть об этих нюансах. Эти системы позволяют создавать столько соединений с базой данных, сколько нужно, не заботясь об управлении ими, поскольку выделяемые соединения не настоящие, а их «дешёвая» имитация, не требующая значительных ресурсов. Когда клиент пытается воспользоваться одной из имитаций, pooling-прокси извлекает настоящее соединение из внутреннего пула и сопоставляет имитацию с реальным соединением. Когда прокси замечает, что соединение больше не используется, он оставляет открытым соединение с клиентом, но агрессивно освобождает и повторно использует соединение с базой данных. Число соединений и уровень «агрессивности» можно настроить, в том числе с учётом таких нюансов, как транзакции, подготовленные выражения и блокировки.
Решите ли вы оптимизировать свой код и включить в него эффективное управление соединениями, или выберете инструмент вроде pgBouncer, в конечном счёте будет определяться требуемым компромиссом между производительностью и сложностью развертывания. Любой вариант подойдёт в зависимости от того, сколько кода вы готовы написать, насколько удобно реализовано управление соединениями в используемом языке программирования и насколько эффективным должен быть проект.
Pooling не ограничивается клиентами баз данных. Мы называли соединения на стороне клиента «дешёвыми», но — увы — они не бесплатны. Они тоже используют память, порты и файловые дескрипторы на стороне клиента — ресурсы, которые ограничены. По этой причине многие языки/библиотеки имеют пулы для HTTP-соединений с одним и тем же сервером, а также используют пулы для других ограниченных ресурсов. Как правило, эти пулы скрыты от глаз до тех пор, пока система не исчерпает ограниченный ресурс, и в этот момент она обычно падает. Знание о подобной специфике сильно помогает при отладке — помимо соединений, виновниками проблем часто выступают файловые дескрипторы.
Настройка общих пулов
Теперь, когда мы увидели, как обычно обрабатываются соединения, можно поговорить о различных комбинациях сервер приложений + база данных и подумать о том, как максимизировать количество запросов, обрабатываемых на сервере приложений, минимизируя количество подключений к базе данных. Хотя эта статья покрывает не все возможные комбинации, большинство из них похожи по крайней мере на один из приведенных ниже примеров, так что понимание их принципов работы поможет разобраться с другими возможными комбинациями. Дайте мне знать, если хотите, чтобы я добавил больше комбинаций/систем.
1. Обработка на основе процессов и потоков
Puma, популярный сервер для запуска Ruby-приложений, предлагает пару механизмов для управления обработчиками входящих HTTP-запросов. Первый механизм — это число запускаемых процессов, представленное в конфигурации директивой workers. Каждый процесс сервера независим и загружает полный стек приложения в память. Таким образом, если приложение занимает N Мб памяти, необходимо убедиться, что на машине по крайней мере доступно workers×N Мб памяти для запуска всех копий. Есть способ смягчить эту проблему: Ruby 2+ поддерживает механизм копирования при записи (copy-on-write), позволяющий запускать множество процессов как один и потом разветвлять его на нужное число без необходимости копировать всю память — общие области памяти будут использоваться совместно до тех пор, пока в них не внесут изменения. Активация функции copy-on-write с помощью директивы preload_app! может помочь снизить потребление памяти по сравнению с приведенной выше формулой (workers×N). Однако не стоит возлагать на неё слишком большие надежды, не протестировав предварительно поведение при длительных нагрузках.
Серверы, основанные исключительно на процессах, такие как Unicorn, останавливаются на этом уровне конфигурации — как и популярные серверы для Python, PHP и других языков, использующие глобальную блокировку или построенные по схеме один поток/один процесс. Каждый процесс может обрабатывать один запрос за раз, но это не гарантирует полную загрузку: пока процесс дожидается выполнения запроса к БД или сетевого запроса к другому сервису, он не будет обрабатывать другие запросы, и соответствующее ядро процессора будет простаивать. Для устранения этой проблемы можно запустить больше процессов, чем имеется ядер CPU (что приведёт к издержкам, связанным с переключением контекста), или задействовать потоки.
Что приводит нас ко второму механизму, который предлагает Puma — числу потоков для запуска в каждом процессе/worker'e. Директива threads позволяет настроить минимальное и максимальное число потоков в пуле потоков каждого worker'а. Использование двух этих директив позволяет контролировать общее число потоков, которые будут действовать как параллельные обработчики запросов для приложения. Очевидно, оно равно произведению числа worker'ов на число потоков.
Эмпирическое правило состоит в том, что обычно на каждое доступное ядро CPU приходится по одному worker'у — при условии, конечно, что для этого достаточно памяти. Это позволяет эффективно использовать память, так что можно прикинуть, сколько RAM нужно, проведя несколько тестов с этим номером. Теперь желательно полностью задействовать CPU — сделать это можно, увеличивая максимальное количество потоков. Как помните, потоки используют память совместно с процессом, так что увеличение их числа не слишком отражается на потреблении памяти. Вместо этого большее число потоков будет всё сильнее загружать процессор, попутно позволяя обрабатывать больше запросов одновременно. Это разумно, поскольку, пока один поток спит в ожидании результатов запроса к БД или сетевого запроса, ядро CPU может переключиться на другой поток из того же процесса. Но помните, что большое число потоков приведёт к конкуренции за блокировки процессов, так что вам придется опытным путем установить, сколько потоков можно добавить для значимого увеличения производительности.
Но как все эта конфигурация влияет на количество соединений с базой данных? Rails использует автоматическое управление подключениями к базе данных, так что каждому запущенному потоку потребуется собственное подключение к базе данных для эффективной работы без ожидания, когда работу завершат другие потоки. Он поддерживает пул соединений (его настройки хранятся в файле database.yml и применяются на уровне процесса/worker'а). Таким образом, если оставить значение по умолчанию, равное 5, Rails будет поддерживать максимум 5 соединений для каждого worker'а. Такой лимит не слишком хорошо сработает, если изменить максимальное число потоков — все они будут бороться за эти пять соединений в пуле. Эмпирическое правило состоит в том, чтобы сделать число соединений в пуле равным максимальному количеству процессов, как рекомендовано в руководстве по развертыванию Heroku Puma.
Но тут возникает другая проблема: количество соединений, равное произведению числа worker'ов на число потоков (workers × threads), благотворно влияет на производительность сервера приложений, но совершенно не подходит для базы данных вроде PostgreSQL и, в некоторых случаях, для MySQL. В зависимости от того, сколько у вас оперативной памяти (в случае Postgres) и сколько CPU (в случае MySQL), данная конфигурация может не сработать. Можно уменьшить объём пула (pool) или значение threads, чтобы сократить число соединений, или число worker'ов, или оба этих параметра. В зависимости от конкретного приложения, все эти решения, вероятно, будут иметь один и тот же эффект: если каждый запрос требует обращений к базе данных, количество подключений к БД всегда будет выступать узким местом, ограничивая число запросов, которые реально обработать. Но если некоторые запросы могут обходиться без обращений к БД, то число worker'ов и потоков можно оставить высоким, а объём пула сделать относительно низким — в результате множество потоков будет обрабатывать запросы, но только небольшая их часть станет конкурировать за подключения к БД в пуле по мере необходимости.
Эту идею можно реализовать другим способом: перевести управление соединениями в код и убедиться, что они эффективно резервируются (checkout) и освобождаются (release). Это особенно важно, если между обращениями к БД делаются сетевые запросы.
Если станет понятно, что управлять соединениями должным образом или настраивать эти цифры затруднительно, и сервер приложений искусственно ограничен пределом на количество подключений к БД, можно будет воспользоваться инструментом вроде pgBouncer, Odyssey, AWS RDS Proxy. Запуск pooling-прокси позволит сделать размер пула равным максимальному числу потоков и попутно обеспечит уверенность в том, что прокси-сервер сделает все максимально эффективно.
Что касается баз данных, PostgreSQL использует обработчики на основе процессов, поэтому приходится проявлять сдержанность в отношении числа соединений при работе с этой БД. MySQL использует потоки, так что количество соединений можно увеличить — хотя это и способно привести к снижению производительности из-за переключения контекста и блокировок.
2. Обработка на основе цикла событий
Node / Deno — первый сервер на основе цикла событий (event loop), который мы рассмотрим. Подобный сервер с конфигурациями по умолчанию способен весьма эффективно использовать ядро процессора, на котором работает, но будет практически полностью игнорировать другие. Да, его внутренние подсистемы и библиотеки вполне могут задействовать и другие ядра, но сейчас мы больше заинтересованы в полезной нагрузке, и поможет в этом кластеризация (clustering). Она достигается путем запуска одного процесса, который принимает все входящие соединения, а затем действует как прокси-сервер и распределяет соединения по другим процессам, работающим на той же машине. У Node имеется модуль кластеризации, входящий в стандартную библиотеку, и популярные серверы вроде PM2 его активно используют. Основное правило здесь состоит в том, чтобы запускать столько же процессов, сколько ядер CPU доступно (конечно, при условии, что памяти достаточно).
Stripe выпустил интересный проект Einhorn, который представляет собой менеджер соединений, существующий вне стека, в котором пишется код. Он запускает собственный процесс, которые принимает соединения, и распределяет их по экземплярам приложения, которые запускает и которыми управляет как дочерними процессами. Инструмент вроде этого очень полезен в системах, основанных на циклах событий, поскольку при возможности всегда будет максимально полно задействовать ядро процессора. Однако сам по себе он не так полезен с Ruby/Python, поскольку, хотя и позволяет запускать несколько процессов, отсутствие потоков означает, что каждый из процессов может обслуживать только один запрос за раз.
Подход на основе кластеризации цикла событий используется системами, которые меняют поведение по умолчанию для языка, основанного на процессах. Сервер Tornado для Python, например, преобразовывает обработчик запросов Python в систему на основе цикла событий, также известную как non-blocking I/O. Также можно настроить его кластеризацию на все доступные ядра CPU (при условии достаточного количества памяти).
Аналогичный подход используется в веб-сервере Falcon для Ruby. В новых версиях Ruby имеется аналог зеленых потоков (green-thread), называемый файбером (fiber), и каждый входящий запрос Falcon обрабатывает с помощью одного такого Ruby-примитива. Файберы не могут автоматически использовать все ядра CPU, поэтому Falcon запускает копию приложения в каждом доступном ядре процессора (опять же, при условии, что памяти достаточно).
Во всех этих случаях нужно настроить пулы соединений в адаптерах БД с целью ограничить число подключений, доступное для каждого процесса. Также можно использовать pooling-прокси, если серверы приложений ограничены лимитом базы данных на соединения, или если управление выделением и высвобождением соединений становится затруднительным.
Redis обрабатывает соединения с помощью цикла событий. То есть он может поддерживать столько соединений, сколько имеется доступных портов, файловых дескрипторов и памяти, при этом каждая операция от каждого соединения обрабатывается по очереди.
3. Внутреннее управление / Кастомная обработка
Образцом в данном случае выступает Go, который, в отличие от всех других примеров, полностью свободен от каких-либо ограничений при обработке запросов — он может по-максимуму использовать доступные ресурсы CPU и памяти. Каждый входящий запрос обрабатывается новой горутиной (goroutine) — легковесной потокоподобной конструкцией. Исполняемая среда Go управляет ей, при этом планирование значительно опережает по эффективности аналогичные механизмы для потоков или процессов. Go автоматически «раскидывает» горутины по всем доступным ядрам CPU, хотя его аппетиты можно несколько обуздать, задав параметр runtime.GOMAXPROCS. Поскольку все происходит в рамках исполняемой среды, память не копируется. Go работает на всех ядрах CPU и не требует запуска отдельной копии приложения на каждом ядре.
Поскольку Go оптимизирован для работы с десятками тысяч параллельных запросов даже на совсем маленьких серверах, его сопряжение с базой данных на основе процессов, такой как PosgreSQL, можно сравнить с картинкой, на которой гоночный автомобиль на полной скорости врезается в кирпичную стену. Если каждая горутина использует SQL-пакет из стандартной библиотеки, будет создано столько соединений, сколько насчитывается горутин — ведь по умолчанию размер пула не ограничен.
Первое, что необходимо сделать с любым приложением на Go, работающем с SQL-базой, — задать предел соединений с помощью SetMaxOpenConns и связанных опций. Go использует внутренний пул с пакетом sql, поэтому каждый запрос будет получать соединение из пула, и сразу после завершения запроса оно будет возвращаться в пул. Другими словами, чтобы провести транзакцию, придется воспользоваться специальными методами, предоставляющими объект, который «обёртывает» открытое соединение, в котором транзакция была запущена — это единственный способ, позволяющий в дальнейшем зафиксировать данные или откатить транзакцию. Это имеет большое значение и при использовании других завязанных на соединение функций, таких как подготовленные выражения или рекомендательные блокировки (advisory locks).
Подобный автоматический подход даёт неожиданное преимущество: он устраняет необходимость в pooling-прокси, поскольку управление соединениями уже осуществляется крайне агрессивно. В вызовы БД по умолчанию встроено эффективное управление соединениями, и соединение используется так же эффективно, как и в pooling-прокси. Системы, устроенные подобным образом, по умолчанию работают эффективно, однако пользователю приходится разбираться с пограничными случаями, когда ручное управление действительно необходимо.
Помимо привычных нюансов, связанных с подготовленными выражениями и рекомендательными блокировками (advisory locks), подобное ручное управление соединениями также потенциально может приводить к проблемам, связанным со взаимной блокировкой. Если задано максимальное число соединений, и некоторым запросам для работы требуется более одного соединения, существует вероятность попадания в порочный круг, в котором запросы бесконечно дожидаются, пока другие запросы освободят соединения. В заметках об определении размера пула для HikariCP приводятся формулы, которые помогают справится с проблемами вроде этой.
Другие языки на основе VM, такие как Java, Scala/Akka, Clojure, Kotlin (все на JVM) и Elixir/Erlang (на BEAM VM) работают аналогичным образом: задействование всех доступных ядер CPU возможно без запуска отдельной копии приложения для каждого ядра. Реализация каждой конкретной системы или библиотеки подключений к БД обычно имеет свои тонкости, однако с ними легко разобраться, используя одну или несколько концепций, приведённых выше.
…
Свяжитесь со мной в Twitter, если хотите добавить примеры работы других систем, у вас есть какие-либо вопросы/замечания/пожелания, или вы обнаружили ошибку.
P.S. от переводчика
Читайте также в нашем блоге: