Архитектура php-clickhouse

Нативный асинхронный клиент ClickHouse для PHP TrueAsync, построенный на официальном C++-клиенте нативного протокола ClickHouse/clickhouse-cpp (v2.6.1, подключён как git-сабмодуль). Пространство имён — TrueAsync\ClickHouse.

clickhouse-cpp владеет протоколом (фрейминг, блоки, система типов, компрессия LZ4/ZSTD). Расширение добавляет к нему три вещи: транспорт поверх реактора TrueAsync, скрытый пул соединений на ABI async-пула и объектный слой PHP (Client/Result/Batch/Summary). Каждая сетевая операция выглядит синхронной, но уступает текущую корутину, пока ждёт сокет.

Это описание текущей реализации. Несколько узлов сознательно оставлены на потом (async-connect, пре-варминг пула, Ping-healthcheck, нативный пакет Cancel) — они собраны в разделе Что ещё не реализовано.

Состав модуля

ФайлОтветственность
clickhouse_async.cppрегистрация классов, объекты Client/Result/Batch/Summary, разбор конфига, склейка с пулом
ch_transport.cppмост транспорта: чтение/запись clickhouse-cpp поверх реактора TrueAsync
ch_client.cppC-фасад над clickhouse::Client: connect, insert, путь результата, batch
ch_exceptions.cppтрансляция исключений C++ → PHP

C-фасад (ch_client.h) держит заголовки clickhouse-cpp вне основного модуля: весь C++ ↔ clickhouse-cpp заперт в ch_*.cpp.

1. Транспорт

clickhouse-cpp буферизует и сжимает поверх транспорта; нижний слой переноса байт (InputStream::DoRead / OutputStream::DoWrite) мы заменяем своим. При этом переиспользуется собственная логика connect / DNS / socket-options clickhouse-cpp (NonSecureSocketFactory + защищённый Socket::handle_) — подменяется только движение байт на неблокирующее, управляемое реактором.

Транспорт выбирается на каждое соединение фабрикой AsyncSocketFactory:

Без TLS (порт 9000, частый случай). AsyncSocket наследует clickhouse::Socket, после подключения переводит дескриптор в неблокирующий режим, а AsyncInput/AsyncOutput делают неблокирующие recv/send. Когда сокет возвращает EAGAIN/EWOULDBLOCK, корутина ждёт готовность через реактор и повторяет операцию. Ожидание следует каноничному паттерну DB-драйверов TrueAsync (как pdo_pgsql_await_socket): одно poll-событие реактора

  • waker-колбэк + один SUSPEND, без аллокации запроса на каждое чтение и без цикла повторного взвода:
c
// ch_transport.cpp — суть ожидания готовности
zend_async_poll_event_t *poll_event = ZEND_ASYNC_NEW_SOCKET_EVENT(fd, events);
zend_async_waker_new_with_timeout(coroutine, timeout_ms, NULL);
zend_async_resume_when(coroutine, &poll_event->base, true,
        zend_async_waker_callback_resolve, NULL);
bool suspended = ZEND_ASYNC_SUSPEND();   // отдать управление event-loop

Это быстрый путь: одно копирование «ядро → буфер библиотеки», без php_stream под ним (он дал бы двойную буферизацию на горячем пути). EOF, ошибка recv/send или прерывание ожидания (например, при отмене корутины) бросают chasync::ConnectionError, clickhouse-cpp разматывает стек, а соединение помечается грязным.

С TLS (порт 9440). TlsSocket открывает php_stream-сокет ssl://host:port (php_stream_xport_create) с опциями контекста verify_peer / verify_peer_name (переключаются tls_verify). IO php_stream-сокета под TrueAsync уже асинхронно — поэтому и рукопожатие, и чтения/записи уступают корутину. Низкоуровневый плоский путь работает только с открытым текстом (реактор не запускает пользовательский TLS-стек), а «TLS из коробки» живёт в слое php_stream; накладные расходы на буферизацию пренебрежимы рядом со стоимостью TLS-криптографии.

