- Comprendre comment les vues et les tables du projet se matérialisent dans ClickHouse.
- Charger des données avec des seeds et contrôler les types ClickHouse ainsi que la disposition des tables.
- Configurer un model de type table avec un engine ClickHouse, une sorting key et un partitioning.
- Transformer une table en model incremental et choisir une incremental strategy.
- Créer un snapshot.
- Utiliser les materialized views de ClickHouse.
Avant de commencer
Suivez d’abord le README de ClickHouse/jaffle-shop-clickhouse. Il explique comment configurer le projet avec dbt Core 1.x, dbt OSS, dbt v2 ou la plateforme dbt, comment le faire pointer vers un ClickHouse local (docker) ou ClickHouse Cloud, comment charger les données d’exemple avecdbt seed, et comment lancer le premier dbt build. Une fois que dbt build s’est exécuté avec succès, revenez ici pour les exemples et configurations spécifiques à ClickHouse.
Après les étapes du README, vous devriez avoir deux bases de données dans ClickHouse :
raw: les six tables sources chargées depuis des CSV files pardbt seed(raw_customers,raw_orders,raw_items,raw_products,raw_stores,raw_supplies).jaffle_shop(leschemade votre profile) : six vues de staging (stg_*) et sept tables de mart (customers,orders,order_items,products,locations,supplies,metricflow_time_spine).
schema différent, remplacez jaffle_shop par votre valeur dans les requêtes ci-dessous.
dbt Core 1.x, dbt OSS, dbt v2 et la plateforme dbt. Toutes les commandes et tous les modèles de ce guide sont identiques dans tous les cas. Les exemples ont été testés avec dbt Core 1.12 et
dbt-clickhouse 1.10, ainsi qu’avec dbt OSS 2.0 sur ClickHouse 26.8 ; dbt v2 s’appuie sur le même adapter, et la plateforme dbt s’appuie sur dbt v2. La sortie de console présentée provient de dbt Core 1.x, et les quelques cas où les moteurs se comportent différemment sont signalés. Consultez la page dbt OSS, dbt v2 et plateforme dbt pour connaître l’état actuel de l’adapter v2, et Connect ClickHouse dans la documentation dbt pour faire vos premier pas sur la plateforme dbt.clickhouse client, la SQL console de ClickHouse Cloud ou le client SQL de votre choix.
Comment le projet est matérialisé
Jaffle Shop configure ses materializations dansdbt_project.yml : les modèles de staging sont des vues et les marts sont des tables.
CREATE OR REPLACE VIEW à chaque exécution. Il ne stocke aucune donnée, sa construction ne coûte donc rien, mais chaque requête portant sur ce model exécute le SQL du model sur les source tables. ClickHouse conserve le SQL compilé du model dans la view definition :
INSERT INTO ... SELECT avec le SQL du model, puis l’échange atomiquement avec la version précédente. Les performances des requêtes sont bien meilleures que celles d’une view, au prix du stockage et d’une reconstruction intégrale de la table à chaque fois. Examinez la table que dbt a créée pour le mart orders :
MergeTree. Il ne déclare pas non plus de sorting key : l’adapter utilise alors ORDER BY tuple(), ce qui signifie que les données ne sont pas triées du tout. Cela convient pour un projet d’exemple, mais pour une table réelle, il vous faudra définir les deux, ce que font les sections suivantes. La page materializations répertorie toutes les configurations de table prises en charge par l’adapter.
Chargement des données avec les seeds
Le projet Jaffle Shop utilise les seeds dbt pour charger ses données brutes depuis les fichiers CSV du répertoireseeds/jaffle-data. Les seeds sont conçus pour de petits jeux de données de référence statiques (tables de codes, correspondances), et non pour alimenter un warehouse ; le projet y recourt par commodité, afin que vous puissiez démarrer sans outil d’ingestion supplémentaire — d’où le fait que les seeds soient désactivés, sauf si vous passez --vars '{"load_source_data": true}'.
Les seeds n’en restent pas moins un bon moyen de comprendre comment dbt crée les tables ClickHouse. dbt infère un type de colonne pour chaque colonne du CSV, et les types inférés diffèrent selon les engines :
Lorsque le type importe, fixez-le avec
column_types. Le projet le fait déjà pour la colonne opened_at du seed raw_stores dans dbt_project.yml :
engine, order_by et partition_by. Par exemple, pour trier le seed raw_orders par date de commande et le partitionner par mois, ajoutez un fichier de propriétés à côté des fichiers CSV : seeds/jaffle-data/_raw_orders.yml :
Utilisez un fichier de propriétés pour ces configurations de seed ClickHouse plutôt que les clés
+order_by ou +engine sous seeds: dans dbt_project.yml. dbt Core 1.x accepte les deux formes, mais dbt v2 ne les reconnaît que dans un fichier de propriétés et rejette les clés de dbt_project.yml avec l’erreur Unrecognized key ... Custom keys must go under +meta.dbt seed --full-refresh supprime et recrée la table ; exécutez donc cette commande avant de construire quoi que ce soit qui dépende directement des données de cette table, comme la vue matérialisée présentée plus loin dans ce guide.
Configurer une table pour ClickHouse
Le martorders est le point de départ naturel : il est interrogé par le mart customers ainsi que par les metrics du projet, et il s’agit d’une table de type événementiel dotée d’un timestamp. Ajoutez un bloc config en haut de models/marts/orders.sql pour choisir l’engine, la sorting key et un schéma de partitioning :
materialized='table' reprend ce que dbt_project.yml définit déjà pour les marts, ce qui rend le model self-describing lorsque vous le basculerez plus tard en incremental. Reconstruisez uniquement ce model :
engine, order_by et partition_by, les modèles de table acceptent primary_key, ttl, settings, query_settings, projections et indexes, et les colonnes peuvent se voir attribuer codec et ttl via un contrat de modèle. Tous ces éléments sont décrits sur la page des matérialisations.
Créer un modèle incrémental
Reconstruireorders intégralement à chaque exécution ne pose pas de problème pour 62 000 lignes, mais devient inadapté pour une table qui grossit de plusieurs millions de lignes par jour. La materialization incrémentale de dbt ne traite que les lignes modifiées depuis la dernière exécution. Convertir le modèle orders nécessite deux ajouts :
unique_key: la colonne qui identifie une ligne, iciorder_id. L’adapter s’en sert pour remplacer les lignes traitées à nouveau au lieu de les dupliquer.- Un filtre incrémental : une clause
whereencadrée par{% if is_incremental() %}qui sélectionne uniquement les lignes à traiter. Elle s’applique lors des exécutions incrémentales, mais pas lors de la première construction de la table (ni lors d’une reconstruction avec--full-refresh). Les commandes portent un timestamp, si bien que le filtre compareordered_atà la valeur la plus récente déjà présente dans la table, référencée via la variable{{ this }}.
models/marts/orders.sql afin que le bloc config et la fin du modèle ressemblent à ceci :
stg_orders tronque ordered_at au jour, le filter utilise donc >= : à chaque exécution, la journée la plus récente est intégralement retraitée et, grâce à unique_key, les rows déjà chargées sont replaced plutôt que dupliquées. C’est ce qui rend l’opération sûre pour les commandes qui arrivent plus tard dans la même journée.
Exécutez le model. La table existe déjà : cette première exécution est donc déjà une exécution incremental, seule la journée la plus récente est retraitée.
nutellaphone who dis? à 11,00 et la taxe correspond aux 6 % de Philadelphie : les tests de données du projet continuent donc de passer. Exécutez l’ensemble du projet afin que les vues de staging et la table order_items voient les nouvelles rows avant orders :
customers, reconstruit à partir de celle-ci, connaît le nouveau client :
Internals
Le query log de ClickHouse montre les statements exécutés par l’adapter pour la mise à jour incrémentale :- Une table
orders__dbt_new_dataest créée et le SQL du model, y compris le filter incrémental, y est inséré. Lors de l’exécution ci-dessus, 378 rows ont été écrites : les 377 commandes du dernier jour déjà chargées, plus la nouvelle. - Une table
orders__dbt_tmpest créée avec la même structure queorders, puis toutes les rows deordersdont l’order_idn’est pas présent dansorders__dbt_new_datay sont copiées. - Toutes les rows de
orders__dbt_new_datasont insérées dansorders__dbt_tmp. Ce sont les étapes 2 et 3 qui remplacent les rows du dernier jour au lieu de les dupliquer. orders__dbt_new_dataest supprimée.orders__dbt_tmpest échangée avecordersau moyen d’un statement atomicEXCHANGE TABLES(via un renommage intermédiaire enorders__dbt_backup), de sorte queorderscontient désormais la nouvelle version.- L’ancienne version est supprimée.
Append strategy
La stratégieappend insère les rows sélectionnées par le model directement dans la target table. Aucune temporary table n’est créée et rien n’est copié : c’est donc l’exécution incremental la moins coûteuse possible. En contrepartie, rien n’est dédupliqué : si le filter incremental sélectionne une row déjà présente dans la table, celle-ci se retrouve en double. Réservez cette stratégie à des données immuables, de type événement, et veillez à ce que le filter ne sélectionne que des rows réellement nouvelles.
Avec ordered_at tronqué au jour, cela revient à remplacer le filter par >. Modifiez le model :
orders est un unique INSERT INTO jaffle_shop.orders ... SELECT ... contenant le SQL du model et le filter incrémental, et il a écrit une seule row.
Stratégie delete et insert
Historiquement, ClickHouse n’a offert qu’une prise en charge limitée des mises à jour et des suppressions, sous la forme de mutations asynchrones. Celles-ci peuvent s’avérer extrêmement coûteuses en IO et sont généralement à éviter. ClickHouse 22.8 a introduit les suppressions légères et ClickHouse 25.7 les mises à jour légères. Avec elles, l’effet d’une instruction de suppression ou de mise à jour est immédiatement visible du point de vue de l’utilisateur, même si elle est matérialisée de manière asynchrone. La stratégiedelete+insert repose sur les suppressions légères et se configure via le paramètre incremental_strategy :
- Une temporary table (
orders__dbt_new_data_<run_id>) est créée, et les rows sélectionnées par le model y sont insérées. - Un
DELETEest exécuté surorderspour chaqueorder_idprésent dans la temporary table. - Les rows de la temporary table sont insérées dans
orders. - La temporary table est supprimée.
Stratégie insert overwrite (expérimentale)
La stratégieinsert_overwrite remplace des partitions entières : elle nécessite donc une configuration partition_by, comme la configuration mensuelle définie sur orders. Elle procède selon les étapes suivantes :
- Créer une staging table (
orders__dbt_new_data_<run_id>) ayant la même structure queorders. - Insérer dans la staging table uniquement les rows sélectionnées par le model.
- Lister les partitions présentes dans la staging table à partir de
system.parts. - Remplacer exactement ces partitions dans
ordersparALTER TABLE ... REPLACE PARTITION ... FROMdepuis la staging table. - Supprimer la staging table.
- Elle est plus rapide que la stratégie par défaut, car elle ne copie pas l’intégralité de la table.
- Elle est plus sûre que les autres stratégies, car elle ne modifie pas la table d’origine tant que l’opération INSERT ne s’est pas exécutée avec succès : en cas d’échec intermédiaire, la table d’origine reste inchangée.
- Elle applique la bonne pratique d’ingénierie des données dite d‘“immutabilité des partitions”, qui simplifie le traitement incrémental et parallèle des données, les rollbacks, etc.
microbatch et on_schema_change.
Créer un snapshot
Les snapshots dbt enregistrent l’évolution des rows d’une table mutable au fil du temps, ce qui permet aux analystes de consulter l’état des données à n’importe quel moment du passé. Ils mettent en œuvre les dimensions à évolution lente de type 2 : chaque version d’une row est stockée avec l’interval durant lequel elle était valide. Le martcustomers est un bon candidat : count_lifetime_orders, lifetime_spend et customer_type changent tous dès qu’un client passe une nouvelle commande. Avant de poursuivre, rétablissez sur le modèle orders la stratégie incrémentale par défaut de la section incrémentale (supprimez incremental_strategy='append' et remettez le filtre à >=), afin que les commandes passées plus tard dans la journée soient bien prises en compte.
Depuis dbt 1.9, les snapshots sont définis en YAML. Créez snapshots/customers_snapshot.yml :
check compare les colonnes listées entre le snapshot courant et la source à chaque exécution et enregistre une nouvelle version dès que l’une d’elles change. Si votre modèle dispose d’une colonne timestamp fiable de type “dernière mise à jour”, la stratégie timestamp est moins coûteuse : définissez strategy: timestamp et updated_at: <column>. Le champ last_ordered_at de Jaffle Shop est tronqué au jour : il ne détecterait donc pas une seconde commande passée le même jour, d’où l’utilisation de check dans cet exemple.
Prenez le premier snapshot :
generate_schema_name du projet place chaque relation dans le schéma du target pour les targets hors production ; une config schema sur le snapshot ne prendrait donc effet qu’avec le target prod. Elle contient une row par client, avec les columns de suivi dbt dbt_valid_from et dbt_valid_to ; cette dernière vaut NULL pour la version courante d’une row :
orders et customers reflètent la nouvelle commande, puis créez un second snapshot :
dbt_valid_to, tandis que la nouvelle version, correspondant à un client returning avec deux commandes, reste ouverte. Danny n’a pas changé : sa row demeure donc intacte :
customers_snapshot__snapshot_upsert, puis la met en place avec EXCHANGE TABLES (ou via un drop suivi d’un rename lorsque le serveur ne prend pas en charge l’échange de tables) : les lecteurs voient ainsi soit la version précédente, soit la nouvelle version du snapshot. Consultez la section snapshot de la page materializations pour la référence de configuration.
Utiliser les vues matérialisées
Jusqu’ici, tout nécessite undbt run pour intégrer de nouvelles données dans les modèles. Les vues matérialisées de ClickHouse fonctionnent différemment : ce sont des déclencheurs à l’insertion. Chaque bloc de lignes inséré dans la table source est transformé par le SELECT de la vue, puis écrit dans une table cible, sans aucune planification. L’adaptateur les expose via la matérialisation materialized_view.
Créez models/marts/daily_store_revenue.sql avec le nombre de commandes et le chiffre d’affaires par magasin et par jour, en lisant directement dans la table des commandes brutes :
engine et order_by s’appliquent à la table cible. SummingMergeTree additionne les colonnes numériques des lignes partageant la même clé de tri lorsqu’il fusionne les parts, ce qui correspond exactement au besoin d’une agrégation par jour et par magasin.
_mv, qui pointe vers la target table via une clause TO. Par défaut (catchup=True), la target table a également été backfillée avec les commandes existantes :
sum() et GROUP BY : SummingMergeTree ne regroupe les lignes ayant la même clé qu’au moment de la fusion des parts en arrière-plan ; jusque-là, les deux commandes de Brooklyn restent deux lignes distinctes dans la table. Agrégez toujours à la lecture (ou utilisez FINAL) avec les engines de type summing et aggregating. De son côté, le modèle incremental orders ne contient encore qu’une seule commande pour Danny jusqu’au prochain dbt run.
Les exécutions suivantes de dbt run conservent la target table et ses données et se contentent de mettre à jour la view definition, via ALTER TABLE ... MODIFY QUERY lorsque la modification le permet : on peut donc sans risque conserver le modèle dans le projet. dbt run --full-refresh reconstruit la target table et en refait le backfill (sauf si catchup vaut False). La page sur les materialized views couvre le reste : les changements de schéma avec on_schema_change, la désactivation du backfill avec catchup, les refreshable materialized views, plusieurs views alimentant la même target et la définition de la target table comme modèle à part entière.