Недавно проводили тест, результаты которого показали, что одно приложение обрабатывает 2000 запросов в секунду на скромном сервере, где это было не единственной нагрузкой. При этом результат каждого запроса записывается в 3-5 таблиц в MySQL. Честно говоря, меня такой результат удивил, поэтому решил поделиться с хабрасообществом описанием архитектуры этого приложения. Подобный подход применим от баннерных показов до чатов и микроблогов, надеюсь кому-нибудь покажется интересным.

Во-первых, это приложение однопоточное. Всё делается одним процессом, работа с сокетами — неблокирующими epoll/select, никаких ожидающих ввода/вывода потоков (threads). С развитием HTTP, сначала появлением Keep-Alive, затем AJAX и набирающим популярность COMET, количество постоянных соединений с веб-сервером растёт, на нагруженных проектах измеряется тысячами и даже десятками тысяч, и если для каждого создавать свой поток (thread) со своим стеком и постоянно переключаться между ними — ресурсов сервера очень быстро не хватит.

Второй ключевой момент — что один SELECT… WHERE pk in (k1, k2, ..., kN) выполняется быстрее, чем несколько SELECT… WHERE pk=… Выполняя работу с базой данных большими пачками можно уменьшить не только число запросов в секунду, но и общую нагрузку.

Предметная область


XBT Tracker (XBTT) — битторрент трекер. Просьба воздержаться от темы авторских прав, ибо торрент официально используется, например, для распространения дистрибутивов линукса и патчей к World of Warcraft. В отличии от ed2k и DC++ есть возможность поместить в один торрент несколько файлов, не пакуя их в архив, и в любой момент проверить целостность файла, а при необходимости восстановить его скачав битые куски.

При скачивании клиент периодически обращается к трекеру, сообщая статистику трафика и получая адреса других раздающих и качающих. Чем чаще такие обращения, тем точнее учёт трафика (если это закрытый трекер) и тем быстрее новые участники раздачи узнают друг о друге.

XBT Tracker, о котором этот пост, написан на си++ и используется на многих зарубежных трекерах, как открытых, так и закрытых, и даже на паре-тройке российских. Другой высокопроизводительный трекер, OpenTracker, закрытых трекеров с учётом трафика не поддерживает, поэтому ему не нужно писать результаты запросов в базу данных, поэтому в данном контексте он менее интересен.

Неблокирующий ввод-вывод


В 90-х годах при работе с сокетами использовался блокирующий ввод-вывод, когда при вызове методов recv и send текущий поток «зависал» до ожидания результатов. Для каждого принятого соединения создавался (fork) отдельный процесс, в котором шла обработка его запроса. Но каждый процесс требует памяти под стэк и процессорного времени на переключение контекста между процессами. На небольших нагрузках это не страшно, да и веб тогда не был интерактивным, полностью в режиме запрос-ответ, динамического контекста (CGI) было мало, в основном — счётчики посещений страницы и примитивные недо-форумы. Apache до сих пор работает таким образом. В apache2 есть возможность использование более лёгких потоков (threads) вместо процессов, но суть осталась прежней.

В качестве альтернативы этому появился неблокирующий ввод-вывод, когда один процесс мог открыть множество сокетов, периодически опрашивать их состояние, и если появились какие-то события, например поступило новое соединение или пришли данные для чтения — обслуживать их. Именно так работает, например, nginx. В Java версии 1.4 и выше для этого есть NIO.

В дальнейшем появились улучшения, например, TCP_DEFER_ACCEPT, позволяющее «откладывать» приём соединения до тех пор, пока по нему не пришли данные, SO_ACCEPTFILTER, откладывающий соединение пока не пришёл полноценный HTTP-запрос. Появилась возможность увеличить длину очереди непринятых соединений (по умолчанию их всего 128) при помощи sysctl kern.ipc.somaxconn в BSD и sysctl net.core.somaxconn в Linux, что особенно актуально если возникают паузы в обработке сокетов.

