Passer au contenu principal

Introduction

Ce guide se concentre sur les optimisations des performances les plus courantes et les plus efficaces pour ClickStack, suffisantes pour optimiser la majorité des charges de travail d’observabilité réelles, généralement jusqu’à plusieurs dizaines de téraoctets de données par jour. Les optimisations sont présentées dans un ordre réfléchi, en commençant par les techniques les plus simples et les plus efficaces, puis en progressant vers des réglages plus avancés et plus spécialisés. Les premières optimisations doivent être appliquées en priorité et apportent souvent, à elles seules, des gains substantiels. À mesure que les volumes de données augmentent et que les charges de travail deviennent plus exigeantes, les techniques suivantes deviennent de plus en plus intéressantes à explorer.

Concepts ClickHouse

Avant d’appliquer l’une des optimisations décrites dans ce guide, il est important de bien connaître quelques concepts de base de ClickHouse. Dans ClickStack, chaque source de données correspond directement à une ou plusieurs tables ClickHouse. Lorsque vous utilisez OpenTelemetry, ClickStack crée et gère un ensemble de tables par défaut qui stockent les logs, les traces et les métriques. Si vous utilisez des schémas personnalisés ou gérez vos propres tables, ces concepts vous sont peut-être déjà familiers. En revanche, si vous envoyez simplement des données via l’OpenTelemetry Collector, ces tables sont créées automatiquement, et c’est sur elles que s’appliqueront toutes les optimisations décrites ci-dessous.
Type de donnéesTable
Logsotel_logs
Tracesotel_traces
Métriques (jauges)otel_metrics_gauge
Métriques (sommes)otel_metrics_sum
Métriques (histogramme)otel_metrics_histogram
Métriques (histogrammes exponentiels)otel_metrics_exponentialhistogram
Métriques (résumé)otel_metrics_summary
Sessionshyperdx_sessions
Dans ClickHouse, les tables sont rattachées à des bases de données. Par défaut, la base de données default est utilisée ; cela peut être modifié dans l’OpenTelemetry collector.
Concentrez-vous sur les logs et les tracesDans la plupart des cas, l’optimisation des performances se concentre sur les tables de logs et de traces. Bien que les tables de métriques puissent être optimisées pour le filtrage, leurs schémas sont volontairement prescriptifs pour les charges de travail de type Prometheus et ne nécessitent généralement pas de modification pour les graphiques standard. Les logs et les traces, en revanche, prennent en charge un éventail plus large de modèles d’accès et bénéficient donc le plus de l’optimisation. Les données de session correspondent à une expérience utilisateur fixe, et leur schéma a rarement besoin d’être modifié.
Vous devez au minimum comprendre les principes fondamentaux suivants de ClickHouse :
ConceptDescription
TablesComment les sources de données dans ClickStack correspondent aux tables ClickHouse sous-jacentes. Dans ClickHouse, les tables utilisent principalement le moteur MergeTree.
PartsComment les données sont écrites sous forme de parts immuables, puis fusionnées au fil du temps.
PartitionsLes partitions regroupent les parts de données d’une table en unités logiques organisées. Ces unités sont plus faciles à gérer, à interroger et à optimiser.
MergesProcessus interne de fusion des parts afin de réduire le nombre de parts à interroger. Indispensable au maintien des performances des requêtes.
GranulesLa plus petite unité de données que ClickHouse lit et écarte lors de l’exécution d’une requête.
Primary (ordering) keysComment la clé ORDER BY détermine l’organisation des données sur disque, la compression et l’élagage des requêtes.
Ces concepts sont au cœur des performances de ClickHouse. Ils déterminent la manière dont les données sont écrites, structurées sur disque et la capacité de ClickHouse à éviter de lire des données au moment de l’exécution des requêtes. Toutes les optimisations présentées dans ce guide, qu’il s’agisse de colonnes matérialisées, de skip indexes, de clés primaires, de projections ou de vues matérialisées, reposent sur ces mécanismes fondamentaux. Nous vous recommandons de consulter la documentation ClickHouse suivante avant d’entreprendre tout travail d’optimisation : Toutes les optimisations décrites ci-dessous peuvent être appliquées directement aux tables sous-jacentes avec le langage ClickHouse SQL standard, soit via la console SQL de ClickHouse Cloud, soit via le client ClickHouse.

Optimisation 1. Matérialiser les attributs fréquemment interrogés

La première optimisation, et la plus simple, pour les utilisateurs de ClickStack consiste à identifier les attributs fréquemment interrogés dans LogAttributes, ScopeAttributes et ResourceAttributes, puis à les convertir en colonnes de premier niveau à l’aide de colonnes matérialisées. À elle seule, cette optimisation suffit souvent à faire évoluer des déploiements ClickStack jusqu’à plusieurs dizaines de téraoctets par jour et doit être appliquée avant d’envisager des techniques d’optimisation plus avancées.

Pourquoi matérialiser les attributs

ClickStack stocke des métadonnées telles que les labels Kubernetes, les métadonnées de service et les attributs personnalisés dans des colonnes Map(String, String). Bien que cela offre de la souplesse, interroger des sous-clés d’une map a une incidence importante sur les performances. Lorsqu’une seule clé est interrogée dans une colonne Map, ClickHouse doit lire toute la colonne map depuis le disque. Si la map contient de nombreuses clés, cela entraîne des E/S inutiles et des requêtes plus lentes que la lecture d’une colonne dédiée. Matérialiser les attributs fréquemment consultés permet d’éviter cette surcharge en extrayant la valeur au moment de l’insertion et en la stockant dans une colonne à part entière. Colonnes matérialisées :
  • Sont calculées automatiquement lors des insertions
  • Ne peuvent pas être définies explicitement dans des instructions INSERT
  • Prennent en charge toute expression ClickHouse
  • Permettent de convertir String en types numériques ou Date plus efficaces
  • Permettent d’utiliser des skip indexes et la clé primaire
  • Réduisent les lectures sur disque en évitant l’accès à l’intégralité de la map
ClickStack détecte automatiquement les colonnes matérialisées extraites de maps et les utilise de manière transparente lors de l’exécution des requêtes, même si les utilisateurs continuent d’interroger le chemin d’attribut d’origine.

Exemple

Prenons le schéma ClickStack par défaut pour les traces, dans lequel les métadonnées Kubernetes sont stockées dans ResourceAttributes :
CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_traces
(
    `Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
    `TraceId` String CODEC(ZSTD(1)),
    `SpanId` String CODEC(ZSTD(1)),
    `ParentSpanId` String CODEC(ZSTD(1)),
    `TraceState` String CODEC(ZSTD(1)),
    `SpanName` LowCardinality(String) CODEC(ZSTD(1)),
    `SpanKind` LowCardinality(String) CODEC(ZSTD(1)),
    `ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
    `ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    `ScopeName` String CODEC(ZSTD(1)),
    `ScopeVersion` String CODEC(ZSTD(1)),
    `SpanAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    `Duration` UInt64 CODEC(ZSTD(1)),
    `StatusCode` LowCardinality(String) CODEC(ZSTD(1)),
    `StatusMessage` String CODEC(ZSTD(1)),
    `Events.Timestamp` Array(DateTime64(9)) CODEC(ZSTD(1)),
    `Events.Name` Array(LowCardinality(String)) CODEC(ZSTD(1)),
    `Events.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
    `Links.TraceId` Array(String) CODEC(ZSTD(1)),
    `Links.SpanId` Array(String) CODEC(ZSTD(1)),
    `Links.TraceState` Array(String) CODEC(ZSTD(1)),
    `Links.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
    `__hdx_materialized_rum.sessionId` String MATERIALIZED ResourceAttributes['rum.sessionId'] CODEC(ZSTD(1)),
    `SampleRate` UInt64 MATERIALIZED greatest(toUInt64OrZero(SpanAttributes['SampleRate']), 1) CODEC(T64, ZSTD(1)),
    `ResourceAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), ResourceAttributes::Array(Tuple(String, String))),
    `SpanAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), SpanAttributes::Array(Tuple(String, String))),
    INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
    INDEX idx_rum_session_id __hdx_materialized_rum.sessionId TYPE bloom_filter(0.001) GRANULARITY 1,
    INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array'),
    INDEX idx_span_attr_key mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_span_attr_items SpanAttributeItems TYPE text(tokenizer = 'array'),
    INDEX idx_duration Duration TYPE minmax GRANULARITY 1,
    INDEX idx_lower_span_name lower(SpanName) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
