Now-Next

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.

Chris van Eijk · · 7 min lezen

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:

MigratieTijdAndere klanten intussen
add column note text1,7 msachter een lopend rapport: 2,0 s gewacht (hierboven)
add column currency text not null default 'EUR'1,5 msidem
add column ref uuid not null default gen_random_uuid()5,0–5,2 sinsert van een andere klant wachtte 4,7 s
add constraint … check (total_cents >= 0)104–264 mslezen geblokkeerd zolang hij duurt
idem, not valid2,1 mslezen geblokkeerd zolang hij duurt
validate constraint115–142 msniet geblokkeerd: lezen 5 ms, insert 9 ms
alter column … set not null179 msniet gemeten
set not null met een geldige check (… is not null)2,1 msniet gemeten
create index1,06–1,09 sinsert wachtte 0,77 s (lezen niet gemeten)
create index concurrently1,28 sniet 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

  1. Altijd een lock_timeout in migratiesessies, met een nieuwe poging. Zonder timeout legt een snelle migratie achter één trage query alle klanten stil.
  2. Geen volatiele defaults op bestaande tabellen. Kolom toevoegen, in batches vullen, default daarna zetten.
  3. Constraints in twee stappen: NOT VALID (en committen), daarna VALIDATE CONSTRAINT. Voor NOT NULL eerst een CHECK (… IS NOT NULL) valideren; na SET NOT NULL kan die hulpcheck weer weg.
  4. Indexen CONCURRENTLY — buiten een transactieblok, en controleer daarna: een gelijktijdige opbouw die faalt, laat een ongeldige index achter.
  5. Wijzigingen aan tenant_id of 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.

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