Artikel
KI-Artikel 8 Min 754

PostgreSQL Partitioning: Wenn die Tabelle 100M Zeilen erreicht

PostgreSQL-Partitionierung in der Praxis: Range, List, Hash, pg_partman, Rails-Migration, VACUUM-Optimierung, Benchmarks mit 200M Zeilen.

Dieser Artikel wurde von einem KI-Modell erstellt und kann Ungenauigkeiten enthalten. Überprüfen Sie die Informationen vor der Verwendung in der Produktion.

Wenn PostgreSQL langsam wird

PostgreSQL kommt mit Tabellen bis 10-20 Millionen Zeilen hervorragend zurecht. Indexe funktionieren, VACUUM kommt hinterher, Abfragen fliegen. Aber wenn eine Tabelle 100 Millionen überschreitet — beginnen die Probleme.

Die Symptome kennt jeder, der mit wachsenden Projekten gearbeitet hat: VACUUM kommt nicht mehr hinterher, die Tabelle bläht sich auf (Table Bloat), Indexe werden riesig und passen nicht mehr in shared_buffers, DELETE alter Daten blockiert die Tabelle minutenlang, und ein simples COUNT(*) mit Datumsfilter dauert 30+ Sekunden.

Partitionierung ist eine in PostgreSQL eingebaute Lösung. Die Tabelle wird physisch in Teile (Partitionen) aufgeteilt, wobei jede eine separate Tabelle auf der Festplatte ist. Abfragen greifen automatisch nur auf relevante Partitionen zu (Partition Pruning), VACUUM arbeitet mit kleinen Tabellen statt einer riesigen, und das Löschen alter Daten ist ein sofortiges DROP statt DELETE von Millionen Zeilen.

Arten der Partitionierung

Range-Partitionierung — nach Bereich

Die häufigste Art. Daten werden nach einem Wertebereich aufgeteilt — normalerweise nach Datum.

CREATE TABLE events (
    id          bigserial,
    event_time  timestamptz NOT NULL,
    user_id     bigint NOT NULL,
    event_type  text NOT NULL,
    payload     jsonb,
    created_at  timestamptz NOT NULL DEFAULT now()
) PARTITION BY RANGE (event_time);

CREATE TABLE events_2026_01 PARTITION OF events
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE events_2026_02 PARTITION OF events
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE events_2026_03 PARTITION OF events
    FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
CREATE TABLE events_2026_04 PARTITION OF events
    FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE events_2026_05 PARTITION OF events
    FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');

CREATE TABLE events_default PARTITION OF events DEFAULT;

Eine Abfrage mit Datumsfilter greift automatisch nur auf die relevante Partition zu:

EXPLAIN SELECT count(*) FROM events
WHERE event_time >= '2026-05-01' AND event_time < '2026-06-01';
-- Nur events_2026_05 wird gescannt

List-Partitionierung — nach Wert

CREATE TABLE orders (
    id          bigserial,
    region      text NOT NULL,
    customer_id bigint NOT NULL,
    total       numeric(12, 2) NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now()
) PARTITION BY LIST (region);

CREATE TABLE orders_eu PARTITION OF orders
    FOR VALUES IN ('DE', 'FR', 'IT', 'ES', 'NL', 'PL');
CREATE TABLE orders_us PARTITION OF orders
    FOR VALUES IN ('US', 'CA', 'MX');
CREATE TABLE orders_asia PARTITION OF orders
    FOR VALUES IN ('JP', 'KR', 'CN', 'IN', 'SG');
CREATE TABLE orders_other PARTITION OF orders DEFAULT;

Hash-Partitionierung — gleichmäßige Verteilung

CREATE TABLE user_activities (
    id          bigserial,
    user_id     bigint NOT NULL,
    action      text NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now()
) PARTITION BY HASH (user_id);

CREATE TABLE user_activities_0 PARTITION OF user_activities
    FOR VALUES WITH (MODULUS 8, REMAINDER 0);
-- ... bis 7
CREATE TABLE user_activities_7 PARTITION OF user_activities
    FOR VALUES WITH (MODULUS 8, REMAINDER 7);

Hash-Partitionierung unterstützt kein Bereichs-Pruning — nur exakte Übereinstimmung. Wird verwendet, wenn kein natürlicher Schlüssel für Range/List existiert.

Zusammengesetzte Partitionierung

CREATE TABLE logs (
    id          bigserial,
    log_time    timestamptz NOT NULL,
    service     text NOT NULL,
    message     text
) PARTITION BY RANGE (log_time);