TTL toDate(Timestamp) + ${TABLES_TTL}
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
Un utilisateur peut filtrer les traces à l’aide de la syntaxe Lucene, par exemple ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c" : Cela produit un prédicat SQL similaire à :
ResourceAttributes['k8s.pod.name'] = 'checkout-675775c4cc-f2p9c'
Comme cela accède à une clé d’une Map, ClickHouse doit lire l’intégralité de la colonne ResourceAttributes pour chaque ligne correspondante, ce qui peut être très volumineux si cette Map contient de nombreuses clés. Si cet attribut est fréquemment utilisé dans les requêtes, il doit être matérialisé en tant que colonne de premier niveau. Pour extraire le nom du pod au moment de l’insertion, ajoutez une colonne matérialisée :
ALTER TABLE otel_v2.otel_traces
ADD COLUMN PodName String
MATERIALIZED ResourceAttributes['k8s.pod.name']
Désormais, les nouvelles données stockeront le nom du pod dans une colonne dédiée, PodName. Les utilisateurs peuvent désormais interroger efficacement les noms de pod à l’aide de la syntaxe Lucene, par exemple PodName:"checkout-675775c4cc-f2p9c" Pour les données nouvellement insérées, cela évite complètement l’accès à la structure Map et réduit considérablement les E/S. Cependant, même si les utilisateurs continuent d’interroger le chemin d’attribut d’origine, par exemple ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c", ClickStack réécrira automatiquement la requête en interne pour utiliser la colonne matérialisée PodName, c.-à-d. en utilisant le prédicat :
PodName = 'checkout-675775c4cc-f2p9c'
Cela permet aux utilisateurs de profiter de l’optimisation sans modifier les tableaux de bord, les alertes ni les requêtes enregistrées.
Par défaut, les colonnes matérialisées sont exclues des requêtes SELECT *. Cela préserve le principe selon lequel les résultats des requêtes peuvent toujours être réinsérés dans la table.

Matérialisation des données historiques

Les colonnes matérialisées ne s’appliquent automatiquement qu’aux données insérées après la création de la colonne. Pour les données existantes, les requêtes sur la colonne matérialisée liront de manière transparente les données depuis le Map d’origine. Si les performances sur les données historiques sont cruciales, la colonne peut être renseignée a posteriori à l’aide d’une mutation, par exemple.
ALTER TABLE otel_v2.otel_traces
MATERIALIZE COLUMN PodName
Cela réécrit les parts existantes pour remplir la colonne. Les mutations s’exécutent sur un seul thread par part et peuvent prendre du temps sur de grands jeux de données. Pour limiter leur impact, les mutations peuvent être limitées à une partition spécifique :
ALTER TABLE otel_v2.otel_traces
MATERIALIZE COLUMN PodName
IN PARTITION '2026-01-02'
La progression des mutations peut être suivie à l’aide de la table system.mutations, par exemple.
SELECT *
FROM system.mutations
WHERE database = 'otel'
  AND table = 'otel_traces'
ORDER BY create_time DESC;
Attendez que is_done = 1 pour la mutation concernée.
Les mutations entraînent une surcharge supplémentaire en IO et en CPU et doivent être utilisées avec parcimonie. Dans bien des cas, il suffit de laisser les anciennes données expirer naturellement et de profiter des améliorations de performances pour les données nouvellement ingérées.

Optimisation 2. Ajout d’index de saut

Après avoir matérialisé les attributs fréquemment interrogés, l’optimisation suivante consiste à ajouter des index de saut de données afin de réduire encore la quantité de données que ClickHouse doit lire lors de l’exécution des requêtes. Les index de saut permettent à ClickHouse d’éviter de parcourir des blocs de données entiers lorsqu’il peut déterminer qu’aucune valeur correspondante n’existe. Contrairement aux index secondaires traditionnels, les index de saut fonctionnent au niveau des granules et sont particulièrement efficaces lorsque les filtres de requête excluent de larges portions du jeu de données. Correctement utilisés, ils peuvent accélérer considérablement le filtrage sur des attributs à forte cardinalité sans modifier la sémantique des requêtes. Prenons le schéma de traces par défaut de ClickStack, qui inclut des index de saut :
CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_traces
(
    `Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
    `TraceId` String CODEC(ZSTD(1)),
    `SpanId` String CODEC(ZSTD(1)),
    `ParentSpanId` String CODEC(ZSTD(1)),
    `TraceState` String CODEC(ZSTD(1)),
    `SpanName` LowCardinality(String) CODEC(ZSTD(1)),
    `SpanKind` LowCardinality(String) CODEC(ZSTD(1)),
    `ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
    `ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    `ScopeName` String CODEC(ZSTD(1)),
    `ScopeVersion` String CODEC(ZSTD(1)),
    `SpanAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    `Duration` UInt64 CODEC(ZSTD(1)),
    `StatusCode` LowCardinality(String) CODEC(ZSTD(1)),
    `StatusMessage` String CODEC(ZSTD(1)),
    `Events.Timestamp` Array(DateTime64(9)) CODEC(ZSTD(1)),
    `Events.Name` Array(LowCardinality(String)) CODEC(ZSTD(1)),
    `Events.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
    `Links.TraceId` Array(String) CODEC(ZSTD(1)),
    `Links.SpanId` Array(String) CODEC(ZSTD(1)),
    `Links.TraceState` Array(String) CODEC(ZSTD(1)),
    `Links.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
    `__hdx_materialized_rum.sessionId` String MATERIALIZED ResourceAttributes['rum.sessionId'] CODEC(ZSTD(1)),
    `SampleRate` UInt64 MATERIALIZED greatest(toUInt64OrZero(SpanAttributes['SampleRate']), 1) CODEC(T64, ZSTD(1)),
    `ResourceAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), ResourceAttributes::Array(Tuple(String, String))),
    `SpanAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), SpanAttributes::Array(Tuple(String, String))),
    INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
    INDEX idx_rum_session_id __hdx_materialized_rum.sessionId TYPE bloom_filter(0.001) GRANULARITY 1,
    INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array'),
    INDEX idx_span_attr_key mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_span_attr_items SpanAttributeItems TYPE text(tokenizer = 'array'),
    INDEX idx_duration Duration TYPE minmax GRANULARITY 1,
    INDEX idx_lower_span_name lower(SpanName) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
TTL toDate(Timestamp) + ${TABLES_TTL}
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
Ces index ciblent trois cas courants :
  • Le filtrage de chaînes à forte cardinalité, comme TraceId, les identifiants de session, les clés d’attribut ou les valeurs
  • Le filtrage sur des sous-clés de Map, accéléré par des index de texte sur les colonnes *AttributeItems
  • Le filtrage sur des plages numériques, comme la durée des spans
La table des logs utilise partout des index text(tokenizer = 'array') au lieu de bloom filters, et ajoute un index text(tokenizer = 'splitByNonAlpha') sur lower(Body) pour la recherche en texte intégral. Voir “Tables and schemas used by ClickStack” pour le DDL complet.

Filtres de Bloom

