PostgreSQL-Partitionierung für High-Volume-SaaS: Wann sie sich lohnt und wie du sie richtig umsetzt
Jedes SaaS-Backend, das lange genug überlebt, züchtet eine Monster-Tabelle. Meist sind es Events, Audit-Logs oder etwas wie Message Deliveries: append-lastig, selten aktualisiert und fast ausschließlich per Zeitbereich abgefragt. Eines Tages braucht eine Dashboard-Query, die früher 40 ms dauerte, 4 Sekunden, Autovacuum kommt nicht mehr hinterher, und jemand im Team sagt die Worte "das sollten wir partitionieren".
PostgreSQL Tabellen-Partitionierung ist ein gutes Werkzeug. Sie ist auch eines der am häufigsten falsch eingesetzten. In Foren wird Partitionierung empfohlen wie früher Antibiotika verschrieben wurden: gegen alles, ob sie helfen kann oder nicht. Teams verbringen Wochen damit, eine Tabelle in Partitionen zu migrieren, sehen null Verbesserung und sitzen am Ende auf einem komplizierteren Schema als vorher. Dieser Beitrag zeigt, was Partitionierung für dich tut, was sie nicht kann und welche Implementierungsdetails darüber entscheiden, ob sie sich auszahlt.
Was Partitionierung wirklich löst
Deklarative Partitionierung teilt eine logische Tabelle in mehrere physische Kind-Tabellen hinter einem gemeinsamen Namen. Postgres leitet jede Zeile beim Insert in die richtige Partition und überspringt, wenn die Query es erlaubt, irrelevante Partitionen zur Abfragezeit komplett. Dieser zweite Teil heißt Partition Pruning, und dort steckt der Wert.
Konkret hilft Partitionierung bei drei Arten von Schmerz.
Erstens: Zeitbereichs-Scans auf riesigen Tabellen. Wenn deine Events-Tabelle drei Jahre Daten hält, aber 95 Prozent der Queries die letzten 30 Tage betreffen, bedeutet eine monatliche Range-Partition, dass diese Queries eine oder zwei Partitionen scannen statt des gesamten Heaps. Indizes auf einer 20-GB-Partition sind flacher und bleiben im Speicher heiß, wie es Indizes auf einer 900-GB-Tabelle nicht tun.
Zweitens: Wartungsoperationen. VACUUM, ANALYZE und REINDEX laufen pro Partition. Auf einer unpartitionierten Tabelle mit mehreren hundert Gigabyte kann ein einzelnes VACUUM Stunden laufen und das Rennen gegen deine Schreibrate verlieren. Auf Partitionen verteilt passiert dieselbe Arbeit in kleineren Häppchen, und alte Partitionen, die keine Writes mehr bekommen, brauchen kaum noch Vacuuming.
Drittens: Daten-Lifecycle. Retention-Policies hören auf, ein Problem zu sein. Einen Monat Daten aus einer großen Tabelle zu löschen bedeutet ein langes DELETE, Millionen tote Tupel und danach eine Vacuum-Rechnung. Mit Partitionen führst du DETACH oder DROP auf einer Kind-Tabelle aus. Das ist in Millisekunden fertig und erzeugt kein Bloat.
Was sie nicht löst
Partitionierung bringt nichts für den Schreibdurchsatz. Ein INSERT geht weiterhin in genau eine Partition, durch dasselbe WAL, auf dieselben Platten. Wenn dein Engpass die Ingest-Rate ist, schau dir Batching, unlogged Staging-Tabellen oder Hardware an, bevor du auf Partitionierung schaust.
Sie bringt auch nichts für Random-Access-Reads. Ein Lookup wie SELECT * FROM orders WHERE id = 48291 war über den Primärschlüssel-Index schon vorher schnell, und nach der Partitionierung wird er womöglich etwas langsamer, weil der Planner mehr Relationen berücksichtigen muss und, sofern die id nicht den Partitionsschlüssel kodiert, keine Partition gepruned werden kann. Dasselbe gilt für den meisten Single-Row-UPDATE-Traffic.
Eine nützliche Regel: Partitionierung belohnt Queries, die sich am Partitionsschlüssel ausrichten, und besteuert Queries, die ihn ignorieren. Wenn dein Workload kein dominantes Zugriffsmuster hat, bekommst du die Steuer ohne die Belohnung.
Wann du PostgreSQL Tabellen-Partitionierung einführen solltest
Es gibt keine harte Schwelle, aber eine praktische: Denk über Partitionierung nach, wenn eine Tabelle grob 50 bis 100 GB überschreitet und weiter wächst, und nur dann, wenn du den Partitionsschlüssel benennen kannst, nach dem die meisten Queries filtern. Bei SaaS-Workloads ist dieser Schlüssel fast immer ein Timestamp.
Bevor du dich festlegst, verifiziere die Theorie mit EXPLAIN ANALYZE auf deinen langsamsten echten Queries. Was du sehen willst: Postgres scannt große Bereiche der Tabelle, entweder als Sequential Scans oder als Index Scans, die Millionen Zeilen über die gesamte Zeitspanne anfassen. Wenn EXPLAIN enge Index Scans zeigt, die in Millisekunden ein paar hundert Zeilen liefern, liegt dein Problem woanders, und Partitionierung wird nichts bewegen. Wir sehen das regelmäßig in Performance-Audits: die Tabelle ist groß, der Instinkt sagt partitionieren, und der Plan sagt, die eigentlichen Kosten sind ein fehlender zusammengesetzter Index oder ein aufgeblähter.
Eine bestehende große Tabelle zu partitionieren ist außerdem nicht umsonst. Postgres hat kein ALTER TABLE, das eine gewöhnliche Tabelle in-place konvertiert. Du legst eine neue partitionierte Tabelle an, befüllst sie und tauschst dann, typischerweise mit einer Dual-Write-Phase oder einem Trigger, der beide während der Migration synchron hält. Plane für diesen Schritt echte Engineering-Zeit ein.
Range-Partitionierung für Event- und Audit-Tabellen
Für append-only Tabellen mit Zeitschlüssel ist monatliche Range-Partitionierung der Default, der zu den meisten SaaS-Produkten passt:
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
tenant_id bigint NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2026_08 PARTITION OF events
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
Achte auf den Primärschlüssel. Jeder Unique-Constraint auf einer partitionierten Tabelle muss den Partitionsschlüssel enthalten, weshalb der Key oben (id, created_at) ist und nicht schlicht id. Dieser Constraint überrascht Teams bei der Migrationsplanung mehr als alles andere, denn er kann sich in Foreign Keys und ORM-Mappings fortpflanzen.
Wähle die Granularität nach deiner Retention-Policy und deinem Volumen, in dieser Reihenfolge. Wenn du 13 Monate Daten behältst und den Rest verwirfst, machen monatliche Partitionen die Retention zu einer Sache von einem Partition-Drop pro Monat. Tägliche Partitionen ergeben nur bei sehr hohem Volumen Sinn; Tausende Partitionen verlangsamen das Planning, und Postgres kommt mit Dutzenden deutlich eleganter zurecht als mit Zehntausenden.
Hash-Partitionierung für schreiblastige Tenant-Tabellen
Range-Partitionierung setzt eine Zeitachse voraus. Manche Tabellen haben keine: zum Beispiel eine Settings- oder Usage-Tabelle pro Tenant mit vielen Writes über den ganzen Schlüsselraum. Dort kann Hash-Partitionierung die Tabelle auf feste Buckets verteilen:
CREATE TABLE tenant_usage (
tenant_id bigint NOT NULL,
metric text NOT NULL,
value bigint NOT NULL,
PRIMARY KEY (tenant_id, metric)
) PARTITION BY HASH (tenant_id);
CREATE TABLE tenant_usage_0 PARTITION OF tenant_usage
FOR VALUES WITH (MODULUS 8, REMAINDER 0);
Die Gewinne hier sind kleiner und vor allem operativ: Vacuum läuft parallel über die Buckets, Indizes bleiben kleiner, und ein pathologischer Tenant kann nicht die ganze Tabelle aufblähen. Sei ehrlich, ob du das brauchst. Hash-Partitionierung taugt nicht für Retention, liefert kein Pruning bei Zeit-Queries, und die Bucket-Anzahl später zu ändern ist schmerzhaft. Viele Teams, die danach greifen, wären mit Design-Arbeit an der Row-Level-Tenancy am Schema selbst besser bedient.
Pruning, Indizes und die Settings, die zählen
Auf jeder unterstützten Postgres-Version stützt sich deklarative Partitionierung auf enable_partition_pruning, das per Default an ist. Das ältere Setting constraint_exclusion spielt nur für Legacy-Setups auf Vererbungsbasis eine Rolle; wenn ein Blogpost dir rät, es für deklarative Partitionen zu tunen, ist der Post veraltet.
Indizes, die auf der Eltern-Tabelle deklariert sind, propagieren automatisch auf jede Partition, auch auf zukünftige. Definiere sie einmal auf Eltern-Ebene und widerstehe der Versuchung, per Partition Extras hinzuzufügen, solange eine bestimmte Partition keinen nachgewiesenen Bedarf hat. Jeder Index wird mit der Partitionsanzahl multipliziert, auf der Platte, in der Write Amplification und in der Planning-Zeit.
Verifiziere das Pruning genauso, wie du das Problem verifiziert hast: mit EXPLAIN. Ein gepruneder Plan listet nur die passenden Partitionen. Wenn du bei einer Query, die auf den Partitionsschlüssel filtert, jede Partition im Plan siehst, stimmt etwas nicht, und meist ist es eine der Fallen unten.
Die Fallen, die in Produktion zubeißen
Parametrisierte Queries können Pruning aushebeln. Nachdem ein Prepared Statement fünfmal gelaufen ist, kann Postgres auf einen generischen Plan wechseln, in dem der Parameterwert zur Planungszeit unbekannt ist. Runtime Pruning rettet das normalerweise, EXPLAIN zeigt es als "Subplans Removed", aber Ausdrücke auf dem Partitionsschlüssel, Casts zwischen Timestamp-Typen oder Stable Functions wie now() an unglücklichen Stellen können dazu führen, dass du alles scannst. Wenn eine Query in psql schnell und aus der Anwendung langsam ist, teste mit plan_cache_mode = force_custom_plan, bevor du dem Treiber die Schuld gibst.
Foreign Keys brauchen Versionsbewusstsein. Foreign Keys von einer partitionierten Tabelle auf eine normale funktionieren seit Postgres 11, Foreign Keys, die auf eine partitionierte Tabelle zeigen, seit Postgres 12. Auf allem, was älter ist, oder auf Schemas, die von vererbungsbasierter Partitionierung migriert wurden, kann die referenzielle Integrität Richtung partitionierter Seite fehlen, ohne dass es jemand gemerkt hat. Legacy-Systeme tragen genau diese Art stiller Schulden, und ein großer Teil von Modernisierungsprojekten besteht darin, sie zu entwirren.
Default-Partitionen können zur Falle werden. Eine DEFAULT-Partition fängt Zeilen, die auf keinen Range passen, was Insert-Fehler verhindert, aber auch stillschweigend Müll sammelt, und ihre Existenz kann das Anlegen neuer Partitionen blockieren, die sich mit bereits darin liegenden Daten überschneiden. Wenn du eine nutzt, überwache ihre Zeilenanzahl und behandle alles über null als Incident.
Partitionen automatisieren mit pg_partman
Manuelles Partitionsmanagement scheitert auf vorhersehbare Weise: Jemand vergisst, die Partition für den nächsten Monat anzulegen, und um Mitternacht am Ersten schlagen Inserts fehl oder stapeln sich in der Default-Partition. Die Extension pg_partman existiert, um genau diesen Fehlermodus zu beseitigen. Du registrierst die Eltern-Tabelle einmal, und ihre Maintenance-Prozedur legt zukünftige Partitionen vorab an und detacht oder droppt abgelaufene gemäß deiner Retention-Einstellung. Plane run_maintenance_proc() mit pg_cron oder einem externen Scheduler ein und alarmiere, wenn sie einen Tag lang nicht gelaufen ist. Das ist kein optionales Tooling für den Produktionsbetrieb; betrachte eine partitionierte Tabelle ohne automatisierte Maintenance als unfertige Arbeit.
Partitionierung zur richtigen Zeit, auf der richtigen Tabelle, für das richtige Query-Muster ist eine der wirkungsvollsten Änderungen, die einem wachsenden SaaS auf Postgres zur Verfügung stehen. Spekulativ gemacht ist sie Schema-Komplexität ohne Gegenwert. Erst messen, dann partitionieren.
Wenn du auf eine Tabelle starrst, die ihrem Design entwachsen ist, und eine zweite Meinung willst, ob PostgreSQL Tabellen-Partitionierung der richtige Schritt ist: Diese Arbeit machen wir regelmäßig bei wolf-tech.io. Melde dich unter hello@wolf-tech.io und erzähl uns, was EXPLAIN ANALYZE dir zeigt.

