SaaS-architectuur
Migraties op een gedeelde multi-tenant tabel in PostgreSQL
ALTER TABLE op een tabel die alle klanten delen, gemeten op PostgreSQL 17: de lockwachtrij, lock_timeout, herschrijvingen, NOT VALID, en tenant_id onder RLS.
In een multi-tenant SaaS met gedeelde tabellen is “een migratie draait één keer” het voordeel — en ook het risico: één ALTER TABLE raakt alle klanten tegelijk. De meeste schemawijzigingen in PostgreSQL zijn snel. Wat ze gevaarlijk maakt, is niet hoe lang ze duren, maar de lock die ze nodig hebben, en wat alle anderen doen terwijl ze daarop wachten.
We maten gangbare migraties op PostgreSQL 17.10, in een wegwerpcontainer met standaardinstellingen: één tabel invoices met 2.074.910 rijen over 100 klanten (195 MB), row-level security met FORCE, en een tweede sessie die als een andere klant via de applicatierol leest en schrijft.
Waarom legt een snelle ALTER TABLE alle klanten stil?
Omdat hij in de rij gaat staan, en iedereen daarna achter hem. Een kolom toevoegen die NULL mag zijn, kost op zichzelf 1,7 ms. We deden het terwijl een rapport van drie seconden over de facturen van één klant nog liep. De ALTER TABLE wachtte 2,5 seconden tot dat rapport klaar was — en een eenvoudige query van een andere klant, een halve seconde later gestart, wachtte 2,0 seconden achter de ALTER TABLE. Aan geen van beide query’s mankeerde iets; de migratie heeft een exclusieve lock nodig, krijgt die niet zolang het rapport loopt, en elke nieuwe query sluit achter de wachtende migratie aan.
Met set lock_timeout = '200ms' in de sessie van de migratie gaf de ALTER TABLE het na 200 ms op, en kostte de query van de andere klant — gestart nadat de migratie het had opgegeven — 5 ms. Langer dan de timeout zelf wacht dan geen enkele query: 200 ms in plaats van zo lang als de traagste lopende query. Daarna probeer je het een paar seconden later opnieuw. Op een gedeelde tabel is een lock-timeout niet optioneel; hij bepaalt of een wijziging van één milliseconde een storing voor alle klanten wordt.
Welke migraties herschrijven de hele tabel?
Gemeten op dezelfde 2.074.910 rijen:
| Migratie | Tijd | Andere klanten intussen |
|---|---|---|
add column note text | 1,7 ms | achter een lopend rapport: 2,0 s gewacht (hierboven) |
add column currency text not null default 'EUR' | 1,5 ms | idem |
add column ref uuid not null default gen_random_uuid() | 5,0–5,2 s | insert van een andere klant wachtte 4,7 s |
add constraint … check (total_cents >= 0) | 104–264 ms | lezen geblokkeerd zolang hij duurt |
idem, not valid | 2,1 ms | lezen geblokkeerd zolang hij duurt |
validate constraint | 115–142 ms | niet geblokkeerd: lezen 5 ms, insert 9 ms |
alter column … set not null | 179 ms | niet gemeten |
set not null met een geldige check (… is not null) | 2,1 ms | niet gemeten |
create index | 1,06–1,09 s | insert wachtte 0,77 s (lezen niet gemeten) |
create index concurrently | 1,28 s | niet geblokkeerd: insert 3 ms |
Een constante default wordt in de catalogus bewaard en kost niets — net als now(), dat één keer wordt uitgerekend. Een volatiele — een willekeurige UUID, clock_timestamp() — laat PostgreSQL elke rij opnieuw schrijven, en de documentatie zegt het expliciet: “Adding a column with a volatile DEFAULT or changing the type of an existing column will require the entire table and its indexes to be rewritten.” Vijf seconden lang kon niemand een factuur opslaan. Voeg de kolom toe zonder default, zet de default meteen met ALTER COLUMN … SET DEFAULT (die geldt alleen voor nieuwe rijen en herschrijft niets), en vul daarna de bestaande rijen in batches.
Een constraint is het goedkoopst in twee stappen: NOT VALID neemt de zware lock 2 ms — mits je meteen commit; in een openstaande transactie blokkeerde hij lezers zolang die transactie duurde — en handhaaft de regel daarna op nieuwe rijen; VALIDATE CONSTRAINT controleert de bestaande rijen onder een lock die volgens de documentatie alleen SHARE UPDATE EXCLUSIVE is — in onze test blokkeerde hij lezen noch invoegen. Dezelfde truc haalt de scan uit SET NOT NULL: met een gevalideerde check (paid_on is not null) sloeg PostgreSQL de scan over en kostte de opdracht 2,1 ms in plaats van 179.
Waarom kun je het type van tenant_id niet wijzigen onder row-level security?
Omdat het beleid ervan afhangt. alter table invoices alter column tenant_id type bigint werd direct geweigerd:
ERROR: cannot alter type of a column used in a policy definition
DETAIL: policy p on table invoices depends on column "tenant_id"
De weg erdoorheen is het beleid verwijderen, het type wijzigen en het beleid opnieuw aanmaken — en het enige wat ertoe doet is dat in één transactie te doen. Dat deden we: beleid weg, type wijzigen, beleid terug, commit, in 2,8 seconden, waarvan bijna alles de herschrijving was. DDL in PostgreSQL is transactioneel, en de transactie houdt de ACCESS EXCLUSIVE-lock op de tabel van de DROP POLICY tot de commit, dus andere sessies wachten in plaats van de tabel zonder beleid te zien. Draai je dezelfde stappen als losse migraties, dan vindt row-level security daartussen geen beleid en weigert alles: met het beleid verwijderd telde de applicatierol 0 facturen en faalde een insert met new row violates row-level security policy. Elke klant ziet een leeg product vanaf het moment dat het beleid weg is tot het terug is. Kijk na hoe je migratietool opdrachten in transacties groepeert voordat je erop vertrouwt.
De herschrijving zelf is de andere reden dat dit een van de beslissingen is die je later niet meer terugdraait: op een grote gedeelde tabel kost het wijzigen van het sleuteltype van de kolom die elk beleid gebruikt een ACCESS EXCLUSIVE-lock, zo lang als het herschrijven van de tabel duurt.
Een checklist voor migraties op gedeelde tabellen
- Altijd een
lock_timeoutin migratiesessies, met een nieuwe poging. Zonder timeout legt een snelle migratie achter één trage query alle klanten stil. - Geen volatiele defaults op bestaande tabellen. Kolom toevoegen, in batches vullen, default daarna zetten.
- Constraints in twee stappen:
NOT VALID(en committen), daarnaVALIDATE CONSTRAINT. VoorNOT NULLeerst eenCHECK (… IS NOT NULL)valideren; naSET NOT NULLkan die hulpcheck weer weg. - Indexen
CONCURRENTLY— buiten een transactieblok, en controleer daarna: een gelijktijdige opbouw die faalt, laat een ongeldige index achter. - Wijzigingen aan
tenant_idof een andere kolom die een beleid gebruikt: beleid verwijderen, wijzigen en opnieuw aanmaken in één transactie, en zorg dat je migratietool dat niet opknipt.
Hoe lang langlopende bewerkingen op één klant locks vasthouden — een klant verwijderen, een partitie droppen — staat in één klant verwijderen in PostgreSQL.
Wat we niet hebben gemeten
Foreign keys die NOT VALID worden toegevoegd, een typewijziging die binair compatibel is (geen herschrijving), migraties op gepartitioneerde tabellen, en migratietools zelf. De tijden gelden voor 2 miljoen rijen op één machine; wat je meeneemt is het lockgedrag.
Bronnen: de PostgreSQL 17-documentatie over ALTER TABLE (herschrijven bij volatiele defaults, NOT VALID, VALIDATE CONSTRAINT en zijn lock, SET NOT NULL die de scan overslaat), gelezen op 1 oktober 2026. Alle metingen zijn van onszelf, op PostgreSQL 17.10, op 1 oktober 2026.