Les index de filtre de Bloom sont le type d’index de saut le plus couramment utilisé dans ClickStack. Ils sont bien adaptés aux colonnes de type chaîne à cardinalité élevée, généralement avec au moins plusieurs dizaines de milliers de valeurs distinctes. Un taux de faux positifs de 0,01 avec une granularité de 1 constitue un bon point de départ et offre un bon équilibre entre le surcoût de stockage et un élagage efficace. En reprenant l’exemple de l’optimisation 1, supposons que le nom du pod Kubernetes ait été matérialisé à partir de ResourceAttributes :
ALTER TABLE otel_traces
ADD COLUMN PodName String
MATERIALIZED ResourceAttributes['k8s.pod.name']
Un index de saut filtre de Bloom peut alors être ajouté pour accélérer le filtrage sur cette colonne :
ALTER TABLE otel_traces
ADD INDEX idx_pod_name PodName
TYPE bloom_filter(0.01)
GRANULARITY 1
Une fois ajouté, l’index de saut doit être matérialisé - voir “Matérialiser l’index de saut.” Une fois créé et matérialisé, ClickHouse peut ignorer des granules entières dont il est certain qu’elles ne contiennent pas le nom du pod demandé, ce qui peut réduire la quantité de données lues lors de requêtes telles que PodName:"checkout-675775c4cc-f2p9c". Les filtres de Bloom sont les plus efficaces lorsque la répartition des valeurs fait qu’une valeur donnée apparaît dans un nombre relativement faible de parties. C’est souvent naturellement le cas dans les charges de travail d’observabilité, où des métadonnées comme les noms de pod, les trace IDs ou les identifiants de session sont corrélés au temps et donc regroupés selon la clé de tri de la table. Comme pour tous les index de saut, les filtres de Bloom doivent être ajoutés de manière sélective et validés sur des modèles de requêtes réels afin de s’assurer qu’ils apportent un bénéfice mesurable - voir “Évaluer l’efficacité des index de saut.”

Index de texte

Les index de texte offrent une alternative aux filtres de Bloom. Un filtre de Bloom est une structure probabiliste capable d’exclure définitivement des granules, mais il présente un taux de faux positifs, si bien que les granules qu’il n’exclut pas doivent tout de même être chargées et évaluées par rapport à la condition WHERE. Les index de texte sont des index inversés qui associent des tokens à des offsets exacts dans une part. Comme ils évaluent les offsets plutôt que les granules et ne produisent aucun faux positif, ils peuvent généralement répondre à la condition WHERE sans charger la colonne sous-jacente. Cette optimisation est appelée direct read. Comme le chargement des données est souvent le principal facteur du temps de requête, direct read peut réduire sensiblement la latence des requêtes. De plus, les index de texte peuvent eux-mêmes être interrogés, ce qui alimente l’autocomplétion et d’autres mécanismes d’introspection dans ClickStack. Deux tokenizers couvrent la plupart des cas d’usage de ClickStack :
TokenizerUtilisé pourColonne typique
arrayIndexation des éléments Array(String) comme tokens completsmapKeys(...), *AttributeItems
splitByNonAlphaRecherche en texte intégral au niveau des mots dans des chaînes de texte libreBody, lower(Body), SpanName

Tokenizer array pour les colonnes Map et Array

Le schéma de logs par défaut indexe mapKeys et les tableaux d’éléments matérialisés avec le tokenizer array :
INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE text(tokenizer = 'array'),
INDEX idx_log_attr_items LogAttributeItems TYPE text(tokenizer = 'array')
Chaque clé de Map (ou élément de tableau) devient un seul jeton. Un filtrage sur une clé d’attribut connue permet alors d’écarter toute ligne qui ne la contient pas, sans parcourir la colonne Map correspondante. C’est ce mécanisme qui rend l’optimisation de lecture directe de Map intéressante.

splitByNonAlpha pour le corps des logs

La recherche en texte intégral sur la colonne Body bénéficie d’un index textuel splitByNonAlpha. ClickStack définit cet index sur lower(Body) afin que les recherches Lucene insensibles à la casse puissent l’utiliser :
INDEX idx_lower_body lower(Body) TYPE text(tokenizer = 'splitByNonAlpha')
Lorsque ClickStack détecte un index text(tokenizer = 'splitByNonAlpha') sur lower(Body), il réécrit les requêtes Lucene à colonne implicite, comme error ou "connection refused", en hasAllTokens(lower(Body), lower(...)), que l’index peut satisfaire sans lire l’intégralité de la colonne Body. Pour la plupart des charges de travail de logs d’observabilité, il s’agit du gain de vitesse de filtrage le plus important disponible.
Index de texte vs tokenbf_v1L’ancien type d’index tokenbf_v1 (toujours utilisé dans le schéma de traces par défaut pour lower(SpanName)) est fonctionnellement similaire, mais est obsolète dans ClickHouse 26.2 et les versions ultérieures. Les nouveaux index de recherche en texte intégral doivent utiliser text(tokenizer = ...).
Pour une référence plus détaillée sur les options de tokenizer, les préprocesseurs et la vérification, consultez la documentation sur la recherche en texte intégral.

Index de texte dans le schéma de logs par défaut

