← Blog

Databases

Postgres-audit: wat we draaien voor elke nieuwe agent

Het is 23:00 in Bangkok. De agent draait al zeven uur en het ops-dashboard ziet er prima uit. De database staat op 100% CPU. Drie pilots zijn zo geëindigd.

Jacob Molkenboer· Oprichter · A Brand New Company· 9 jun 2026· 8 min
Open houten kaartenbak met messing tabblad, limoengroen label en geklemd grootboekpapier op ivoren ondergrond.

Het is 23:00 in Bangkok. De agent die we om 16:00 hebben uitgerold draait al zeven uur. Het ops-dashboard van de klant toont 18.400 verwerkte facturen. Dat is prima. De primaire Postgres van de klant staat op 100% CPU, de read replica loopt 41 minuten achter, en een accountmanager is net gebeld omdat het klantportaal time-outs geeft. Dat is niet prima.

De agent deed precies wat we hem hadden opgedragen. De database was niet gebouwd voor wat de agent aan het doen was.

We hebben veertien agents in productie gezet. Drie ervan hebben hun onderliggende Postgres in de eerste week om zeep geholpen. Elke keer was het een van dezelfde drie dingen: een ontbrekende index op een kolom waar de agent ineens om gaf, autovacuum die niet bijhield op de tabel waar de agent constant naar schreef, of een jsonb-kolom die niemand ooit had gemeten. Nu draaien we een checklist voor er een agent een connection string krijgt.

Wat de drie pilots gemeen hadden

Voor de agent komt, heeft een Postgres-workload meestal een menselijk verkeerspatroon. Een paar honderd queries per minuut. De meeste reads komen van een of twee warme pagina's. Writes worden gebatcht door een nachtelijke cron. De database is al jaren 'prima'.

Een automation-agent heeft een ander profiel. Hij leest een tabel die het mensen-dashboard nooit aanraakte. Hij schrijft 200 rijen per minuut in plaats van 5.000 rijen één keer per nacht. Hij checkt elke loop dezelfde status-kolom en geeft nooit op. Hij is niet langzamer dan een menselijk team. Hij is ook niet aardiger.

Takeaway

Een automation-agent is een heel geduldige junior developer met oneindig veel handen. Als je Postgres een zwakke plek heeft, vindt de agent die binnen een uur.

Sequentiële scans op de werktabel

De eerste failure mode is altijd dezelfde. De agent pollt een 'werk te doen'-tabel met een query als WHERE status = 'pending' AND assigned_at IS NULL. De tabel heeft 400.000 rijen. Er staat een index op id en verder niks. Elke poll is een sequentiële scan. De agent pollt elke twee seconden. De database leest de hele tabel nu 30 keer per minuut.

Dit vind je in 10 seconden.

SELECT schemaname,
       relname,
       seq_scan,
       idx_scan,
       n_live_tup,
       pg_size_pretty(pg_relation_size(relid)) AS size
FROM pg_stat_user_tables
WHERE n_live_tup > 10000
ORDER BY seq_scan DESC
LIMIT 20;

Elke rij in de top 20 waar seq_scan wezenlijk groter is dan idx_scan, op een tabel groter dan een paar duizend rijen, is een kandidaat. We kijken vervolgens op welke kolommen de agent gaat filteren en voegen een partial index toe die past bij de feitelijke querystructuur:

CREATE INDEX CONCURRENTLY invoices_pending_idx
  ON invoices (assigned_at)
  WHERE status = 'pending';

De CONCURRENTLY doet ertoe. De eerste keer dat we hem vergaten op een tabel van 12 GB lag het klantportaal negen minuten plat terwijl de index onder een exclusive lock werd gebouwd. De Postgres-docs over CREATE INDEX zijn helder over de kosten van de niet-concurrent variant.

Autovacuum die nooit klaar is

De tweede failure mode is trager en gemener. De agent schrijft 200 rijen per minuut naar een tabel die voorheen één nachtelijke batch zag. Elke update creëert een dead tuple. Autovacuum staat op default-instellingen, wat betekent dat hij aanslaat zodra 20% van de tabel dood is. Een redelijke drempel voor een tabel met één batch per nacht. Niet redelijk als de agent dezelfde rij acht keer overschrijft tijdens één workflow.

Draai dit:

SELECT relname,
       n_live_tup,
       n_dead_tup,
       round(n_dead_tup * 100.0 / NULLIF(n_live_tup, 0), 1) AS pct_dead,
       last_autovacuum,
       last_autoanalyze
FROM pg_stat_user_tables
WHERE n_live_tup > 1000
ORDER BY n_dead_tup DESC
LIMIT 20;

Alles waar pct_dead boven de 20% zit en last_autovacuum ouder is dan een paar uur, is autovacuum-honger. Of de tabel haalt de drempel nooit, of hij haalt 'm constant en autovacuum kan de writes niet bijhouden.

De fix is per-tabel-tuning, niet globaal. Verlaag de scale factor voor de tabellen die de agent daadwerkelijk aanraakt:

ALTER TABLE invoices SET (
  autovacuum_vacuum_scale_factor = 0.02,
  autovacuum_analyze_scale_factor = 0.01
);

Het officiële hoofdstuk over routine vacuuming behandelt de rekensom en de trade-offs uitgebreider dan hier past. Het punt om mee te nemen: de defaults zijn afgestemd op tabellen waar een mens naar schrijft. Agents zijn geen mensen.

De jsonb-kolom die niemand mat

Nummer drie is de variant die ons het meest schrik aanjaagt, omdat hij onzichtbaar is. Het schema heeft een kolom met de naam payload jsonb of metadata jsonb of raw jsonb. Hij is twee jaar geleden toegevoegd om 'de rest van de webhook later op te slaan'. Het dashboard leest hem nooit. De applicatie schrijft er nooit meer dan 4 KB in. Niemand weet wat erin zit.

Dan komt de agent. Die pipet de hele model-response in raw als audit trail. Elke rij is nu 80 KB. TOAST doet 90% van het werk dat de database doet. Replicatie begint achter te lopen omdat elk WAL-record de hele jsonb meedraagt. De applicatieserver is prima. Het dashboard is prima. De cijfers op het bord zien er prima uit. De disk staat in brand.

Vind ze voor de agent ze vindt:

SELECT relname,
       pg_size_pretty(pg_relation_size(relid))         AS table_size,
       pg_size_pretty(pg_total_relation_size(relid)
                      - pg_relation_size(relid))       AS toast_and_indexes,
       pg_size_pretty(pg_total_relation_size(relid))   AS total
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 15;

Wanneer de toast_and_indexes-kolom groter is dan de tabel zelf, heb je een jsonb-kolom die werk doet waar niemand om vroeg. Het juiste antwoord is bijna nooit 'maak de jsonb kleiner'. Het is 'stop met de hele webhook erin te schrijven'. Verplaats de audit trail naar object storage, bewaar een pointer in de rij, en laat Postgres weer een database zijn.

Connection pool, de realiteit

Het vierde punt op de checklist is het aantal connections. De documentatie van je agent-framework zegt: zet max_connections op wat je agent nodig heeft. De Postgres-documentatie zegt: elke connection kost ruwweg 10 MB RAM en een process. Beide kloppen. Geen van beide vertelt je dat de applicatieserver, de read replicas, de analytics-jobs, de migration tool, en de vier mensen op pgAdmin ook connections vasthouden.

SELECT state, count(*)
FROM pg_stat_activity
GROUP BY state
ORDER BY count(*) DESC;

Als idle in transaction iets anders is dan bijna nul, heeft de agent een bug in transaction-management en moet je die fixen voor je live gaat. Als active consistent in de buurt van max_connections zit, heb je PgBouncer in transaction mode voor de agent nodig, geen hogere max_connections. De cap verhogen op een database die op OS-niveau al vol zit, verandert een wachtrij in een meltdown.

Lock waits die geen mens ooit ziet

Het laatste punt is de variant die alleen onder agent-verkeer opduikt. Mensen serialiseren zichzelf: één persoon klikt 'verwerk factuur', wacht, klikt dan de volgende. Agents niet. Twee workers die dezelfde rij tegelijk oppakken zullen netjes deadlocken als je SELECT FOR UPDATE SKIP LOCKED hebt gebruikt, en zullen elkaar oneindig blokkeren als je dat niet hebt gedaan.

SELECT blocked.pid       AS blocked_pid,
       blocked.query     AS blocked_query,
       blocking.pid      AS blocking_pid,
       blocking.query    AS blocking_query,
       now() - blocked.query_start AS blocked_for
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.wait_event_type = 'Lock'
ORDER BY blocked_for DESC;

Draai dat een uur onder load. Als je dezelfde query aan beide kanten van de join ziet verschijnen, klopt de job-claim-logica van je agent niet. Het patroon dat in productie werkt:

SELECT id
FROM invoices
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 10;

Dit is ook een goed moment om te beseffen dat Postgres historisch geweigerd heeft query hints te ondersteunen, vanuit het principe dat de planner vertrouwd hoort te worden. Dat argument wordt nu publiekelijk opnieuw gevoerd op de hackers-mailinglijst richting Postgres 19, en de discussie is het waard om te volgen als je geeft om planner-stabiliteit. Wat daar ook uit komt, zet geen productie-agent in op een hint die nog niet bestaat. Zorg dat je indexes kloppen.

De vijf-minuten-audit, in volgorde

De checklist die we op elke Postgres draaien voor er een agent een connection string krijgt:

  1. Sequentiële scans op grote tabellen. Alles boven een paar duizend rijen dat vaker wordt gescand dan geïndexeerd.
  2. Percentage dead tuples en timestamp van de laatste autovacuum. Alles wat dood, hot en niet-bezocht is, krijgt per-tabel-tuning.
  3. TOAST-grootte per tabel. Alles waar TOAST groter is dan de heap levert een gesprek op over wat er in de jsonb zit.
  4. Verdeling van connection states. Idle-in-transaction is een code-bug. Verzadigde active is een pooler-probleem.
  5. Lock waits onder gesimuleerde agent-load. Draai de agent een uur tegen staging en kijk naar pg_blocking_pids.

Niets hiervan is exotisch. Alles wordt overgeslagen omdat de database al jaren prima werkt en de agent-demo dinsdag goed verliep.

Toen we eerder dit jaar de incasso-agent voor een Nederlandse bouwdienstverlener bouwden, ving de audit een jsonb-kolom die per dag met 4 GB zou zijn gegroeid. We hebben de audit trail binnen een week naar S3 verhuisd en de agent draait sindsdien rustig door. De langere versie van hoe we AI-agents aan bestaande databases knopen zonder ze te breken, staat op de werk-pagina.

Het kleinste dat je vandaag kunt doen: open psql tegen je productiedatabase, plak de eerste query hierboven, en kijk naar de top 20 rijen. Als daar iets bij staat dat je verbaast, ben je nog niet klaar voor een agent.

Kern

Een automation-agent is een geduldige junior developer met oneindig veel handen. Als je Postgres een zwakke plek heeft, vindt hij die binnen een uur.

FAQ

Hoe lang duurt deze audit op een typische klantdatabase?

Ongeveer 30 minuten tegen een read replica. De vijf queries zijn read-only en goedkoop. Het lezen van de resultaten en beslissen wat je gaat fixen kost meer tijd dan ze draaien.

Hebben we pg_stat_statements nodig?

Niet voor deze audit. Elke view die we hier gebruiken zit in vanilla Postgres. pg_stat_statements is handig voor de volgende laag van tuning, maar je hebt het niet nodig om de drie failure modes uit deze post te vangen.

Werkt de checklist op RDS of Cloud SQL?

Ja. De queries zijn identiek. De autovacuum-tuning gebruikt parameter groups in plaats van postgresql.conf, maar de per-tabel ALTER TABLE SET werkt op dezelfde manier.

En Supabase of Neon?

Dezelfde queries, dezelfde fixes. Op serverless Postgres weegt het connection-pool-punt zwaarder, niet minder, omdat cold connections duur zijn. Draai de pg_stat_activity-check na een echte load test.

process automationai agentsarchitectureoperationstoolingworkflow

Iets bouwen?

Start een project