StatusTypeRelevance

Status|104

Was brauchen wir, um Energie-UseCases ins System zu bringen?
ClickHouse? Superset? Und wie bleibt das Ganze trotzdem handhabbar?


Dieses Dokument beschreibt, wie wir DuckDB als Speicher für analytische Workloads durch ClickHouse ersetzen.
Es liefert zunächst das Warum und die Zielarchitektur, danach folgen separate Dokumente zur Implementierung, zu Beispielen und zum Alerting.

Modell-Definitionen wie model.yaml werden in der Implementierungsdokumentation detailliert erklärt. In diesem Dokument reicht der Architekturvertrag: Git ist die führende Quelle für Schema, Migration und Betriebsgrenzen; ClickHouse ist das ausführende System, nicht der Ort für manuelle Schema-Pflege.


🎯 Ziel

  • ClickHouse läuft als StatefulSet in AKS. Persistenz auf Azure Disks, Azure Blob Storage für Import/Export und Backups.
  • Die Parquet-Konvertierung bleibt und schreibt in den Data Lake (Blob). Von dort erfolgen Batch-Loads nach ClickHouse, Change Data Capture bleibt erstmal draußen.
  • Parquet bleibt die Single Source of Truth; ClickHouse ist das schnelle, query-optimierte Derivat für Analytics und Visualisierung.
  • In ClickHouse liegen strukturierte, performante MergeTree-Tabellen; Modellierung über deklarative Modelle, Views und Materialized Views, gesteuert aus Git.
  • Schema Git-first: Änderungen entstehen im Repository, werden reviewed und als Migrationen eingespielt. Kein „ClickHouse als Editor“.
  • Superset hängt direkt an ClickHouse: lesend, mit minimalen Schreibrechten (View-Definitionen).
  • Tenancy: eine Datenbank pro Tenant (tenant_<name>). Spaces als Space-first-Tabellen (fact_<space>_<domain>[_<entity>]). tenant_public ist eine spezielle read-only Datenbank, die alle lesen dürfen.
  • RLS nur innerhalb einer Org, falls Feinschnitt in einem Space nötig ist.
  • Retention via TTL auf Tabellen/Partitionen. Alte Daten werden automatisch entsorgt.
  • Betrieb ist von Anfang an Teil des Konzepts: Monitoring, Backups, Migrationstransparenz und Loader-Fehlerbilder gehören zum ersten produktiven Setup dazu und nicht erst zu einer späteren Härtungsrunde.

📦 Status Quo (Alt)

@startuml
!define C4P https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/v2.7.0
!includeurl C4P/C4_Container.puml
LAYOUT_LEFT_RIGHT()
 
Person(remote, "remote client", "Liest Parquet via duckdb-lib")
 
Container(ingest, "ingest", "Service/Workflow", "Schreibt Parquet mit duckdb-lib")
Container(parquet, "parquet-files", "object store", "Parquet im Object Storage")
 
Boundary(modeling, "modeling-workflow") {
  Container(dbt, "dbt", "Transformation/Jobs", "Liest Parquet, schreibt Modelle")
}
 
ContainerDb(duckstore, "duckdb-storage", "DuckDB", "Azure File")
Container(superset, "superset", "BI/Analytics", "Dashboards, SQL, optional duckdb-lib auf Parquet")
 
Rel(ingest, parquet, "schreibt", "duckdb-lib")
Rel(remote, parquet, "liest", "duckdb-lib")
Rel(dbt, parquet, "liest", "Parquet")
Rel(dbt, duckstore, "schreibt", "DuckDB")
Rel(superset, duckstore, "liest", "SQL")
Rel(superset, parquet, "optional: liest", "duckdb-lib")
@enduml
  • DuckDB/Datei wird heute für Modellierung (schreibend) und Superset (lesend/teilweise schreibend via Views) genutzt.
  • 📋 Ziel: DuckDB als Speicher ersetzen, Logik und Workflows beibehalten.

Architekturprinzipien

  • Isolation: harte Trennung je Tenant über eigene Datenbank (tenant_<name>).
  • Space-first: Tabellen je Space mit konsistenter Benennung fact_<space>_<domain>[_<entity>], Views v_<space>_<domain>, Rollups mv_<space>_<domain>[_<entity>].
  • Git-first: Modellfiles (model.yaml) → generierte DDL → nummerierte Migrationen via CI/Flux. Keine Live-Hotfixes.
  • Schmale, domänenspezifische Tabellen statt Monster-Basistabelle. Zeit-Partitionierung, sinnvolles ORDER BY.
  • [Public-Daten: separate DB tenant_public mit globaler read-only Rolle.]
  • RLS: nur innerhalb einer Organisation, wenn ein Space intern nochmal geschnitten werden muss.
  • Klare Vertragsgrenzen: Das Tabellenmodell beschreibt die physische Struktur modellierter Tabellen; Ingestion, Rollout-Logik und betriebliche Policies werden separat umgesetzt, aber architektonisch mitgedacht.

