Skip to main content
Le protocole natif est le protocole binaire, orienté connexion, que les clients et serveurs ClickHouse utilisent pour communiquer via TCP. Il transporte les requêtes SQL, les données de résultat, les charges utiles d’INSERT, la télémétrie d’exécution et les signaux d’erreur. C’est sur ce protocole que reposent le client en ligne de commande, le driver C++ et la plupart des drivers natifs tiers. Cette page décrit le protocole lui-même : le tramage des paquets, la machine à états de la connexion, la négociation de version et le corps de chaque message autre que Block. Les octets contenus dans les paquets de la famille Data (le Block, ses colonnes et les encodages propres à chaque type) relèvent d’un autre sujet, documenté dans la spécification du format Native.
Spécification complémentaireCette page forme un ensemble avec la spécification du format Native, publiée conjointement. Les deux spécifications se répartissent clairement les rôles : cette page couvre la couche des paquets et la couche de transport, tandis que la spécification du format Native couvre les octets contenus dans les paquets de la famille Data.
Quelques propriétés valent dans tous les cas. Le protocole est binaire et positionnel : il n’existe aucune balise de champ, sauf dans BlockInfo, si bien qu’un seul octet mal placé désynchronise tout ce qui suit. Il est avec état, et chaque connexion TCP traite une seule requête à la fois — il n’y a pas de multiplexage. Les entiers de taille fixe sont encodés en little-endian.

Vue d’ensemble

Dans le format binaire, chaque message commence par un code de type de paquet VarUInt, suivi d’un corps dont la structure dépend de ce code et de la version de protocole négociée. Une connexion se déroule en trois phases : un handshake initial unique, puis un nombre quelconque d’échanges Ping ou Query, et enfin la fermeture : Le protocole TCP natif transporte toujours les données tabulaires au format Native, quelle que soit la clause FORMAT de la requête SQL. La conversion vers RowBinary, CSV, JSON, etc. incombe au client, qui l’effectue après avoir décodé les blocs Native. (L’interface HTTP emprunte un chemin de code distinct qui, lui, respecte la clause FORMAT ; HTTP n’est pas traité ici.)

Sécurité

Sécurité du transport (TLS)

TLS opère au niveau de la couche transport, en dessous du protocole. Lorsqu’il est activé, l’intégralité du flux TCP est chiffrée, et les messages du protocole restent identiques octet pour octet, que TLS soit utilisé ou non.

Authentification

L’authentification a lieu pendant le handshake, dans le message ClientHello. Les champs user et password transitent sous forme de chaînes de caractères en clair : c’est donc le chiffrement au niveau de la couche transport (TLS) qui protège les identifiants en transit. Lorsque le champ user est vide, le serveur utilise l’utilisateur de session par défaut configuré, défini par le paramètre serveur default_session_user (dont la valeur par défaut est default), qui peut être surchargé pour chaque listener dans la section protocols. Si le serveur est configuré avec un utilisateur de session par défaut vide, ou si sa version est antérieure à 26.8, un champ user vide est rejeté et une exception est levée. Notez que clickhouse-client n’envoie jamais de nom d’utilisateur vide : il le remplace par default côté client. L’authentification SSH par défi-réponse est disponible à partir de la version de protocole 54466. Voir Authentification SSH par défi-réponse.

Secret interserveur

Pour l’exécution de requêtes distribuées, les serveurs s’authentifient mutuellement en prouvant qu’ils connaissent un secret partagé, sans jamais faire transiter ce secret sur le réseau. Chaque paquet Query contient un auth_hash SHA-256 de 32 octets dans le champ 4 de Query, calculé à partir d’un sel, d’un nonce, du secret configuré et de la requête ; le serveur destinataire le recalcule, puis compare les deux valeurs. Ce mécanisme dépend de la fonctionnalité INTERSERVER_SECRET (v54441). Les clients externes envoient toujours une chaîne vide dans ce champ. Voir Authentification inter-serveur.

Gestion des versions et activation conditionnelle des fonctionnalités

Négociation de version

Lors du handshake, le client et le serveur déclarent chacun la version de protocole maximale qu’ils prennent en charge. La version négociée correspond à la plus basse des deux :
Chaque message échangé par la suite s’appuie sur la version négociée pour déterminer quels champs figurent dans le format binaire.

Activation conditionnelle des fonctionnalités

Une fonctionnalité est identifiée par la version du protocole qui l’a introduite ; elle est active dès lors que la version négociée est supérieure ou égale à ce numéro.
Lorsqu’une fonctionnalité est active, ses champs doivent figurer dans le format binaire. Le protocole étant strictement positionnel, omettre un champ soumis à une activation conditionnelle des fonctionnalités corrompt le flux d’octets pour tous les champs suivants.

Tableau des fonctionnalités

Enveloppe de paquet

Chaque message transmis dans le format binaire présente la même structure externe, dans les deux sens :
Les tableaux complets des types de paquets figurent dans la référence des types de paquets. Le type de paquet est un VarUInt, et non un octet de taille fixe. Pour les valeurs inférieures à 128, un VarUInt produit le même octet unique, mais les implémentations doivent utiliser l’encodage VarUInt afin de rester compatibles si de futurs types de paquets atteignent ou dépassent 128. La référence des messages décrit uniquement le corps de chaque paquet, c’est-à-dire les octets qui suivent le code du type de paquet. La numérotation des champs commence à 1, avec le premier champ du corps.

