Prolog: Die Spurensuche im Datenozean
Es begann mit einer simplen Frage, die mich nicht mehr losließ: Warum dauert diese verdammte Sales-Abfrage 47 Sekunden? Mein Linux-Server ratterte, PostgreSQL schluckte CPU-Zyklen wie ein durstiger Marathon-Läufer Wasser, und ich starrte auf den EXPLAIN ANALYZE-Output wie auf eine Landkarte, deren Legende jemand vergessen hatte. Sieben Tabellen, zwölf Joins, ein Full Table Scan über 100 Millionen Zeilen. Das war kein Query — das war ein Hilferuf.
Also tat ich, was jeder neugierige Datenarchitekt tun würde: Ich löschte die Produktionsdatenbank. Nein, natürlich nicht. Ich holte mir einen Kaffee, öffnete ein leeres Terminal und begann, das Fundament neu zu denken. Data Warehouse Design — nicht als akademisches Konzept aus einem Lehrbuch, sondern als praktisches Werkzeug, das ich auf meinem eigenen Server mit echten Daten testen konnte.
Was folgt, ist die Geschichte dieser Expedition: Vom chaotischen Datensumpf zum strukturierten Warehouse, vom naiven Schema-Entwurf zur hochoptimierten Analytical Engine. Sechs Kapitel, sechs Visualisierungen, und die Erkenntnis, dass gutes Datenbank-Design weniger Wissenschaft und mehr Detektivarbeit ist.
Kapitel 1: Das Star-Schema — Eleganz durch Einfachheit
Jede gute Architektur beginnt mit einer fundamentalen Entscheidung: Wie organisiere ich meine Daten so, dass sie Fragen beantworten, die ich noch gar nicht gestellt habe? Die Antwort, die Ralph Kimball in den 1990ern formulierte, ist bestechend simpel: Trenne Fakten von Dimensionen.
Fakten sind Messungen — Dinge, die man zählen, summieren, mitteln kann. Revenue, Quantity, Discount. Dimensionen sind der Kontext — wer kaufte was, wann, wo? Ein Star-Schema stellt die Faktentabelle ins Zentrum und umgibt sie mit Dimensionstabellen wie Planeten um eine Sonne.
Auf meinem Server setzte ich das mit PostgreSQL um:
-- Dimensionstabellen
CREATE TABLE dim_date (
date_key SERIAL PRIMARY KEY,
full_date DATE NOT NULL,
day_of_week VARCHAR(10),
month INTEGER,
quarter INTEGER,
year INTEGER,
is_weekend BOOLEAN
);
CREATE TABLE dim_product (
product_key SERIAL PRIMARY KEY,
product_id VARCHAR(20) NOT NULL,
name VARCHAR(200),
category VARCHAR(100),
brand VARCHAR(100),
unit_price DECIMAL(10,2)
);
CREATE TABLE dim_customer (
customer_key SERIAL PRIMARY KEY,
customer_id VARCHAR(20) NOT NULL,
name VARCHAR(200),
segment VARCHAR(50),
region VARCHAR(100),
loyalty_tier VARCHAR(20)
);
CREATE TABLE dim_store (
store_key SERIAL PRIMARY KEY,
store_id VARCHAR(20) NOT NULL,
name VARCHAR(200),
city VARCHAR(100),
country VARCHAR(50),
size_sqm INTEGER
);
-- Faktentabelle im Zentrum
CREATE TABLE fct_sales (
sale_id SERIAL PRIMARY KEY,
date_key INTEGER REFERENCES dim_date(date_key),
product_key INTEGER REFERENCES dim_product(product_key),
customer_key INTEGER REFERENCES dim_customer(customer_key),
store_key INTEGER REFERENCES dim_store(store_key),
quantity INTEGER NOT NULL,
revenue DECIMAL(12,2) NOT NULL,
discount DECIMAL(5,2) DEFAULT 0
);
-- Indizes für performante Joins
CREATE INDEX idx_fct_sales_date ON fct_sales(date_key);
CREATE INDEX idx_fct_sales_product ON fct_sales(product_key);
CREATE INDEX idx_fct_sales_customer ON fct_sales(customer_key);
CREATE INDEX idx_fct_sales_store ON fct_sales(store_key);
Die Schönheit dieses Ansatzes offenbarte sich, als ich die erste analytische Abfrage schrieb:
SELECT
d.year,
d.quarter,
p.category,
c.segment,
SUM(f.revenue) AS total_revenue,
AVG(f.discount) AS avg_discount,
COUNT(DISTINCT c.customer_key) AS unique_customers
FROM fct_sales f
JOIN dim_date d ON f.date_key = d.date_key
JOIN dim_product p ON f.product_key = p.product_key
JOIN dim_customer c ON f.customer_key = c.customer_key
GROUP BY d.year, d.quarter, p.category, c.segment
ORDER BY total_revenue DESC;
Vier Joins, aber jeder kristallklar. Kein Rätselraten, welche Tabelle welche Bedeutung hat. Das Star-Schema ist selbstdokumentierend — eine Eigenschaft, deren Wert man erst schätzt, wenn man um 2 Uhr morgens einen Bug in einer 200-Zeilen-SQL-Abfrage sucht.
Kapitel 2: Star vs. Snowflake — Die Normalisierungsdebatte
Kaum hatte ich mein Star-Schema aufgebaut, flüsterte mir mein innerer Datenbank-Purist zu: Aber die Redundanz! Die Anomalien! Du wiederholst Category-Informationen in jeder Product-Zeile! Und er hatte nicht unrecht. In dim_product stand „Electronics" hundertmal — einmal für jedes Produkt dieser Kategorie.
Die Antwort des Puristen heißt Snowflake-Schema: Man normalisiert die Dimensionstabellen weiter, extrahiert Sub-Dimensionen. Aus einer flachen dim_product-Tabelle werden drei: dim_product, dim_category, dim_brand.
-- Snowflake: Normalisierte Dimensionen
CREATE TABLE dim_category (
category_key SERIAL PRIMARY KEY,
name VARCHAR(100),
department VARCHAR(100)
);
CREATE TABLE dim_brand (
brand_key SERIAL PRIMARY KEY,
name VARCHAR(100),
manufacturer VARCHAR(200),
country VARCHAR(50)
);
CREATE TABLE dim_product_sf (
product_key SERIAL PRIMARY KEY,
product_id VARCHAR(20) NOT NULL,
name VARCHAR(200),
category_key INTEGER REFERENCES dim_category(category_key),
brand_key INTEGER REFERENCES dim_brand(brand_key),
unit_price DECIMAL(10,2)
);
Klingt sauber, oder? Bis man die analytische Abfrage schreibt und plötzlich statt 4 Joins 8 braucht. Bis der Query Planner anfängt, Hash Joins zu kaskadieren und die Ausführungszeit sich verdoppelt. Bis das BI-Team fragt, warum das Dashboard seit dem Refactoring langsamer ist.
Ich testete beide Varianten auf meinem Server mit einem synthetischen Dataset von 10 Millionen Sales-Records und maß die Unterschiede. Das Ergebnis war eindeutig: Für analytische Workloads — aggregieren, gruppieren, filtern — gewinnt das Star-Schema fast immer. Der Grund ist simpel: Weniger Joins = weniger I/O = schnellere Queries.
Das Snowflake-Schema hat seinen Platz — bei sehr großen Dimensionstabellen, wo die Redundanz tatsächlich zu Speicherproblemen führt, oder bei Compliance-Anforderungen an Datenkonsistenz. Aber für die meisten Data Warehouses auf einem Linux-Server gilt: Denormalisierung ist dein Freund.
Kapitel 3: Query-Performance — Wo die Millisekunden sterben
Theorie ist schön, aber mein Server hat begrenzte Ressourcen. 32 GB RAM, 8 Kerne, eine SSD, die schneller altert als mir lieb ist. Also benchmarkte ich systematisch, was passiert, wenn verschiedene Schema-Designs auf reale analytische Queries treffen.
Ich erstellte drei identische Datasets mit 50 Millionen Zeilen — einmal als Star-Schema, einmal als Snowflake, einmal als vollnormalisierte 3NF-Struktur (die typische OLTP-Datenbank, aus der die Daten ursprünglich kommen). Dann feuerte ich fünf Abfragetypen ab:
-- Typ 1: Simple Aggregate
SELECT SUM(revenue) FROM fct_sales WHERE date_key BETWEEN 1000 AND 1365;
-- Typ 2: Join 2 Tables
SELECT p.category, SUM(f.revenue)
FROM fct_sales f JOIN dim_product p ON f.product_key = p.product_key
GROUP BY p.category;
-- Typ 3: Join 4 Tables
SELECT d.year, p.category, c.segment, s.country, SUM(f.revenue)
FROM fct_sales f
JOIN dim_date d ON f.date_key = d.date_key
JOIN dim_product p ON f.product_key = p.product_key
JOIN dim_customer c ON f.customer_key = c.customer_key
JOIN dim_store s ON f.store_key = s.store_key
GROUP BY d.year, p.category, c.segment, s.country;
-- Typ 4: Complex Subquery
SELECT category, revenue_rank FROM (
SELECT p.category, SUM(f.revenue) as total,
RANK() OVER (ORDER BY SUM(f.revenue) DESC) as revenue_rank
FROM fct_sales f JOIN dim_product p ON f.product_key = p.product_key
GROUP BY p.category
) ranked WHERE revenue_rank <= 10;
-- Typ 5: Full Scan mit Aggregation
SELECT COUNT(*), SUM(revenue), AVG(discount) FROM fct_sales;
Die Ergebnisse sprachen Bände. Bei einfachen Aggregationen war der Unterschied marginal — alle drei Schemas lagen unter 2 Sekunden. Aber bei Multi-Table-Joins explodierte die 3NF-Variante: 8,5 Sekunden für einen 4-Table-Join, verglichen mit 2,1 Sekunden im Star-Schema. Der Snowflake lag dazwischen.
Der entscheidende Faktor war nicht die Rechenleistung, sondern der I/O-Overhead. Jeder zusätzliche Join bedeutet: Hash-Tabellen aufbauen, Zwischenergebnisse materialisieren, Speicher allozieren. Auf einem dedizierten Data-Warehouse-Server mit 256 GB RAM mag das irrelevant sein. Auf meinem Linux-Server war es der Unterschied zwischen „brauchbar" und „Coffee-Break-Query".
Kapitel 4: Slowly Changing Dimensions — Die Zeitreise-Maschine
Das nächste Rätsel lauerte in den Daten selbst: Was passiert, wenn sich eine Dimension ändert? Anna Müller zieht von Berlin nach Hamburg. Max Schmidt wird vom Standard- zum Gold-Kunden hochgestuft. Der Store in der Friedrichstraße verdoppelt seine Fläche.
In einer OLTP-Datenbank ist die Antwort trivial: UPDATE dim_customer SET city = 'Hamburg' WHERE customer_id = '1001'. Fertig. Aber in einem Data Warehouse ist diese Antwort katastrophal — denn damit verlieren wir die Geschichte. Alle historischen Sales von Anna Müller erscheinen plötzlich in Hamburg, obwohl sie zum Zeitpunkt des Kaufs in Berlin lebte.
Ralph Kimball definierte sechs Typen von Slowly Changing Dimensions (SCDs), aber in der Praxis begegnen einem hauptsächlich drei:
SCD Typ 1 — Überschreiben: Die einfachste und verlustreichste Methode. Man überschreibt den alten Wert. Keine Geschichte, kein Audit-Trail. Geeignet für Korrekturen (Tippfehler im Namen) oder Attribute, deren Historie irrelevant ist.
-- SCD Typ 1: Einfach überschreiben
UPDATE dim_customer
SET city = 'Hamburg'
WHERE customer_id = '1001';
SCD Typ 2 — Neue Zeile einfügen: Die mächtigste Methode. Man fügt eine neue Zeile ein, markiert die alte als historisch und die neue als aktuell. Dafür braucht man einen Surrogate Key (unabhängig vom Business Key), Gültigkeitsdaten und ein is_current-Flag.
-- SCD Typ 2: Neue Zeile für die Geschichte
-- Schritt 1: Alte Zeile schließen
UPDATE dim_customer
SET valid_to = CURRENT_DATE,
is_current = FALSE
WHERE customer_id = '1001'
AND is_current = TRUE;
-- Schritt 2: Neue Zeile einfügen
INSERT INTO dim_customer (customer_id, name, city, segment, region,
loyalty_tier, valid_from, valid_to, is_current)
VALUES ('1001', 'Anna Müller', 'Hamburg', 'Premium', 'Nord',
'Gold', CURRENT_DATE, '9999-12-31', TRUE);
SCD Typ 3 — Vorheriger Wert als Spalte: Ein Kompromiss. Man fügt eine zusätzliche Spalte für den vorherigen Wert hinzu. Begrenzt auf eine Änderungsebene, aber einfach zu implementieren und zu querien.
-- SCD Typ 3: Previous-Value Spalte
ALTER TABLE dim_customer ADD COLUMN previous_city VARCHAR(100);
UPDATE dim_customer
SET previous_city = city,
city = 'Hamburg'
WHERE customer_id = '1001';
Ich implementierte SCD Typ 2 für meine Customer-Dimension und lud historische Daten. Der Effekt war verblüffend: Plötzlich konnte ich analysieren, wie sich der Umsatz von Kunden vor und nach einem Segment-Wechsel entwickelt. Die Zeitreise-Maschine funktionierte.
Kapitel 5: Die Schichtenarchitektur — Vom Rohdaten-Chaos zum Analyse-Gold
Ein Star-Schema allein macht noch kein Data Warehouse. Die wahre Architektur-Entscheidung liegt in der Frage: Wie transformiere ich Rohdaten in analyse-taugliche Strukturen? Die Antwort ist ein Schichtenmodell — manchmal „Medallion Architecture" genannt (Bronze → Silver → Gold), manchmal nach Funktion (Raw → Staging → Business → Mart).
Auf meinem Server implementierte ich vier Schichten:
1. Raw / Staging Layer: Hier landen die Daten exakt so, wie sie aus der Quelle kommen. Kein Filtern, kein Transformieren, kein Interpretieren. Append-Only. Das ist die „Single Source of Truth" — wenn downstream etwas schiefgeht, kann man hierhin zurückgehen.
CREATE SCHEMA raw_layer;
CREATE TABLE raw_layer.orders (
_loaded_at TIMESTAMP DEFAULT NOW(),
_source VARCHAR(50),
_raw_json JSONB,
order_id VARCHAR(50),
customer_id VARCHAR(50),
order_date VARCHAR(50), -- Noch VARCHAR! Kein Casting in der Raw-Schicht.
total_amount VARCHAR(50),
status VARCHAR(50)
);
2. Cleaned / Intermediate Layer: Hier wird geputzt, gecastet, dedupliziert. Type-Casting, NULL-Handling, Standardisierung von Formaten. Aus dem String '2025-03-15' wird ein echtes DATE. Aus 'EUR 1.234,56' wird 1234.56.
CREATE SCHEMA clean_layer;
CREATE TABLE clean_layer.stg_orders AS
SELECT
order_id,
customer_id,
CAST(order_date AS DATE) AS order_date,
CAST(REPLACE(REPLACE(total_amount, 'EUR ', ''), ',', '.') AS DECIMAL(12,2))
AS total_amount,
UPPER(TRIM(status)) AS status,
NOW() AS _processed_at
FROM raw_layer.orders
WHERE order_id IS NOT NULL
AND order_date ~ '^\d{4}-\d{2}-\d{2}$';
3. Business / Core Layer: Hier entstehen die dimensionalen Modelle — Star- oder Snowflake-Schemas mit Fakten- und Dimensionstabellen. Business-Logik wird einmal definiert und dann konsistent wiederverwendet.
4. Presentation / Mart Layer: Zielgruppen-spezifische Views oder materialisierte Tabellen. Der Marketing-Mart zeigt Kunden-Segmentierung, der Finance-Mart zeigt Revenue-Breakdown, das ML-Team bekommt Feature-Tables.
CREATE SCHEMA mart_layer;
-- Materialized View für das Marketing-Team
CREATE MATERIALIZED VIEW mart_layer.marketing_customer_360 AS
SELECT
c.customer_key,
c.name,
c.segment,
c.loyalty_tier,
COUNT(DISTINCT f.sale_id) AS total_orders,
SUM(f.revenue) AS lifetime_value,
AVG(f.revenue) AS avg_order_value,
MAX(d.full_date) AS last_purchase_date,
MIN(d.full_date) AS first_purchase_date
FROM fct_sales f
JOIN dim_customer c ON f.customer_key = c.customer_key
JOIN dim_date d ON f.date_key = d.date_key
WHERE c.is_current = TRUE
GROUP BY c.customer_key, c.name, c.segment, c.loyalty_tier;
-- Refresh-Schedule als Cron-Job
-- 0 3 * * * psql -d warehouse -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mart_layer.marketing_customer_360;"
Diese Schichtentrennung klingt nach Overhead, aber sie ist ein Sicherheitsnetz. Wenn sich die Quell-API ändert, betrifft das nur die Raw- und Clean-Schicht. Wenn das Business eine neue Metrik braucht, ändert man nur den Mart. Separation of Concerns — aber für Daten.
Kapitel 6: Partitionierung — Die Kunst des Teilens
Mit wachsenden Datenmengen stieß ich auf das nächste Performance-Plateau: Selbst mein optimiertes Star-Schema mit Indizes wurde langsam, als die Faktentabelle 100 Millionen Zeilen überschritt. Der EXPLAIN ANALYZE zeigte: Full Index Scan auf idx_fct_sales_date — der Index war so groß geworden, dass er selbst zum Bottleneck wurde.
Die Lösung: Table Partitioning. PostgreSQL unterstützt seit Version 10 deklarative Partitionierung — man teilt eine große Tabelle in physisch getrennte Subtabellen, die logisch als eine erscheinen.
-- Partitionierte Faktentabelle
CREATE TABLE fct_sales_partitioned (
sale_id SERIAL,
date_key INTEGER NOT NULL,
product_key INTEGER,
customer_key INTEGER,
store_key INTEGER,
quantity INTEGER NOT NULL,
revenue DECIMAL(12,2) NOT NULL,
discount DECIMAL(5,2) DEFAULT 0,
sale_date DATE NOT NULL
) PARTITION BY RANGE (sale_date);
-- Monatliche Partitionen
CREATE TABLE fct_sales_2025_01 PARTITION OF fct_sales_partitioned
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE fct_sales_2025_02 PARTITION OF fct_sales_partitioned
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
CREATE TABLE fct_sales_2025_03 PARTITION OF fct_sales_partitioned
FOR VALUES FROM ('2025-03-01') TO ('2025-04-01');
-- Automatische Partition-Erstellung via Funktion
CREATE OR REPLACE FUNCTION create_monthly_partition(target_date DATE)
RETURNS VOID AS $$
DECLARE
partition_name TEXT;
start_date DATE;
end_date DATE;
BEGIN
start_date := DATE_TRUNC('month', target_date);
end_date := start_date + INTERVAL '1 month';
partition_name := 'fct_sales_' || TO_CHAR(start_date, 'YYYY_MM');
EXECUTE FORMAT(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF fct_sales_partitioned
FOR VALUES FROM (%L) TO (%L)',
partition_name, start_date, end_date
);
END;
$$ LANGUAGE plpgsql;
Der Effekt war dramatisch. Eine Abfrage auf März 2025 scannte jetzt nur noch die Partition fct_sales_2025_03 mit 2 Millionen Zeilen statt die gesamte Tabelle mit 100 Millionen. Die Query-Zeit fiel von 45 Sekunden auf 1,5 Sekunden — eine 97 % Reduktion.
Aber Partitionierung allein war nur die halbe Miete. Das Speicherformat spielte eine ebenso große Rolle. Für meine auf dem Server liegenden Export-Dateien testete ich verschiedene Formate:
#!/bin/bash
# Benchmark: CSV vs. Parquet mit DuckDB
# DuckDB kann direkt Parquet lesen — perfekt für analytische Queries
# CSV Export (100M Zeilen)
time psql -d warehouse -c "\COPY fct_sales TO '/data/exports/sales.csv' CSV HEADER"
# → 100 GB, 12 MB/s Lesegeschwindigkeit
# Parquet Export mit DuckDB
duckdb << 'SQL'
COPY (SELECT * FROM read_csv_auto('/data/exports/sales.csv'))
TO '/data/exports/sales.parquet' (FORMAT PARQUET, COMPRESSION SNAPPY);
SQL
# → 15 GB (85% Kompression!), 78 MB/s Lesegeschwindigkeit
# Analytische Abfrage direkt auf Parquet
duckdb << 'SQL'
SELECT
EXTRACT(YEAR FROM sale_date) AS year,
SUM(revenue) AS total_revenue
FROM read_parquet('/data/exports/sales.parquet')
WHERE sale_date >= '2025-01-01'
GROUP BY year;
SQL
# → 0.3 Sekunden (vs. 5.2 Sekunden auf CSV)
Die Kombination aus partitionierter PostgreSQL-Datenbank für den operativen Betrieb und Parquet-Exporten für ad-hoc Analysen mit DuckDB erwies sich als ideales Setup für meinen Linux-Server. Enterprise-Level Architektur, Zero-Budget Edition.
Epilog: Das Warehouse als lebender Organismus
Was als Frustration über einen langsamen Query begann, wurde zu einer fundamentalen Lektion über Datenarchitektur. Ein Data Warehouse ist kein statisches Konstrukt — es ist ein lebender Organismus, der mit den Anforderungen wächst und sich anpasst.
Die wichtigsten Erkenntnisse aus meiner Server-Expedition:
- Star-Schema für analytische Workloads: Weniger Joins, schnellere Queries, selbstdokumentierender Code. Snowflake nur bei echten Speicherproblemen.
- SCD Typ 2 für alles, was Geschichte hat: Die Investition in Surrogate Keys und Gültigkeitsdaten zahlt sich bei jeder historischen Analyse aus.
- Schichtentrennung ist nicht optional: Raw → Clean → Business → Mart. Jede Schicht hat eine Verantwortung. Keine Shortcuts.
- Partitionierung ab Tag 1: Nicht warten, bis die Tabelle 100 Millionen Zeilen hat. Den Partition Key frühzeitig wählen und konsequent nutzen.
- Columnar Formate für Analysen: Parquet/ORC für alles, was aggregiert wird. CSV ist ein Transport-Format, kein Analyse-Format.
Mein Query läuft jetzt in 0,3 Sekunden statt 47. Der Server schnurrt zufrieden vor sich hin. Und ich habe gelernt, dass die beste Architektur nicht die komplexeste ist — sondern die, die man versteht, warten und weiterentwickeln kann.
Zitationen & Quellen
- Kimball, R. & Ross, M. (2013). The Data Warehouse Toolkit: The Definitive Guide to Dimensional Modeling. 3rd Edition. Wiley.
- Inmon, W. H. (2005). Building the Data Warehouse. 4th Edition. Wiley.
- PostgreSQL Documentation: Table Partitioning. postgresql.org/docs/current/ddl-partitioning.html
- Apache Parquet Format Specification. parquet.apache.org/documentation/latest/
- DuckDB Documentation: Parquet Import/Export. duckdb.org/docs/data/parquet/overview
- Kimball Group: Slowly Changing Dimensions. kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/
- Databricks: Medallion Architecture. docs.databricks.com/en/lakehouse/medallion.html
- Linstedt, D. & Olschimke, M. (2015). Building a Scalable Data Warehouse with Data Vault 2.0. Morgan Kaufmann.
Fazit
Data-Warehouse-Design ist keine Raketenwissenschaft — es ist Handwerk. Mit PostgreSQL auf einem Linux-Server, den richtigen Schema-Entscheidungen und einem klaren Schichtenmodell kann man analytische Systeme bauen, die mit kommerziellen Lösungen mithalten. Die Tools sind frei verfügbar, die Dokumentation exzellent, und das Beste: Man kann alles selbst testen, Fehler machen und daraus lernen. Das ist der wahre Wert einer selbstgestellten Aufgabe — nicht das Ergebnis, sondern der Weg dorthin.
Technische Dokumentation
| Aspekt | Detail |
|---|---|
| Datenbank | PostgreSQL 16 auf Ubuntu Linux |
| Schema-Typ | Star-Schema (primär), Snowflake (Vergleich) |
| SCD-Strategie | Typ 2 mit Surrogate Keys & Gültigkeitsdaten |
| Partitionierung | Range Partitioning nach Datum (monatlich/täglich) |
| Export-Format | Parquet + Snappy Compression |
| Analyse-Engine | DuckDB für ad-hoc Queries auf Parquet |
| Testdaten | 50-100 Millionen synthetische Sales-Records |
| Visualisierung | matplotlib 3.8+, Python 3.11 |
Prologue: Searching for Clues in the Data Ocean
It started with a simple question that wouldn't let me go: Why does this damn sales query take 47 seconds? My Linux server rattled, PostgreSQL consumed CPU cycles like a thirsty marathon runner drinks water, and I stared at the EXPLAIN ANALYZE output like a map whose legend someone had forgotten. Seven tables, twelve joins, a full table scan over 100 million rows. This wasn't a query — it was a cry for help.
So I did what any curious data architect would do: I deleted the production database. No, of course not. I got myself a coffee, opened an empty terminal, and began rethinking the foundation. Data Warehouse Design — not as an academic concept from a textbook, but as a practical tool I could test on my own server with real data.
What follows is the story of this expedition: From a chaotic data swamp to a structured warehouse, from naive schema design to a highly optimized analytical engine. Six chapters, six visualizations, and the realization that good database design is less science and more detective work.
Chapter 1: The Star Schema — Elegance Through Simplicity
Every good architecture begins with a fundamental decision: How do I organize my data so it answers questions I haven't even asked yet? The answer Ralph Kimball formulated in the 1990s is strikingly simple: Separate facts from dimensions.
Facts are measurements — things you can count, sum, average. Revenue, Quantity, Discount. Dimensions are the context — who bought what, when, where? A star schema places the fact table at the center and surrounds it with dimension tables like planets around a sun.
On my server, I implemented this with PostgreSQL:
-- Dimension tables
CREATE TABLE dim_date (
date_key SERIAL PRIMARY KEY,
full_date DATE NOT NULL,
day_of_week VARCHAR(10),
month INTEGER,
quarter INTEGER,
year INTEGER,
is_weekend BOOLEAN
);
CREATE TABLE dim_product (
product_key SERIAL PRIMARY KEY,
product_id VARCHAR(20) NOT NULL,
name VARCHAR(200),
category VARCHAR(100),
brand VARCHAR(100),
unit_price DECIMAL(10,2)
);
CREATE TABLE dim_customer (
customer_key SERIAL PRIMARY KEY,
customer_id VARCHAR(20) NOT NULL,
name VARCHAR(200),
segment VARCHAR(50),
region VARCHAR(100),
loyalty_tier VARCHAR(20)
);
CREATE TABLE dim_store (
store_key SERIAL PRIMARY KEY,
store_id VARCHAR(20) NOT NULL,
name VARCHAR(200),
city VARCHAR(100),
country VARCHAR(50),
size_sqm INTEGER
);
-- Fact table at the center
CREATE TABLE fct_sales (
sale_id SERIAL PRIMARY KEY,
date_key INTEGER REFERENCES dim_date(date_key),
product_key INTEGER REFERENCES dim_product(product_key),
customer_key INTEGER REFERENCES dim_customer(customer_key),
store_key INTEGER REFERENCES dim_store(store_key),
quantity INTEGER NOT NULL,
revenue DECIMAL(12,2) NOT NULL,
discount DECIMAL(5,2) DEFAULT 0
);
-- Indexes for performant joins
CREATE INDEX idx_fct_sales_date ON fct_sales(date_key);
CREATE INDEX idx_fct_sales_product ON fct_sales(product_key);
CREATE INDEX idx_fct_sales_customer ON fct_sales(customer_key);
CREATE INDEX idx_fct_sales_store ON fct_sales(store_key);
The beauty of this approach revealed itself when I wrote the first analytical query:
SELECT
d.year,
d.quarter,
p.category,
c.segment,
SUM(f.revenue) AS total_revenue,
AVG(f.discount) AS avg_discount,
COUNT(DISTINCT c.customer_key) AS unique_customers
FROM fct_sales f
JOIN dim_date d ON f.date_key = d.date_key
JOIN dim_product p ON f.product_key = p.product_key
JOIN dim_customer c ON f.customer_key = c.customer_key
GROUP BY d.year, d.quarter, p.category, c.segment
ORDER BY total_revenue DESC;
Four joins, but each crystal clear. No guessing which table carries which meaning. The star schema is self-documenting — a quality you only appreciate when you're hunting a bug in a 200-line SQL query at 2 AM.
Chapter 2: Star vs. Snowflake — The Normalization Debate
Barely had I built my star schema when my inner database purist whispered: But the redundancy! The anomalies! You're repeating category information in every product row! And he wasn't wrong. In dim_product, "Electronics" appeared a hundred times — once for every product in that category.
The purist's answer is called the Snowflake Schema: You normalize the dimension tables further, extracting sub-dimensions. One flat dim_product table becomes three: dim_product, dim_category, dim_brand.
-- Snowflake: Normalized dimensions
CREATE TABLE dim_category (
category_key SERIAL PRIMARY KEY,
name VARCHAR(100),
department VARCHAR(100)
);
CREATE TABLE dim_brand (
brand_key SERIAL PRIMARY KEY,
name VARCHAR(100),
manufacturer VARCHAR(200),
country VARCHAR(50)
);
CREATE TABLE dim_product_sf (
product_key SERIAL PRIMARY KEY,
product_id VARCHAR(20) NOT NULL,
name VARCHAR(200),
category_key INTEGER REFERENCES dim_category(category_key),
brand_key INTEGER REFERENCES dim_brand(brand_key),
unit_price DECIMAL(10,2)
);
Sounds clean, right? Until you write the analytical query and suddenly need 8 joins instead of 4. Until the query planner starts cascading hash joins and execution time doubles. Until the BI team asks why the dashboard has been slower since the refactoring.
I tested both variants on my server with a synthetic dataset of 10 million sales records and measured the differences. The result was clear: For analytical workloads — aggregating, grouping, filtering — the star schema wins almost every time. The reason is simple: Fewer joins = less I/O = faster queries.
The snowflake schema has its place — with very large dimension tables where redundancy actually causes storage problems, or with compliance requirements for data consistency. But for most data warehouses on a Linux server: Denormalization is your friend.
Chapter 3: Query Performance — Where Milliseconds Go to Die
Theory is nice, but my server has limited resources. 32 GB RAM, 8 cores, an SSD aging faster than I'd like. So I systematically benchmarked what happens when different schema designs meet real analytical queries.
I created three identical datasets with 50 million rows — one as star schema, one as snowflake, one as fully normalized 3NF structure (the typical OLTP database the data originally comes from). Then I fired five query types:
-- Type 1: Simple Aggregate
SELECT SUM(revenue) FROM fct_sales WHERE date_key BETWEEN 1000 AND 1365;
-- Type 2: Join 2 Tables
SELECT p.category, SUM(f.revenue)
FROM fct_sales f JOIN dim_product p ON f.product_key = p.product_key
GROUP BY p.category;
-- Type 3: Join 4 Tables
SELECT d.year, p.category, c.segment, s.country, SUM(f.revenue)
FROM fct_sales f
JOIN dim_date d ON f.date_key = d.date_key
JOIN dim_product p ON f.product_key = p.product_key
JOIN dim_customer c ON f.customer_key = c.customer_key
JOIN dim_store s ON f.store_key = s.store_key
GROUP BY d.year, p.category, c.segment, s.country;
-- Type 4: Complex Subquery
SELECT category, revenue_rank FROM (
SELECT p.category, SUM(f.revenue) as total,
RANK() OVER (ORDER BY SUM(f.revenue) DESC) as revenue_rank
FROM fct_sales f JOIN dim_product p ON f.product_key = p.product_key
GROUP BY p.category
) ranked WHERE revenue_rank <= 10;
-- Type 5: Full Scan with Aggregation
SELECT COUNT(*), SUM(revenue), AVG(discount) FROM fct_sales;
The results spoke volumes. For simple aggregations, the difference was marginal — all three schemas came in under 2 seconds. But for multi-table joins, the 3NF variant exploded: 8.5 seconds for a 4-table join, compared to 2.1 seconds in the star schema. The snowflake fell in between.
The decisive factor wasn't computing power but I/O overhead. Every additional join means: building hash tables, materializing intermediate results, allocating memory. On a dedicated data warehouse server with 256 GB RAM, this might be irrelevant. On my Linux server, it was the difference between "usable" and "coffee break query."
Chapter 4: Slowly Changing Dimensions — The Time Travel Machine
The next puzzle lurked in the data itself: What happens when a dimension changes? Anna Müller moves from Berlin to Hamburg. Max Schmidt gets upgraded from Standard to Gold customer. The store on Friedrichstraße doubles its floor space.
In an OLTP database, the answer is trivial: UPDATE dim_customer SET city = 'Hamburg' WHERE customer_id = '1001'. Done. But in a data warehouse, this answer is catastrophic — because it destroys history. All historical sales for Anna Müller suddenly appear in Hamburg, even though she was living in Berlin at the time of purchase.
Ralph Kimball defined six types of Slowly Changing Dimensions (SCDs), but in practice you mainly encounter three:
SCD Type 1 — Overwrite: The simplest and most destructive method. You overwrite the old value. No history, no audit trail. Suitable for corrections (typos in names) or attributes whose history is irrelevant.
-- SCD Type 1: Simply overwrite
UPDATE dim_customer
SET city = 'Hamburg'
WHERE customer_id = '1001';
SCD Type 2 — Insert New Row: The most powerful method. You insert a new row, mark the old one as historical and the new one as current. This requires a surrogate key (independent of the business key), validity dates, and an is_current flag.
-- SCD Type 2: New row for history
-- Step 1: Close old row
UPDATE dim_customer
SET valid_to = CURRENT_DATE,
is_current = FALSE
WHERE customer_id = '1001'
AND is_current = TRUE;
-- Step 2: Insert new row
INSERT INTO dim_customer (customer_id, name, city, segment, region,
loyalty_tier, valid_from, valid_to, is_current)
VALUES ('1001', 'Anna Müller', 'Hamburg', 'Premium', 'Nord',
'Gold', CURRENT_DATE, '9999-12-31', TRUE);
SCD Type 3 — Previous Value as Column: A compromise. You add an additional column for the previous value. Limited to one level of change, but simple to implement and query.
-- SCD Type 3: Previous-Value Column
ALTER TABLE dim_customer ADD COLUMN previous_city VARCHAR(100);
UPDATE dim_customer
SET previous_city = city,
city = 'Hamburg'
WHERE customer_id = '1001';
I implemented SCD Type 2 for my customer dimension and loaded historical data. The effect was stunning: Suddenly I could analyze how customer revenue developed before and after a segment change. The time travel machine worked.
Chapter 5: Layer Architecture — From Raw Data Chaos to Analytics Gold
A star schema alone doesn't make a data warehouse. The real architectural decision lies in the question: How do I transform raw data into analysis-ready structures? The answer is a layered model — sometimes called "Medallion Architecture" (Bronze → Silver → Gold), sometimes named by function (Raw → Staging → Business → Mart).
On my server, I implemented four layers:
1. Raw / Staging Layer: Data lands here exactly as it comes from the source. No filtering, no transforming, no interpreting. Append-only. This is the "Single Source of Truth" — when something goes wrong downstream, you can go back here.
CREATE SCHEMA raw_layer;
CREATE TABLE raw_layer.orders (
_loaded_at TIMESTAMP DEFAULT NOW(),
_source VARCHAR(50),
_raw_json JSONB,
order_id VARCHAR(50),
customer_id VARCHAR(50),
order_date VARCHAR(50), -- Still VARCHAR! No casting in the raw layer.
total_amount VARCHAR(50),
status VARCHAR(50)
);
2. Cleaned / Intermediate Layer: This is where we clean, cast, and deduplicate. Type casting, NULL handling, format standardization. The string '2025-03-15' becomes a real DATE. The string 'EUR 1,234.56' becomes 1234.56.
CREATE SCHEMA clean_layer;
CREATE TABLE clean_layer.stg_orders AS
SELECT
order_id,
customer_id,
CAST(order_date AS DATE) AS order_date,
CAST(REPLACE(REPLACE(total_amount, 'EUR ', ''), ',', '.') AS DECIMAL(12,2))
AS total_amount,
UPPER(TRIM(status)) AS status,
NOW() AS _processed_at
FROM raw_layer.orders
WHERE order_id IS NOT NULL
AND order_date ~ '^\d{4}-\d{2}-\d{2}$';
3. Business / Core Layer: This is where dimensional models are born — star or snowflake schemas with fact and dimension tables. Business logic is defined once and reused consistently.
4. Presentation / Mart Layer: Audience-specific views or materialized tables. The marketing mart shows customer segmentation, the finance mart shows revenue breakdown, the ML team gets feature tables.
CREATE SCHEMA mart_layer;
-- Materialized View for the marketing team
CREATE MATERIALIZED VIEW mart_layer.marketing_customer_360 AS
SELECT
c.customer_key,
c.name,
c.segment,
c.loyalty_tier,
COUNT(DISTINCT f.sale_id) AS total_orders,
SUM(f.revenue) AS lifetime_value,
AVG(f.revenue) AS avg_order_value,
MAX(d.full_date) AS last_purchase_date,
MIN(d.full_date) AS first_purchase_date
FROM fct_sales f
JOIN dim_customer c ON f.customer_key = c.customer_key
JOIN dim_date d ON f.date_key = d.date_key
WHERE c.is_current = TRUE
GROUP BY c.customer_key, c.name, c.segment, c.loyalty_tier;
-- Refresh schedule as cron job
-- 0 3 * * * psql -d warehouse -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mart_layer.marketing_customer_360;"
This layer separation sounds like overhead, but it's a safety net. When the source API changes, only the raw and clean layers are affected. When the business needs a new metric, you only change the mart. Separation of Concerns — but for data.
Chapter 6: Partitioning — The Art of Dividing
With growing data volumes, I hit the next performance plateau: Even my optimized star schema with indexes became slow when the fact table exceeded 100 million rows. EXPLAIN ANALYZE showed: Full Index Scan on idx_fct_sales_date — the index had grown so large it became a bottleneck itself.
The solution: Table Partitioning. PostgreSQL has supported declarative partitioning since version 10 — you split a large table into physically separate subtables that logically appear as one.
-- Partitioned fact table
CREATE TABLE fct_sales_partitioned (
sale_id SERIAL,
date_key INTEGER NOT NULL,
product_key INTEGER,
customer_key INTEGER,
store_key INTEGER,
quantity INTEGER NOT NULL,
revenue DECIMAL(12,2) NOT NULL,
discount DECIMAL(5,2) DEFAULT 0,
sale_date DATE NOT NULL
) PARTITION BY RANGE (sale_date);
-- Monthly partitions
CREATE TABLE fct_sales_2025_01 PARTITION OF fct_sales_partitioned
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE fct_sales_2025_02 PARTITION OF fct_sales_partitioned
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
CREATE TABLE fct_sales_2025_03 PARTITION OF fct_sales_partitioned
FOR VALUES FROM ('2025-03-01') TO ('2025-04-01');
-- Automatic partition creation via function
CREATE OR REPLACE FUNCTION create_monthly_partition(target_date DATE)
RETURNS VOID AS $$
DECLARE
partition_name TEXT;
start_date DATE;
end_date DATE;
BEGIN
start_date := DATE_TRUNC('month', target_date);
end_date := start_date + INTERVAL '1 month';
partition_name := 'fct_sales_' || TO_CHAR(start_date, 'YYYY_MM');
EXECUTE FORMAT(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF fct_sales_partitioned
FOR VALUES FROM (%L) TO (%L)',
partition_name, start_date, end_date
);
END;
$$ LANGUAGE plpgsql;
The effect was dramatic. A query on March 2025 now scanned only the partition fct_sales_2025_03 with 2 million rows instead of the entire table with 100 million. Query time dropped from 45 seconds to 1.5 seconds — a 97% reduction.
But partitioning alone was only half the battle. Storage format played an equally important role. For my export files on the server, I tested various formats:
#!/bin/bash
# Benchmark: CSV vs. Parquet with DuckDB
# DuckDB can read Parquet directly — perfect for analytical queries
# CSV Export (100M rows)
time psql -d warehouse -c "\COPY fct_sales TO '/data/exports/sales.csv' CSV HEADER"
# → 100 GB, 12 MB/s read speed
# Parquet Export with DuckDB
duckdb << 'SQL'
COPY (SELECT * FROM read_csv_auto('/data/exports/sales.csv'))
TO '/data/exports/sales.parquet' (FORMAT PARQUET, COMPRESSION SNAPPY);
SQL
# → 15 GB (85% compression!), 78 MB/s read speed
# Analytical query directly on Parquet
duckdb << 'SQL'
SELECT
EXTRACT(YEAR FROM sale_date) AS year,
SUM(revenue) AS total_revenue
FROM read_parquet('/data/exports/sales.parquet')
WHERE sale_date >= '2025-01-01'
GROUP BY year;
SQL
# → 0.3 seconds (vs. 5.2 seconds on CSV)
The combination of partitioned PostgreSQL for operational use and Parquet exports for ad-hoc analysis with DuckDB turned out to be the ideal setup for my Linux server. Enterprise-level architecture, zero-budget edition.
Epilogue: The Warehouse as a Living Organism
What started as frustration over a slow query became a fundamental lesson in data architecture. A data warehouse isn't a static construct — it's a living organism that grows and adapts with requirements.
The key takeaways from my server expedition:
- Star schema for analytical workloads: Fewer joins, faster queries, self-documenting code. Snowflake only when you have genuine storage problems.
- SCD Type 2 for everything that has history: The investment in surrogate keys and validity dates pays off with every historical analysis.
- Layer separation is not optional: Raw → Clean → Business → Mart. Each layer has a responsibility. No shortcuts.
- Partitioning from day 1: Don't wait until the table has 100 million rows. Choose the partition key early and use it consistently.
- Columnar formats for analytics: Parquet/ORC for everything that gets aggregated. CSV is a transport format, not an analytics format.
My query now runs in 0.3 seconds instead of 47. The server purrs contentedly. And I've learned that the best architecture isn't the most complex one — it's the one you can understand, maintain, and evolve.
Citations & Sources
- Kimball, R. & Ross, M. (2013). The Data Warehouse Toolkit: The Definitive Guide to Dimensional Modeling. 3rd Edition. Wiley.
- Inmon, W. H. (2005). Building the Data Warehouse. 4th Edition. Wiley.
- PostgreSQL Documentation: Table Partitioning. postgresql.org/docs/current/ddl-partitioning.html
- Apache Parquet Format Specification. parquet.apache.org/documentation/latest/
- DuckDB Documentation: Parquet Import/Export. duckdb.org/docs/data/parquet/overview
- Kimball Group: Slowly Changing Dimensions. kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/
- Databricks: Medallion Architecture. docs.databricks.com/en/lakehouse/medallion.html
- Linstedt, D. & Olschimke, M. (2015). Building a Scalable Data Warehouse with Data Vault 2.0. Morgan Kaufmann.
Conclusion
Data warehouse design isn't rocket science — it's craftsmanship. With PostgreSQL on a Linux server, the right schema decisions, and a clear layered model, you can build analytical systems that compete with commercial solutions. The tools are freely available, the documentation is excellent, and the best part: you can test everything yourself, make mistakes, and learn from them. That's the true value of a self-directed project — not the result, but the journey.
Technical Documentation
| Aspect | Detail |
|---|---|
| Database | PostgreSQL 16 on Ubuntu Linux |
| Schema Type | Star Schema (primary), Snowflake (comparison) |
| SCD Strategy | Type 2 with Surrogate Keys & Validity Dates |
| Partitioning | Range Partitioning by date (monthly/daily) |
| Export Format | Parquet + Snappy Compression |
| Analytics Engine | DuckDB for ad-hoc queries on Parquet |
| Test Data | 50-100 million synthetic sales records |
| Visualization | matplotlib 3.8+, Python 3.11 |