🏗️ Tabellen anlegen (Schema, Engines, Partitionierung)

Schema-Quelle (Git-first)

  • Pro Tabelle ein leichtgewichtiges Modellfile und daraus generierte DDL.
  • Änderungen via Pull Request; nummerierte Migrationen laufen per CI/Flux.
  • DB-Name wird beim Rendern parametrisiert ({{DB}}=tenant_<name>).
  • Für modellierte Tabellen ist das Git-Modell der Sollzustand. Direkte manuelle Schemaänderungen in ClickHouse sind kein regulärer Pfad.

Die Modell-Definitionen entstammen unserer Git-first-Pipeline und werden in der Implementierungsdokumentation vollständig beschrieben. Für den Kontext genügt zu wissen: Diese YAML-Dateien definieren die physische Tabellenstruktur, insbesondere Spalten, Typen, Engine, Partitionierung, Ordnung und TTL. Die konkrete Feldmenge, Versionierung und Validierung des Modellformats liegt in der Implementierung; unbekannte oder nicht unterstützte Angaben sollen dort fail-fast behandelt werden statt stillschweigend in SQL durchzurutschen.

Beispiel model.yaml (Space-first, Timeseries/Facts)

name: fact_reservationdata_device
partition_by: toYYYYMM(ts)
order_by: [device_id, ts]
ttl: ts + INTERVAL 365 DAY DELETE
columns:
  - { name: ts,          type: DateTime }
  - { name: device_id,   type: String,                 nullable: false }
  - { name: status_code, type: LowCardinality(String) }
  - { name: location,    type: String }
  - { name: attrs_json,  type: String }   # optionale Zusatzattribute

Hinweis: Defaults wie nullable müssen in der Implementierung eindeutig festgelegt sein. Das Architekturkonzept setzt voraus, dass derselbe Modellinput immer dieselbe DDL erzeugt.

Generierte DDL (je Org-DB):

-- in Datenbank: tenant_test
CREATE TABLE IF NOT EXISTS tenant_test.fact_reservationdata_device
(
  ts          DateTime,
  device_id   String,
  status_code LowCardinality(String),
  location    String,
  attrs_json  String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)
ORDER BY (device_id, ts)
TTL ts + INTERVAL 365 DAY DELETE
SETTINGS index_granularity = 8192;

Warum MergeTree?
Spaltenbasiert, komprimiert, partitionierbar, Hintergrund-Merges und TTL. Ergebnis: schnelle Filter, kein Vollscan, Inserts ohne globale Locks.

Abgrenzung

  • Das Tabellenmodell beschreibt modellierte physische Tabellen.
  • Zusätzliche Objekte wie Views, Materialized Views oder Policies können ebenfalls aus Git kommen, folgen aber nicht zwingend demselben Vertragsausschnitt wie das Tabellenmodell.
  • Handgeschriebene SQL-Artefakte dürfen nicht den Sollzustand modellierter Tabellen unterlaufen; für diese Tabellen bleibt das Modell die führende Quelle.

💾 Daten schreiben (Ingestion)

Quelle ist Parquet im Lake (Landing/Curated). Ein Loader-Job schreibt batchweise in die jeweilige Org-DB:

-- file(): Loader-Pod sieht Filesystem
INSERT INTO tenant_test.fact_reservationdata_device
SELECT * FROM file('/mnt/curated/reservationdata/device/2025-11/*.parquet', 'Parquet');
 
-- url()/azure() via SAS
INSERT INTO tenant_test.fact_reservationdata_device
SELECT * FROM url('https://.../curated/reservationdata/device/2025-11/*.parquet?sv=...', 'Parquet');
 
-- azureBlobStorage()
INSERT INTO tenant_test.fact_reservationdata_device
SELECT *
FROM azureBlobStorage(
    'myaccount.blob.core.windows.net',
    'mycontainer',
    'curated/reservationdata/device/2025-11/',
    'sv=...&ss=...&srt=...&sp=rl&se=2025-12-01T00:00Z&sig=...',
    'Parquet'
);