Tramage par fragments (v54470+)

Lorsque la fonctionnalité CHUNKED_PROTOCOL est négociée (voir le handshake), chaque paquet transmis est enveloppé dans un tramage par fragments. Cet enveloppement s’applique par sens de communication : les sens client→serveur et serveur→client sont négociés séparément et peuvent aboutir à des modes différents (par fragments ou sans tramage). Disposition wire de chaque paquet :
Disposition wire par fragment :
Le VarUInt du type de paquet se trouve à l’intérieur du flux fragmenté : il constitue le premier octet de la charge utile du paquet (le premier octet du premier fragment), et non un octet distinct envoyé avant le tramage. La charge utile des fragments de chaque paquet correspond à l’intégralité de [VarUInt packet_type_code][message body] issu de l’enveloppe de paquet. Si un client laisse le type de paquet en dehors du flux fragmenté, le pair lit cet octet de type comme le premier octet de la taille de fragment u32, ce qui désynchronise la connexion. Un même paquet peut être réparti sur plusieurs fragments si le tampon du rédacteur se remplit en cours de paquet ; la coupure peut intervenir n’importe où, y compris à l’intérieur du VarUInt du type de paquet. Le lecteur concatène les charges utiles des fragments et traite le zéro final de 4 octets comme une frontière de paquet transparente : il le consomme, mais ne le transmet pas au composant qui lit les corps de paquets. Les paquets sans corps sont malgré tout encapsulés : un paquet d’un seul octet tel que Ping ou Pong devient [u32 size = 1][0x04][u32 0] une fois la fragmentation négociée. Toute mention d’« un seul octet dans le format binaire » ailleurs sur cette page désigne la forme antérieure à la fragmentation. Négociation. ServerHello et Addendum transportent chacun deux champs String, un par direction, dont les valeurs sont choisies parmi {"chunked", "notchunked", "chunked_optional", "notchunked_optional"} :
  • chunked / notchunked sont stricts : le côté concerné exige exactement ce mode.
  • Les variantes _optional sont souples : elles acceptent le mode choisi par l’autre côté.
La valeur convenue pour chaque direction est déterminée par paire : Côté client, la préférence d’ENVOI du client est négociée avec la préférence de RÉCEPTION du serveur, et inversement. Chronologie. Les chaînes de négociation transitent sans tramage : ClientHello → ServerHello (préférences du serveur) → Addendum (valeurs négociées par le client). Le passage au tramage s’applique à chaque octet envoyé après le vidage de l’Addendum. L’Addendum lui-même, le ClientHello et le ServerHello sont toujours transmis sans tramage.

Cycle de vie de la connexion

À tout instant, une connexion se trouve dans un seul des quatre états suivants : HANDSHAKE, READY, READING_RESPONSE ou terminée. Le protocole ne gérant pas le multiplexage, un client qui envoie une nouvelle requête avant d’avoir entièrement lu la réponse précédente entremêle les octets transmis et corrompt le flux.

États

Le chemin nominal descend en ligne droite — HANDSHAKE → READY → READING_RESPONSE → READY — avec la boucle Ping/Pong sur elle-même, tandis que toutes les transitions d’échec convergent vers l’unique état final Terminated.

Phase de handshake

Authentifie le client et négocie la version du protocole. Cette phase a lieu une seule fois par connexion, avant toute autre opération. La connexion TCP vient d’être ouverte et aucun message n’a encore été échangé. Le déroulement est le suivant :
  1. Le client envoie ClientHello avec la version maximale du protocole qu’il prend en charge.
  2. Le client lit la réponse et la traite selon le type de paquet :
  3. Si negotiated_version ≥ 54458 (fonctionnalité ADDENDUM), le client envoie un Addendum. Cette décision repose sur la version négociée, et non sur la version déclarée par le client.
En cas de succès, la connexion passe à l’état READY ; en cas d’erreur, elle est interrompue.

Phase Ping

Vérification de disponibilité au niveau applicatif, indépendante du keepalive TCP. Un aller-retour Ping/Pong réussi confirme que la connexion TCP est active dans les deux sens et que le serveur répond. Le Ping est sans état et n’est lié à aucune requête : plusieurs Pings successifs sont donc indépendants les uns des autres. À partir de l’état READY, le déroulement est le suivant :
  1. Le client envoie Ping.
  2. Le client lit la réponse :

Phase de requête

Le client soumet un statement SQL ; le serveur renvoie en continu des blocs de résultats ainsi que la télémétrie d’exécution. La réponse est une séquence de paquets se terminant par exactement un EndOfStream ou un Exception. À partir de l’état READY, le déroulement est le suivant : Si une erreur survient à n’importe quel moment, le serveur envoie un paquet Exception au lieu de EndOfStream, ce qui met fin à la requête.
  1. Le client envoie Query avec un query_id unique (généralement un UUID).
  2. Le client envoie les éventuelles tables externes, puis le marqueur Data vide. Le paquet Data vide a table_name = "", num_columns = 0, num_rows = 0. Le serveur ne commence à exécuter la requête qu’après avoir reçu ce marqueur. Une table externe peut être répartie sur plusieurs paquets Data portant le même table_name ; le premier d’entre eux fixe le schéma de la table, et chaque paquet ultérieur pour ce nom doit contenir les mêmes noms et types de colonnes, dans le même ordre, faute de quoi le serveur rejette la requête avec INCORRECT_DATA (voir Data).
  3. Le client passe à l’état READING_RESPONSE et vide son tampon d’écriture.
  4. Le client lit les paquets de réponse en boucle et les traite selon leur type :