Обслуживание запросов


Запросы в XBTT очень простые, их обработка не требует особых вычислительных ресурсов, все необходимые данных он держит в памяти, поэтому нет проблем выполнять их в том же процессе, что и работу с сокетами. В случае более серьёзных задач по-прежнему требуется создавать отдельные потоки для их обслуживания.

Один из выходов — создание пула потоков (thread pool), которым передаётся запрос для обработки, после чего поток возвращается обратно в пул. Если свободных потоков нет — запрос ждёт в очереди. Такой подход позволяет уменьшить общее число используемых потоков, и каждый раз не приходиться создавать новый и убивать после завершения обработки запроса.

Ещё лучший механизм под названием «актёры» (actors) есть в языках эрланг (erlang) и скала (scala), возможно — в виде библиотек — и для других языков. Обработка выполняется посредством асинхронной передачи сообщений между актёрами, что можно себе представить как пересылку е-мейлов с входящим почтовым ящиком у каждого, но эта тема выходит за рамки данного поста (например, вот свежий пост об этом).

Пакетная работа с базой данных


Результат каждого обращения к трекеру XBTT записывает в несколько таблиц. У пользователя увеличивается его скачанный и залитый трафик. Увеличивается статистика у торрента. Заполняется таблица текущих участников раздачи. Плюс пара служебных таблиц с историей закачек.

При традиционном способе обработки на каждый запрос к трекеру выполнялось бы как минимум 3 отдельных INSERT или UPDATE, клиент ждал бы их исполнения, таким образом серверу базы данных пришлось бы выполнять по 3 запроса на каждое обращение к трекеру.

XBTT выполняет их не сразу, а накапливает большую пачку INSERT… VALUES (...), (...). ..., (...) ON DUPLICATE KEY UPDATE f1=VALUES(f1), ..., fN=VALUES(fN), и исполняет раз в несколько секунд. За счёт чего число запросов в базу уменьшается с нескольких на запрос к трекеру до нескольких в минуту. Также периодически он перечитывает необходимые данные, которые могли измениться извне (веб-интерфейс независим от трекера).

Критичность откладывания записи


В данном приложении потеря части данных совершенно не критична. Если в базу не будет записана статистика торрент-трафика за несколько секунд — ничего страшного не произойдёт. Хотя при аварийном завершении он записывает накопившиеся буфера в базу, на сервере может быть UPS на случай отключения электричества и т.п. — гарантии что все переданные клиентом данные записаны на диск нет. Для баннерн��й сети это тоже не страшно, но бывают задачи где сохранение всех данных критично.

Аналогично, не во всех приложениях есть возможность хранения всех данных в памяти. Для обработки запроса клиента может потребоваться выборка данных из базы.

Но и в таком случае блочная обработка данных возможна. Организуется конвейер (pipeline; для его реализации отлично подойдут actors) из нескольких стадий, на каждой происходит сборка группы данных для запроса, как только набралось достаточное количество (естественно, настраиваемое) или прошло некоторое время (например, 10-100 миллисекунд), за которое нужного количества не набралось, — делается групповой запрос к базе, где вместо «ключ=значение» ставится условие «ключ IN (накопленный список)».

Если необходимо заблокировать (lock) эти записи, то к запросу можно добавить FOR UPDATE SKIP LOCKED (естественно, запись результатов нужно будет выполнять в том же соединении к базе, той же транзакции). Можно использовать Prepared Statement в тех базах данных, которые это поддерживают, чтобы один раз выполнить анализ запроса (parse), выбрать оптимальный план его выполнения, а потом каждый раз только подставлять в него данные. Чтобы уменьшить количество таких подготовленных запросов, число параметров к нему можно брать только степенями двойки: 1, 2, 4, 8, 16, 32… Также можно группировать (batch) запросы, сначала не исполняя каждый, а только добавляя в пачку, а потом выполнить все сразу.