Schema-Drift

  • Streng: Spalten explizit benennen; neue Felder zuerst migrieren, dann laden.
  • Flexibel: Staging-Tabelle stg_reservationdata_device mit Nullable/String, danach typisiertes INSERT … SELECT ins Ziel.
  • Grundsatz: Schema-Drift ist kein stiller Convenience-Fall. Bei unklaren Typänderungen oder zusätzlichen Feldern ist Staging der sichere Standard, nicht kreative Ad-hoc-Koerzierung im Loader.

🔍 Daten lesen (Superset & Ad-hoc)

  • Superset nutzt pro Tenant eine Connection (Org-User). Datasets zeigen nur Tabellen in tenant_<name>.
  • (tenant_public als separate read-only Connection.)
  • Ad-hoc: direkt aus Parquet (file('*.parquet','Parquet')) nur für Analysen, nicht für Dashboards.

🧱 Multi-Tenancy

Datenbanken

  • tenant_<name> pro Tenant, z. B. tenant_test.
  • Spaces als Space-first-Tabellen (fact_<space>_*); optional eigene DB pro Space, falls SLA/Retention stark abweichen.

Das Modell ist fachlich sauber, erzeugt aber Betriebsaufwand. Provisionierung, Rechte, Secrets, Migrationen und Superset-Connections müssen daher als Plattformaufgabe mitgedacht werden und nicht als bloßer Nebeneffekt der Tabellenanlage.

Rollen & Benutzer

-- Org test
CREATE DATABASE IF NOT EXISTS tenant_test;
 
CREATE ROLE IF NOT EXISTS role_tenant_test;
CREATE USER IF NOT EXISTS user_tenant_test IDENTIFIED BY '***';
GRANT role_tenant_test TO user_tenant_test;
 
-- Rechte NUR auf die eigene DB des Tenants
GRANT SELECT, INSERT, ALTER, CREATE, DROP ON tenant_test.* TO role_tenant_test;
REVOKE ALL ON *.* FROM role_tenant_test;

Public-Org (read-only für alle)

CREATE DATABASE IF NOT EXISTS tenant_public;
CREATE ROLE IF NOT EXISTS role_public_read;
GRANT SELECT ON tenant_public.* TO role_public_read;
GRANT role_public_read TO user_tenant_test;  -- analog für weitere Orgs

RLS (optional, nur innerhalb einer Org)

-- Beispiel: Space "reservationdata" feiner auf Region EU begrenzen
CREATE ROLE IF NOT EXISTS role_tenant_test_reservationdata_eu;
 
CREATE ROW POLICY IF NOT EXISTS rp_reservationdata_eu
ON tenant_test.fact_reservationdata_device
FOR SELECT USING JSONExtractString(attrs_json, 'region') = 'EU';
 
GRANT SELECT ON tenant_test.* TO role_tenant_test_reservationdata_eu;

Daten-Sharing (Zukunft)

  • Empfohlen: physisches Publish per Job von tenant_A nach tenant_B oder tenant_public:
INSERT INTO tenant_B.shared_from_A_reservationdata_device_hourly
SELECT * FROM tenant_A.v_reservationdata_device_hourly_export;
  • Alternative: Materialized-View-Pull mit dediziertem Sharing-Account (nur wenn bewusst gewollte Laufzeitabhängigkeit).

🔁 Migration (DuckDB → ClickHouse)

  1. Inventar & Freeze: Tabellen/Views aus DuckDB erfassen, Schema in YAML/SQL fixieren.
  2. Parquet-Export: Falls Daten nur in DuckDB liegen, Export nach Parquet (partitioniert nach Zeit/Domain).
  3. DDL anwenden: CI/Flux spielt Migrationen in tenant_<name> ein.
  4. Initialload: Batch-Inserts aus Parquet, parallel pro Partition (Monat/Jahr).
  5. Validierung: Counts je Partition; Stichproben-Aggregate und Hash-Checks.
  6. Cutover: Superset-Datasource auf die Org-DB umstellen; DuckDB befristet read-only als Fallback.

Für modellierte Tabellen gilt: Modelländerungen und deren resultierende Migrationen müssen zusammen lesbar und reviewbar bleiben. Ein Repo, in dem Modell und manuelle Änderungen gegeneinander laufen, wäre unnötiger Nebel mit SQL-Parfüm.

Die Validierung vor dem Cutover sollte nicht nur auf Gesamtcounts beruhen, sondern mindestens partitionsweise erfolgen; je nach Domäne zusätzlich mit ausgewählten Aggregaten, Null-Raten oder Kardinalitäten für kritische Felder.