À la réception de EndOfStream ou d’un Exception traité, la connexion revient à l’état READY. Une violation de protocole ou une erreur d’E/S y met fin.
Le cas num_rows == 0 piège souvent les nouvelles implémentations. Un bloc sans ligne est un marqueur de limite ou un en-tête de schéma, et non un signal de fin de flux. Seuls EndOfStream ou Exception mettent fin à la réponse.

Phase INSERT

La phase INSERT correspond à la phase de requête, avec deux échanges supplémentaires. Le client soumet un statement INSERT, puis le serveur répond par un bloc de schéma décrivant la table cible. Le client envoie ensuite en flux des paquets Data contenant les lignes, suivis du marqueur Data vide. Enfin, le serveur termine par EndOfStream ou Exception. À partir de l’état READY, la requête SQL est un INSERT de la forme INSERT INTO <table> [(<cols>)] VALUES, sans littéral VALUES (...) intégré, puisque les données des lignes transitent par les paquets Data. Le déroulement est le suivant :
  1. Le client envoie Query avec body défini sur la requête SQL INSERT.
  2. Le client envoie les éventuelles tables externes (rare pour un INSERT). Contrairement à la phase de requête, il n’envoie pas de marqueur Data vide à ce stade. Le paquet Query de l’INSERT est envoyé avec des données en attente : le bloc vide de fin de données est donc reporté à l’étape 5. S’il était envoyé avant le bloc de schéma, le serveur l’interpréterait comme la fin du flux de lignes, terminerait l’INSERT sans aucune ligne, puis analyserait le premier véritable paquet de lignes comme un paquet de premier niveau orphelin.
  3. Le client consomme les paquets de métadonnées (TableColumns, Progress, ProfileInfo, Log, ProfileEvents) jusqu’à recevoir le paquet Data de schéma — un bloc avec 0 ligne mais une structure de colonnes complète (noms et types). Le bloc de schéma fait office de contrat : les lignes que le client envoie ensuite doivent respecter la forme de ces colonnes.
  4. Le client envoie le ou les blocs de données. Pour chaque bloc, il écrit VarUInt(ClientPacket::Data = 2), puis String("") pour le nom vide de table externe, puis le bloc. Les types de colonnes doivent correspondre, position par position, aux colonnes du bloc de schéma.
  5. Le client envoie le marqueur de fin d’entrée : un paquet Data contenant un bloc vide (0 colonne, 0 ligne).
  6. Le client consomme le flux de réponse jusqu’à EndOfStream (succès) ou Exception (échec).
INSERT asynchrone (v54484+). Lorsque la requête comporte async_insert = 1, le serveur place les lignes en file d’attente et les vide par lots. À partir de la version négociée ≥ 54484 (PROGRESS_IN_ASYNC_INSERT), une fois le vidage terminé, le serveur émet un paquet Progress supplémentaire, immédiatement suivi des ProfileEvents de l’insertion, puis de EndOfStream. En dessous de 54484, le serveur n’envoie pas ce Progress final. Il s’agit d’un paquet Progress ordinaire ; comme le serveur réinitialise le pipeline de requête avant d’y intégrer les compteurs d’écriture, l’incrément ne contient en pratique que le temps écoulé, et les statistiques de lignes et d’octets écrits parviennent au client via les ProfileEvents qui l’accompagnent. Un client qui consomme déjà les Progress entrelacés à l’étape 6 n’a qu’à accepter un paquet supplémentaire. La connexion revient à l’état READY à la réception de EndOfStream ou d’une Exception gérée. Les violations de protocole et les erreurs d’E/S y mettent fin.

Référence des messages

Les champs sont présentés dans l’ordre de transmission. La colonne Type utilise les valeurs suivantes :
  • VarUInt — entier non signé de longueur variable (voir VarUInt).
  • String — octets préfixés par un VarUInt (voir String).
  • UInt8, Int32, etc. — entiers little-endian de largeur fixe.
  • Bool — un seul octet, 0x00 ou 0x01.
La colonne Role indique qui utilise chaque champ :
  • client — défini par les clients externes.
  • interserveur — pertinent uniquement pour la communication entre serveurs ; les clients externes y écrivent une valeur par défaut.
  • universal — utilisé par les deux.
Ces tableaux ne décrivent que le corps de chaque paquet, situé après le code du type de paquet.

ClientHello (type de paquet 0)

Client → Serveur. Premier message envoyé après l’ouverture de la connexion TCP.

ServerHello (type de paquet 0)

