SaaS-architectuur
Eén klant verwijderen in PostgreSQL: cascade, batch of DROP
Eén klant uit een gedeelde PostgreSQL 17-database halen, gemeten: cascade, batches of een partitie droppen; locks, batchgrootte, schijf en wat blijft.
Een klant zegt op en vraagt of zijn gegevens weg mogen. In een multi-tenant SaaS waarin alle klanten in dezelfde tabellen staan, is dat één DELETE — en vier vragen die niemand stelt tot de dag dat hij draait: hoe lang duurt het, houdt het de andere klanten op, wordt de database kleiner, en is het ook echt weg?
We maten ze alle vier op PostgreSQL 17.10, in een wegwerpcontainer met standaardinstellingen: 100 klanten, 311.205 facturen en 1.244.820 factuurregels, 235 MB. De grootste klant had 60.000 facturen en 240.000 regels, een middelgrote 1.200 facturen. Beide foreign keys (invoices → tenants, invoice_lines → invoices) staan op on delete cascade, en beide hebben een index op de verwijzende kolom.
Hoe lang duurt het om één klant te verwijderen?
| Klant | delete from tenants (cascade) | Expliciet, kindtabellen eerst | WAL geschreven |
|---|---|---|---|
| middelgroot (1.200 facturen) | 29 ms | 31 ms | 1 MB |
| grootste (60.000 facturen) | 1.342–1.351 ms | 1.341–1.357 ms | 48 MB |
Elk getal is twee keer gemeten vanuit dezelfde begindatabase, met vlak voor elke run een checkpoint (na een checkpoint schrijft PostgreSQL volledige pagina’s in de WAL; zonder checkpoint is het WAL-getal lager). Cascade of tabel voor tabel maakt geen verschil. Wat de expliciete route wél laat zien, is waar de tijd zit: de 240.000 factuurregels verwijderen kostte 140 ms, de 60.000 facturen daarna 1.185–1.202 ms. De kosten zitten niet in de rijen maar in de foreign key: PostgreSQL draait voor elke verwijderde factuur een trigger voor referentiële integriteit, die gaat zoeken naar regels die er nog naar verwijzen — ook als je die net zelf allemaal hebt verwijderd. EXPLAIN ANALYZE op die stap liet het zien: Trigger for constraint invoice_lines_invoice_id_fkey: time=1157.535 calls=60000, van 1.214 ms in totaal.
Die trigger is ook de reden dat de index op de verwijzende kolom ertoe doet. Zonder index is elke controle een scan van de kindtabel; in onze gids voor multi-tenant SaaS op PostgreSQL kostte het verwijderen van een klant met 2.000 facturen daardoor 21,8 seconden in plaats van 0,12.
Houdt het verwijderen van een klant de andere klanten op?
Niet rechtstreeks — niet bij lezen, en niet bij schrijven door andere klanten. We startten het verwijderen van de grootste klant en hielden de transactie open. Intussen, in een tweede sessie:
- een factuur toevoegen voor een andere klant kostte 10 ms;
- het tellen van de facturen van de klant die verwijderd werd gaf nog steeds 60.000 — tot de commit ziet iedereen de oude rijen;
- een factuur toevoegen voor de klant die verwijderd werd wachtte 2,8 seconden, tot de commit, en faalde toen met
violates foreign key constraint.
Een DELETE vergrendelt alleen de rijen die hij verwijdert, plus de klantrij. Waar je rekening mee houdt is dus alles wat nog schrijft voor de vertrekkende klant — zijn applicatie, maar ook achtergrondtaken: zet die uit voordat je begint. Eén indirecte kost blijft: zolang de transactie openstaat, kan vacuum nergens in de database dode rijen opruimen, voor geen enkele klant. Nog een reden om hem kort te houden.
Welke batchgrootte gebruik je om in batches te verwijderen?
In batches verwijderen houdt elke transactie kort, en dat beperkt replicatievertraging en de tijd dat iets op die rijen wacht. We verwijderden de grootste klant in batches — kies een batch factuur-id’s, verwijder hun regels en de facturen, commit — bij vier batchgroottes:
| Batch | Batches | Per batch (mediaan) | Totaal |
|---|---|---|---|
| 1.000 facturen | 60 | 27,7 ms | 1,7 s |
| 2.000 | 30 | 76,2 ms | 2,3 s |
| 3.000 | 20 | 203,5 ms | 4,1 s |
| 5.000 | 12 | 248,2 ms | 3,0 s |
Batches van 3.000 en 5.000 duurden in totaal twee tot drie keer zo lang als één DELETE in één keer. EXPLAIN ANALYZE op de eerste batch liet zien waarom: bij 1.000 id’s gebruikte de planner de indexen (index scans, vier regels per factuur); bij 5.000 koos hij een hash join over een sequentiële scan van beide tabellen — alle 1.244.820 regels en alle 311.205 facturen. Elke batch kostte ongeveer evenveel (233–264 ms bij 5.000; 27–31 ms bij 1.000), dus een grotere batch haalde niets in; hij las alleen veel meer dan hij verwijderde. En de curve is niet glad — 3.000 was in totaal trager dan 5.000.
Kies de batchgrootte dus met EXPLAIN ANALYZE op één batch, niet op gevoel, en kijk opnieuw als de tabel groeit: waar het plan omslaat hangt af van je gegevens en van hoe je de batchquery schrijft, niet van een vuistregel.
Wordt de database kleiner als je een klant verwijdert?
Nee. Na het verwijderen van de grootste klant (19% van alle rijen) was de database nog steeds 235 MB, en na VACUUM ook. De PostgreSQL-documentatie zegt het ronduit: vacuum “will not return the space to the operating system”, behalve bij helemaal lege pagina’s aan het eind van een tabel.
Verloren is de ruimte niet. Een nieuwe klant van precies dezelfde omvang paste in de vrijgekomen ruimte van de tabellen zonder dat die groeiden (167 MB ervoor, 167 MB erna). De indexen groeiden wel, met 13 MB (van 60 naar 73 MB): de sleutels van de nieuwe rijen komen achteraan in de indexen te staan, niet waar die van de oude klant stonden.
VACUUM FULL geeft de ruimte wel terug — 235 MB werd 191 MB — maar herschrijft de tabel onder een exclusieve lock: 1.329 ms voor de regels en 311 ms voor de facturen, en in die tijd kan niemand die tabellen lezen. Voor één vertrekkende klant is dat zelden de moeite waard.
Zijn de gegevens van de klant na een DELETE echt weg?
Niet uit de gegevensbestanden. We gaven elke factuur van de middelgrote klant een herkenbare notitie, verwijderden de klant, draaiden VACUUM en een checkpoint. Een query vond 0 rijen. Het gegevensbestand van de tabel bevatte de tekst nog 1.219 keer, bij 1.200 facturen: vacuum markeert de ruimte als vrij, maar wist de oude bytes niet. Na VACUUM FULL stond hij 0 keer in het nieuwe gegevensbestand; het oude bestand wordt verwijderd, en dat is iets anders dan dat de blokken van de schijf zijn gewist.
Ook op andere plekken blijven de gegevens na de DELETE bestaan, onder meer:
- Het write-ahead log: de tekst stond nog in de recentste WAL-segmenten. Met WAL-archivering leeft hij voort zolang je het archief bewaart.
- Back-ups: elke basisback-up van vóór de
DELETEbevat de klant tot die back-up verloopt. - Je eigen kopieën: een auditlog dat volledige rijen opslaat — de standaard in de triggers uit ons artikel over een auditlog per klant — bewaart de gegevens van de klant, net als exports, zoekindexen en caches. Het serverlog ook: een constraintfout zet de sleutelwaarden in zijn
DETAIL-regel.
“Verwijderd” betekent dus: niet meer bereikbaar via de database. Hoe lang de andere kopieën mogen bestaan is een vraag over je bewaartermijnen, en een eerlijk antwoord aan de klant noemt ze.
Is een partitie droppen sneller?
Veel sneller, met twee valkuilen. We bouwden dezelfde gegevens met beide tabellen gepartitioneerd per lijst op tenant_id — de grootste klant in een eigen partitie, alle andere in een default-partitie, primaire sleutels (tenant_id, id) en een foreign key (tenant_id, invoice_id) van regels naar facturen.
De grootste klant verwijderen — zijn regelpartitie droppen, zijn facturenpartitie loskoppelen en droppen, de klantrij verwijderen — kostte 2–3 ms per opdracht en 7,5 ms voor de commit, schreef 206 kB WAL in plaats van 48 MB, en gaf de schijfruimte direct terug (van 244 naar 198 MB).
Valkuil 1: de volgorde. De voor de hand liggende volgorde — eerst de regelpartitie loskoppelen, dan de facturenpartitie — faalt:
ERROR: removing partition "p_invoices_t1" violates foreign key constraint "p_lines_t1_tenant_id_invoice_id_fkey"
DETAIL: Key (tenant_id, invoice_id)=(1, 1) is still referenced from table "p_lines_t1".
Een losgekoppelde partitie houdt haar foreign key. Drop eerst de verwijzende partitie (of koppel haar los én drop haar), en koppel daarna pas de partitie los waarnaar verwezen wordt.
Valkuil 2: de lock. Een partitie loskoppelen en een partitie droppen nemen allebei een ACCESS EXCLUSIVE-lock op de hele gepartitioneerde tabel en op de default-partitie — waar alle andere klanten staan — niet alleen op het deel van die klant; loskoppelen vergrendelt ook de tabel waar de foreign key naar wijst (tenants), zo liet pg_locks zien. Terwijl de drop van de regelpartitie in een open transactie stond, wachtte een query op de regels van een andere klant 2,0 seconden. Erger: de drop moet wachten op elke lopende query op die tabel, en iedereen daarna staat in de rij achter hem. Met een rapport van drie seconden dat al liep, wachtte de drop 2,5 seconden en een eenvoudige query voor een andere klant 2,0 seconden. Met set lock_timeout = '200ms' gaf de drop het na 200 ms op, en kostte de query van de andere klant 11 ms — daarna probeer je het opnieuw.
DETACH PARTITION … CONCURRENTLY neemt een lichtere lock, maar de documentatie is duidelijk: het “cannot be run in a transaction block and is not allowed if the partitioned table contains a default partition”. Zet je grote klanten in een eigen partitie en kleine in een default-partitie, dan kun je het niet gebruiken.
Een checklist voor het verwijderen van één klant
- Elke foreign-keykolom heeft een index. Zonder index is verwijderen een scan per ouderrij.
- Zet eerst alles uit wat voor de klant schrijft: zijn applicatie en achtergrondtaken. Die zijn het die wachten.
- Kies de batchgrootte op het plan, niet op gevoel.
EXPLAIN ANALYZEop één batch; leest hij hele tabellen om een paar duizend rijen te verwijderen, maak de batch dan kleiner of schrijf de query anders. - Verwacht niet dat de database krimpt. De ruimte gaat naar de volgende klant;
VACUUM FULLalleen als je de schijf terug nodig hebt en je de lock kunt veroorloven. - Partitie droppen: eerst de verwijzende partitie, met een
lock_timeout, in een transactie die verder niets doet. - Vertel de klant waar zijn gegevens nog staan: WAL-archief, back-ups, auditlog, logs en exports, elk met hun bewaartermijn.
Wil de klant eerst een kopie, dan staat hoe je met pg_dump precies zijn rijen exporteert — en waarom die export je tellerstanden niet mag bevatten — in één klant exporteren met pg_dump en row-level security.
Wat we niet hebben gemeten
Verwijderen op een replica of met logische replicatie, TRUNCATE van een partitie, schema per klant (DROP SCHEMA … CASCADE), en verwijderen onder row-level security als applicatierol. De tijden komen uit één container op één machine; wat je meeneemt zijn de verhoudingen, niet de milliseconden.
Bronnen: de PostgreSQL 17-documentatie over routine vacuuming en ALTER TABLE (DETACH PARTITION CONCURRENTLY), gelezen op 1 oktober 2026. Alle metingen zijn van onszelf, op PostgreSQL 17.10, op 1 oktober 2026.