Späteres Auslagern eines Spaces in eine eigene DB: INSERT … SELECT partitioniert umziehen. Solange DDL kompatibel ist, kein Drama.


🕓 Retention

  • TTL pro Tabelle, z. B. 365 Tage:
TTL ts + INTERVAL 365 DAY DELETE
  • Abweichungen je Space/Use-Case über eigene Tabellen oder Space-spezifische Settings. Langzeit-Archiv bleibt Parquet im Lake.

⚙️ Betrieb

  • Deployment: ClickHouse StatefulSet auf AKS, möglichst ohne ZooKeeper-Abhängigkeit. ✅
  • Storage: Azure Managed Disks (Premium SSD). Backups per BACKUP TO in Blob; optional Argo CronWorkflows. 🚧
  • Security: Org-separate User/Roles, Secrets via SealedSecrets; TLS/HTTPS.
  • Monitoring: System-Tabellen, CPU/IO, parts, merges; Alarm bei Merge-Backlog/Part-Explosion.
  • Operabilität ab Tag 1: Migrationen, Loader und Backups brauchen nachvollziehbare Logs, Status und Fehlerbilder. Ein produktives Setup ohne sichtbare Render-, Apply- und Load-Fehler wäre nur observability-freies Hoffen.

📊 Superset

  • Eine Connection pro Org (Credential = user_tenant_<name>).
  • Datasets nach Space-Präfix filtern, z. B. reservationdata_*.
  • tenant_public als zusätzliche read-only Connection.
  • Schemaänderungen kommen aus CI/Migrationen, nicht via Editor-Klickerei.

🧭 Entscheidungsleitplanken

  • Kein Streaming, solange Batch reicht.
  • Keine Data-Virtualization-Spielereien: Parquet = Quelle, ClickHouse = Query-Backend.
  • Trennung standardmäßig per Org-DB; RLS nur als Feinschnitt innerhalb einer Org.
  • Wenn ein Space stark abweicht oder eigene SLAs/TTL braucht: optional eigene DB pro Space.
  • Alles über Git+CI: reproduzierbar, reviewbar, rückführbar.
  • Rollout-Sicherheit geht vor Rollout-Geschwindigkeit: partielle Fehler pro Org müssen erwartbar sein und im Betriebsmodell sichtbar werden.

🔄 Komponenten-Mapping (Ist → Soll)

KomponenteHeuteNeu
VolumeOperatorerstellt Storage (Azure Files) für DuckDBentfällt
StorageManagerAzure Files für DuckDBProvisioniert Speicher & Org-Credentials für ClickHouse
Parquet-Konvertierungschreibt DuckDB-Filesschreibt Parquet; optionaler Loader in jeweilige Org-DB
ModellierungDuckDB-Schemadeklarative Modelle/SQL je Tabelle, Migrationen via CI/Flux in tenant_<name>
Superset(-Tenant-Operator)liest DuckDBpro Org eigene Connection; [separates tenant_public read-only]

💼 Beispielkasten: Energy - Visualisierung Netzzustand ⚡

View in Org-DB aus Space-first-Tabelle:

-- Datenbank: tenant_test
CREATE OR REPLACE VIEW tenant_test.v_reservationdata_device_status_hourly AS
SELECT
  device_id,
  toStartOfHour(ts) AS ts_hour,
  sumIf(1, status_code = 'online')  AS cnt_online,
  sumIf(1, status_code = 'offline') AS cnt_offline
FROM tenant_test.fact_reservationdata_device
GROUP BY device_id, ts_hour;

Domänen-Tabelle (falls Energy groß wird, Space „gridstate“):

CREATE TABLE IF NOT EXISTS tenant_test.fact_gridstate_measurements
(
  ts          DateTime,
  region_id   UInt32,
  sensor_id   String,
  voltage     Float32,
  current     Float32,
  power_kw    Float32,
  temperature Float32,
  attrs_json  String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)
ORDER BY (region_id, sensor_id, ts)
TTL ts + INTERVAL 365 DAY DELETE;

🚀 Implementierung

Die Implementierung bedient zwei Sichten:

  1. Schema-/Migration-Sicht
    Baut/ändert Tabellen, Views, Policies. Läuft, wenn sich das Modell ändert.
  2. Ingestion-Sicht
    Holt Parquet, lädt nach ClickHouse, validiert. Läuft, wenn Daten da sind.

Beide Sichten bleiben bewusst getrennt: Schema-Änderung und Datenladung sind unterschiedliche Betriebsabläufe, auch wenn sie sich auf dieselben Tabellen beziehen.

