Now-Next

SaaS-architectuur

Eén klant verhuizen met logische replicatie en rijfilters

Eén klant naar een eigen PostgreSQL 17-database verhuizen met een gefilterde publicatie, gemeten: replica-identity-val, WAL-kosten, omschakelen, sequences.

Chris van Eijk · · 7 min lezen

Eén klant is de gedeelde database ontgroeid — of moet om contractuele redenen een eigen database krijgen — en moet verhuizen zonder lange onderbreking. Sinds PostgreSQL 15 kan logische replicatie dat voor één klant: een publicatie met een rijfilter, WHERE (tenant_id = 1), kopieert alleen de rijen van die klant en blijft zijn wijzigingen doorsturen tot je omschakelt.

We deden het op PostgreSQL 17.10, tussen twee wegwerpcontainers met standaardinstellingen en wal_level = logical op de bron: 311.205 facturen en 1.244.820 factuurregels over 100 klanten, en we verhuisden de grootste (60.000 facturen, 240.000 regels). De kopie zelf bleek het makkelijke deel. De stap daarvoor kan ervoor zorgen dat geen enkele klant nog iets kan wijzigen of verwijderen.

Waarom falen UPDATE en DELETE na een publicatie met een rijfilter?

Omdat de filterkolom geen deel is van de replica identity. We maakten de publicatie:

create publication move_t1 for table
  tenants where (id = 1),
  invoices where (tenant_id = 1),
  invoice_lines where (tenant_id = 1);

en direct daarna, op de bron:

update invoices set total_cents = total_cents + 1 where id = 5;
ERROR:  cannot update table "invoices"
DETAIL:  Column used in the publication WHERE expression is not part of the replica identity.

Hetzelfde voor DELETE op invoice_lines, en voor een update van een rij van klant 50 — een klant die helemaal niet verhuist. Invoegen bleef werken. De documentatie noemt de regel: als een publicatie updates of deletes publiceert, “the row filter WHERE clause must contain only columns that are covered by the replica identity”. Standaard is de replica identity de primaire sleutel, id, en daar zit tenant_id niet in. Zodra de publicatie bestaat, kan je product wel facturen aanmaken, maar er geen wijzigen of verwijderen, voor geen enkele klant.

De replica identity moet dus eerst veranderen, vóór je de publicatie maakt.

Wat kost REPLICA IDENTITY FULL?

Twee manieren om tenant_id in de replica identity te krijgen: REPLICA IDENTITY FULL (de hele oude rij gaat mee in de WAL), of een unieke index waar hij in zit, (tenant_id, id), als REPLICA IDENTITY USING INDEX. We maten een update van 115.738 facturen van negen andere klanten, twee keer per variant, elke keer op een verse kopie met vlak ervoor een checkpoint:

Replica identity op invoicesTijd van de updateWAL geschreven
standaard (primaire sleutel), geen publicatie (een publicatie schrijft zelf geen WAL)863–872 ms58 MB
FULL, met de gefilterde publicatie872–881 ms69 MB
unieke index (tenant_id, id), met de publicatie1.151 ms71 MB

FULL kostte 19% meer WAL en geen meetbare tijd. De indexroute vereist bovendien dat tenant_id NOT NULL is. De extra index kostte bij deze update 22% meer WAL en 33% meer tijd — en een index is iets wat elke insert blijft bijhouden, ook na de verhuizing, tenzij je hem weer weghaalt. Voor een verhuizing die uren duurt, is hier FULL voor die duur en daarna terug naar DEFAULT de goedkoopste van de twee. Met primaire sleutels die al met tenant_id beginnen, speelt de vraag helemaal niet. Op het doel kost FULL weinig zolang daar de primaire sleutel staat, en die neemt pg_dump -s mee: het abonnement zoekt daarmee de rijen op die het moet wijzigen.

Hoe lang duren de kopie en het inhalen?

Met het schema aangemaakt op het doel (pg_dump -s van de drie tabellen) en het abonnement gestart, stonden alle drie de tabellen na 1,3 seconde op gereed: 1 klant, 60.000 facturen, 240.000 regels. De initiële kopie gebruikt hetzelfde rijfilter, dus de andere 99 klanten kwamen nooit op het doel.

