Архитектура 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.cpp | C-фасад над 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, без аллокации запроса на каждое чтение и без цикла повторного взвода:
// 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_acquire | accept-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(enumCompression, по умолчанию LZ4),tls/tls_verify,pool['max'],hosts[]иopen_strategy(enumOpenStrategy). 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::ServerException | ServerException (getCode() = код ошибки ClickHouse) |
ProtocolError / CompressionError / AssertionError | ProtocolException |
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) пока не делается.