mehr dazu siehe 02 - DuckDB - ClickHouse Implementierung


🧩 Visualisierung

System Context (mit Org-DBs & Public)

@startuml
!define C4P https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/v2.7.0
!includeurl C4P/C4_Context.puml
LAYOUT_TOP_DOWN()
 
Person(analyst, "Data Analyst", "Baut Dashboards in Superset")
Person(eng, "Data Engineer", "Pflegt Modelle & Migrationen in Git")
 
System_Boundary(sdk, "SDK") {
  System(superset, "Superset", "Visualisierung & Ad-hoc SQL")
  System(ch, "ClickHouse", "Analytische DB (MergeTree) - DBs: tenant_<name>, tenant_public")
  System(datalake, "Data Lake (Blob)", "Parquet als Source of Truth")
  System(storageMgr, "StorageManager", "Provisioniert Speicher & Secrets")
  System(ci, "CI/Flux", "spielt Migrationen & Loader-Jobs ein")
}
 
Rel(analyst, superset, "nutzt")
Rel(superset, ch, "SQL read / Views")
Rel(ch, datalake, "Batch-Load aus Parquet", "file()/url()/azure()")
Rel(eng, ci, "pusht PR/Migrationen")
Rel(ci, ch, "führt DDL/Jobs aus")
Rel(ci, datalake, "schreibt/liest Parquet")
Rel(storageMgr, ch, "liefert Secrets/ConnStrings")
@enduml

Container (je Org eigene Connection)

@startuml
!define C4P https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/v2.7.0
!includeurl C4P/C4_Container.puml
LAYOUT_TOP_DOWN()
 
Container(modelrepo, "Git Repo", "YAML/SQL", "Schema & Migrationen")
System_Boundary(sdk, "SDK") {
  Container(superset, "Superset", "Python", "Dashboards & SQL Lab (1 Connection pro Org, extra: tenant_public)")
  Container(ch, "ClickHouse StatefulSet", "C++", "DBs: tenant_<name>, tenant_public; MergeTree, TTL")
  Container(loader, "Loader (Argo Workflow)", "Batches", "INSERT ... SELECT FROM Parquet → tenant_<name>.*")
  Container(ci, "CI/FluxCD", "Pipelines", "Generiert DDL & spielt Migrationen/Jobs ein")
  Container(storageMgr, "StorageManager", "Service", "Provisionierung & Secrets")
  Container(datalake, "Azure Blob", "Parquet", "Landing/Curated")
  Container(backup, "Blob Backup", "Snapshots", "BACKUP TO")
}
 
Rel(superset, ch, "SQL (read, Views) je Org-Conn")
Rel(loader, ch, "INSERT → tenant_<name>.*")
Rel(loader, datalake, "read Parquet")
Rel(ci, ch, "DDL/Migrationen")
Rel(ci, loader, "Job Trigger")
Rel(modelrepo, ci, "Pull")
Rel(storageMgr, ch, "liefert Secrets/ConnStrings")
Rel(ch, backup, "BACKUP TO")
@enduml

Komponenten (Ingestion/Schema, Org-Scopes)

@startuml
!define C4P https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/v2.7.0
!includeurl C4P/C4_Component.puml
LAYOUT_TOP_DOWN()
 
Container_Boundary(ing, "Ingestion & Schema (pro Org)") {
  Component(conv, "Parquet-Konvertierung", "Batch", "schreibt Parquet in Lake")
  Component(loader, "ClickHouse Loader", "Argo Step", "INSERT ... SELECT FROM file()/url() → tenant_<name>.*")
  Component(ddlgen, "DDL-Generator", "CI Step", "YAML → CREATE/ALTER TABLE (param: db=tenant_<name>)")
  Component(migrations, "Migration Runner", "CI/Flux", "spielt SQL in tenant_<name> ein")
}
 
Component(datalake, "Data Lake (Blob)", "Parquet", "Source of Truth")
Component(ch, "ClickHouse", "DB", "tenant_<name>, tenant_public")
 
Rel(conv, datalake, "write Parquet")
Rel(loader, datalake, "read Parquet")
Rel(loader, ch, "INSERT")
Rel(ddlgen, migrations, "liefert DDL")
Rel(migrations, ch, "CREATE/ALTER TABLE in tenant_<name>")
@enduml

✨ Samples

siehe 03 - Samples


🚨 ClickHouse - Alerting Table

siehe 04 - ClickHouse - Alerting