Daarna bleven we schrijven: 1.000 nieuwe facturen met 4.000 regels voor de klant die verhuist, een update van elke tiende factuur van die klant, en een update van 115.738 facturen van andere klanten, die de bron decodeert en wegfiltert. We legden na de laatste schrijfactie de WAL-positie van de bron vast; de slot had die binnen 0,7 seconde bevestigd, en op het doel stond precies wat de bron voor die klant had: 61.000 facturen en 244.000 regels, met identieke checksums over id’s en bedragen — dus ook de updates waren aangekomen.

Dat inhalen is de kern van de onderbreking voor de klant: zijn schrijfacties stoppen, één keer pg_current_wal_lsn() vastleggen, wachten tot confirmed_flush_lsn van de slot die vastgelegde positie voorbij is — niet de huidige, die blijft opschuiven omdat andere klanten doorschrijven — dan de sequences zetten en zijn verbindingen naar de nieuwe database wijzen. Die laatste twee stappen hebben we niet getimed.

Wat neemt logische replicatie niet mee?

De sequences. De documentatie is duidelijk — “Sequence data is not replicated” — en de eerste insert op de nieuwe database liet zien wat dat betekent:

ERROR:  duplicate key value violates unique constraint "invoices_pkey"
DETAIL:  Key (id)=(1) already exists.

De sequence op het doel stond op 1, terwijl de facturen van de klant id’s tot 311.205 en hoger hadden. Zet elke sequence op het doel voordat de klant daar schrijft, als onderdeel van het omschakelen, niet na de eerste fout. De veiligste waarde is die van de sequence op de bron: max(id) op het doel is alleen het hoogste id van deze klant, en dat volstaat zolang de nieuwe database nooit id’s van elders krijgt.

Een stilgezet abonnement is niet onschuldig. We schakelden het abonnement uit en werkten op de bron de facturen van 29 andere klanten bij. Die replicatieslot hield daarna 91 MB WAL vast die niet opgeruimd kon worden. Met de standaard max_slot_wal_keep_size = -1 geldt: “replication slots may retain an unlimited amount of WAL files”. Een verhuizing die een weekend stilstaat, laat de schijf van de bron vollopen. Verwijder het abonnement (en daarmee zijn slot) zodra de verhuizing klaar is, en zet een grens zolang hij loopt — wetend dat een slot die over de grens gaat ongeldig wordt, en de verhuizing dan opnieuw begint met een verse kopie.

Een checklist voor het verhuizen van één klant

  1. Eerst de replica identity: FULL (of een unieke index met tenant_id) op elke tabel waarvan de filterkolom niet in de replica identity zit, vóór CREATE PUBLICATION … WHERE. Anders falen de updates en deletes van elke klant op die tabellen. tenants where (id = 1) heeft niets nodig: id is daar de primaire sleutel.
  2. Schema op het doel uit pg_dump -s, zonder de publicatie.
  3. Abonneren en wachten tot pg_subscription_rel elke tabel op gereed toont.
  4. Omschakelen: schrijfacties van de klant stoppen, de WAL-positie van de bron één keer vastleggen, wachten tot de slot die heeft bevestigd, setval op elke sequence, verbinding omzetten.
  5. Opruimen: abonnement verwijderen, replica identity terugzetten, en de klant uit de bron halen — hoe lang dat duurt en wat er op de schijf achterblijft, staat in één klant verwijderen in PostgreSQL.
  6. Begrens de slot met max_slot_wal_keep_size zolang de verhuizing loopt, en houd hem in de gaten.

Heeft de klant alleen een kopie nodig en geen live verhuizing, dan is één klant exporteren met pg_dump eenvoudiger. Naar welk isolatiemodel je hem verhuist, staat in multi-tenant architectuur: de isolatiemodellen.

Wat we niet hebben gemeten

Schemawijzigingen tijdens de verhuizing, een klant waarvan tenant_id verandert, row-level security op het doel, large objects, en een bron onder echte productielast: onze schrijflast was een piek, geen dag.

Bronnen: de PostgreSQL 17-documentatie over rijfilters, beperkingen van logische replicatie (sequences) en replicatie-instellingen (max_slot_wal_keep_size), gelezen op 1 oktober 2026. Alle metingen zijn van onszelf, op PostgreSQL 17.10, op 1 oktober 2026.

Laten we praten

Wat gaan we bouwen?

Eén gesprek is genoeg om te weten of we bij elkaar passen. Vertel wat je voor je ziet — wij zeggen hoe snel dat kan, en of wij daar de juiste partij voor zijn.

Start het gesprek