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_publicist 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>], Viewsv_<space>_<domain>, Rollupsmv_<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_publicmit 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 ZusatzattributeHinweis: 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_devicemitNullable/String, danach typisiertesINSERT … SELECTins 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 OrgsRLS (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_Anachtenant_Bodertenant_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)
- Inventar & Freeze: Tabellen/Views aus DuckDB erfassen, Schema in YAML/SQL fixieren.
- Parquet-Export: Falls Daten nur in DuckDB liegen, Export nach Parquet (partitioniert nach Zeit/Domain).
- DDL anwenden: CI/Flux spielt Migrationen in
tenant_<name>ein. - Initialload: Batch-Inserts aus Parquet, parallel pro Partition (Monat/Jahr).
- Validierung: Counts je Partition; Stichproben-Aggregate und Hash-Checks.
- 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 … SELECTpartitioniert 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 TOin 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)
| Komponente | Heute | Neu |
|---|---|---|
| VolumeOperator | erstellt Storage (Azure Files) für DuckDB | entfällt |
| StorageManager | Azure Files für DuckDB | Provisioniert Speicher & Org-Credentials für ClickHouse |
| Parquet-Konvertierung | schreibt DuckDB-Files | schreibt Parquet; optionaler Loader in jeweilige Org-DB |
| Modellierung | DuckDB-Schema | deklarative Modelle/SQL je Tabelle, Migrationen via CI/Flux in tenant_<name> |
| Superset(-Tenant-Operator) | liest DuckDB | pro 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:
- Schema-/Migration-Sicht
Baut/ändert Tabellen, Views, Policies. Läuft, wenn sich das Modell ändert. - 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")
@endumlContainer (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")
@endumlKomponenten (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