Рост нагрузки на современные веб-сервисы и приложения требует постоянной оптимизации их архитектуры и обработки запросов. Особенно актуальной становится задача увеличения пропускной способности при обработке большого объёма входящих запросов, что характерно для систем мониторинга и уведомлений, таких как Healthchecks.io. В этой статье рассмотрим подробно, как команда Healthchecks.io справилась с вызовом обработки пиковых нагрузок и кардинально повысила производительность своих серверов, реализовав инновационный подход к обработке «пингов» — сигналов о состоянии задач.
Healthchecks.io — это сервис для мониторинга выполнения периодических задач, например, cron-заданий. Система получает пинговые HTTP-запросы от клиентов, которые свидетельствуют о корректном исполнении задач. При этом нагрузка на сервера варьируется в течение времени: в среднем около 500 запросов в секунду, но в начале каждой минуты и часа количество пингов достигает 4000 и более 10 000 соответственно. Такая «шиповатая» интенсивность требует гибкой и эффективной обработки.
Изначально обработка поступающих запросов была реализована на основе Python с Django ORM. Такой подход отлично подходит для небольших нагрузок, до нескольких сотен запросов в секунду, поскольку каждый запрос обрабатывается отдельно, с обновлением и вставкой данных в базу через ORM. Однако по мере роста популярности и числа пользователей стало очевидно, что этот подход не выдерживает экстремальных нагрузок, и нужно искать более производительные решения. В качестве альтернативы команда разработчиков Healthchecks.io создала закрытую версию обработчика на языке Go.
Архитектура приложения состояла из HTTP-обработчика, который помещал каждое поступившее задание (Job) в канал с буфером, а затем воркер-горутина последовательно набирала задания из канала и запускала хранимую процедуру PostgreSQL, которая выполняла SELECT, UPDATE, INSERT и DELETE операции. Такой «конвейер» ограничивался одним соединением с базой данных и последовательно обрабатывал поступающие запросы. Для визуализации ситуации был сделан аналог OpenTTD — представление горутины в виде шахты, базы данных как электростанции, а транзакции — в виде поездов. До определённого предела система работала стабильно и эффективно. Позже количество горутин было увеличено с одной до четырёх на каждом из трёх серверов, таким образом достигнув 12 последовательных процессов записи в базу.
Это уже позволило обрабатывать около 5000 запросов в секунду в сумме. Однако рост количества запросов и возникшие пиковые нагрузки требовали дальнейших улучшений. Первая попытка заключалась в пакетной обработке (batching) — сборе сразу нескольких заданий и передаче их в хранимую процедуру как массив для последовательной обработки внутри базы. На первый взгляд идея обещала увеличение скорости, так как можно было сократить число вызовов к базе и уменьшить накладные расходы. Тем не менее опыт этой реализации показал ряд проблем.
Во-первых, усложнилась логика передачи данных — пришлось вводить сложные пользовательские типы, чтобы передавать массивы в PostgreSQL. Отладка процедур на PL/pgSQL, вызвавших друг друга на несколько уровней, оказалась очень непростой. Хотя небольшое увеличение пропускной способности и имело место, оно не было значительным, а серьёзные баги, найденные в этом коде, вынудили вернуться к предыдущему подходу. Тем не менее идея пакетной обработки продолжала интересовать автора. В рамках более глубокой оптимизации появилась новая концепция, основанная на использовании PostgreSQL COPY для вставки данных.
В документации pgx было отмечено, что COPY работает быстрее INSERT уже при малом числе строк — от 5 и более. Это открыло возможности увеличить скорость обработки, одновременно сдвигая больше логики из базы в приложения, что опирается на горизонтальное масштабирование веб-слоя и снижает нагрузку на базу данных, которая изначально является узким местом для записи. Реализация второго подхода стартовала с сохранения актуального способа получения и проверки запросов в HTTP-обработчике Go-приложения, помещающего Job в буферизированный канал. Основное отличие заключалось в том, что воркер теперь накапливал задачи в партию — до 100 элементов или по таймауту в 5 миллисекунд. Далее воркер запускал одну транзакцию, в рамках которой последовательно выполнялись запрос SELECT для получения данных по всем элементам партии, обновления статусных записей через pipelined запросы и самая быстрая вставка ping-записей через COPY протокол.
Эта схема позволила добиться существенного увеличения скорости обработки. При этом после завершения транзакции пакет целиком записывался в базу, что обеспечивало целостность и устойчивость данных. Чтобы избежать ошибок и конфликтов, вызванных одновременным обновлением одних и тех же записей, служебный механизм сортировал задания для предотвращения «взаимных блокировок» при конкурентном выполнении транзакций. В системе добавили дополнительную логику для поддержки уникальности: пинги, обращающиеся к проверкам по «slug» (человеко-читаемые идентификаторы), требовали предварительного поиска в базе, а проверки могли создаваться динамически через механизм автоматического провижининга. Важной составляющей стала регулярная очистка старых записей ping, чтобы избежать раздувания базы, при этом работа по очистке отделена в отдельный горутин-сервис, чтобы не влиять на обработку входящих запросов.
Несмотря на отказ от хранимых процедур и переход к обычным SQL запросам, использование common table expressions (WITH) и объединений запросов позволило сохранить эффективность исполнения кода и уменьшить overhead работы с базой. Для тестирования корректности работы нового Go-приложения использовались старые Django-тесты, адаптированные под реальные HTTP-запросы к Go-приложению. Это позволило убедиться, что функциональность сохраняется, несмотря на изменённый внутренний механизм обработки. Для улучшения задержек на стороне клиента и снижения median latency была реализована стратегия с двумя воркерами. Пока один из них отправляет текущий пакет в базу, другой уже собирает следующий.
Конкуренция между воркерами контролируется мьютексом, гарантирующим, что сборка партии данных происходит последовательно и без конфликта. В результате текущий высокопроизводительный Go-пинг обработчик успешно справляется с нагрузками свыше 11 000 запросов в секунду, без накопления очередей и задержек. На тестовой машине удалось добиться более 20 000 запросов в секунду, что существенно превосходит предыдущие версии. Цикл обработки ping-запроса теперь включает несколько этапов: от принятия запроса на балансировщике HAPoxy с начальным ограничением скорости, через NGINX с более жёсткой фильтрацией и геоблокировкой, передача в Go-приложение, проверка кеша 404-ответов для отсутствующих проверок, попадание задания в очередь и пакетная обработка воркерами с последующей записью в PostgreSQL. Для будущего роста команда рассматривает несколько направлений.
Одно из них — повышение мощности серверов с более быстрыми CPU и NVMe накопителями, так как аппаратная база уже лучше начальной, но ещё не достигла предела доступных ресурсов. Кроме того, регулярные обновления PostgreSQL вносят новые оптимизации, улучшающие производительность. Дополнительную выгоду можно получить, продолжая тонко настраивать размер партий и число воркеров, а также параметры базы и ограничения на входящие запросы. Однако важный вызов заключается в том, чтобы заблаговременно подготовиться к масштабированию, когда нагрузки неизбежно превысят возможности одного узла базы данных. Для этого необходимы архитектурные решения, позволяющие масштабировать систему горизонтально, например, через шардирование данных либо использование распределённых баз.