Начальный TCP-connect пока блокирующий (конструктор clickhouse::Socket); асинхронный connect — задел на будущее. Реактором управляется путь чтения/записи.

2. Пул соединений

Пул встроен в Client и скрыт как механизм: отдельного класса нет, а query / insert / insertBatch прозрачно забирают и возвращают физическое соединение (одно соединение на корутину, как в PDO-пуле). Конкурентные корутины не должны делить сокет, иначе их чтения и записи переплелись бы на проводе.

Реализован напрямую на ABI async-пула TrueAsync (ZEND_ASYNC_NEW_POOL), создаётся лениво при первом использовании. Параметры: max (по умолчанию 10, задаётся через 'pool' => ['max' => N]), min = 0. Пре-варминга нет: ABI прогревал бы min внутри конструктора, до того как выставлен user_data, поэтому фабрика там ещё не может работать — соединения создаются по требованию, в контексте корутины.

Колбэки ABI:

КолбэкПоведение
factoryоткрыть новое соединение из конфига Client; выбрать стартовый хост по open_strategy и собрать для соединения список failover
destructorзакрыть clickhouse::Client
healthcheck / before_acquireaccept-all (нативного Ping пока нет)
before_releaseвернуть false, если соединение broken → пул его уничтожает, а не отдаёт в idle; иначе оставить в пуле

Когда все max заняты, ACQUIRE ждёт освобождения. Мёртвое соединение никогда не возвращается в пул (см. обработку ошибок), поэтому следующий acquire автоматически попадает на свежее.

Время жизни соединения. insert() забирает соединение и сразу его возвращает. Result (стриминг) и Batch удерживают своё соединение и держат живым сам Client (владельца пула) через refcount, пока объект не уничтожен; брошенный посреди потока Result роняет своё (грязное) соединение. Уничтожение Client закрывает пул.

getPool(): \Async\Pool лениво создаёт и кэширует штатную обёртку пула из ABI (ZEND_ASYNC_NEW_POOL_OBJ) — продвинутый запасной механизм со статистикой, circuit breaker, управлением жизненным циклом и ручными acquire/release.

3. PHP API

Поверхность API целиком описана в Использовании и Конфигурации; здесь — внутренние особенности.

  • Конфигурация — массив в new Client([...]) (fluent-билдера нет). Разбираются ключи host/port/database/user/password, compression (enum Compression, по умолчанию LZ4), tls/tls_verify, pool['max'], hosts[] и open_strategy (enum OpenStrategy).
  • query(sql, params?, options?)Result. Из options читается только settings (per-query настройки ClickHouse). Result реализует \Iterator на уровне C: rewind() подтягивает первую строку, дальнейшие блоки тянутся лениво через NextBlock() в корутине-потребителе. Это однопроходный итератор (не Generator); колоночные ссылки и интернированные ключи-имена кэшируются на блок, чтобы цикл по строкам не пересоздавал shared_ptr и не перехешировал имена. query() примиривает первый блок данных ещё до возврата — поэтому к этому моменту инструкция уже выполнена (DDL / INSERT … SELECT срабатывают, даже если результат не читать), и серверные ошибки всплывают здесь.
  • insert(table, columns, rows) — колоночная вставка; типы колонок берутся из серверного sample-блока INSERT (никогда не выводятся из PHP-значений). Форма строки проверяется перед отправкой; несовпадение — \ValueError.
  • insertBatch(table, columns)Batch — каждый flush() это самодостаточная вставка буферизованных строк (BeginInsert / SendInsertBlock / EndInsert); запись блока даёт асинхронный backpressure. Между флашами соединение простаивает (не в середине сессии), поэтому деструктор не делает сетевого IO; несброшенные строки отбрасываются.
  • Привязка параметров — нативные {name:Type} ClickHouse: на стороне сервера, типизированные, защищённые от инъекций.
  • Статистика результата. Внутренние колбэки OnProgress / OnProfile накапливают счётчики из пакетов Progress/Profile; summary() отдаёт их как объект Summary (readRows, readBytes, writtenRows, writtenBytes, totalRowsToRead, rowsBeforeLimit, elapsed). Пользовательского колбэка прогресса нет.