Serveur → Client. Réponse à ClientHello lorsque l’authentification réussit. Rule — élément de password_complexity_rules : Cette liste reflète la politique de mots de passe configurée par l’opérateur du serveur et n’a qu’une valeur indicative : le serveur n’applique pas ces règles pendant la poignée de main. Un client qui permet de modifier ou de définir un mot de passe peut s’appuyer sur ces règles pour signaler les erreurs avant d’envoyer au serveur un mot de passe non conforme.
Pour limiter la consommation de ressources face à un serveur malveillant ou mal configuré, plafonnez la valeur décodée de count à 256 entrées, et chaque String pattern et message à 4096 octets. Un count égal à 0 (aucune paire ne suit) est le cas habituel pour les serveurs sur lesquels aucune politique de mots de passe n’est configurée.

Addendum (sans type de paquet)

Client → Serveur, conditionné par ADDENDUM (v54458). Envoyé immédiatement après la fin de la poignée de main. Il ne s’agit pas d’un type de paquet distinct : les champs sont transmis bruts dans le format binaire, sans octet de type de paquet en préfixe. Le passage au tramage fragmenté n’intervient qu’après le vidage de cet Addendum ; l’Addendum lui-même n’est pas tramé.

Ping (type de paquet 4)

Client → Serveur. Aucun corps — avant le tramage fragmenté, le paquet se compose d’un seul octet 0x04 ; lorsque la fragmentation est négociée, cet octet devient la charge utile, d’un seul octet, d’un fragment (voir tramage fragmenté).

Pong (type de paquet 4)

Serveur → Client. Aucun corps — le paquet se résume à un seul octet 0x04 avant le tramage fragmenté ; lorsque la fragmentation est négociée, cet octet constitue la charge utile, longue d’un octet, d’un fragment (voir tramage fragmenté).

Exception (type de paquet 2)

Serveur → Client. Envoyé lorsque le serveur rencontre une erreur, quelle que soit la phase.

Query (type de paquet 1)

Client → Serveur.

ClientInfo (intégré dans Query)

Client → Serveur, intégré dans le corps de Query (champ 2). Conditionné par CLIENT_INFO (v54032). (Certains champs de ClientInfo dépendent de versions ultérieures, comme indiqué ci-dessous pour chaque champ.)
Disposition dépendante de l’interface (champs 7–12)Les champs 7–12 ci-dessus correspondent à la branche TCP. Lorsque query_interface (champ 6) n’est pas TCP, ces champs sont remplacés par une disposition wire différente — il ne s’agit pas de simples omissions facultatives : un décodeur doit donc bifurquer en fonction du champ 6.
  • query_interface = 2 (HTTP) : ce sont les informations de la requête HTTP transmise par le serveur qui sont écrites à la place — http_method (UInt8), http_user_agent (String), puis forwarded_for (String, conditionné par X_FORWARDED_FOR_IN_CLIENT_INFO v54443), http_referer (String, conditionné par REFERER_IN_CLIENT_INFO v54447), et enfin http_handler_name (String) et http_request_url (String) (tous deux conditionnés par HTTP_HANDLER_IN_CLIENT_INFO v54490, dans cet ordre). Aucun des champs os_user/client_hostname/client_name/version_*/protocol_version n’est présent. Les deux derniers contiennent le nom du gestionnaire HTTP défini en SQL qui a été sélectionné ainsi que l’URL de la requête, afin qu’ils soient propagés jusqu’aux partitionnés distants (ils sont vides pour une requête HTTP ne passant pas par un gestionnaire).
  • Toute autre interface : aucun des champs TCP (7–12) ni aucun des champs HTTP n’est écrit ; le flux se poursuit directement avec quota_key.
À l’issue de cette branche, la disposition redevient commune : quota_key (champ 13) et distributed_depth (champ 14) suivent pour toutes les interfaces, puis version_patch (champ 15) n’est écrit que pour TCP.Cette branche concerne principalement le trafic interserveur, lorsque le serveur initiateur transmet une requête arrivée à l’origine via HTTP. Un décodeur qui lit systématiquement les champs TCP interprétera mal ces paquets, en prenant http_method ou http_user_agent pour quota_key.
Encodage OpenTelemetry (champ 16) :

Authentification inter-serveur

Le champ 4 du paquet Query (auth_hash) ne contient pas, dans le format binaire, le secret de cluster partagé. Envoyer le secret brut ferait échouer l’authentification tout en le divulguant. Un serveur agissant en tant que client interserveur prouve plutôt qu’il connaît le secret au moyen d’un hash SHA-256 salé :
  1. Passer en mode interserveur. Le serveur qui se connecte le signale dans ClientHello : le champ user contient le marqueur interserveur et password est vide. Il ajoute ensuite deux autres chaînes de caractères — le nom du cluster et un salt de 32 octets nouvellement généré (encodeSHA256 d’une valeur aléatoire) — immédiatement après les champs user/password, au sein du même paquet ClientHello. Le serveur lit ces deux chaînes avant d’envoyer ServerHello ; un client doit donc les écrire d’emblée. Attendre d’abord ServerHello provoque un interblocage, car le serveur reste bloqué dans l’attente de ces chaînes.
  2. Obtenir le nonce. ServerHello transporte un nonce UInt64 de 8 octets lorsque INTERSERVER_SECRET_V2 (v54462) est négocié.
  3. Calculer le hash. Pour chaque paquet Query qui n’est pas une InitialQuery, le client écrit encodeSHA256(salt + nonce + cluster_secret + query + query_id + initial_user + external_roles + current_roles) dans le champ 4, soit un digest de 32 octets. (nonce est représenté sous forme de chaîne décimale, présente uniquement si la version négociée est ≥ v54462 ; external_roles n’est ajouté que lorsque INTERSERVER_EXTERNALLY_GRANTED_ROLES (v54472) est négocié ; current_roles est la liste sérialisée des noms de rôles — [VarUInt count][String]*count, sans l’octet de présence — ajoutée uniquement lorsque INTERSERVER_CURRENT_ROLES (v54488) est négocié et que la liste est présente.) Pour une InitialQuery, ou lorsqu’aucun secret de cluster n’est configuré, le client écrit à la place une chaîne vide.
  4. Vérifier. Le serveur lit le champ 4 avec une limite de 32 octets et recalcule la même concaténation à partir de sa propre copie du secret de cluster ; la connexion est rejetée si les digests diffèrent.