CREATE TABLE logs_2026_05 PARTITION OF logs
    FOR VALUES FROM ('2026-05-01') TO ('2026-06-01')
    PARTITION BY LIST (service);

CREATE TABLE logs_2026_05_api PARTITION OF logs_2026_05
    FOR VALUES IN ('api');
CREATE TABLE logs_2026_05_worker PARTITION OF logs_2026_05
    FOR VALUES IN ('worker');
CREATE TABLE logs_2026_05_other PARTITION OF logs_2026_05 DEFAULT;

Indexe auf partitionierten Tabellen

-- Index wird automatisch auf JEDER Partition erstellt
CREATE INDEX idx_events_user_id ON events (user_id);

-- Unique Index MUSS Partitionsschlüssel enthalten
CREATE UNIQUE INDEX idx_events_id_time ON events (id, event_time);

-- Funktioniert NICHT:
-- CREATE UNIQUE INDEX idx_events_id ON events (id);
-- ERROR: unique constraint must include all partitioning columns

Automatische Partitionserstellung

pg_partman

CREATE EXTENSION pg_partman;

SELECT partman.create_parent(
    p_parent_table := 'public.events',
    p_control := 'event_time',
    p_type := 'native',
    p_interval := 'monthly',
    p_premake := 3
);

SELECT partman.run_maintenance();

-- Retention: Partitionen älter als 12 Monate automatisch löschen
UPDATE partman.part_config
SET retention = '12 months',
    retention_keep_table = false
WHERE parent_table = 'public.events';

Manuelle Erstellung via SQL-Funktion

CREATE OR REPLACE FUNCTION create_monthly_partitions(
    table_name text,
    months_ahead int DEFAULT 3
) RETURNS void AS $$
DECLARE
    start_date date;
    end_date date;
    partition_name text;
    i int;
BEGIN
    FOR i IN 0..months_ahead LOOP
        start_date := date_trunc('month', current_date + (i || ' months')::interval);
        end_date := start_date + '1 month'::interval;
        partition_name := format('%s_%s', table_name, to_char(start_date, 'YYYY_MM'));

        IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = partition_name) THEN
            EXECUTE format(
                'CREATE TABLE %I PARTITION OF %I FOR VALUES FROM (%L) TO (%L)',
                partition_name, table_name, start_date, end_date
            );
        END IF;
    END LOOP;
END;
$$ LANGUAGE plpgsql;

Alte Daten löschen: DROP vs DELETE

DELETE: Langsam und schmerzhaft

DELETE FROM events WHERE event_time < now() - interval '1 year';
-- Zeit: 30-60 Minuten
-- Sperren: Row-Level-Locks für die gesamte Dauer
-- WAL: generiert Gigabytes an WAL-Logs
-- VACUUM: danach VACUUM FULL nötig (weitere 30-60 Min)

DROP PARTITION: Sofort

DROP TABLE events_2025_01;
-- Zeit: < 1 Sekunde
-- Sperren: minimal
-- WAL: fast keine
-- VACUUM: nicht nötig

Oder sicher mit DETACH:

ALTER TABLE events DETACH PARTITION events_2025_01 CONCURRENTLY;
DROP TABLE events_2025_01;

VACUUM und Wartung

Auf einer monolithischen 500M-Zeilen-Tabelle kann VACUUM Stunden dauern. Auf einer partitionierten Tabelle wird jede Partition separat bearbeitet.

-- "Heiße" aktuelle Partition: aggressiver Autovacuum
ALTER TABLE events_2026_05 SET (
    autovacuum_vacuum_scale_factor = 0.01,
    autovacuum_vacuum_cost_delay = 2
);

-- "Kalte" Archiv-Partition
ALTER TABLE events_2025_01 SET (
    autovacuum_enabled = false
);

Partition Pruning

SET enable_partition_pruning = on;  -- Standard

EXPLAIN (ANALYZE) SELECT * FROM events
WHERE event_time >= '2026-05-01' AND event_time < '2026-06-01';
-- Nur events_2026_05 wird gescannt

Wenn Pruning NICHT funktioniert

-- Funktion auf Partitionsschlüssel
SELECT * FROM events WHERE date_trunc('month', event_time) = '2026-05-01';
-- KEIN Pruning. Umschreiben als Bereich:
SELECT * FROM events
WHERE event_time >= '2026-05-01' AND event_time < '2026-06-01';

