Contexte
SELECT.
Contrairement aux bases de données transactionnelles comme Postgres, dans ClickHouse, une vue matérialisée n’est qu’un déclencheur qui exécute une requête sur des blocs de données au moment de leur insertion dans une table. Le résultat de cette requête est inséré dans une deuxième table « cible ». Si d’autres lignes sont insérées, les résultats sont de nouveau envoyés vers la table cible, où les résultats intermédiaires sont mis à jour et fusionnés. Le résultat fusionné équivaut à l’exécution de la requête sur l’ensemble des données d’origine.
La principale raison d’utiliser des vues matérialisées est que les résultats insérés dans la table cible correspondent au résultat d’une agrégation, d’un filtrage ou d’une transformation appliqués aux lignes. Ces résultats constituent souvent une représentation plus compacte des données d’origine (par exemple, une esquisse partielle dans le cas des agrégations). Cela, combiné au fait que la requête permettant de lire les résultats depuis la table cible est simple, garantit des temps de requête plus courts que si le même calcul était effectué sur les données d’origine, en transférant le calcul (et donc la latence des requêtes) du temps de requête vers le temps d’insertion.
Dans ClickHouse, les vues matérialisées sont mises à jour en temps réel à mesure que les données alimentent la table sur laquelle elles reposent, et fonctionnent davantage comme des index mis à jour en continu. Cela contraste avec d’autres bases de données, où les vues matérialisées sont généralement des instantanés statiques d’une requête qui doivent être actualisés (comme les vues matérialisées actualisables dans ClickHouse).
Exemple
toStartOfDay :
SELECT ci-dessus sur les données insérées dans votes, et envoie les résultats vers up_down_votes_per_day :
TO est ici essentielle : elle indique la destination des résultats, c’est-à-dire up_down_votes_per_day.
Nous pouvons à nouveau alimenter notre table Votes à partir de l’insert précédent :
up_down_votes_per_day - nous devrions avoir 1 ligne par jour :
votes) à 5 000 en stockant le résultat de notre requête. Le point essentiel, toutefois, est que si de nouveaux votes sont insérés dans la table votes, de nouvelles valeurs seront envoyées vers up_down_votes_per_day pour le jour correspondant, où elles seront automatiquement fusionnées de manière asynchrone en arrière-plan afin de ne conserver qu’une seule ligne par jour. up_down_votes_per_day restera donc toujours à la fois compacte et à jour.
Comme la fusion des lignes est asynchrone, il peut y avoir plus d’une ligne par jour lorsqu’un utilisateur exécute une requête. Pour garantir que toutes les lignes en attente soient fusionnées à l’exécution de la requête, nous avons deux options :
- Utiliser le modificateur
FINALsur le nom de la table. C’est ce que nous avons fait pour la requête de comptage ci-dessus. - Agréger selon la clé de tri utilisée dans notre table finale, c.-à-d.
CreationDate, et faire la somme des métriques. En général, cette approche est plus efficace et plus souple (la table peut servir à d’autres usages), mais la première peut être plus simple pour certaines requêtes. Nous montrons les deux ci-dessous :
Un exemple plus complexe
Score et la moyenne du CommentCount. La requête permettant de calculer cela pourrait ressembler à ceci :
posts.
À titre d’exemple, et pour éviter de charger les données des posts depuis S3, nous allons créer une table dupliquée posts_null avec le même schéma que posts. Cependant, cette table ne stockera aucune donnée et sera simplement utilisée par la vue matérialisée lorsque des lignes sont insérées. Pour empêcher le stockage des données, nous pouvons utiliser le type de moteur de table Null.
/dev/null. Notre vue matérialisée calculera et stockera nos statistiques de synthèse lorsque notre table posts_null recevra des lignes à l’insertion — ce n’est qu’un déclencheur. En revanche, les données brutes ne seront pas stockées. Même si, dans notre cas, nous voulons probablement conserver les posts d’origine, cette approche peut être utilisée pour calculer des agrégats tout en évitant le surcoût de stockage des données brutes.
La vue matérialisée devient donc :
State à la fin de nos fonctions d’agrégation. Cela garantit que l’état d’agrégation de la fonction est renvoyé au lieu du résultat final. Celui-ci contient des informations supplémentaires permettant à cet état partiel de fusionner avec d’autres états. Par exemple, dans le cas d’une moyenne, cela inclut un décompte et la somme de la colonne.
Les états d’agrégation partiels sont nécessaires pour calculer des résultats corrects. Par exemple, pour calculer une moyenne, faire simplement la moyenne des moyennes de sous-plages produit des résultats incorrects.Nous créons maintenant la table cible de cette vue,
post_stats_per_day, qui stocke ces états d’agrégation partiels :
SummingMergeTree suffisait pour stocker des décomptes, nous avons besoin d’un type de moteur plus avancé pour d’autres fonctions : AggregatingMergeTree.
Pour que ClickHouse sache que des états d’agrégation seront stockés, nous définissons Score_quantiles et AvgCommentCount avec le type AggregateFunction, en précisant la fonction à l’origine des états partiels ainsi que le type de leurs colonnes sources. Comme avec SummingMergeTree, les lignes ayant la même valeur de clé ORDER BY seront fusionnées (Day dans l’exemple ci-dessus).
Pour alimenter post_stats_per_day via notre vue matérialisée, nous pouvons simplement insérer toutes les lignes de posts dans posts_null :
En production, vous attacheriez probablement la vue matérialisée à la tableNotre requête finale doit utiliser le suffixeposts. Nous avons utiliséposts_nullici pour illustrer la table Null.
Merge pour nos fonctions (car les colonnes stockent des états d’agrégation partiels) :
GROUP BY au lieu d’utiliser FINAL.
Autres applications
Filtrage et transformation
posts_null pourrait recevoir les insertions, avec une requête SELECT filtrant les lignes avant leur insertion dans la table posts. Par exemple, supposons que nous souhaitions transformer une colonne Tags de notre table posts. Celle-ci contient une liste de noms de tags délimités par des barres verticales. En les convertissant en tableau, nous pouvons plus facilement effectuer des agrégations par valeur de tag.
Nous pourrions effectuer cette transformation lors de l’exécution d’unLa vue matérialisée utilisée pour cette transformation est présentée ci-dessous :INSERT INTO SELECT. La vue matérialisée nous permet d’encapsuler cette logique dans le DDL de ClickHouse et de conserver unINSERTsimple, la transformation étant appliquée à toutes les nouvelles lignes.
Table de recherche
comments suivante :
PostId.
Supposons qu’un utilisateur souhaite filtrer sur un UserId précis et calculer son Score moyen :
PostId pour la colonne de filtrage UserId. Ces valeurs peuvent ensuite être utilisées pour effectuer une recherche efficace.
Dans cet exemple, notre vue matérialisée peut être très simple et ne sélectionner que PostId et UserId depuis comments à l’insertion. Ces résultats sont ensuite envoyés vers une table comments_posts_users triée par UserId. Nous créons ci-dessous une version Null de la table Comments et l’utilisons pour alimenter notre vue et la table comments_posts_users :
Chaînage / cascade de vues matérialisées
Vues matérialisées et JOINs
Vues matérialisées actualisablesCe qui suit s’applique uniquement aux vues matérialisées incrémentielles. Les vues matérialisées actualisables exécutent périodiquement leur requête sur l’ensemble du jeu de données cible et prennent entièrement en charge les JOINs. Envisagez de les utiliser pour des JOINs complexes si vous pouvez tolérer une moindre fraîcheur des résultats.
JOIN, mais avec une contrainte essentielle : la vue matérialisée ne se déclenche que lors d’insertions dans la table source (la table la plus à gauche dans la requête). Les tables situées à droite dans les JOINs ne déclenchent pas de mise à jour, même si leurs données changent. Ce comportement est particulièrement important lors de la création de vues matérialisées incrémentielles, où les données sont agrégées ou transformées au moment de l’insertion.
Lorsqu’une vue matérialisée incrémentielle est définie à l’aide d’un JOIN, la table la plus à gauche de la requête SELECT sert de source. Lorsque de nouvelles lignes sont insérées dans cette table, ClickHouse exécute la requête de la vue matérialisée uniquement sur ces lignes nouvellement insérées. Les tables situées à droite dans le JOIN sont lues dans leur intégralité pendant cette exécution, mais les modifications qui les affectent à elles seules ne déclenchent pas la vue.
Ce comportement fait que les JOINs dans les vues matérialisées s’apparentent à une jointure de type snapshot sur des données de dimension statiques.
Cela fonctionne bien pour enrichir les données avec des tables de référence ou de dimension. En revanche, les mises à jour des tables situées à droite (par ex., les métadonnées utilisateur) ne mettront pas rétroactivement à jour la vue matérialisée. Pour voir les données mises à jour, de nouvelles insertions doivent arriver dans la table source.
Exemple
users.
Pour rappel, les schémas de nos tables sont :
users soit déjà remplie :
Alignement entre regroupement et triLa clause
GROUP BY de la vue matérialisée doit inclure DisplayName, UserId et Day afin de correspondre à la clause ORDER BY de la table cible SummingMergeTree. Cela garantit que les lignes sont correctement agrégées et fusionnées. L’omission de l’un de ces éléments peut entraîner des résultats incorrects ou des fusions inefficaces.daily_badges_by_user.
Bonnes pratiques pour les JOIN dans les vues matérialisées
-
Utilisez la table la plus à gauche comme déclencheur. Seule la table située à gauche de l’instruction
SELECTdéclenche la vue matérialisée. Les modifications apportées aux tables de droite ne déclencheront pas de mise à jour. - Insérez au préalable les données jointes. Assurez-vous que les données des tables jointes existent avant d’insérer des lignes dans la table source. Le JOIN est évalué au moment de l’insert ; des données manquantes entraîneront donc des lignes sans correspondance ou des valeurs nulles.
- Limitez les colonnes récupérées via les jointures. Sélectionnez uniquement les colonnes nécessaires dans les tables jointes afin de limiter l’utilisation de la mémoire et de réduire la latence au moment de l’insert (voir ci-dessous).
- Évaluez les performances au moment de l’insert. Les JOIN augmentent le coût des inserts, en particulier avec de grandes tables du côté droit. Mesurez les taux d’insert à l’aide de données de production représentatives.
- Préférez les dictionnaires pour les recherches simples. Utilisez les Dictionaries pour les recherches clé-valeur (par exemple, associer un ID utilisateur à un nom) afin d’éviter des opérations JOIN coûteuses.
-
Alignez
GROUP BYetORDER BYpour optimiser les fusions. Lorsque vous utilisezSummingMergeTreeouAggregatingMergeTree, assurez-vous queGROUP BYcorrespond à la clauseORDER BYdans la table cible afin de permettre une fusion efficace des lignes. - Utilisez des alias de colonnes explicites. Lorsque plusieurs tables ont des noms de colonnes identiques, utilisez des alias pour éviter toute ambiguïté et garantir des résultats corrects dans la table cible.
- Tenez compte du volume et de la fréquence des inserts. Les JOIN fonctionnent bien avec des charges d’insertion modérées. Pour une ingestion à haut débit, envisagez d’utiliser des tables de staging, des pré-jointures ou d’autres approches telles que Dictionaries et les Refreshable Materialized Views.
Utilisation de la table source dans les filtres et les jointures
Exemple de scénario
Explication
mvw1 et mvw2, qui effectuent des opérations similaires, avec une légère différence dans la façon dont elles font référence à la table source t0.
Dans mvw1, la table t0 est référencée directement dans une sous-requête (SELECT * FROM t0) située à droite du JOIN. Lorsque des données sont insérées dans t0, la requête de la vue matérialisée s’exécute en remplaçant t0 par le bloc de données inséré. Cela signifie que l’opération de JOIN porte uniquement sur les lignes nouvellement insérées, et non sur l’ensemble de la table.
Dans le second cas, avec la jointure sur vt0, la vue lit toutes les données de t0. Cela garantit que l’opération de JOIN prend en compte toutes les lignes de t0, et pas seulement le bloc nouvellement inséré.
La principale différence réside dans la façon dont ClickHouse gère la table source dans la requête de la vue matérialisée. Lorsqu’une vue matérialisée est déclenchée par une insertion, la table source (t0 dans ce cas) est remplacée par le bloc de données inséré. Ce comportement peut être mis à profit pour optimiser les requêtes, mais il nécessite également une attention particulière afin d’éviter des résultats inattendus.
Cas d’usage et points à noter
IN (SELECT id FROM t0) ne contient que les lignes nouvellement insérées, ce qui peut aider à filtrer t1 en fonction de cet ensemble.
Exemple avec Stack Overflow
users.
badges, par exemple.
users en utilisant les identifiants des utilisateurs figurant dans les lignes de badge insérées :
2936484. Cette recherche est également optimisée grâce à une clé de tri de table sur Id.
Vues matérialisées et unions
UNION ALL sont couramment utilisées pour combiner des données issues de plusieurs tables sources dans un seul jeu de résultats.
Bien que UNION ALL ne soit pas directement pris en charge dans les vues matérialisées incrémentielles, vous pouvez obtenir le même résultat en créant une vue matérialisée distincte pour chaque branche SELECT et en écrivant leurs résultats dans une table cible commune.
Pour cet exemple, nous utiliserons le jeu de données Stack Overflow. Prenons les tables badges et comments ci-dessous, qui représentent les badges obtenus par un utilisateur et les commentaires qu’il publie sur des posts :
INSERT INTO suivantes :
badges ou comments, une approche naïve peut consister à essayer de créer une vue matérialisée à partir de la requête d’union précédente :
comments. Par exemple :
badges n’activeront pas la vue, si bien que user_activity ne sera pas mis à jour :
comments :
badges sont répercutées dans la table user_activity :
Traitement parallèle ou séquentiel
parallel_view_processing.
Par défaut, ce paramètre vaut 0 (false), ce qui signifie que les vues matérialisées sont exécutées séquentiellement dans l’ordre des uuid.
Par exemple, considérons la table source suivante et 3 vues matérialisées, chacune envoyant des lignes vers une table target :
target, tout en indiquant son nom et l’heure d’insertion.
L’insertion d’une ligne dans la table source prend ~3 secondes, chaque vue s’exécutant séquentiellement :
SELECT :
uuid des vues :
parallel_view_processing=1 est activé. Dans ce cas, les vues sont exécutées en parallèle, sans aucune garantie quant à l’ordre dans lequel les lignes arrivent dans la table cible :
Quand utiliser le traitement parallèle
parallel_view_processing=1 peut considérablement améliorer le débit d’insertion, comme indiqué ci-dessus, en particulier lorsque plusieurs vues matérialisées sont attachées à une même table. Cependant, il est important d’en comprendre les contreparties :
- Pression accrue sur les insertions : toutes les vues matérialisées sont exécutées simultanément, ce qui augmente l’utilisation du CPU et de la mémoire. Si chaque vue effectue des calculs lourds ou des JOINs, cela peut surcharger le système.
- Nécessité d’un ordre d’exécution strict : dans de rares flux de travail où l’ordre d’exécution des vues est important (par exemple, avec des dépendances en chaîne), l’exécution parallèle peut entraîner un état incohérent ou des conditions de concurrence. Bien qu’il soit possible de concevoir le système pour éviter cela, ce type de configuration reste fragile et peut ne plus fonctionner dans de futures versions.
Valeurs par défaut historiques et stabilitéL’exécution séquentielle a longtemps été le mode par défaut, en partie à cause de la complexité de la gestion des erreurs. Historiquement, une défaillance dans une vue matérialisée pouvait empêcher l’exécution des autres. Les versions plus récentes ont amélioré ce point en isolant les défaillances par bloc, mais l’exécution séquentielle offre toujours une sémantique d’échec plus claire.
parallel_view_processing=1 lorsque :
- Vous avez plusieurs vues matérialisées indépendantes
- Vous cherchez à maximiser les performances d’insertion
- Vous savez que le système est capable de prendre en charge l’exécution concurrente des vues
- Les vues matérialisées dépendent les unes des autres
- Vous avez besoin d’une exécution prévisible et ordonnée
- Vous effectuez du débogage ou un audit du comportement des insertions et souhaitez un rejeu déterministe
Vues matérialisées et expressions de table communes (CTE)
Les expressions de table communes ne sont pas matérialiséesClickHouse ne matérialise pas les CTE ; il remplace directement la définition du CTE dans la requête, ce qui peut entraîner plusieurs évaluations d’une même expression (si le CTE est utilisé plus d’une fois).
- Si votre CTE référence une table différente de la table source (c.-à-d. celle à laquelle la vue matérialisée est rattachée) et qu’elle est utilisée dans une clause
JOINouIN, elle se comportera comme une sous-requête ou une jointure, et non comme un déclencheur. - La vue matérialisée ne se déclenchera toujours que lors des insertions dans la table source principale, mais la CTE sera réexécutée à chaque insertion, ce qui peut entraîner une surcharge inutile, en particulier si la table référencée est volumineuse.