SaaS-architectuur
Takenwachtrij per klant in PostgreSQL: de luidruchtige buur
Eén klant zet 1.000 taken klaar, tien andere elk vijf: FIFO tegen round-robin met SKIP LOCKED in PostgreSQL 17, nagemeten, plus een plannerval van 10 s.
Een takenwachtrij in PostgreSQL is een opgelost probleem: één tabel, FOR UPDATE SKIP LOCKED, zoveel workers als je wilt. In een multi-tenant SaaS heeft hij één gebrek dat een test met één klant nooit laat zien. Als één klant tienduizend facturen importeert, wacht de wachtwoordherstelmail van elke andere klant daarachter.
We hebben gemeten hoe lang, en wat het kost om dat op te lossen. Vier workers, één klant die 1.000 taken van 20 ms klaarzet, tien kleine klanten die een seconde later elk vijf taken klaarzetten. Met een gewone first-in-first-out-wachtrij was de mediane wachttijd van de kleine klanten 4,5 seconde. Met round-robin per klant 0,10 tot 0,15 seconde. Daarna bleek de round-robin-query een eigen val te hebben: met een scheve achterstand en klanten zonder werk in de rotatie kostte de voor de hand liggende schrijfwijze 10 seconden per taak.
Waarom vertraagt één klant de taken van alle andere?
Omdat een FIFO-wachtrij sorteert op aankomst, niet op klant. De gangbare query pakt de oudste taak die niemand anders heeft vergrendeld:
select id, tenant_id
from jobs
where status = 'queued'
order by id
limit 1
for update skip locked;
SKIP LOCKED is wat dit met meerdere workers laat werken; de PostgreSQL-documentatie noemt het geschikt “to avoid lock contention with multiple consumers accessing a queue-like table”. Maar de volgorde is globaal. Een taak die na 1.000 andere binnenkomt, begint na die 1.000, van wie ze ook zijn.
De opzet
PostgreSQL 17.10 in een wegwerpcontainer, standaardinstellingen. Eén tabel jobs en een partiële index voor de wachtende taken:
create table jobs (
id bigint generated always as identity primary key,
tenant_id int not null,
status text not null default 'queued',
enqueued_at timestamptz not null default clock_timestamp(),
started_at timestamptz,
finished_at timestamptz
);
create index jobs_queued_fifo on jobs (id) where status = 'queued';
Vier workers, elk met een eigen verbinding, werken met het claimpatroon: in één korte transactie kiezen ze een taak met SKIP LOCKED en zetten die op running; het werk — 20 ms, gesimuleerd — gebeurt buiten die transactie; een tweede opdracht zet hem op done. De grote klant zet bij de start 1.000 taken klaar; een seconde later zetten tien kleine klanten er elk vijf klaar. We maten de wachttijd van enqueued_at tot started_at, en draaiden elk scenario minstens twee keer.
FIFO tegen round-robin
Voor round-robin houdt een tweede tabel bij wanneer elke klant voor het laatst aan de beurt was, en de query vraagt: welke klant wacht het langst, en wat is de oudste vrije taak van die klant?
create table queue_tenants (
tenant_id int primary key,
last_served timestamptz not null default '-infinity'
);
create index on queue_tenants (last_served);
select j.id, qt.tenant_id
from queue_tenants qt
cross join lateral (
select id from jobs
where tenant_id = qt.tenant_id and status = 'queued'
order by enqueued_at, id
limit 1
for update skip locked
) j
order by qt.last_served
limit 1;
In dezelfde claimtransactie zet de worker last_served van die klant op clock_timestamp(). De vergrendeling staat in de subquery, en dat mag in PostgreSQL — “the rows locked are those returned to the outer query by the sub-query” — dus een vergrendelde taak slaat alleen die ene taak over, niet de klant. Dat doet ertoe: het voor de hand liggende alternatief, de taken per klant nummeren met row_number() over (partition by tenant_id …), kun je niet op hetzelfde queryniveau vergrendelen, omdat FOR UPDATE “cannot be specified with WINDOW”.
| Wachtrij | Kleine klanten: mediane wachttijd | Kleine klanten: langste wachttijd | Grote klant klaar na | Eén keer ophalen |
|---|---|---|---|---|
| FIFO | 4,46–4,48 s | 4,59 s | 5,4 s | 0,12 ms |
| round-robin per klant | 0,10–0,15 s | 0,30 s | 6,0 s | 1,05–1,12 ms |
De bandbreedtes dekken elke meting: vier van FIFO, zes van round-robin (met de subquery gesorteerd op id en op enqueued_at, wat bij deze omvang geen verschil maakte). De kleine klanten gingen van wachten op de hele achterstand naar wachten op een paar rondes. De grote klant betaalt daarvoor: zijn laatste taak was 0,6 seconde later klaar, omdat er 50 kleine taken tussendoor gingen en elke ophaalactie duurder werd.
Dat de grote klant niet tot één worker wordt beperkt, is het punt van de vergrendeling in de subquery. In zijn eentje, zonder andere klanten in de wachtrij, kostten zijn 1.000 taken 5,42 s met FIFO en 5,70 s met round-robin — alle vier workers bleven bezig, voor 5 % extra. Had je de rij van de klant vergrendeld in plaats van de taak, dan had hij maar één taak tegelijk gekregen.
Waarom kost de round-robin-query 10 seconden bij een grote achterstand?
Omdat de planner uitgaat van een gemiddelde klant, en in een wachtrij is geen enkele klant gemiddeld. We vulden de tabel met 100.000 wachtende taken van de grote klant en één taak voor elk van tien kleine klanten, met 1.000 klanten in queue_tenants, waarvan er 989 niets hadden klaarstaan. Daarna maten we één ophaalactie met de subquery gesorteerd op id, de voor de hand liggende keuze, naast een partiële index op (tenant_id, id) where status = 'queued':
queue_tenants bevat | Aan de beurt | Subquery order by id | Subquery order by enqueued_at, id |
|---|---|---|---|
| 1.000 klanten, 989 zonder werk | kleine klant | 9.960 ms | 1,17 ms |
| alleen de 11 klanten met werk | grote klant | 0,25 ms | 0,15 ms |
| alleen de 11 klanten met werk | kleine klant | 10,3 ms | 0,15 ms |
| 10.000 klanten, 9.989 zonder werk | kleine klant | niet gemeten | 10,5 ms |
Met order by id koos de planner ervoor de wachtende taken in id-volgorde af te lopen en op tenant_id te filteren, in de verwachting de klant binnen een paar rijen tegen te komen. Voor de grote klant klopt dat. Voor een kleine klant liep hij langs 100.004 taken van de grote; voor een klant zonder wachtende taken liep hij de hele achterstand voor niets af — 989 keer per ophaalactie, zo’n miljoen gelezen buffers. De FIFO-index weghalen hielp niet: de planner liep dan op dezelfde manier de primaire sleutel af, weer in ongeveer 10 seconden.
De oplossing was de subquery sorteren op een kolom die alleen de index per klant kan leveren:
create index jobs_queued_tenant
on jobs (tenant_id, enqueued_at, id)
where status = 'queued';
Met order by enqueued_at, id in de subquery werd elke opzoeking één korte afdaling in die index: 1,17 ms met 989 klanten zonder werk, 0,15 ms als de tabel alleen klanten met werk bevatte. Bekijk het plan van je eigen ophaalquery met een scheve achterstand, niet met een lege testrij. Het onze liep de index op last_served af onder de Limit en stopte bij de eerste klant met een vrije taak; een plan dat eerst alle klanten sorteert, kan per klant een taak vergrendelen tot het eind van de transactie. Bij een achterstand van 1.000 taken kostten beide versies 1,1 ms en zagen ze er hetzelfde uit.
De laatste regel laat de andere kostenpost zien: elke klant zonder werk in queue_tenants is één opzoeking in de index per ophaalactie, en bij 10.000 klanten was dat 10,5 ms. Houd je in die tabel alleen klanten met wachtend werk, dan is het weer 0,15 ms. Een klant toevoegen als hij iets klaarzet is één insert … on conflict do nothing; hem weghalen als zijn wachtrij leeg is, is waar een race zit — een taak die binnenkomt tussen je controle en je delete — en dat deel hebben we niet gemeten.
En row-level security?
Een worker bedient elke klant en kan dus niet met een vaste klantcontext draaien. De wachtrijtabel zelf is infrastructuur en heeft meestal geen beleid; het werk van de taak hoort als die klant te draaien. Zet de klant uit de geclaimde rij met SET LOCAL binnen de transactie die het werk doet — niet met SET, dat op een gepoolde verbinding die klant aan de volgende taak doorgeeft. Wat er anders misgaat, hebben we gemeten in PgBouncer en RLS: klantcontext, SET LOCAL, gedeelde plannen.
Wat we niet hebben gemeten
Prioriteiten, een maximum aan gelijktijdige taken per klant, gewogen eerlijkheid (een betalende laag die twee beurten krijgt), meer dan vier workers, taken die hun vergrendeling de hele duur van het werk vasthouden, herhaalpogingen, en het bijhouden van queue_tenants onder belasting. De tijden komen van één machine met een warme cache; lees ze als verhoudingen. Die verhoudingen zijn de uitkomst: FIFO liet kleine klanten 30 tot 45 keer langer wachten, en een ophaalquery die bij een kleine achterstand in orde leek, was bij een scheve achterstand vier ordes van grootte trager.
Dit is het soort beslissing uit multi-tenant SaaS op PostgreSQL: zes beslissingen: goedkoop om goed te doen vóór de eerste grote klant, duur daarna.
Bronnen: de PostgreSQL 17-documentatie over SELECT (de vergrendelclausule, SKIP LOCKED, vergrendelen in een subquery en de beperking bij WINDOW), gelezen op 30 september 2026. Alle metingen zijn van onszelf, in PostgreSQL 17.10, op 30 september 2026.