-- Abfrage ohne Partitionsschlüssel
SELECT * FROM events WHERE user_id = 12345;
-- Scannt ALLE Partitionen. Immer Partitionsschlüssel-Filter hinzufügen.

Integration mit Rails

class CreatePartitionedEvents < ActiveRecord::Migration[8.0]
  def up
    execute <<-SQL
      CREATE TABLE events (
        id          bigserial,
        event_time  timestamptz NOT NULL,
        user_id     bigint NOT NULL,
        event_type  text NOT NULL,
        payload     jsonb,
        created_at  timestamptz NOT NULL DEFAULT now(),
        PRIMARY KEY (id, event_time)
      ) PARTITION BY RANGE (event_time);

      CREATE TABLE events_2026_05 PARTITION OF events
        FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
      CREATE TABLE events_default PARTITION OF events DEFAULT;

      CREATE INDEX idx_events_user_id ON events (user_id);
    SQL
  end

  def down
    drop_table :events, force: :cascade
  end
end
class Event < ApplicationRecord
  self.primary_key = [:id, :event_time]

  scope :in_month, ->(date) {
    where(event_time: date.beginning_of_month...(date.end_of_month + 1.day))
  }

  scope :recent, -> { where(event_time: 30.days.ago..) }
  scope :by_type, ->(type) { where(event_type: type) }
end

# Partition Pruning funktioniert automatisch
Event.in_month(Date.today).by_type('purchase').count

Benchmarks: Mit und ohne Partitionierung

Tabelle: 200M Zeilen, 50GB, PostgreSQL 16, 8 Kerne, 32GB RAM.

Abfrage Ohne Partitionen Mit Partitionen Unterschied
COUNT aktueller Monat 28,4s 0,9s 31x
SELECT nach user_id + Datum 4,2s 0,08s 52x
GROUP BY Datum, 3 Monate 45s 3,2s 14x
Einzelnes INSERT 1,2ms 1,3ms ~1x
DELETE eines Monats (30M Zeilen) 38 min 0,3s (DROP) 7600x
Wartung Ohne Mit Partitionen
VACUUM 45 min 2-3 min pro Partition
REINDEX 30 min 1-2 min pro Partition

Häufige Fallstricke

1. Foreign Keys zu partitionierten Tabellen

PostgreSQL < 17 unterstützt keine Foreign Keys, die auf partitionierte Tabellen verweisen. Verwenden Sie Validierung auf Anwendungsebene. PostgreSQL 17+ unterstützt dies nativ.

2. Zu viele Partitionen

Tägliche Partitionen × 3 Jahre = 1095 Partitionen. Planning Time wächst linear. Empfehlung: nicht mehr als 100-200 Partitionen pro Tabelle.

3. Abfragen ohne Partitionsschlüssel

-- Scannt ALLE Partitionen
SELECT * FROM events WHERE user_id = 12345;

-- Immer Partitionsschlüssel-Filter hinzufügen
SELECT * FROM events
WHERE user_id = 12345 AND event_time >= now() - interval '30 days';

Abschließende Checkliste

Vor der Partitionierung

  • [ ] Tabelle > 50M Zeilen oder wächst > 5M/Monat
  • [ ] Partitionsschlüssel bestimmen: normalerweise Timestamp
  • [ ] Granularität bestimmen: Monat (meistens), Woche (sehr aktiv), Jahr (langsames Wachstum)
  • [ ] Abfragen prüfen: 80%+ sollten nach Partitionsschlüssel filtern

Erstellung

  • [ ] PARTITION BY RANGE für zeitliche Daten
  • [ ] DEFAULT-Partition — Pflicht, um Datenverlust zu vermeiden
  • [ ] PRIMARY KEY enthält Partitionsschlüssel
  • [ ] Indexe auf Elterntabelle — automatisch auf allen Partitionen

Automatisierung

  • [ ] pg_partman oder Rake-Task für Voraberstellung
  • [ ] Cron: tägliche Erstellung neuer Partitionen
  • [ ] Retention: automatisches DROP alter Partitionen
  • [ ] Monitoring: Partitionsanzahl, Größen, Dead Tuples

Produktion

  • [ ] Partitionsgrößen: sollten nicht ungleichmäßig wachsen
  • [ ] Dead Tuples: heiße Partition braucht häufigeren Vacuum
  • [ ] Planning Time: wenn > 10ms — zu viele Partitionen
  • [ ] Partition Pruning: EXPLAIN für wichtige Abfragen prüfen

Kommentare (0)