Les clients externes (non interserveur) n’entrent jamais dans ce mode et envoient toujours un auth_hash vide.

Setting

Encodé directement dans la liste de paramètres du corps de Query (le paquet Query, champ 3). La liste est toujours présente, quelle que soit la version négociée, et se termine par un Setting dont la clé est vide — un unique VarUInt 0, sans indicateurs ni valeur à la suite. Seul l’encodage de chaque paramètre dépend de la version négociée, selon SETTINGS_SERIALIZED_AS_STRINGS (v54429). v54429+ (STRINGS_WITH_FLAGS) — chaque paramètre est représenté par le triplet suivant : Les champs 2 et 3 sont absents lorsque key est vide. Avant 54429 (BINARY) — chaque paramètre est de la forme [String key][type-specific binary value] : le champ flags n’est pas écrit, et la valeur est encodée sous la forme binaire native du paramètre (par exemple un integer de largeur fixe ou une string préfixée par sa longueur) plutôt que sous forme de string décimale ou textuelle. La liste se termine toujours par une key vide. Un client ciblant une version négociée inférieure à 54429 doit lire et écrire cette forme binaire, et non le triplet ci-dessus. (Les paramètres personnalisés définis par l’utilisateur font exception : ils comportent toujours flags et une valeur de type string, quel que soit l’encodage.) Le champ flags regroupe :
  • 0x01 — Important : le paramètre affecte le résultat de la requête et ne doit pas être ignoré silencieusement par les pairs plus anciens.
  • 0x02 — Custom : un paramètre personnalisé défini par l’utilisateur.
  • 0x0c — un champ de niveau sur 2 bits, et non un indicateur indépendant : 0x00 = Production, 0x04 = Obsolete, 0x08 = Experimental, 0x0c = Beta. Lisez les 2 bits (flags & 0x0c) — un test naïf flags & 0x04 classerait à tort Beta (0x0c) comme Obsolete.
  • 0x80 — HotReload (rechargement de la configuration sans redémarrage ; défini dans l’enum des indicateurs, présent principalement pour les paramètres de coordination).

Paramètre

Paramètres de requête, pour les requêtes paramétrées telles que SELECT {x:UInt64}. Ils sont encodés de la même manière qu’un Setting dont l’indicateur Custom (0x02) est activé, et se terminent de la même façon par une clé vide.
La valeur du paramètre correspond à la représentation SQL de la valeur, et non à un littéral brut. Les paramètres de type String doivent être transmis déjà entourés de guillemets simples (par exemple, la valeur de {name:String} est 'Alice', et non Alice) ; sinon, le parseur de valeurs du serveur les rejette.

Data (type de paquet 1 serveur→client, type de paquet 2 client→serveur)

Dans les deux sens. Transporte les blocs de résultats, les données d’INSERT, les tables externes et les marqueurs de fin de données. Le format wire est symétrique : dans les deux sens, un préfixe table_name précède le bloc. Seul l’octet du type de paquet diffère.
Le marqueur de fin de données est un paquet dont le bloc est vide (0 colonne et 0 ligne), quelle que soit la valeur de table_name. Le serveur ne considère un paquet Data client comme marqueur de fin que si le bloc décodé est vide (block.empty()) ; un paquet avec table_name = "" et un bloc non vide est un paquet de lignes ordinaire, et non un marqueur de fin. Un flux de lignes d’INSERT se compose donc d’une suite de blocs Data non vides, clôturée par un unique bloc Data vide. Les variantes de blocs et leur signification sont décrites dans Variantes de blocs.

Progress (type de paquet 3)

Serveur → Client. Envoyé périodiquement pendant l’exécution de la requête. Tous les champs sont de type VarUInt, et chaque paquet contient des incréments depuis le paquet Progress précédent, et non des totaux cumulés. Avant l’envoi, le serveur lit ses compteurs, les remet atomiquement à zéro, puis calcule elapsed_ns comme le temps écoulé depuis le dernier envoi. Un client doit donc cumuler localement les paquets successifs pour obtenir des totaux courants : traiter un paquet comme une valeur absolue fait reculer l’affichage de la progression ou fausse le décompte à la baisse dès que plus d’un paquet est reçu.

