TL;DR: В нативном протоколе ClickHouse клиенты должны дождаться пакета EndOfStream для завершения запроса. Между пакетами с результатами Data и пакетом EndOfStream ClickHouse отправляет несколько пакетов метаданных (Logs, Progress, ProfileEvents). В сочетании с TCP_NODELAY и сбросом окна перегрузки TCP (cwnd) после периода простоя эти непакетированные данные могут превысить размер окна, что вынуждает совершать дополнительный цикл обмена данными (RTT). Мы воспроизвели это детерминированным образом с помощью сетевых пространств имен Linux и сократили «хвостовую» задержку путем пакетирования метаданных на уровне приложения.
Один из наших клиентов заметил дополнительную задержку около 32 мс при выполнении простых SELECT-запросов между клиентом и сервером.
В конфигурации этого клиента клиент и сервер находятся на расстоянии около 32 мс друг от друга (исходящий трафик). Когда соединение простаивало в течение 2 секунд или дольше, выполнение следующего запроса занимало около 64 мс от начала до конца: в 2 раза больше времени кругового обращения (RTT) вместо ожидаемых 32 мс (1× RTT).
Нам нужно было выяснить, что именно вызывает этот дополнительный цикл обмена данными.
Расследование привело к интересным выводам как о стеке TCP в Linux, так и о нативном протоколе ClickHouse.
Сервер ClickHouse поддерживает широкий спектр протоколов. HTTP и Native TCP — два основных способа взаимодействия с сервером ClickHouse. Проблема клиента связана с протоколом Native TCP, поэтому мы сосредоточимся на нем. Чтобы понять проблему клиента, важно немного знать о том, как работает нативный протокол ClickHouse.
Нативный протокол ClickHouse — это протокол с сохранением состояния, построенный поверх транспортного протокола TCP. Клиент и сервер обмениваются различными протокольными сообщениями на разных этапах (Handshake, INSERT или SELECT). Официальная спецификация нативного TCP-протокола подробно описывает принцип его работы.
Вот упрощенная версия того, как работают SELECT-запросы на уровне протокола.
- Клиент отправляет Query (содержит SQL, настройки, флаги и т. д.).
- Сервер передает потоком пакеты Data, содержащие строки результата.
- Сервер также отправляет промежуточные пакеты метаданных (ProfileEvents, Progress, Logs).
- Сервер отправляет EndOfStream (EOS), чтобы отметить, что конкретный запрос завершен.
Для клиента это означает, что он должен продолжать чтение из соединения до тех пор, пока не получит пакет EndOfStream для этого конкретного запроса, прежде чем запрос будет считаться выполненным.
TCP — это хорошо известный транспортный протокол, и принципы его работы выходят за рамки этой статьи. Здесь мы сосредоточимся в основном на двух характеристиках протокола TCP, которые необходимы для точного понимания проблемы клиента.
- Окно перегрузки (Congestion window)
- TCP_NODELAY (алгоритм Нейгла)
Окно перегрузки (в отличие от окна получателя) не настраивается ни на стороне клиента, ни на стороне сервера. Это значение, которое обычно начинается с малого (на основе initcwnd, начального значения окна перегрузки в Linux) и продолжает расти до тех пор, пока сеть между клиентом и сервером не столкнется с проблемами перегрузки (например, сервер может отправить такое количество TCP-пакетов, прежде чем ждать подтверждения ACK от клиента). Это широко известно как медленный старт TCP.
По сути, TCP ограничивает количество неподтвержденных данных в полете с помощью этого окна перегрузки (cwnd).
Значение initcwnd обычно составляет 10 MSS (максимальный размер сегмента), но иногда отличается. В случае клиента оно составляло 8 MSS.
Важно отметить, что окно перегрузки постоянно растет, и если соединение простаивает некоторое время (например, 2 секунды в случае клиента), стек TCP в Linux сбрасывает текущий размер окна перегрузки обратно до значения initcwnd. Это время простоя называется тайм-аутом повторной передачи (RTO), и оно рассчитывается динамически на основе RTT сети с нижним (200 миллисекунд) и верхним (120 секунд) пределами в стеке TCP Linux.
Учитывая возраст TCP, одно из его поведений по умолчанию заключается в пакетировании записей сокета перед их отправкой в сеть для уменьшения сетевых накладных расходов. Это часто называют алгоритмом Нейгла, и это происходит внутри стека TCP Linux, где приложение практически не имеет контроля.
Современные системы с низкой задержкой (такие как ClickHouse) отключают это, устанавливая TCP_NODELAY=1, что дает ядру команду отправлять записи сокета немедленно.
Подвох здесь в том, что если TCP_NODELAY включен и приложение выполняет несколько записей в сокет для этих пакетов метаданных (ProfileEvents, Log, Progress), каждая из этих записей становится отдельным TCP-сегментом в сети.
Если сервер выполняет больше мелких записей, чем может вместить уменьшенное значение cwnd, ядро должно остановиться и дождаться подтверждения ACK от клиента, прежде чем отправлять оставшиеся пакеты, что приводит к дополнительной задержке в один цикл обмена данными.
Воспроизведение проблемы клиента требует имитации того же сетевого RTT между клиентом и сервером (~32 мс), чтобы сделать проблему видимой. Для простоты мы запускаем все запросы (с периодом простоя 2 секунды) через одно TCP-соединение по нативному протоколу между клиентом и сервером.
Здесь задействованы два компонента:
- Программа на Go, которая выполняет реальные запросы, чтобы наблюдать проблему, с которой столкнулся клиент (дополнительная «хвостовая» задержка ~32 мс).
- Сервер ClickHouse внутри сетевого пространства имен с примененными сетевыми условиями клиента (имитация задержки исходящего трафика ~32 мс).
Мы могли бы использовать Docker или Podman для создания сетевого пространства имен. Однако мы намеренно настроили его вручную с помощью netns, чтобы упростить понимание имитации сети.
Затем мы использовали tc (traffic control) для имитации задержки WAN в 32 мс между клиентом и сервером. Мы также настроили начальное окно перегрузки TCP на 8 MSS (initcwnd 8).
- Иметь бинарный файл ClickHouse (< 26.9) (тот, который мы используем для воспроизведения проблемы).
- Создать сеть для имитации инфраструктуры клиента (клиент и сервер на расстоянии ~32 мс).
- Проверить имитируемую сеть (вы должны увидеть ~32 мс).
Эта программа на Go выполняет простые запросы через установленное соединение с 2-секундным периодом простоя между запросами. Мы также разделяем общую задержку запроса и «хвостовую» задержку (время между последним пакетом Data и пакетом EndOfStream), чтобы более четко наблюдать «хвостовую» задержку (как до, так и после исправления).
Программа на Go выполняет N идентичных запросов SELECT max(id) по одному повторно используемому соединению и разделяет задержку каждого запроса на:
- first_row (сам результат запроса) и
- tail (первая строка → конец потока).
tail — это ключ: норма ≈ 0 мс; задержка = ровно 1× RTT.
- Запустить сервер внутри имитируемой сети.
- Создать таблицу и строки для экспериментов.
- Запустить программу на Go, чтобы увидеть проблему в действии.
Вы должны увидеть результаты, подобные следующим:
Наблюдение: первая строка прибыла через ~33 мс (1× RTT), но соединение зависло еще на ~32 мс («хвостовая» задержка) в ожидании пакета EndOfStream.
Дополнительный RTT (~32 мс) возникает из-за того, что после сброса окна перегрузки (с большого значения до initcwnd, которое в случае клиента было 8) после 2-секундного простоя сервер не может отправить все оставшиеся пакеты (Data + пакеты метаданных) за один цикл обмена данными.
Сервер отправил 8 TCP-сегментов, исчерпал пространство cwnd и приостановил работу, пока клиент не подтвердил (ACK) первую партию, что потребовало дополнительных 32 мс на полный круговой обход (round trip).
Существует несколько способов обхода этой проблемы, снижающих вероятность её возникновения:
- Отключите ProfileEvents на стороне сервера (пакеты ProfileEvents являются одной из причин большого количества TCP-пакетов в сети).
- Отключите сброс окна перегрузки (cwnd) соединения до его начального значения (initcwnd) после периода простоя (tcp_slow_start_after_idle=0) на серверном узле (в случае клиента это GKE; это также рекомендуется Google Cloud для высокопроизводительного TCP).
- Увеличьте initcwnd (по умолчанию равен 10, в случае клиента — 8) на серверном узле.
- Усовершенствуйте протокол ClickHouse Native, чтобы создавать меньше TCP-пакетов с помощью пакетной обработки на уровне приложения.
- Используйте опцию TCP_CORK вместо TCP_NODELAY.
К сожалению, в данном случае клиент практически не мог повлиять на обходные пути (1), (2) и (3), поэтому единственными жизнеспособными вариантами оставались (4) и (5). Вариант 5 (TCP_CORK) выглядел интересным, но у него есть несколько недостатков:
- Стек TCP ядра по-прежнему полностью контролирует заполнение этих TCP-сегментов.
- Он специфичен для ядра Linux.
Вариант (4) в любом случае казался хорошим улучшением на стороне сервера. Поэтому мы решили сделать так, чтобы протокол ClickHouse Native создавал меньше TCP-пакетов с помощью пакетной обработки данных на уровне приложения в конце запроса.
Патч прост: мы группируем эти пакеты метаданных на уровне приложения в одну запись в сокет (что потенциально сокращает число TCP-пакетов), которые затем могут быть отправлены за минимальное количество круговых обходов даже при сбросе окна перегрузки.
Есть один нюанс: мы не можем слепо группировать все пакеты метаданных, так как некоторые пакеты, например Progress, чувствительны ко времени и должны доставляться клиенту немедленно (представьте, что клиент хочет узнать реальный прогресс запроса, а пакетная обработка на сервере задерживает его, передавая неверное состояние прогресса).
Мы можем убедиться, что наша пакетная обработка работает, изучив перехваченные TCP-пакеты: общее количество TCP-пакетов, создаваемых сервером для того же запроса, сокращается почти вдвое. Это устраняет дополнительные ~32 мс задержки хвостика (tail latency), которые мы наблюдали изначально.
Предположим, сервер ClickHouse работает на порту 9000.
Если запустить тот же скрипт на Go с одновременным перехватом TCP-пакетов PUSH (PSH, тех, в которых передаются фактические данные приложения) через tshark:
Даже до изучения перехваченных pcap-файлов мы видим, что было перехвачено в общей сложности 50 таких TCP-пакетов от сервера к клиенту.
Также обратите внимание на множество крошечных TCP-сегментов:
Тот же скрипт на Go при перехвате на пропатченном сервере с пакетной обработкой:
Вы можете четко видеть, что количество переданных TCP-пакетов вдвое меньше, чем было раньше.
Изучение перехваченных пакетов ясно показывает, что PSH-пакеты гораздо более «заполнены» по сравнению с многочисленными крошечными пакетами ранее.
Запуск того же скрипта с точно такой же конфигурацией, как описано при воспроизведении реальной проблемы клиента, показывает, как исчезла хвостовая задержка ~32 мс:
Обратите внимание, что для отправки сервером EndOfStream (eos) клиенту не требуется дополнительный круговой обход.
Высокая хвостовая задержка не всегда вызывается медленными базами данных или долго выполняющимися запросами; иногда причина скрыта во взаимодействии между сбросами данных на уровне приложения и поведением TCP на уровне ядра.
- Сброс простаивающих TCP-соединений: после периода простоя RTO стек TCP Linux уменьшает cwnd обратно до initcwnd, ограничивая количество неподтвержденных пакетов, которые могут поместиться в один круговой обход.
- TCP_NODELAY имеет обратную сторону: отключение алгоритма Нейгла ускоряет отдельные операции записи, но выполнение множества мелких вызовов write() может создать неожиданное количество TCP-сегментов, которые способны быстро исчерпать маленькое окно перегрузки.
- Пакетная обработка на уровне приложения: группировка пакетов метаданных (ProfileEvents, Logs, Progress) в одну запись буфера перед сбросом резко сокращает количество TCP-сегментов, сохраняя предсказуемость хвостовой задержки даже при простаивающих соединениях с высоким RTT.
Начните сегодня
Хотите узнать, как ClickHouse работает с вашими данными? Начните работу с ClickHouse Cloud за считанные минуты и получите 300 долларов в виде бесплатных кредитов.