4. Маппинг типов

Полная таблица — на странице Маппинг типов. Архитектурно важно: типы всегда приходят от сервера (на чтении — из колонок блока, на записи — из sample-блока INSERT) и кодируются clickhouse-cpp; клиент ничего не угадывает по PHP-значениям.

5. Обработка ошибок

Все ошибки — исключения. На каждой границе PHP ↔ clickhouse-cpp C++-исключение переводится в PHP-исключение (ch_translate_and_throw). Если PHP-исключение уже ожидает (например, отмена корутины) — оно побеждает, перевод не затирает его.

Источник (C++)PHP-исключение
clickhouse::ServerExceptionServerException (getCode() = код ошибки ClickHouse)
ProtocolError / CompressionError / AssertionErrorProtocolException
chasync::ConnectionError (транспорт)ConnectionException
clickhouse::ValidationError (ошибка вызывающего)\ValueError
база clickhouse::Error (UnimplementedError, OpenSSLError) и прочееClickHouseException

Иерархия PHP: ClickHouseException наследует \RuntimeException; Connection/Server/Protocol наследуют её. \ValueError стоит особняком — это LogicException (ошибка кода), а не сбой времени выполнения.

«Отравленные» соединения. ConnectionError, ProtocolError, CompressionError и AssertionError означают мёртвый транспорт или рассинхрон на проводе — соединение помечается broken и при возврате уничтожается пулом. ServerException (чистый пакет ошибки) и ValidationError (проверка до отправки) оставляют соединение пригодным.

6. Отмена

Отдельного пакета Cancel пока нет. При отмене корутины ожидание готовности сокета прерывается: ожидающий PHP-исключение об отмене побеждает, транспорт бросает ConnectionError, соединение помечается broken и выбрасывается из пула; следующий acquire прозрачно получает свежее. (Отправку нативного Cancel и drain-and-reuse можно добавить позже; сейчас безопасный вариант — закрыть соединение.)

7. Failover и стратегия открытия

'hosts' => [...] даёт основной хост (первый) и список failover-эндпоинтов (остальные). На уровне соединения они передаются в clickhouse::ClientOptions через SetEndpoints, и clickhouse-cpp делает по ним свой in-order failover при подключении. open_strategy решает, с какого хоста каждое соединение пула начинает:

  • InOrder (по умолчанию) — всегда с основного (остальные — чистый failover);
  • RoundRobin — стартовый хост ротируется между соединениями (счётчик conn_seq);
  • Random — случайный стартовый хост на соединение.

Стратегия — на соединение, а не на запрос: ClickHouse привязывает соединение к одному хосту на всё время жизни.

8. Транзакции

Не предоставляются: транзакции ClickHouse экспериментальны (allow_experimental_transactions), только для MergeTree (без Replicated), не вкладываются и не могут быть закоммичены после исключения. Методов begin/commit/rollback нет.

Что ещё не реализовано

Сознательные заделы на будущее (отмечены в коде), не меняющие текущую публичную поверхность:

  • Асинхронный TCP-connect — сейчас начальное подключение блокирующее (конструктор clickhouse::Socket); реактором управляется только чтение/запись.
  • Пре-варминг пула (min) — пул создаётся лениво, соединения открываются по требованию; min зафиксирован в 0.
  • Ping-healthcheck — колбэк проверки сейчас accept-all; дешёвый нативный Ping перед выдачей соединения — на будущее.
  • Явный таймаут acquire — при исчерпании пула acquire ждёт освобождения; настраиваемого таймаута пока нет.
  • Нативный пакет Cancel при отмене запроса (см. Отмена).
  • Retry с dedup-токеном — авто-повтор идемпотентных запросов (insert_deduplication_token для INSERT) пока не делается.