ProfileInfo (type de paquet 6)

Serveur → Client. Envoyé une seule fois par requête, vers la fin de l’exécution.

Totals (type de paquet 7)

Serveur → Client. Envoyé pour les requêtes contenant WITH TOTALS. Le format wire est identique à celui de Data : une string table_name (toujours vide) suivie d’un bloc. Seul l’octet de type de paquet diffère.

Extremes (type de paquet 8)

Serveur → Client. Envoyé lorsque le paramètre extremes est activé. Le format wire est identique à celui de Data. Le bloc contient exactement 2 lignes : la ligne 0 contient le minimum de chaque colonne et la ligne 1, le maximum.

Log (type de paquet 10)

Serveur → Client. Envoyé lorsque la requête dispose d’une file d’attente de logs active (paramètre send_logs_level ; voir streaming des logs). Même enveloppe et même format de corps que Data. Le bloc a une valeur fixe num_columns = 8 et un schéma prédéfini. Chaque ligne de log correspond à une ligne sur l’ensemble des 8 colonnes, et un même paquet Log peut contenir de nombreuses lignes.
Les 8 colonnes, dans cet ordre précis :

ProfileEvents (type de paquet 14)

Serveur → Client. Transmet les compteurs de performance de chaque requête. Enveloppe et format de corps identiques à ceux de Data. Le bloc comporte un nombre fixe de colonnes (num_columns = 6) et un schéma prédéfini. Chaque événement correspond à une ligne.
Les 6 colonnes :
Le type des éléments de la colonne value varie d’un paquet à l’autre : les serveurs plus anciens émettent du UInt64, les plus récents du Int64. Lisez la string de type de la colonne dans l’en-tête du bloc plutôt que de présupposer une largeur donnée.

TableColumns (type de paquet 11)

Serveur → Client, conditionné par COLUMN_DEFAULTS_METADATA (v54410). Le serveur l’envoie avant le bloc de schéma de l’INSERT afin de transmettre les métadonnées des valeurs par défaut des colonnes, mais uniquement lorsque la version négociée est ≥ 54410 et que le paramètre input_format_defaults_for_omitted_fields est activé. En dessous de 54410, ce paquet n’est jamais envoyé ; un client plus ancien ne doit donc pas l’attendre, car le bloc Data de schéma arrive directement. Un client v54410+ doit être capable de gérer les deux cas : un TableColumns facultatif, suivi du bloc de schéma.
Corps compressé à partir de v54481À partir de la version négociée 54481 (COMPRESSED_LOGS_PROFILE_EVENTS_COLUMNS), le serveur écrit les deux champs via le même chemin de sortie, éventuellement compressé. Ainsi, lorsque la requête a compression = true, l’intégralité du corps de TableColumns (external_table + columns_description) se trouve dans la trame de compression, et le client le lit via le flux décompressé correspondant. Lorsque la requête n’utilise pas de compression, le corps est transmis non compressé dans le format binaire, exactement comme l’indique le tableau ci-dessus. Ce point est important pour les réponses de schéma d’INSERT : un client qui adapte la gestion de la compression pour Log et ProfileEvents, mais pas pour TableColumns, interprétera mal la réponse lorsque la compression de la requête est activée.

TimezoneUpdate (type de paquet 17)

Serveur → Client, conditionné par TIMEZONE_UPDATES (v54464). Envoyé à un seul endroit : l’initialiseur de la fonction de table input (une requête de la forme INSERT INTO <table> SELECT ... FROM input('<structure>'), qui reçoit en flux des lignes envoyées par le client). Juste après avoir envoyé le bloc Data contenant le schéma d’entrée (voir la phase INSERT), le serveur émet TimezoneUpdate avec la valeur actuelle de session_timezone dans le contexte de la requête, afin que le client analyse les lignes qu’il s’apprête à envoyer selon le même fuseau horaire. Le serveur n’émet pas ce paquet lors de modifications arbitraires via SET session_timezone en cours de requête, ni pour indiquer au client comment formater les blocs de résultats ultérieurs. Le paquet arrive une seule fois, immédiatement après le bloc de schéma d’entrée et avant que le client ne commence à envoyer les blocs de lignes. Un décodeur qui ignore TimezoneUpdate DOIT tout de même consommer la String qui suit afin de préserver l’alignement du wire.

Authentification SSH par challenge-réponse (types de paquet 11, 12, 18)

