Rails Postgres Table Partitioning: Tijd-Gebaseerde Partities voor Grote Tabellen in Productie
Een fintech klant belde me op een zondag omdat hun events tabel de vier miljard rijen voorbij was geschoten en het dashboard niet meer laadde. Elke query die de tabel raakte scande weken aan koude historie alleen om het laatste uur te tonen. Autovacuum draaide non-stop en kwam nooit klaar. Backups duurden elf uur. Ze hadden er twee jaar lang grotere Postgres instances tegenaan gegooid en zaten nu op de grootste RDS box die AWS verkocht.
We hadden geen grotere box nodig. We hadden Rails Postgres table partitioning nodig. Twee weken later antwoordde diezelfde tabel in milliseconden, autovacuum stond bijna de hele dag stil, en een maand oude data droppen was een DETACH PARTITION in plaats van een DELETE van uren. Na negentien jaar Rails heb ik deze migratie vaak genoeg gedaan om de scherpe randen te kennen, en dit is het productie-playbook.
Wat Rails Postgres Table Partitioning Echt Doet
Postgres declarative partitioning splitst één logische tabel in vele fysieke kindtabellen op basis van een sleutel — een datumkolom voor tijdseries, een account id voor multi-tenant data, of een hash voor gelijkmatige verdeling. Je applicatie blijft de oudertabel queryen. De Postgres planner leest de partition key uit je WHERE clause en raakt alleen de partities aan die hij nodig heeft.
De winst stapelt zich op. Indexen krimpen omdat elke partitie zijn eigen index heeft over een kleinere slice. Autovacuum heeft minder te kauwen per partitie en is in minuten klaar in plaats van uren. Je kunt oude partities direct droppen of detachen in plaats van een DELETE af te vuren die de tabel opblaast. Backups kun je gelaagd maken — hete partities op snelle storage, koude partities op goedkope storage. Sequential scans, als ze gebeuren, scannen één partitie in plaats van het hele monster.
De prijs is reëel. Gepartitioneerde tabellen hebben constraints die gewone tabellen niet hebben. Foreign keys naar een gepartitioneerde tabel werken niet in oudere Postgres versies en slechts gedeeltelijk in nieuwere. Unique constraints moeten de partition key bevatten. De query planner heeft de partition key in je WHERE nodig om partities te kunnen prunen, en ActiveRecord scopes die hem weglaten scannen alles. Rails Postgres table partitioning is een van die features waarbij de operationele kost hoger is dan de schema kost, en je moet hem niet adopteren voordat je hem nodig hebt.
Wanneer Rails Postgres Table Partitioning Loont (en Wanneer Niet)
De vuistregel die ik gebruik: partitioneer wanneer de tabel boven de 100 GB of boven de 500 miljoen rijen zit of wanneer je regelmatig grote stukken op datum dropt. Onder die getallen, fix je indexen, voeg een partial index toe voor hete rijen, draai VACUUM ANALYZE, en kom er een kwartaal later op terug.
Sterke fit voor tijd-gebaseerde Rails Postgres table partitioning:
- Append-only event streams — analytics events, webhook ontvangsten, audit logs, Sidekiq job records
- Tijdseries telemetrie — applicatie metrics, IoT readings, request logs voor compliance
- Notification en outbox tabellen die voor altijd groeien en vooral in een recent venster gequeryd worden
- Soft-delete tabellen waar de meeste reads rijen uit de laatste N dagen raken
- Per-tenant write-heavy tabellen die goed combineren met hash partitioning op
account_id
Slechte fit:
- Tabellen onder de 50 GB waar queries al goed indexen gebruiken
- Tabellen zonder een duidelijke partition key in elke hete query
- Tabellen met frequente updates over veel partities (de planner kan writes niet prunen)
- Tabellen die foreign keys naar zich hebben vanuit veel andere tabellen
Als je nog op Postgres 12 of ouder zit, upgrade voordat je gaat partitioneren. Declarative partitioning vóór Postgres 13 mist te veel features (logical replication van gepartitioneerde tabellen, partities aanhechten zonder lange locks, partition-wise joins standaard aan) voor een serieuze productie-uitrol.
Tijd-Gebaseerde Rails Postgres Table Partitioning Opzetten
Laten we een hypothetische events tabel partitioneren per maand. Het schema voor een verse tabel ziet er zo uit:
class CreatePartitionedEvents < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
CREATE TABLE events (
id bigserial,
account_id bigint NOT NULL,
kind text NOT NULL,
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);
SQL
execute <<~SQL
CREATE INDEX index_events_on_account_id_and_occurred_at
ON events (account_id, occurred_at DESC);
SQL
end
def down
drop_table :events
end
end
Twee dingen om te benoemen. Ten eerste bevat de primary key occurred_at omdat Postgres vereist dat de partition key onderdeel is van elke unique constraint, inclusief de primary key. ActiveRecord behandelt id nog steeds als primary key voor model doeleinden, maar Postgres heeft het composiet nodig. Ten tweede worden indexen op de ouder automatisch aangemaakt op elke kindpartitie, nu en in de toekomst. Declareer ze niet per partitie.
Het bijbehorende ActiveRecord model:
class Event < ApplicationRecord
self.primary_key = :id
scope :for_account, ->(id) { where(account_id: id) }
scope :in_window, ->(range) { where(occurred_at: range) }
end
Maak nu maandpartities. De simpelste versie om mee te beginnen:
class CreateEventPartitionsForApril2026 < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
CREATE TABLE events_2026_04 PARTITION OF events
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
SQL
end
def down
execute "DROP TABLE events_2026_04"
end
end
Dat is het. Inserts in events voor april landen in events_2026_04. Queries met occurred_at in april scannen alleen die partitie. Queries die twee maanden bestrijken scannen er twee. Queries zonder occurred_at scannen ze allemaal — schrijf je scopes daarnaar.
Productie Patroon 1: Append-Only Tijdseries Met Auto-Aangemaakte Partities
Partities met de hand maken is prima voor week één. Tegen maand drie ben je het vergeten, een insert landt buiten elke partitie, en Postgres geeft no partition of relation "events" found for row. Je background jobs gaan stuk om middernacht UTC op de eerste van de maand. Laat dit niet gebeuren.
Er zijn twee redelijke aanpakken. Plan een Rails job die partities vooruit aanmaakt, of installeer pg_partman en laat de database het doen. Ik draai beide bij verschillende klanten en ze werken allebei. De Rails-versie leest natuurlijker voor een Rails team en vereist geen extra extensie; pg_partman is meer battle-tested op schaal.
Hier is de Rails-side versie, die ik prefereer voor teams onder honderd engineers:
class Partitions::EventsCreator
LOOKAHEAD_MONTHS = 3
def self.ensure_future_partitions
base = Time.current.beginning_of_month
LOOKAHEAD_MONTHS.times do |i|
month = base + i.months
create_partition(month)
end
end
def self.create_partition(month)
name = "events_#{month.strftime('%Y_%m')}"
start = month.strftime("%Y-%m-%d")
finish = (month + 1.month).strftime("%Y-%m-%d")
ActiveRecord::Base.connection.execute(<<~SQL)
CREATE TABLE IF NOT EXISTS #{name}
PARTITION OF events
FOR VALUES FROM ('#{start}') TO ('#{finish}');
SQL
end
end
Plan hem dagelijks met welke scheduler je ook vertrouwt — ik behandel de cron-overlap valkuilen in de Rails Postgres advisory locks post, en je moet dit absoluut daarin wikkelen. Drie maanden vooruitkijken betekent dat een ontspoorde scheduler je een kwartaal geeft om het te merken voordat inserts gaan falen.
Oude partities droppen is de tweede helft van de operatie:
class Partitions::EventsRetainer
RETENTION_MONTHS = 12
def self.detach_old_partitions
cutoff = (Time.current - RETENTION_MONTHS.months).beginning_of_month
partitions_older_than(cutoff).each do |name|
ActiveRecord::Base.connection.execute(
"ALTER TABLE events DETACH PARTITION #{name} CONCURRENTLY"
)
ActiveRecord::Base.connection.execute("DROP TABLE #{name}")
end
end
def self.partitions_older_than(cutoff)
sql = <<~SQL
SELECT inhrelid::regclass::text AS name
FROM pg_inherits
WHERE inhparent = 'events'::regclass
SQL
ActiveRecord::Base.connection.select_values(sql).select do |name|
name =~ /events_(\d{4})_(\d{2})$/ &&
Date.new($1.to_i, $2.to_i, 1) < cutoff.to_date
end
end
end
DETACH PARTITION CONCURRENTLY (Postgres 14+) doet de detach zonder writers te blokkeren. Daarna is de partitie gewoon een normale tabel — je kunt hem direct DROP TABLE‘en, geen lange DELETE, geen autovacuum nasleep, geen bloat. Deze ene operatie is vaak de hele reden waarom teams Rails Postgres table partitioning adopteren.
Productie Patroon 2: Composite Partitioning per Tenant en Tijd
Voor een multi-tenant SaaS waar één grote klant je write-volume kan domineren, partitioneer eerst op account_id (met LIST of HASH) en sub-partitioneer elke tenant op tijd. De grote tenant krijgt isolatie; de kleine tenants delen. Gecombineerd met de Pundit autorisatie patronen die ik schreef voor multi-tenant SaaS en je tenancy verhaal staat van begin tot eind.
CREATE TABLE events (
id bigserial,
account_id bigint NOT NULL,
occurred_at timestamptz NOT NULL,
payload jsonb NOT NULL,
PRIMARY KEY (id, account_id, occurred_at)
) PARTITION BY LIST (account_id);
CREATE TABLE events_acct_42 PARTITION OF events
FOR VALUES IN (42)
PARTITION BY RANGE (occurred_at);
CREATE TABLE events_acct_42_2026_04 PARTITION OF events_acct_42
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE events_default PARTITION OF events DEFAULT
PARTITION BY RANGE (occurred_at);
CREATE TABLE events_default_2026_04 PARTITION OF events_default
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
De afweging: meer partities, meer catalog overhead, meer voor de planner om te overwegen. Hou het totale aantal partities onder ongeveer tienduizend en hou je hete scopes beperkt tot één tenant binnen één tijdsvenster. Als je merkt dat de planner traag wordt, controleer dan dat partition_pruning = on (default) en dat je WHERE beide sleutels bevat.
Valkuilen in Rails Postgres Table Partitioning
Dit zijn de fouten die ik persoonlijk productie-incidenten heb zien veroorzaken.
Unique constraints op niet-partition kolommen breken stilletjes. Een unique_by: :external_id op een gepartitioneerde events tabel compileert prima en handhaaft uniqueness binnen elke partitie, niet globaal. Als je globaal unieke niet-key kolommen nodig hebt, kun je dat niet handhaven in een gepartitioneerde tabel — verplaats die uniqueness naar een aparte niet-gepartitioneerde reference tabel.
Foreign keys die naar een gepartitioneerde tabel wijzen waren onmogelijk vóór Postgres 12 en zijn nog steeds onhandig. Als comments.event_id naar events.id verwijst, en events is gepartitioneerd, moet Postgres bij een insert overal naar de existence check kijken. Vermijd dit ontwerp waar je kunt. Voor audit-stijl data, denormaliseer de parent reference in plaats van een echte FK te gebruiken.
ActiveRecord migraties roundtrippen niet schoon. Gepartitioneerde tabellen gebruiken SQL features die de schema dumper niet kan representeren. Zet je repo over op config.active_record.schema_format = :sql zodat db/structure.sql de partition definities correct vastlegt. Als je dit vergeet, is je test database een gewone tabel terwijl productie gepartitioneerd is. Vraag me niet hoe ik dat weet.
COPY en bulk insert negeren partition routing prestaties. Ze werken, maar voor zeer grote loads, kopieer direct naar de doelpartitie. De router voegt op schaal tientallen procenten overhead toe.
Autovacuum settings zijn per-partitie. Een drukke partitie heeft mogelijk andere settings nodig dan een slaperige historische. Het goede nieuws: je kunt ALTER TABLE events_2026_04 SET (autovacuum_vacuum_scale_factor = 0.05) doen en alleen de hete tunen. Ik ging hier diep op in in de Postgres autovacuum tuning gids.
Een Bestaande Grote Tabel Migreren naar Gepartitioneerd Zonder Downtime
De migratie zelf is het engste deel. Je kunt niet zomaar ALTER TABLE events PARTITION BY RANGE (occurred_at) op een gevulde tabel. Postgres vereist dat de gepartitioneerde ouder leeg is.
Het patroon dat ik gebruik, dat werkte op tabellen tot ongeveer een miljard rijen zonder downtime:
- Maak een nieuwe gepartitioneerde tabel
events_partitionedmet dezelfde kolommen en de partitioning scheme. - Maak partities die het hele bereik van bestaande data bestrijken plus een paar maanden vooruit.
- Backfill in batches met een Rails job die rijen kopieert van
eventsnaarevents_partitioned, geordend opoccurred_at, metINSERT ... SELECTen eenLIMIT. Gebruik het Rails Postgres advisory lock patroon zodat twee pods de backfill niet tegelijk draaien. - Voeg een trigger toe op de originele
eventstabel die nieuwe inserts en updates spiegelt naarevents_partitioned. Dit is het meest risicovolle stuk — schrijf eerst characterisation tests. - Zodra de backfill is bijgepraat en de trigger een dag stil is geweest (rijen matchen in beide), in één enkele transactie:
RENAMEeventsnaarevents_old,RENAMEevents_partitionednaarevents, drop de trigger. - Sanity-check counts en een paar queries, drop dan
events_oldna een week.
Voor tabellen boven een paar miljard rijen, neem het maintenance window. De fintech klant waar ik mee opende nam een vier uur durend zaterdag-window met read-only modus liever dan het risico van een live cutover. Soms is de saaie keuze de juiste keuze.
De gedetailleerde neef van dit patroon is het zero-downtime database migratie playbook — zelfde principes, andere schema operatie.
FAQ
Hoeveel partities kan een Postgres tabel hebben voordat de prestaties degraderen?
In Postgres 14 en later blijft planning snel tot in de lage duizenden partities per tabel dankzij runtime pruning. Ik behandel 1.000 partities als een comfortabel plafond en 10.000 als een waarschuwingssignaal. Daarboven wordt query planning tijd zelf de bottleneck. Als je meer nodig hebt, partitioneer met grovere granulariteit (kwartaal in plaats van maand) of shard op applicatieniveau.
Moet ik pg_partman gebruiken of mijn eigen partition manager schrijven in Rails?
Beide werken. pg_partman is de juiste keuze als je operations team al Postgres extensies draait, je voorgebakken retention en pre-creation logica wilt, en je geen partitioning concerns in je Rails repo wilt. Schrijf je eigen versie als je een klein team bent dat Ruby code en bestaande observability prefereert. De Rails versie is vijftig regels en eenvoudiger te begrijpen; pg_partman handelt edge cases af waar je nog niet aan gedacht hebt.
Werkt Rails Postgres table partitioning met Active Record associaties en joins?
Ja, transparant — je queryt de oudertabel en Active Record weet of geeft niet om dat hij gepartitioneerd is. Wat je moet checken is dat elke hete query de partition key in zijn WHERE heeft zodat Postgres kan prunen. Event.where(account_id: 42).where(occurred_at: 1.day.ago..) pruned tot één partitie. Event.where(account_id: 42) alleen scant elke partitie. Voeg Bullet of strict loading toe voor hygiëne, maar partition pruning controleer je met EXPLAIN.
Kan ik partitioning toevoegen aan een tabel die al foreign keys naar zich heeft?
Het is pijnlijk. De migratie hierboven (rename-and-swap) moet elke inkomende FK opnieuw aanmaken tegen de nieuwe gepartitioneerde tabel, en events.id is op zichzelf niet meer uniek — de unique constraint bevat de partition key. In de praktijk, denormaliseer inkomende FKs naar business-key referenties (een event_uuid kolom met een applicatie-niveau invariant) voordat je partitioneert. Dit is een van de sterkste redenen om nieuwe write-heavy tabellen vanaf het begin met partitioning in gedachten te ontwerpen, zelfs als je het niet op dag één aanzet.
Hulp nodig met een Rails Postgres prestatie-migratie zonder het systeem plat te leggen? TTB Software is gespecialiseerd in Rails infrastructuurwerk zoals dit — partitioning, replication, vacuum tuning, de hele stack. We doen dit al negentien jaar.
About the Author
Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation