Now-Next

SaaS-architectuur

Tellen per klant in PostgreSQL: count, schatting of teller

count(*) per klant in PostgreSQL 17, gemeten: waarom het voor de grootste klant 5× trager werd, wanneer schattingen fout zitten, wat een tellerrij kost.

Chris van Eijk · · 7 min lezen

“412 openstaande facturen.” Elke multi-tenant SaaS toont aantallen per klant — op het dashboard, in een tabblad, naast een filter — en elk daarvan is een count(*) onder row-level security. Voor een kleine klant kost dat niets. Voor de grootste klant is die telling meestal de oorzaak als “het dashboard traag is” — maanden na de lancering.

We maten drie manieren om aan dat getal te komen op PostgreSQL 17.10, in een wegwerpcontainer met standaardinstellingen: 2.074.910 facturen over 100 klanten met een scheve verdeling (de grootste 400.000, dan 200.000, aflopend tot 4.000), een index op (tenant_id, issued_on), en row-level security met FORCE, bevraagd als applicatierol. Tijden zijn de mediaan van vijf metingen.

Hoe traag is count(*) voor één klant?

Klantcount(*)count(*) where status = 'overdue'
grootste (400.000 facturen)13,2 ms27,4 ms
tiende (40.000)2,3 ms4,3 ms
kleinste (4.000)0,35 ms1,2 ms

Een gewone telling gebruikte een index-only scan op (tenant_id, issued_on) met Heap Fetches: 0: PostgreSQL telde indexregels en raakte de tabel niet aan. Een telling met een voorwaarde op een kolom die niet in de index staat, moet de rijen zelf lezen en kostte twee tot drie keer zoveel. Met een extra index op (tenant_id, status) werd ook die telling een index-only scan: 2,3 ms voor de grootste klant in plaats van 27,4, en 0,14 ms voor de kleinste. De kosten groeien met de omvang van de klant, al minder dan evenredig: honderd keer zoveel rijen kostte 37 keer zoveel tijd. Dat alles op een net gevacuümde database. Het probleem begint als de gegevens veranderen.

Waarom werd count(*) voor de grootste klant 5× trager?

We zetten 20% van de facturen van de grootste klant op betaald — één opdracht die 80.000 rijen wijzigt, een gewone maand voor een drukke klant — en telden opnieuw: 64,7 ms in plaats van 13,2, met Heap Fetches: 424.560. De index-only scan kan de tabel alleen overslaan voor pagina’s die de visibility map als volledig zichtbaar markeert, en elke bijgewerkte pagina verloor die markering. Er zijn meer heap fetches dan rijen omdat de index nu ook regels voor de oude rijversies bevat. Onze update raakte elke vijfde factuur en dus bijna elke pagina van die klant; updates die in recente maanden geclusterd zitten, raken minder pagina’s en kosten minder. De visibility map bijwerken is een van de taken van vacuum.

Waarom draaide vacuum dan niet? Met autovacuum aan wachtten we na de update 90 seconden: geen autovacuum, en de telling kostte nog steeds 65,4 ms. De drempel van de tabel is 50 dode rijen plus autovacuum_vacuum_scale_factor — standaard 20% — “of table size”: 50 + 0,2 × 2.074.910 = 415.032 dode rijen. De grootste klant had er 80.000 gemaakt. In een gedeelde tabel is het verloop van één klant een klein deel van het geheel, dus autovacuum wacht, terwijl de tellingen van die klant, en elke andere index-only scan over de pagina’s van die klant, traag blijven. Andere klanten, van wie de pagina’s niet geraakt zijn, merken niets.

De oplossing is een drempel per tabel. Met alter table invoices set (autovacuum_vacuum_scale_factor = 0.01) draaide autovacuum 31 seconden later, en was de telling terug op 13,3 ms met Heap Fetches: 0.

Kun je het aantal van een klant ook schatten?

Voor grote klanten wel. EXPLAIN geeft de schatting van de planner zonder te tellen, en onder row-level security zit het beleid erin, dus als applicatierol is het een schatting voor deze klant:

KlantGeschatWerkelijk
grootste393.541400.000
tweede201.889200.000
tiende40.04640.000
kleinste van 1004.5654.000

Dat werkt omdat de planner per kolom een lijst van de meest voorkomende waarden bijhoudt, standaard 100, en bij 100 klanten staat elke klant erop. (Of een klant erop staat, lees je af in pg_stats.most_common_vals.) Daarna voegden we 1.900 kleine klanten toe (50 tot 650 facturen per klant) en analyseerden opnieuw. De grootste klanten bleven goed geschat (397.604; 40.920; 8.860 tegen 400.000; 40.000; 8.000). Elke klant buiten de 100 meest voorkomende kreeg dezelfde schatting: 401 rijen — voor klanten die er in werkelijkheid 50, 350 of 650 hadden. Voor hen kent de planner alleen een gemiddelde: de overige rijen gedeeld door het overige aantal verschillende klanten.

Dat geeft een bruikbare regel: gebruik de schatting voor een klant die groot genoeg is om bij de meest voorkomende waarden te horen, en tel daaronder exact. We testten schattingen alleen voor het totaal van een klant; een telling met een extra voorwaarde zoals een status is een schatting op een schatting. De kleine klanten, waar de schatting niets zegt, zijn precies die waarvoor count(*) een milliseconde of minder kost.

Wat kost een tellertabel?

Het andere klassieke antwoord: houd het aantal bij in een tabel, één rij per klant, bijgewerkt door een trigger bij elke insert. We maten inserts met pgbench, 8 clients gedurende 10 seconden (de kolom met de drukke klant twee keer):

VariantInserts voor één drukke klantVerspreid over 100 klanten
geen teller10.637–10.963 per seconde10.979 per seconde
tellerrij per klant (update … set n = n + 1)2.452–2.458 per seconde10.574 per seconde
deltarij per insert (insert into deltas)10.919–10.966 per seconde10.921 per seconde

Verspreid over veel klanten kost de tellerrij bijna niets. Voor één drukke klant haalde hij de doorvoer 77% omlaag en ging de gemiddelde latentie van 0,75 naar 3,26 ms: elke insert moet op de lock van dezelfde rij wachten tot de vorige transactie gecommit is. Dat is precies je grootste klant, in zijn drukste uur.

Deltarijen die alleen worden toegevoegd, zitten elkaar niet in de weg: elke insert schrijft zijn eigen rij. De prijs verschuift naar het lezen. Met 109.135 niet-samengevoegde delta’s kostten de teller plus de som van de delta’s 12,6 ms — nauwelijks beter dan tellen, dat op dat moment op dezelfde, nog niet gevacuümde tabel 19–21 ms kostte. Een taak die de delta’s in de teller samenvoegt, deed dat in 100 ms voor allemaal, en daarna las het aantal in 0,15 ms en klopte het exact met het werkelijke aantal (509.273). Een deltatabel loont alleen als iets hem regelmatig samenvoegt.

Twee dingen gelden voor elke tellertabel: hij heeft hetzelfde row-level-security-beleid nodig als de tabel die hij telt, en verwijderingen en statuswijzigingen hebben een eigen trigger nodig, anders loopt het getal weg.

Een checklist voor aantallen per klant

  1. Index-only scans hebben een gevacuümde tabel nodig. Zet autovacuum_vacuum_scale_factor per grote gedeelde tabel; 20% van een tabel met alle klanten is een drempel die één klant zelden alleen haalt.
  2. Zet de kolommen waarop je telt in de index als de telling een voorwaarde heeft: (tenant_id, status) bracht de telling van de grootste klant van 27,4 naar 2,3 ms.
  3. Schat voor grote klanten, tel voor kleine. De schatting is goed voor klanten in de lijst van meest voorkomende waarden, en een constante voor alle anderen.
  4. Een tellerrij per klant overleeft geen drukke klant. Gebruik deltarijen en voeg ze samen, of accepteer de telling.
  5. Tellertabellen krijgen ook een beleid. Een tabel met aantallen per klant bevat klantgegevens.

Wat we niet hebben gemeten

Materialized views die periodiek worden ververst, pg_class.reltuples (alleen voor een hele tabel, nooit per klant), tellingen met meerdere voorwaarden tegelijk, een hoger statistiekdoel voor tenant_id, en tellers die ook verwijderingen en statuswijzigingen volgen.

Bronnen: de PostgreSQL 17-documentatie over routine vacuuming (vacuumdrempel, visibility map) en autovacuum-instellingen (autovacuum_vacuum_scale_factor, standaard 20% van de tabelgrootte), 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