Conditionnée par SSH_AUTHENTICATION (v54466), et uniquement sur activation explicite. Une connexion entre dans le flux SSH lorsque ClientHello envoie user = " SSH KEY AUTHENTICATION " + <real_user> (avec les espaces de début et de fin) et password = "". Le serveur lit le préfixe, le supprime pour retrouver l’utilisateur réel et bascule en mode challenge-réponse. Ce flux remplace l’authentification par mot de passe, et l’échange challenge-réponse a lieu avant ServerHello : le serveur diffère sa réponse Hello jusqu’à ce que l’authentification réussisse.
  1. Le client envoie ClientHello avec le préfixe marqueur SSH et un mot de passe vide.
  2. Le client envoie SSHChallengeRequest (paquet 11). Le serveur n’a pas encore envoyé ServerHello : il traite d’abord l’authentification et reste bloqué à cette étape en attendant ce paquet.
  3. Le serveur répond par un SSHChallenge contenant des octets aléatoires (paquet 18).
  4. Le client construit la string à signer et signe celle-ci, et non le challenge brut, puis envoie SSHChallengeResponse (paquet 12) avec la signature. Le message signé est la concaténation octet par octet, sans séparateur, de quatre parties dans cet ordre exact :
  5. Le serveur vérifie la signature à l’aide de la clé publique enregistrée pour l’utilisateur, en reconstruisant la même string decimal(protocol_version) + default_database + user + challenge. En cas de succès, il envoie ServerHello (la même réponse que dans le flux par mot de passe) et le handshake se poursuit normalement (Addendum, etc.) ; en cas d’échec, il renvoie une Exception et ferme la connexion. L’authentification échouera pour tout client qui ne signe que les octets bruts du challenge.
Il s’agit de l’inverse du handshake par mot de passe, dans lequel ServerHello suit immédiatement ClientHello. Avec l’authentification SSH auth, ServerHello n’est envoyé qu’une fois la signature vérifiée ; le challenge-réponse SSH s’intercale donc dans le handshake avant la réception de tout ServerHello.
Les clients externes qui n’utilisent pas SSH auth ne voient jamais les paquets 11, 12 ou 18 : ceux-ci ne transitent jamais sur le wire, sauf si l’utilisateur active explicitement ce mode via le préfixe du nom d’utilisateur.

MergeTreeAllRangesAnnouncementResponse (type de paquet 14)

Client → Serveur, interserveur uniquement. Conditionné par parallel_replicas_protocol_version ≥ 8 (voir VERSIONED_PARALLEL_REPLICAS_PROTOCOL). Les clients externes n’envoient jamais ce paquet. Lorsque la version de parallel-replicas négociée est ≥ 8, le cycle requête/réponse de l’initiateur pour un MergeTreeAllRangesAnnouncement envoyé par un follower (type de paquet 15, sens serveur→client) est modifié comme suit :
  1. Un follower ouvre son pipeline de lecture et envoie MergeTreeAllRangesAnnouncement à l’initiateur.
  2. Uniquement lorsque le mode de l’annonce est différent de Default (WithOrder = 1 ou ReverseOrder = 2, tous deux utilisés pour les lectures parallèles ordonnées), l’initiateur répond avec MergeTreeAllRangesAnnouncementResponse. Pour mode = Default = 0, l’initiateur ne répond pas et le follower n’attend pas : en mode Default, les plages sont distribuées avec chaque MergeTreeReadTaskRequest, si bien que la liste des parties de données n’est jamais nécessaire en amont.
  3. Le follower attend la réponse de manière bloquante (lorsqu’elle est attendue) avant d’émettre son premier MergeTreeReadTaskRequest (paquet serveur 16, envoyé follower→initiateur ; l’initiateur répond avec MergeTreeReadTaskResponse, paquet client 10), et s’appuie sur la liste des parties de données renvoyée pour restreindre la construction des sources aux seules parties de données dont son flux #split_i est propriétaire.
En dessous de la version 8, l’annonce fonctionne en mode fire-and-forget quel que soit le mode, et le follower construit des sources sur toutes les parties de données connues localement (comportement legacy).

Corps

Corps de RangesInDataPartsDescription

Corps RangesInDataPartDescription

Corps de MergeTreePartInfo

Corps MarkRanges

Référence des types de paquets

Client → Serveur

Serveur → Client

QueryPlan

QueryPlan est un paquet client réservé aux communications interserveurs. Il suit un paquet Query dont le stage vaut QueryPlan (7) et contient un unique flux de plan sérialisé. Avant la version 10, les champs 2 et 3 sont absents. Un initiateur dont l’une des limites d’exécution diffère de la valeur par défaut ne doit pas envoyer cette ancienne disposition : il transmet à la place la requête SQL d’origine, ce qui permet à l’ancien destinataire de reconstituer les limites à partir des paramètres de la requête.

Configuration

Cette section présente les paramètres ajustables qui régissent les connexions du protocole natif : Les valeurs par défaut indiquées ci-dessous correspondent à une version récente du serveur ; elles peuvent varier selon les versions et les déploiements.

Paramètres de la couche transport

Options de socket

Délais d’expiration

Les délais d’expiration s’imbriquent comme suit :
Le keepalive de l’OS se déclenche en premier et peut détecter silencieusement les pairs injoignables au niveau du kernel. Le délai d’expiration de réception (timeout) de l’application constitue la ligne de défense suivante. Enfin, le délai d’inactivité (idle timeout) intervient en dernier recours pour libérer les connexions restées longtemps inutilisées.

Limites de connexion

Une connexion qui envoie régulièrement des requêtes peut rester ouverte indéfiniment : seules les connexions inactives sont fermées au bout d’une heure, et aucune durée de vie maximale n’est définie par défaut.

Paramètres de la couche applicative

Ces paramètres sont transmis avec chaque requête dans la liste des paramètres du paquet Query. Ils modifient ce que le serveur envoie dans le format binaire, ou la façon dont ces données sont découpées en trames.