Le schéma otel_logs par défaut, synchronisé depuis l’amont, inclut tous les index de texte mentionnés ci-dessus : text(tokenizer = 'array') sur TraceId, sur chaque mapKeys(...) et tableau *AttributeItems, ainsi que text(tokenizer = 'splitByNonAlpha') sur lower(Body) pour la recherche en texte intégral. Pour le DDL canonique, voir “Tables et schémas utilisés par ClickStack” ; le même schéma est reproduit ci-dessous.
CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_logs
(
  `Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
  `TraceId` String CODEC(ZSTD(1)),
  `SpanId` String CODEC(ZSTD(1)),
  `TraceFlags` UInt8,
  `SeverityText` LowCardinality(String) CODEC(ZSTD(1)),
  `SeverityNumber` UInt8,
  `ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
  `Body` String CODEC(ZSTD(1)),
  `ResourceSchemaUrl` LowCardinality(String) CODEC(ZSTD(1)),
  `ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
  `ScopeSchemaUrl` LowCardinality(String) CODEC(ZSTD(1)),
  `ScopeName` String CODEC(ZSTD(1)),
  `ScopeVersion` LowCardinality(String) CODEC(ZSTD(1)),
  `ScopeAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
  `LogAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
  `EventName` String CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.cluster.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.cluster.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.container.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.container.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.deployment.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.deployment.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.namespace.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.namespace.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.node.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.node.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.pod.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.pod.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.pod.uid` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.pod.uid'] CODEC(ZSTD(1)),
  `__hdx_materialized_deployment.environment.name` LowCardinality(String) MATERIALIZED ResourceAttributes['deployment.environment.name'] CODEC(ZSTD(1)),
  `ResourceAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), ResourceAttributes::Array(Tuple(String, String))),
  `ScopeAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), ScopeAttributes::Array(Tuple(String, String))),
  `LogAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), LogAttributes::Array(Tuple(String, String))),
  INDEX idx_trace_id TraceId TYPE text(tokenizer = 'array'),
  INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE text(tokenizer = 'array'),
  INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array'),
  INDEX idx_scope_attr_key mapKeys(ScopeAttributes) TYPE text(tokenizer = 'array'),
  INDEX idx_scope_attr_items ScopeAttributeItems TYPE text(tokenizer = 'array'),
  INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE text(tokenizer = 'array'),
  INDEX idx_log_attr_items LogAttributeItems TYPE text(tokenizer = 'array'),
  INDEX idx_lower_body lower(Body) TYPE text(tokenizer = 'splitByNonAlpha')
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (toStartOfFiveMinutes(Timestamp), ServiceName, Timestamp)
TTL toDateTime(Timestamp) + ${TABLES_TTL}
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1, enable_block_number_column = 1, enable_block_offset_column = 1;

Index min-max

Les index min-max stockent la valeur minimale et maximale par granule et sont extrêmement légers. Ils sont particulièrement efficaces pour les colonnes numériques et les requêtes sur des intervalles. Même s’ils n’accélèrent pas forcément toutes les requêtes, leur coût est faible et il vaut presque toujours la peine de les ajouter pour les colonnes numériques. Les index min-max fonctionnent le mieux lorsque les valeurs numériques sont soit naturellement ordonnées, soit limitées à des intervalles étroits dans chaque part. Supposons qu’un offset Kafka soit fréquemment interrogé dans SpanAttributes :
SpanAttributes['messaging.kafka.offset']
Cette valeur peut être matérialisée et convertie en un type numérique :
ALTER TABLE otel_traces
ADD COLUMN KafkaOffset UInt64
MATERIALIZED toUInt64(SpanAttributes['messaging.kafka.offset'])
Un index MinMax peut alors être ajouté :
ALTER TABLE otel_traces
ADD INDEX idx_kafka_offset KafkaOffset TYPE minmax GRANULARITY 1
Cela permet à ClickHouse d’ignorer efficacement des parts de données lors du filtrage sur des plages d’offsets Kafka, par exemple pour le débogage du consumer lag ou du comportement de rejeu. Là encore, l’index doit être matérialisé avant d’être disponible.

Matérialiser un index de saut

Après l’ajout d’un index de saut, celui-ci ne s’applique qu’aux données nouvellement ingérées. Les données historiques ne bénéficieront de l’index qu’une fois celui-ci explicitement matérialisé. Si vous avez déjà ajouté un index de saut, par exemple :
ALTER TABLE otel_traces ADD INDEX idx_kafka_offset KafkaOffset TYPE minmax GRANULARITY 1;
Vous devez créer explicitement l’index pour les données déjà présentes :
ALTER TABLE otel_traces MATERIALIZE INDEX idx_kafka_offset;
Matérialisation des index de sautLa matérialisation d’un index de saut est généralement peu coûteuse et peut être exécutée en toute sécurité, en particulier pour les index minmax. Pour les index filtre de Bloom sur de gros volumes de données, les utilisateurs peuvent préférer matérialiser partition par partition afin de mieux maîtriser l’utilisation des ressources, par exemple.
ALTER TABLE otel_v2.otel_traces
MATERIALIZE INDEX idx_kafka_offset
IN PARTITION '2026-01-02';
La matérialisation d’un index de saut s’exécute comme une mutation. Sa progression peut être surveillée à l’aide des tables système.

SELECT *
FROM system.mutations
WHERE database = 'otel'
  AND table = 'otel_traces'
ORDER BY create_time DESC;
Attendez que la valeur is_done soit égale à 1 pour la mutation correspondante. Une fois l’opération terminée, vérifiez que les données de l’index ont bien été créées :
SELECT database, table, name,
       data_compressed_bytes,
       data_uncompressed_bytes,
       marks_bytes
FROM system.data_skipping_indices
WHERE database = 'otel'
  AND table = 'otel_traces'
  AND name = 'idx_kafka_offset';
Des valeurs non nulles indiquent que l’index a bien été matérialisé. Il est important de noter que la taille de l’index de saut affecte directement les performances des requêtes. Des index de saut très volumineux, de l’ordre de dizaines ou de centaines de gigaoctets, peuvent prendre un temps non négligeable à être évalués pendant l’exécution des requêtes, ce qui peut réduire, voire annuler, leur bénéfice. En pratique, les indexes MinMax sont généralement très petits et peu coûteux à évaluer, ce qui les rend presque toujours sans risque à matérialiser. Les indexes filtre de Bloom, en revanche, peuvent grossir considérablement selon la cardinalité, la granularité et la probabilité de faux positifs. La taille d’un filtre de Bloom peut être réduite en augmentant le taux de faux positifs autorisé. Par exemple, faire passer le paramètre de probabilité de 0.01 à 0.05 produit un index plus petit, évalué plus rapidement, au prix d’un élagage moins agressif. Même si moins de granules peuvent être ignorés, la latence globale des requêtes peut s’améliorer grâce à une évaluation plus rapide de l’index. Le réglage des paramètres du filtre de Bloom est donc une optimisation qui dépend de la charge de travail et doit être validée à l’aide de query patterns réels et de volumes de données proches de ceux de la production. Pour plus de détails sur les index de saut, consultez le guide “Comprendre les data skipping indexes dans ClickHouse.”

Évaluer l’efficacité des index de saut

Le moyen le plus fiable d’évaluer l’élagage des index de saut consiste à utiliser EXPLAIN indexes = 1, qui indique combien de parts et de granules sont éliminées à chaque étape du plan de requête. Dans la plupart des cas, on cherche à observer une forte réduction du nombre de granules à l’étape Skip, idéalement après que la clé primaire a déjà réduit l’espace de recherche. Les index de saut sont évalués après le partition élagage et l’élagage de la clé primaire ; leur impact se mesure donc le mieux par rapport aux parts et granules restants. EXPLAIN confirme si l’élagage a bien lieu, mais ne garantit pas pour autant un gain net de performances. L’évaluation des index de saut a un coût, surtout si l’index est volumineux. Comparez toujours les performances des requêtes avant et après l’ajout et la matérialisation d’un index afin de confirmer un réel gain de performances. Par exemple, prenons l’index de saut filtre de Bloom par défaut pour TraceId inclus dans le schéma Traces par défaut :
INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1
Vous pouvez utiliser EXPLAIN indexes = 1 pour voir son efficacité sur une requête sélective :
EXPLAIN indexes = 1
SELECT *
FROM otel_v2.otel_traces
WHERE (ServiceName = 'accounting')
  AND (TraceId = 'aeea7f401feb75fc5af8eb25ebc8e974');

ReadFromMergeTree (otel_v2.otel_traces)
Indexes:
  PrimaryKey
    Keys:
      ServiceName
    Parts: 6/18
    Granules: 255/35898
  Skip
    Name: idx_trace_id
    Description: bloom_filter GRANULARITY 1
    Parts: 1/6
    Granules: 1/255
Dans ce cas, le filtre sur la clé primaire réduit d’abord fortement le jeu de données (de 35 898 granules à 255), puis le filtre de Bloom affine encore cette sélection jusqu’à une seule granule (1/255). C’est le schéma idéal pour les index de saut : l’élagage par la clé primaire restreint la recherche, puis l’index de saut élimine l’essentiel de ce qui reste. Pour valider l’impact réel, exécutez un benchmark de la requête avec des paramètres constants et comparez le temps d’exécution. Utilisez FORMAT Null pour éviter la surcharge liée à la sérialisation des résultats, et désactivez le cache des conditions de requête afin de garantir la reproductibilité des exécutions :
SELECT *
FROM otel_traces
WHERE (ServiceName = 'accountingservice') AND (TraceId = '4512e822ca3c0c68bbf5d4a263f9943d')
SETTINGS use_query_condition_cache = 0
2 rows in set. Elapsed: 0.025 sec. Processed 8.52 thousand rows, 299.78 KB (341.22 thousand rows/s., 12.00 MB/s.)
Peak memory usage: 41.97 MiB.
Exécutez maintenant la même requête en désactivant les index de saut :
SELECT *
FROM otel_traces
WHERE (ServiceName = 'accountingservice') AND (TraceId = '4512e822ca3c0c68bbf5d4a263f9943d')
FORMAT Null
SETTINGS use_query_condition_cache = 0, use_skip_indexes = 0;
0 rows in set. Elapsed: 0.702 sec. Processed 1.62 million rows, 56.62 MB (2.31 million rows/s., 80.71 MB/s.)
Peak memory usage: 198.39 MiB.
La désactivation de use_query_condition_cache garantit que les résultats ne sont pas influencés par des décisions de filtrage mises en cache, et le paramètre use_skip_indexes = 0 établit une base de référence claire pour la comparaison. Si l’élagage est efficace et que le coût d’évaluation de l’index est faible, la requête avec index devrait être sensiblement plus rapide, comme dans l’exemple ci-dessus.
Si EXPLAIN montre un élagage minimal des granules, ou si l’index de saut est très volumineux, le coût d’évaluation de l’index peut annuler tout bénéfice. Utilisez EXPLAIN indexes = 1 pour confirmer l’élagage, puis effectuez un benchmark pour vérifier les améliorations de performances de bout en bout.

Quand ajouter des skip indexes

Les skip indexes doivent être ajoutés avec discernement, en fonction des types de filtres que les utilisateurs appliquent le plus souvent et de la répartition des données dans les parts et les granules. L’objectif est d’écarter suffisamment de granules pour compenser le coût d’évaluation de l’index lui-même, d’où l’importance d’effectuer des benchmarks sur des données représentatives d’un environnement de production. Pour les colonnes numériques utilisées dans des filtres, un skip index minmax est presque toujours un bon choix. Il est léger, peu coûteux à évaluer et peut être efficace pour les prédicats de plage, en particulier lorsque les valeurs sont faiblement ordonnées ou limitées à des plages étroites au sein des parts. Même lorsque minmax n’apporte rien à un query pattern donné, sa surcharge reste généralement assez faible pour justifier de le conserver. Pour les colonnes de type chaîne, privilégiez les index textuels lorsqu’ils sont pris en charge ; sinon, rabattez-vous sur les Bloom filters. Les index textuels accélèrent les mêmes filtres d’égalité et IN que les Bloom filters, et permettent en plus les prédicats basés sur des tokens (hasToken, hasAllTokens, has) utilisés par la recherche en texte intégral et l’optimisation Map direct read. Sur les clusters plus anciens qui ne prennent pas encore en charge les index textuels, les Bloom filters restent un choix solide. Les Bloom filters sont particulièrement efficaces pour les colonnes de type chaîne à forte cardinalité, où chaque valeur apparaît relativement peu souvent, ce qui signifie que la plupart des parts et des granules ne contiennent pas la valeur recherchée. En règle générale, les Bloom filters sont surtout prometteurs lorsque la colonne contient au moins 10 000 valeurs distinctes, et donnent souvent les meilleurs résultats à partir de 100 000 valeurs distinctes. Ils sont également plus efficaces lorsque les valeurs correspondantes sont regroupées dans un petit nombre de parts séquentielles, ce qui se produit généralement lorsque la colonne est corrélée à la clé de tri. Là encore, cela dépend fortement des cas : rien ne remplace des tests en conditions réelles.

Optimisation 3. Map direct read

Lorsque vous filtrez sur une sous-clé d’une Map, par exemple LogAttributes['k8s.pod.name'] = 'checkout', ClickHouse doit lire l’intégralité de la colonne Map LogAttributes depuis le disque et décompacter chaque ligne pour évaluer le prédicat. Matérialiser les attributs fréquemment interrogés résout ce problème pour les clés connues à l’avance, mais cette approche ne passe pas à l’échelle pour des attributs arbitraires sur lesquels les utilisateurs filtrent à la volée. Même si un schéma possède des index sur mapKeys et mapValues, ces index peuvent indiquer si une ligne contient une clé donnée, et si elle contient une valeur donnée, mais pas si la clé et la valeur appartiennent à la même entrée. Autrement dit, mapKeys répond à mapContainsKey(ResourceAttributes, 'foo') et mapValues répond à mapContainsValue(ResourceAttributes, 'bar'), mais aucun des deux ne répond à ResourceAttributes['foo'] = 'bar'. En concaténant les clés et les valeurs dans une seule colonne Array(String), l’optimisation Map direct read permet de répondre à ResourceAttributes['foo'] = 'bar' sans charger la map sous-jacente. Les maps sont souvent volumineuses et grossissent avec l’augmentation du volume. Combinés à une réécriture de requête au niveau de l’application, les filtres d’égalité sur n’importe quelle sous-clé de Map deviennent un simple appel has(...) s’appuyant sur cet index, sans désérialisation de Map au moment de la requête. De plus, le seul coût de stockage est celui du text index, car la colonne sous-jacente est une colonne ALIAS et n’est pas stockée. Cette optimisation est automatique. ClickStack fournit les colonnes et index nécessaires dans les tables de logs et de traces par défaut, et réécrit les filtres d’accès aux sous-clés de Map à l’exécution lorsque le ClickHouse server connecté prend en charge la primitive sous-jacente. Si votre schéma ne contient pas ces colonnes, ou si vous avez des colonnes Map supplémentaires que vous souhaitez accélérer au-delà de celles fournies par défaut, poursuivez votre lecture pour les activer.

Schéma

Pour chaque colonne Map que vous souhaitez accélérer, ClickStack définit une colonne Array(String) ALIAS qui concatène chaque clé et sa valeur avec = :
ALTER TABLE otel_logs
ADD COLUMN LogAttributeItems Array(String)
ALIAS arrayMap(
  (arr) -> concat(arr.1, '=', arr.2),
  LogAttributes::Array(Tuple(String, String))
)
La forme ALIAS signifie que le tableau n’ajoute aucun octet sur le disque. ClickHouse le calcule à l’exécution des requêtes et lors de la création de l’index. Un skip index text(tokenizer = 'array') sur la colonne ALIAS stocke un token par paire key=value, que ClickHouse utilise pour ignorer des granules sans toucher à la Map source :
ALTER TABLE otel_logs
ADD INDEX idx_log_attr_items LogAttributeItems
TYPE text(tokenizer = 'array')
Après avoir créé l’index sur une table existante, matérialisez-le afin que les données historiques puissent en bénéficier (voir “Matérialiser le skip index”). Les schémas ClickStack par défaut comprennent ces colonnes et index :
Tablecolonnes ALIASindex textuels
otel_logsResourceAttributeItems, ScopeAttributeItems, LogAttributeItemsidx_res_attr_items, idx_scope_attr_items, idx_log_attr_items
otel_tracesResourceAttributeItems, SpanAttributeItemsidx_res_attr_items, idx_span_attr_items

Réécriture de requête

Lorsqu’un utilisateur filtre sur une sous-clé de Map dans la ClickStack UI ou via le SDK, ClickStack réécrit :
LogAttributes['k8s.pod.name'] = 'checkout'
dans :
has(LogAttributeItems, concat('k8s.pod.name', '=', 'checkout'))
La forme réécrite exploite l’index de texte sur LogAttributeItems, élimine des lignes entières qui ne contiennent pas le jeton key=value, et ne désérialise jamais la Map LogAttributes d’origine pour les lignes non correspondantes. Pour les workloads d’observabilité à forte cardinalité, cela permet généralement de réduire d’un ordre de grandeur les E/S par rapport à l’accès à la Map par indice. La réécriture s’effectue automatiquement — les requêtes enregistrées, les tableaux de bord et les alertes qui font référence à LogAttributes['key'] bénéficient de cette accélération sans aucune modification.

Exigences de version de ClickHouse

La réécriture de requête nécessite une version de ClickHouse qui prend en charge le pruning direct par token sur des colonnes de tableau indexées en texte. ClickStack détecte la version du serveur connecté (SELECT version(), mise en cache par connexion) et n’émet la forme réécrite que lorsque le serveur atteint ou dépasse le seuil. Les serveurs plus anciens reviennent automatiquement à la forme d’indice Map d’origine.
Branche ClickHouseVersion minimale
26.226.2.19.43
26.326.3.12.3
26.426.4.3.37
26.5+Toutes les versions
Pourquoi ALIAS, et non MATERIALIZEDLe tableau items offre une vue sur des données déjà présentes dans la colonne Map. Le stocker deux fois — une fois dans la Map, une fois dans le tableau — doublerait les E/S d’écriture sans permettre de nouveaux types de requêtes. L’index de texte sur la colonne ALIAS est construit lors de l’insert à partir des mêmes données source, donc cette optimisation n’ajoute au disque que l’empreinte de l’index.

Optimisation 4. Modification de la clé primaire

La clé primaire est l’un des éléments les plus importants de l’optimisation des performances dans ClickHouse pour la plupart des charges de travail. Pour l’optimiser efficacement, vous devez comprendre son fonctionnement et la manière dont elle interagit avec vos schémas de requêtes. En définitive, la clé primaire doit correspondre à la façon dont les utilisateurs accèdent aux données, en particulier aux colonnes sur lesquelles des filtres sont le plus souvent appliqués. Bien que la clé primaire influe aussi sur la compression et l’organisation du stockage, son objectif principal reste les performances des requêtes. Dans ClickStack, les clés primaires fournies par défaut sont déjà optimisées pour les modes d’accès les plus courants en observabilité ainsi que pour une forte compression. Les clés par défaut des tables de logs, de traces et de métriques sont conçues pour offrir de bonnes performances dans les workflows typiques. Le filtrage sur des colonnes qui apparaissent plus tôt dans la clé primaire est plus efficace que sur celles qui apparaissent plus tard. Même si la configuration par défaut convient à la plupart des utilisateurs, il existe des cas où la modification de la clé primaire peut améliorer les performances pour certaines charges de travail.
Remarque sur la terminologieDans tout ce document, le terme « clé de tri » est utilisé indifféremment avec « clé primaire ». À strictement parler, ces notions diffèrent dans ClickHouse, mais dans ClickStack, elles renvoient généralement aux mêmes colonnes spécifiées dans la clause ORDER BY de la table. Pour plus de détails, consultez la documentation ClickHouse sur le choix d’une clé primaire différente de la clé de tri.
Avant de modifier une clé primaire, il est vivement recommandé de lire notre guide expliquant le fonctionnement des index primaires dans ClickHouse : Le réglage de la clé primaire dépend de la table et du type de données. Une modification bénéfique pour une table et un type de données donnés peut ne pas s’appliquer à d’autres. L’objectif est toujours d’optimiser un type de données particulier, par exemple les logs. En général, vous optimiserez surtout les tables de logs et de traces. Il est rare que des modifications de clé primaire soient nécessaires pour les autres types de données. Vous trouverez ci-dessous les clés primaires par défaut des tables ClickStack pour les logs et les traces.
  • Logs (otel_logs) - (toStartOfFiveMinutes(Timestamp), ServiceName, Timestamp)
  • Traces (otel_traces) - (ServiceName, SpanName, toDateTime(Timestamp))
Consultez « Tables et schémas utilisés par ClickStack » pour connaître les clés primaires utilisées par les tables des autres types de données. Les tables de traces sont optimisées pour le filtrage par nom de service, puis par nom de span, puis par timestamp. Les tables de logs commencent par un bucket temporel de cinq minutes afin que les analyses sur des plages de temps exploitent d’abord l’index primaire, puis affinent par nom de service dans chaque bucket — une organisation bien adaptée au workflow courant « que s’est-il passé au cours des N dernières minutes pour le service X ? ». Même si l’idéal est d’appliquer les filtres dans l’ordre de la clé primaire, les requêtes tirent tout de même un grand bénéfice du filtrage sur n’importe laquelle de ces colonnes, quel que soit l’ordre, ClickHouse éliminant les données avant leur lecture. Lors du choix d’une clé primaire, d’autres éléments entrent également en compte pour déterminer l’ordre optimal des colonnes. Voir « Choisir une clé primaire. » Les clés primaires doivent être modifiées séparément pour chaque table. Ce qui a du sens pour les logs peut ne pas en avoir pour les traces ou les métriques.

Choisir une clé primaire

Commencez par déterminer si vos schémas d’accès diffèrent sensiblement des valeurs par défaut pour une table donnée. Par exemple, si vous filtrez le plus souvent les logs par nœud Kubernetes avant le nom du service, et que cela correspond à un cas d’usage dominant, cela peut justifier de modifier la clé primaire.
Modifier la clé primaire par défautLes clés primaires par défaut suffisent dans la plupart des cas. Les modifications doivent être apportées avec prudence et uniquement avec une bonne compréhension des schémas de requêtes. Modifier une clé primaire peut dégrader les performances d’autres workflows, il est donc indispensable d’effectuer des tests.
Une fois les colonnes souhaitées identifiées, vous pouvez commencer à optimiser votre clé de tri/primaire. Quelques règles simples peuvent vous aider à choisir une clé de tri. Les points suivants peuvent parfois entrer en conflit, examinez-les donc dans cet ordre. Essayez de sélectionner au maximum 4 à 5 clés à l’issue de ce processus :
  1. Sélectionnez des colonnes qui correspondent à vos filtres et schémas d’accès habituels. Si vous commencez généralement les investigations d’observabilité en filtrant sur une colonne spécifique, par exemple le nom du pod, cette colonne sera fréquemment utilisée dans les clauses WHERE. Faites passer ces colonnes avant celles qui sont utilisées moins souvent.
  2. Privilégiez les colonnes qui permettent d’exclure une grande partie du total des lignes lorsqu’un filtre est appliqué, afin de réduire la quantité de données à lire. Les noms de service et les codes de statut sont souvent de bons candidats — dans ce dernier cas, uniquement si vous filtrez sur des valeurs qui excluent la plupart des lignes. Par exemple, filtrer sur les codes 200 correspondra, dans la plupart des systèmes, à la majorité des lignes, contrairement aux erreurs 500, qui ne représenteront qu’un petit sous-ensemble.
  3. Privilégiez les colonnes susceptibles d’être fortement corrélées avec d’autres colonnes de la table. Cela contribuera à garantir que ces valeurs soient elles aussi stockées de manière contiguë, ce qui améliorera la compression.
  4. Les opérations GROUP BY (agrégations pour les graphiques) et ORDER BY (tri) sur les colonnes de la clé de tri peuvent gagner en efficacité mémoire.
Après avoir identifié le sous-ensemble de colonnes pour la clé de tri, celles-ci doivent être déclarées dans un ordre précis. Cet ordre peut influencer de manière significative à la fois l’efficacité du filtrage sur les colonnes de clé secondaires dans les requêtes et le taux de compression des fichiers de données de la table. En règle générale, il est préférable d’ordonner les clés par cardinalité croissante. Cela doit être mis en balance avec le fait que le filtrage sur les colonnes qui apparaissent plus tard dans la clé de tri sera moins efficace que sur celles qui apparaissent plus tôt dans le tuple. Trouvez le bon équilibre entre ces comportements et tenez compte de vos schémas d’accès. Surtout, testez différentes variantes. Pour mieux comprendre les clés de tri et savoir comment les optimiser, nous vous recommandons de lire “Choisir une clé primaire.” et, pour approfondir encore l’optimisation des clés primaires et les structures de données internes, consultez “Une introduction pratique aux index primaires clairsemés dans ClickHouse.”

Modification de la clé primaire

Si vous connaissez bien vos patterns d’accès avant l’ingestion des données, supprimez simplement la table, puis recréez-la pour le type de données concerné. L’exemple ci-dessous montre une façon simple de créer une nouvelle table de logs à partir du schéma existant, mais avec une nouvelle clé primaire qui place la colonne SeverityText avant ServiceName.
1

Créer une nouvelle table

CREATE TABLE otel_logs_temp AS otel_logs
PRIMARY KEY (SeverityText, ServiceName, Timestamp)
ORDER BY (SeverityText, ServiceName, Timestamp)
Clé de tri vs clé primaireNotez que, dans l’exemple ci-dessus, vous devez spécifier une PRIMARY KEY et un ORDER BY. Dans ClickStack, elles sont presque toujours identiques. Le ORDER BY contrôle l’organisation physique des données, tandis que la PRIMARY KEY définit l’index clairsemé. Dans de rares cas, sur des workloads très volumineux, elles peuvent différer, mais la plupart des utilisateurs devraient les garder alignées.
2

Échanger puis supprimer la table

L’instruction EXCHANGE sert à permuter les noms des tables de façon atomique. La table temporaire (qui devient alors l’ancienne table par défaut) peut ensuite être supprimée.
EXCHANGE TABLES otel_logs_temp AND otel_logs
DROP TABLE otel_logs_temp
Cependant, la clé primaire ne peut pas être modifiée sur une table existante. Pour la changer, il faut créer une nouvelle table. Le processus suivant permet de conserver les anciennes données tout en continuant à les interroger de façon transparente (en utilisant leur clé existante dans la ClickStack UI, si nécessaire), tandis que les nouvelles données sont exposées via une nouvelle table optimisée pour les patterns d’accès des utilisateurs. Cette approche garantit qu’il n’est pas nécessaire de modifier les pipelines d’ingestion : les données continuent d’être envoyées vers les noms de table par défaut, et tous les changements restent transparents pour les utilisateurs.
Le rechargement de données historiques dans une nouvelle table vaut rarement la peine à grande échelle. Le coût en compute et en IO est généralement élevé et ne justifie pas les gains de performance. À la place, laissez les données plus anciennes expirer via TTL, tandis que les données plus récentes bénéficient de la clé améliorée.
Le même exemple, où SeverityText devient la première colonne de la clé primaire, est repris ci-dessous. Dans ce cas, une table est créée pour les nouvelles données, tout en conservant l’ancienne table pour l’analyse historique.
1

Créer une nouvelle table

Créez la nouvelle table avec la clé primaire souhaitée. Notez le suffixe _23_01_2025 : adaptez-le à la date actuelle. Par exemple :
CREATE TABLE otel_logs_23_01_2025 AS otel_logs
PRIMARY KEY (SeverityText, ServiceName, Timestamp)
ORDER BY (SeverityText, ServiceName, Timestamp)
2

Créer une table Merge

Le moteur Merge (à ne pas confondre avec MergeTree) ne stocke pas lui-même les données, mais permet de lire simultanément depuis n’importe quel nombre d’autres tables.
CREATE TABLE otel_logs_merge
AS otel_logs
ENGINE = Merge(currentDatabase(), 'otel_logs*')
currentDatabase() suppose que la commande est exécutée dans la bonne base de données. Sinon, indiquez explicitement le nom de la base de données.
Vous pouvez maintenant interroger cette table pour vérifier qu’elle renvoie bien les données de otel_logs.
3

Mettre à jour le ClickStack UI pour lire depuis la table Merge

Configurez le ClickStack UI pour utiliser otel_logs_merge comme table pour la source de données de logs.À ce stade, les écritures continuent d’aller vers otel_logs avec la clé primaire d’origine, tandis que les lectures utilisent la table Merge. Cela n’entraîne aucun changement visible pour les utilisateurs ni aucun impact sur l’ingestion.
4

Échanger les tables

Une instruction EXCHANGE est maintenant utilisée pour permuter atomiquement les noms des tables otel_logs et otel_logs_23_01_2025.
EXCHANGE TABLES otel_logs AND otel_logs_23_01_2025
Les écritures vont désormais vers la nouvelle table otel_logs avec la clé primaire mise à jour. Les données existantes restent dans otel_logs_23_01_2025 et restent accessibles via la table Merge. Le suffixe indique la date à laquelle la modification a été appliquée et correspond au timestamp le plus récent contenu dans cette table.Ce processus permet de modifier les clés primaires sans interruption de l’ingestion et sans impact visible pour les utilisateurs.
Ce processus peut être adapté si d’autres modifications des clés primaires sont nécessaires. Par exemple, si vous décidez qu’en réalité SeverityNumber doit faire partie de la clé primaire une semaine plus tard, plutôt que SeverityText. Le processus suivant peut être répété autant de fois que nécessaire en cas de modification de la clé primaire.
1

Créer une nouvelle table

Créez la nouvelle table avec la clé primaire souhaitée. Dans l’exemple ci-dessous, 30_01_2025 est utilisé comme suffixe pour indiquer la date de la table. Par exemple :
CREATE TABLE otel_logs_30_01_2025 AS otel_logs
PRIMARY KEY (SeverityNumber, ServiceName, TimestampTime)
ORDER BY (SeverityNumber, ServiceName, TimestampTime)
2

Échanger les tables

Une instruction EXCHANGE est maintenant utilisée pour permuter atomiquement les noms des tables otel_logs et otel_logs_30_01_2025.
EXCHANGE TABLES otel_logs AND otel_logs_30_01_2025
Les écritures vont désormais vers la nouvelle table otel_logs avec la clé primaire mise à jour. Les anciennes données restent dans otel_logs_30_01_2025, accessibles via la table Merge.
Tables redondantesSi des politiques TTL sont en place, ce qui est recommandé, les tables avec d’anciennes clés primaires qui ne reçoivent plus d’écritures se videront progressivement à mesure que les données expireront. Elles doivent être surveillées et nettoyées périodiquement une fois qu’elles ne contiennent plus de données. À l’heure actuelle, ce processus de nettoyage est manuel.

Accélération de la recherche de lignes avec les colonnes de bloc

Le schéma de logs ClickStack par défaut active deux paramètres MergeTree qui n’affectent pas directement les performances des requêtes, mais accélèrent sensiblement les consultations du détail des lignes dans l’UI de ClickStack :
SETTINGS enable_block_number_column = 1, enable_block_offset_column = 1
Avec ces paramètres, chaque ligne de la table comporte une paire implicite (_block_number, _block_offset) qui l’identifie de manière unique au sein d’une part. Lorsque vous cliquez sur une ligne de log dans la ClickStack UI pour ouvrir le panneau de détail, ClickStack exécute une requête complémentaire pour récupérer cette seule ligne. Sans colonnes de bloc, la clause WHERE de la ligne doit inclure suffisamment de colonnes — généralement la clé primaire ainsi que Body et SeverityText — pour lever toute ambiguïté. Avec les colonnes de bloc, la clé primaire, _block_number et _block_offset suffisent. Les grandes colonnes comme Body ne sont jamais lues pour le lookup, ce qui accélère effectivement la requête. ClickStack détecte ce réglage à partir de l’instruction CREATE de la table et génère automatiquement une clause WHERE plus légère lorsque les deux colonnes sont activées. Aucune modification de la configuration de l’application n’est nécessaire. Pour activer cette optimisation sur une table de logs ou de traces existante :
ALTER TABLE otel_logs
MODIFY SETTING enable_block_number_column = 1, enable_block_offset_column = 1
Les paramètres s’appliquent aux données écrites après l’ALTER. Les parts existantes continuent d’utiliser l’ancien mécanisme de recherche ligne par ligne jusqu’à ce qu’une fusion les réécrive.

Optimization 5. Tirer parti des vues matérialisées

ClickStack peut exploiter les vues matérialisées incrémentielles pour accélérer les visualisations qui reposent sur des requêtes riches en agrégations, comme le calcul de la durée moyenne des requêtes par minute au fil du temps. Cette fonctionnalité peut considérablement améliorer les performances des requêtes et s’avère généralement particulièrement utile pour les déploiements de plus grande taille, à partir d’environ 10 To par jour, tout en permettant de passer à une échelle de l’ordre du pétaoctet par jour. Les vues matérialisées incrémentielles sont en Beta et doivent être utilisées avec précaution. Pour en savoir plus sur l’utilisation de cette fonctionnalité dans ClickStack, consultez notre guide dédié « ClickStack - Vues matérialisées. »

Optimisation 6. Exploiter les projections

Les projections constituent une optimisation avancée de dernier niveau, à envisager une fois les colonnes matérialisées, les skip indexes, les clés primaires et les vues matérialisées évaluées. Même si les projections et les vues matérialisées peuvent sembler proches, dans ClickStack, elles répondent à des besoins différents et s’utilisent de préférence dans des cas distincts.
En pratique, une projection peut être considérée comme une copie supplémentaire, masquée, de la table qui stocke les mêmes lignes dans un ordre physique différent. La projection dispose ainsi de son propre index primaire, distinct de la clé ORDER BY de la table de base, ce qui permet à ClickHouse d’écarter plus efficacement les données pour les modèles d’accès qui ne correspondent pas à l’ordre initial. Les vues matérialisées peuvent produire un effet similaire en écrivant explicitement les lignes dans une table cible distincte avec une clé de tri différente. La différence essentielle est que les projections sont maintenues automatiquement et de manière transparente par ClickHouse, tandis que les vues matérialisées sont des tables explicites que ClickStack doit enregistrer et sélectionner délibérément. Lorsqu’une requête cible la table de base, ClickHouse évalue l’organisation de base et les projections disponibles, échantillonne leurs index primaires, puis sélectionne celle qui peut produire le bon résultat en lisant le moins de granules possible. Cette décision est prise automatiquement par l’analyseur de requêtes. Dans ClickStack, les projections sont donc surtout adaptées au réordonnancement pur des données, lorsque :
  • Les modèles d’accès diffèrent fondamentalement de la clé primaire par défaut
  • Il n’est pas pratique de couvrir tous les workflows avec une seule clé de tri
  • Vous voulez que ClickHouse choisisse de manière transparente l’organisation physique optimale
Pour la pré-agrégation et l’accélération des métriques, ClickStack privilégie clairement les vues matérialisées explicites, qui donnent à la couche applicative un contrôle total sur la sélection et l’utilisation des vues. Pour plus d’informations, voir :

Exemple de projections

Supposons que votre table de traces soit optimisée pour le modèle d’accès par défaut de ClickStack :
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
Si vous avez également un workflow principal qui filtre sur TraceId (ou qui regroupe et filtre fréquemment selon ce champ), vous pouvez ajouter une projection qui stocke les lignes triées par TraceId et par temps :
ALTER TABLE otel_v2.otel_traces
ADD PROJECTION prj_traceid_time
(
    SELECT *
    ORDER BY (TraceId, toDateTime(Timestamp))
);
Utiliser des caractères génériquesDans l’exemple de projection ci-dessus, un caractère générique (SELECT *) est utilisé. Bien que sélectionner un sous-ensemble de colonnes puisse réduire la surcharge d’écriture, cela limite également les cas dans lesquels la projection peut être utilisée, car seules les requêtes pouvant être entièrement satisfaites par ces colonnes sont éligibles. Dans ClickStack, cela restreint souvent l’utilisation des projections à des cas très spécifiques. C’est pourquoi il est généralement recommandé d’utiliser un caractère générique afin d’en maximiser l’applicabilité.
Comme pour les autres changements d’organisation des données, la projection n’affecte que les nouvelles parts écrites. Pour l’appliquer aux données existantes, matérialisez-la :
ALTER TABLE otel_v2.otel_traces
MATERIALIZE PROJECTION prj_traceid_time;
La matérialisation d’une projection peut prendre beaucoup de temps et consommer des ressources importantes. Comme les données d’observabilité expirent généralement via TTL, cela ne doit être fait qu’en cas d’absolue nécessité. Dans la plupart des cas, il suffit de laisser la projection s’appliquer uniquement aux données nouvellement ingérées, afin d’optimiser les intervalles de temps les plus fréquemment interrogés, comme les 24 dernières heures.
ClickHouse peut choisir automatiquement la projection lorsqu’il estime qu’elle parcourra moins de granules que l’organisation de base. Les projections sont plus fiables lorsqu’elles correspondent à une simple réorganisation de l’ensemble complet des lignes (SELECT *) et que les filtres de la requête correspondent étroitement au ORDER BY de la projection. Les requêtes qui filtrent sur TraceId (en particulier avec une égalité) et incluent un intervalle de temps tireraient parti de la projection ci-dessus. Par exemple :
-- Fetch a specific trace quickly
SELECT *
FROM otel_traces
WHERE TraceId = 'aeea7f401feb75fc5af8eb25ebc8e974'
  AND Timestamp >= now() - INTERVAL 1 DAY
ORDER BY Timestamp;

-- Trace-scoped aggregation
SELECT
  toStartOfMinute(Timestamp) AS t,
  count() AS spans
FROM otel_traces
WHERE TraceId = 'aeea7f401feb75fc5af8eb25ebc8e974'
  AND Timestamp >= now() - INTERVAL 1 DAY
GROUP BY t
ORDER BY t;
Les requêtes qui ne filtrent pas sur TraceId, ou qui filtrent principalement sur d’autres dimensions qui ne figurent pas en tête de la clé de tri de la projection, n’en tirent généralement aucun bénéfice (et peuvent alors lire via l’organisation de base à la place).
Les projections peuvent aussi stocker des agrégations (comme les vues matérialisées). Dans ClickStack, les agrégations basées sur des projections ne sont généralement pas recommandées, car leur sélection dépend de l’analyseur ClickHouse et leur usage peut être plus difficile à contrôler et à anticiper. Préférez plutôt des vues matérialisées explicites, que ClickStack peut enregistrer et sélectionner de manière intentionnelle au niveau de la couche applicative.
En pratique, les projections conviennent surtout aux workflows où vous passez fréquemment d’une recherche large à une analyse détaillée centrée sur une trace (par exemple, pour récupérer tous les spans d’un TraceId donné).

Coûts et recommandations

  • **Surcoût à l’insert **: une projection SELECT * avec une clé de tri différente revient, en pratique, à écrire les données deux fois, ce qui augmente les E/S en écriture et peut nécessiter davantage de CPU et de débit disque pour maintenir l’ingestion.
  • **À utiliser avec parcimonie **: les projections sont surtout utiles pour des modes d’accès réellement différents, lorsqu’un second ordre physique permet un pruning significatif pour une grande part des requêtes, par exemple si deux équipes interrogent le même jeu de données de manière fondamentalement différente.
  • **Validez avec des benchmarks **: comme pour tout réglage, comparez la latence réelle des requêtes et l’utilisation des ressources avant et après l’ajout et la matérialisation d’une projection.
Pour plus de contexte, voir :

Projections légères avec _part_offset

Les projections légères sont en bêta pour ClickStackLes projections légères basées sur _part_offset ne sont pas recommandées pour les charges de travail ClickStack. Bien qu’elles réduisent le stockage et les E/S en écriture, elles peuvent entraîner davantage d’accès aléatoires à l’exécution des requêtes, et leur comportement en production à l’échelle de l’observabilité est encore en cours d’évaluation. Cette recommandation pourra évoluer à mesure que la fonctionnalité gagnera en maturité et que nous disposerons de plus de données opérationnelles.
Les versions plus récentes de ClickHouse prennent également en charge des projections encore plus légères, qui ne stockent que la clé de tri de la projection ainsi qu’un pointeur _part_offset vers la table de base, au lieu de dupliquer des lignes complètes. Cela peut réduire considérablement le surcoût de stockage, et de récentes améliorations permettent un élagage au niveau des granules, ce qui les rapproche davantage de véritables index secondaires. Voir :

Alternatives

Si vous avez besoin de plusieurs clés de tri, les projections ne sont pas la seule option. Selon les contraintes opérationnelles et la manière dont vous souhaitez que ClickStack achemine les requêtes, envisagez :
  • de configurer votre collecteur OpenTelemetry pour écrire dans deux tables avec des clés de tri différentes, et de créer des sources ClickStack distinctes pour chaque table.
  • de créer une vue matérialisée comme pipeline de copie, c.-à-d. d’attacher une vue matérialisée à la table principale qui redirige les lignes brutes vers une table secondaire avec une clé de tri différente (un modèle de dénormalisation ou de routage). Créez une source pour cette table cible. Vous trouverez des exemples ici.
Dernière modification le 25 juin 2026