Rails Postgres partitioning: miljardenrij-tabellen beheren met pg_partman en native Postgres-partities
Rails Postgres partitioning gids: beheer miljardenrij-tabellen met native Postgres declarative partitioning, pg_partman-automatisering en veilige Rails-migraties.
Een logistieke klant belde me afgelopen donderdag omdat hun shipment_events-tabel de 1,4 miljard rijen was gepasseerd en hun nachtelijke VACUUM nu elf uur duurde, soms doorlopend tot in het ochtendverkeer. Queries die vroeger 40ms kostten duurden nu 3 seconden, autovacuum kon het schrijfvolume niet meer bijbenen, en de DBA begon te mompelen over een grotere server kopen. Ik vertelde ze wat ik het afgelopen drie jaar tegen een tiental teams heb gezegd: je hebt geen grotere server nodig, je hebt Rails Postgres partitioning nodig. Twee weken later was diezelfde tabel opgesplitst in 60 maandpartities, waren queries weer op 40ms, was VACUUM klaar in minder dan twintig minuten per partitie, en werd een jaar aan oude data droppen een enkele DETACH PARTITION in plaats van een twee dagen durende DELETE.
Na negentien jaar Rails heb ik elke generatie “hoe gaan we om met enorme tabellen”-advies zien passeren: sharding-gems, Octopus, hete data verplaatsen naar Cassandra, denormaliseren naar materialized views. Native Rails Postgres partitioning — declarative partitioning geleverd in Postgres 10 en volwassen geworden tot en met 16 — maakt vrijwel alles daarvan overbodig voor de tabellen die daadwerkelijk pijn veroorzaken: events, logs, audit trails, telemetry, tijdreeksmetingen, alles append-zwaar met een schone tijds- of tenantdimensie. Deze post is het exacte recept dat ik gebruik om partitioning in een bestaande Rails-app te introduceren zonder downtime.
Wat Rails Postgres partitioning eigenlijk oplost
Rails Postgres partitioning is native Postgres declarative partitioning, aangesloten via Rails-migraties en modellen. Je neemt één enorme logische tabel en splitst hem op in vele kleinere fysieke tabellen (partities) die Postgres behandelt als één. Een SELECT ... WHERE occurred_at BETWEEN ... leest alleen de partities waarvan de range met de filter overlapt — partition pruning — dus een query die vroeger 1,4 miljard rijen scande, scant nu 20 miljoen. Vacuum, analyze, indexrebuilds en bulk deletion opereren allemaal één partitie per keer, dus onderhoudsvensters krimpen van uren naar minuten.
De drie problemen die Rails Postgres partitioning oplost op echte productieworkloads:
Vacuum-churn. Autovacuum op een 500GB-tabel is een nachtmerrie omdat hij de hele heap moet doorlopen om dode tuples te vinden. Op een partitioned tabel ziet alleen de huidige partitie zware writes, dus alleen die partitie hoeft vaak gevacuumd te worden. Oude partities zijn read-only en effectief gratis in onderhoud.
Indexbloat en rebuildtijd. Een BTREE-index op een miljard-rij-tabel duurt eindeloos om te REINDEX CONCURRENTLY. Per-partitie indexen zijn 30-50x kleiner en rebuilden in seconden. Als een index opblaast na een migratie, fix je de betreffende partitie, niet de hele tabel.
Deletion op schaal. Retentie is een echte requirement in de meeste SaaS — GDPR, kosten, of gewoon gezond verstand. DELETE FROM events WHERE created_at < NOW() - INTERVAL '2 years' op een enorme tabel genereert gigabytes aan WAL, blokkeert autovacuum en duurt uren. ALTER TABLE events DETACH PARTITION events_2024_01 is instant en genereert vrijwel geen WAL.
Partitioning lost geen hot-partitionproblemen op, maakt een trage query niet snel als je niet filtert op de partition key, en vervangt geen goede indexering. Het is een onderhouds- en schaalinstrument, geen magisch prestatiestof.
Wanneer je moet grijpen naar Rails Postgres partitioning
Ik gebruik een simpele test: is de tabel groter dan 100GB, is hij append-zwaar, en filtert elke belangrijke query op een natuurlijke tijds- of tenantdimensie? Zo ja, partitioneer. Als de tabel klein is, of writes verspreid zijn over alle rijen, of je queries geen natuurlijke filterdimensie hebben, dan maakt partitioning het erger, niet beter.
Goede kandidaten die ik het afgelopen jaar heb gepartitioneerd:
shipment_events(range opoccurred_at, maandelijks)webhook_deliveries(range opcreated_at, wekelijks)audit_logs(range opcreated_at, maandelijks) — past mooi bij de audit-tooling die ik behandelde in Rails Audit Logging met PaperTrailllm_call_records(range opcreated_at, wekelijks) — waar ik de per-tenant kostendata neerzet uit Rails LLM Cost Trackingiot_readings(range opmeasured_at, dagelijks)analytics_page_views(list optenant_id, één partitie per grote tenant)
Slechte kandidaten: users, orders, products, de meeste referentiedatatabellen. Bij twijfel, draai SELECT pg_size_pretty(pg_relation_size('your_table')) — onder 50GB, doe het nog niet.
Native declarative partitioning opzetten in Rails
Je kunt een bestaande Postgres-tabel niet in-place partitioneren — je moet een nieuwe partitioned tabel maken en data ernaartoe verplaatsen. Rails heeft nog geen first-class DSL voor partitioning, dus ik schrijf de DDL direct in een migratie. Dat is prima; het is saaie SQL en het werkt overal.
class CreatePartitionedShipmentEvents < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
CREATE TABLE shipment_events_new (
id BIGSERIAL,
shipment_id BIGINT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
occurred_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);
CREATE INDEX idx_shipment_events_new_shipment
ON shipment_events_new (shipment_id, occurred_at DESC);
CREATE INDEX idx_shipment_events_new_type
ON shipment_events_new (event_type, occurred_at DESC);
SQL
end
def down
execute "DROP TABLE shipment_events_new"
end
end
Twee dingen waar mensen op struikelen. Ten eerste, de primary key op een partitioned tabel moet de partition key bevatten. PRIMARY KEY (id, occurred_at) is niet optioneel — Postgres kan geen globale uniqueness afdwingen over partities zonder de partition key te indexeren. Ten tweede, indexen die op de parent-tabel worden gedeclareerd, worden automatisch op elke partitie aangemaakt, huidige en toekomstige. Dit is wat je wilt; het betekent dat je nooit hoeft te onthouden om een index toe te voegen aan een nieuwe partitie.
Nu heb je daadwerkelijke partities nodig. Je kunt ze met de hand aanmaken:
class CreateShipmentEventPartitions < ActiveRecord::Migration[8.0]
def up
(Date.new(2024, 1, 1)..Date.new(2026, 12, 1)).step(30) do |start|
month = start.strftime("%Y_%m")
finish = start.next_month
execute <<~SQL
CREATE TABLE shipment_events_#{month}
PARTITION OF shipment_events_new
FOR VALUES FROM ('#{start.iso8601}') TO ('#{finish.iso8601}');
SQL
end
end
end
Dit werkt, maar het is een onderhoudslast — iemand moet onthouden om elke maand een migratie te draaien om de volgende partitie toe te voegen, anders beginnen writes te falen wanneer occurred_at buiten elke bestaande range valt. Daarvoor bestaat pg_partman.
Rails Postgres partitioning automatiseren met pg_partman
pg_partman is een Postgres-extensie die partitie-aanmaak, retentie en onderhoud automatiseert. Je registreert een parent-tabel één keer bij pg_partman, vertelt hem “houd zes maanden aan toekomstige partities aan en drop alles ouder dan drie jaar”, en een background-onderhoudsjob doet de rest. Het is het stuk dat ik in elk Rails Postgres partitioning-project installeer na de eerste week.
Installeer het als een gewone Postgres-extensie (op RDS staat het op de extension-whitelist; op self-hosted gewoon apt install postgresql-16-partman). Dan in een Rails-migratie:
class EnablePgPartman < ActiveRecord::Migration[8.0]
def up
execute "CREATE SCHEMA IF NOT EXISTS partman"
execute "CREATE EXTENSION IF NOT EXISTS pg_partman SCHEMA partman"
end
end
Registreer de parent-tabel bij pg_partman. Deze ene functieaanroep is de hele configuratie:
class RegisterShipmentEventsWithPartman < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
SELECT partman.create_parent(
p_parent_table => 'public.shipment_events_new',
p_control => 'occurred_at',
p_type => 'native',
p_interval => '1 month',
p_premake => 6
);
UPDATE partman.part_config
SET retention = '3 years',
retention_keep_table = false,
infinite_time_partitions = true
WHERE parent_table = 'public.shipment_events_new';
SQL
end
end
p_premake => 6 houdt zes maanden aan lege partities vóór vandaag, zodat een burst van “toekomstig-gedateerde” writes nooit een gap raakt. retention => '3 years' markeert alles ouder voor automatisch droppen. retention_keep_table => false betekent dat gedropte partities volledig verwijderd worden — zet op true als je eerst wilt detachen en archiveren naar S3.
pg_partman heeft een maintenance-functie die periodiek moet draaien. In elke Rails Postgres partitioning-opzet stuur ik dit als een Solid Queue recurring job:
class PgPartmanMaintenanceJob < ApplicationJob
queue_as :maintenance
def perform
ActiveRecord::Base.connection.execute(
"CALL partman.run_maintenance_proc()"
)
end
end
# config/recurring.yml
production:
pg_partman_maintenance:
class: PgPartmanMaintenanceJob
schedule: "every hour"
Eén keer per uur is ruim voldoende. Deze job maakt nieuwe partities aan voordat ze nodig zijn, past retentie toe en herindexeert als geconfigureerd. Als je nog op cron zit, gebruik dan bin/rails runner "PgPartmanMaintenanceJob.perform_now" — hoe dan ook, het moet ergens draaien.
Bestaande data migreren naar een Rails Postgres partitioned tabel
De truc met Rails Postgres partitioning is miljarden bestaande rijen verplaatsen zonder onderhoudsvenster. Het patroon dat ik elke keer gebruik is: maak de partitioned tabel naast de oude, dubbel-schrijven vanuit de applicatie, backfill in batches, dan de namen wisselen in één enkele transactie.
Dubbel-schrijven vanuit een model-concern is de veiligste aanpak. Elke write gaat naar beide tabellen tot we omschakelen:
class ShipmentEvent < ApplicationRecord
self.table_name = "shipment_events"
after_create :mirror_to_partitioned_table
private
def mirror_to_partitioned_table
return unless Feature.enabled?(:partitioned_shipment_events_dual_write)
self.class.connection.exec_insert(<<~SQL, "MirrorShipmentEvent", [
[nil, id], [nil, shipment_id], [nil, event_type],
[nil, payload.to_json], [nil, occurred_at], [nil, created_at]
])
INSERT INTO shipment_events_new
(id, shipment_id, event_type, payload, occurred_at, created_at)
VALUES ($1, $2, $3, $4::jsonb, $5, $6)
ON CONFLICT DO NOTHING
SQL
end
end
Het ON CONFLICT DO NOTHING is belangrijk omdat de backfill-job zal racen met de dubbel-write, en je wilt dat de winnaar idempotent is. Backfill in batches, lage prioriteit, mikkend op een paar duizend rijen per keer:
class BackfillShipmentEventsJob < ApplicationJob
queue_as :low
BATCH_SIZE = 5_000
def perform(last_id: 0)
rows = ShipmentEvent
.where("id > ?", last_id)
.order(:id)
.limit(BATCH_SIZE)
.to_a
return if rows.empty?
values = rows.map do |e|
"(#{e.id}, #{e.shipment_id}, #{ActiveRecord::Base.connection.quote(e.event_type)}, " \
"#{ActiveRecord::Base.connection.quote(e.payload.to_json)}::jsonb, " \
"#{ActiveRecord::Base.connection.quote(e.occurred_at)}, " \
"#{ActiveRecord::Base.connection.quote(e.created_at)})"
end.join(",")
ActiveRecord::Base.connection.execute(<<~SQL)
INSERT INTO shipment_events_new
(id, shipment_id, event_type, payload, occurred_at, created_at)
VALUES #{values}
ON CONFLICT DO NOTHING
SQL
BackfillShipmentEventsJob.set(wait: 200.milliseconds).perform_later(last_id: rows.last.id)
end
end
De wait: 200.milliseconds tussen batches is wat replicatielag onder controle houdt en andere workloads laat ademen. Op een 1,4-miljard-rij-tabel duurde dit ongeveer 40 uur, continu draaiend op de achtergrond terwijl de app verkeer bediende. Als je op Postgres 16 of later zit, kun je ook pg_dump --data-only doorpipen via COPY voor de historische data en alleen recente rijen dubbel-schrijven, wat sneller is maar iets meer coördinatie vergt.
Zodra de tellingen matchen (SELECT COUNT(*) FROM shipment_events vs shipment_events_new), wissel in één enkele transactie:
class SwapShipmentEventsTables < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
BEGIN;
ALTER TABLE shipment_events RENAME TO shipment_events_legacy;
ALTER TABLE shipment_events_new RENAME TO shipment_events;
COMMIT;
SQL
end
end
Houd shipment_events_legacy een week rond voor het geval je moet terugrollen, drop hem daarna. Als je bijzonder voorzichtig bent, leg er een Strong Migrations-guard overheen — dat patroon behandelde ik in Rails Strong Migrations.
Correct queryen op een partitioned tabel
De meest voorkomende Rails Postgres partitioning-fout die ik zie zijn queries die niet filteren op de partition key. Als je ShipmentEvent.where(shipment_id: 42).first schrijft zonder occurred_at-bound, moet Postgres elke partitie scannen. Het werkt nog steeds, maar je verliest het hele voordeel.
Twee fixes. Ten eerste, voeg een default scope toe die de tijdsgrens verplicht stelt:
class ShipmentEvent < ApplicationRecord
scope :in_window, ->(from:, to:) {
where(occurred_at: from..to)
}
scope :recent, -> { in_window(from: 30.days.ago, to: Time.current) }
end
Nu schrijven callers ShipmentEvent.recent.where(shipment_id: 42) en slaat pruning aan. Ten tweede, verifieer pruning met EXPLAIN:
puts ShipmentEvent
.in_window(from: 30.days.ago, to: Time.current)
.where(shipment_id: 42)
.explain
Je wilt Append zien over alleen de recente partities, niet Seq Scan over allemaal. Zie je dat laatste, dan verslaat iets in je scope de constant-folding pruner — meestal een berekende timestamp binnen een subquery in plaats van een bind-parameter.
FAQ: Rails Postgres partitioning
Wanneer moet ik een Postgres-tabel partitioneren in Rails?
Partitioneer wanneer de tabel groter is dan 100GB, append-zwaar is, en elke belangrijke query filtert op een natuurlijke tijds- of tenantdimensie. Onder 100GB overschrijden de operationele kosten van partitioning de onderhoudsbesparingen. Boven 100GB beginnen VACUUM, REINDEX en retentie-deletes allemaal pijn te doen op manieren die partitioning netjes oplost.
Vereist Rails Postgres partitioning pg_partman?
Nee, native Postgres declarative partitioning werkt zonder — je moet dan zelf elke periode nieuwe partities aanmaken. pg_partman automatiseert partitie-aanmaak, retentie en onderhoud, dus ik installeer het in elk echt project. Op managed services zoals AWS RDS en Google Cloud SQL staat pg_partman op de extension-whitelist en installeert als elke andere extensie.
Kan ik een bestaande tabel in-place partitioneren met Rails?
Niet direct. Postgres ondersteunt het niet om een reguliere tabel in-place om te zetten in een partitioned tabel. Het patroon is een nieuwe partitioned tabel naast de oude maken, dubbel-schrijven vanuit de applicatie, backfill in batches, en de namen wisselen in één enkele transactie. Dit is haalbaar met nul downtime en is wat ik doorloop in de migratiesectie hierboven.
Hoe werkt Rails Postgres partitioning samen met foreign keys?
Foreign keys die vanuit een partitioned tabel wijzen naar een andere tabel werken prima. Foreign keys die naar een partitioned tabel wijzen worden ondersteund vanaf Postgres 12 maar hebben caveats — de verwijzende tabel heeft een index nodig die matcht met de partition key, en cascading deletes over partities kunnen traag zijn. Voor zeer hete append-zware tabellen sla ik inkomende foreign keys meestal volledig over en dwing referentiële integriteit af in de applicatielaag.
Hulp nodig bij het partitioneren van een grote Postgres-tabel in je Rails-app, of bij het uitstippelen van een database-schaalstrategie die daadwerkelijk standhoudt? TTB Software is gespecialiseerd in Rails-performance en Postgres op schaal. We doen dit al negentien jaar.
Related Articles
Rails ActiveRecord Encryption: PII versleutelen at rest met deterministische en niet-deterministische modes
Rails ActiveRecord encryption gids: versleutel PII at rest met deterministische en niet-deterministische modes, key m...
Rails Delegated Types: Single Table Inheritance Vervangen Zonder de Rommel
Rails delegated types uitgelegd: vervang STI met per-type tabellen. Migratiestrategie, querypatronen, N+1-valkuilen e...
Rails Postgres LISTEN/NOTIFY: Real-Time Events Zonder Redis of Message Broker
Rails Postgres LISTEN/NOTIFY gids: real-time events met ActionCable, connectiepatronen, productie-valkuilen. Wanneer ...