Compression

L’indicateur compression du paquet Query (champ 6) active ou désactive la compression ; ces paramètres déterminent quel codec est utilisé lorsqu’elle est activée.

Streaming des logs

Lorsque send_logs_level est défini sur une valeur autre que "none", le serveur émet des paquets Log pendant l’exécution de la requête.

Rapport de progression

Il s’agit d’un minimum visé, et non d’un maximum strict : le serveur peut envoyer des paquets Progress moins souvent lorsque la requête ne progresse pas assez vite.

Enveloppe de résultat

INSERT asynchrone

Traçage distribué

Paramètres hors périmètre

Ces paramètres sont parfois pris à tort pour des paramètres de niveau protocole, mais ils contrôlent l’exécution SQL, le stockage ou l’utilisation du CPU, et non le comportement wire. Une implémentation du protocole n’a pas besoin de les traiter de manière particulière.
  • max_threads — parallélisme lors de l’exécution des requêtes.
  • max_memory_usage — plafond de mémoire par requête.
  • max_block_size, preferred_block_size_bytes — dimensionnement interne des blocs lors du traitement des requêtes ; les blocs au format wire en sont indépendants.
  • compile_expressions — compilation JIT ; concerne uniquement le CPU.
  • async_insert_max_data_size — tampon de file d’attente côté serveur.
  • Tous les paramètres input_format_* et output_format_*, à l’exception de la famille input_format_native_* / output_format_native_* — les paramètres autres que native sélectionnent ou ajustent d’autres formats (par exemple via HTTP) et ne modifient pas les blocs Data du protocole natif.
Les paramètres *_native_* font exception : ils modifient les octets contenus dans les blocs Data transmis en TCP natif, et une implémentation du protocole doit donc les prendre en compte. output_format_native_encode_types_in_binary_format remplace, dans le champ type de la colonne, la string textuelle par un encodage binaire du type (sauf pour une requête secondaire, voir disposition wire des colonnes) ; output_format_native_write_json_as_string émet les colonnes JSON sous forme de String ; et output_format_native_use_flattened_dynamic_and_json_serialization sélectionne la disposition FLATTENED pour Dynamic/JSON. Comme ils portent sur le corps du bloc et non sur l’enveloppe du paquet, ils sont décrits dans la spécification du Native Format — voir disposition wire des colonnes et types versionnés.

Glossaire

Cancel — un paquet initié par le client (type 3) qui interrompt une requête en cours d’exécution. Non détaillé sur cette page. Marqueur de fin de données client — un paquet Data vide (0 colonne, 0 ligne) que le client envoie pour clore un flux d’entrée. Sa position varie selon le type de requête :
  • Requête normale (SELECT, etc.) : envoyé après le paquet Query et les éventuels paquets Data de tables externes pour signaler qu’il n’y a « plus de données externes ». Le serveur commence alors l’exécution.
  • INSERT : le client n’envoie pas de marqueur avant le schéma. Le serveur envoie d’abord le bloc de schéma, le client transmet ses blocs Data de lignes, et c’est seulement ensuite qu’il envoie le paquet Data vide pour terminer le flux de lignes. Un marqueur vide envoyé avant le bloc de schéma serait interprété comme une fin immédiate des lignes, et les données seraient perdues.
Feature — une modification du format wire introduite dans une version spécifique du protocole. Active lorsque la version négociée est supérieure ou égale à la version de la feature. Voir versionnement et feature gates. Interserveur — une étiquette de rôle désignant un champ qui n’a de sens que dans les requêtes distribuées entre serveurs. Les clients externes y écrivent une valeur par défaut (généralement une string vide, 0 ou false). Version négociée — min(client_version, server_version), calculée lors du handshake. Détermine quelles features sont actives pendant toute la durée de vie de la connexion. Paquet — un message wire : un code de type de paquet VarUInt suivi d’un corps dont le format dépend du type. Voir enveloppe de paquet. Code de type de paquet — le VarUInt placé en tête d’un paquet, qui identifie son format. Les valeurs 0 à 18 sont actuellement attribuées. Voir la référence des types de paquets. Flux de réponse — la séquence de paquets émis par le serveur pendant une requête. De longueur indéterminée, il se termine par exactement un EndOfStream (succès) ou un Exception (échec). Voir la phase de requête. Bloc de schéma — le bloc d’en-tête (un bloc avec des colonnes mais 0 ligne) que le serveur envoie pendant la phase INSERT pour annoncer la structure des colonnes attendue avant que le client n’envoie les données. Liste de paramètres — une séquence de tuples (key, flags, value) dans le corps de Query, terminée par une clé vide. Transporte la configuration de la couche applicative propre à chaque requête. Voir Setting. Stage — un champ VarUInt du paquet Query (champ 5) qui détermine jusqu’où le serveur exécute la requête. Les clients externes envoient généralement 2 (Complete) ; les requêtes distribuées et les plans de requête sérialisés utilisent les valeurs supérieures. Voir le champ 5 de Query pour l’ensemble complet des valeurs wire. Terminateur — un paquet qui met fin à un flux. La réponse à Query se termine par EndOfStream (succès) ou Exception (échec). Le flux d’entrée du client se termine par le marqueur Data vide.