Now-Next

SaaS-architectuur

Auditlog per klant in PostgreSQL: wat een trigger kost

Een auditlog met triggers in PostgreSQL 17, nagemeten: rij- tegen statementtriggers, hele rijen tegen gewijzigde kolommen, en row-level security op het log.

Chris van Eijk · · 7 min lezen

Vroeg of laat vraagt een klant wie een kredietlimiet heeft gewijzigd, en wanneer. In een multi-tenant SaaS moet dat antwoord uit een auditlog komen dat elke klant voor zijn eigen rijen kan lezen en dat niemand kan bewerken. Een trigger is de gebruikelijke manier om het te vullen, omdat die ook de wijziging vangt van een script of een migratie die nooit door je applicatie ging.

We hebben gemeten wat die trigger kost. Bij 20.000 wijzigingen van één rij kwam er 32 microseconde per wijziging bij. Bij één bulk-UPDATE van 100.000 rijen, zoals een migratie of een backfill die oplevert, werd de opdracht 4,4 keer zo traag met een rijtrigger en 3,6 keer met een statementtrigger. Alleen de gewijzigde kolommen opslaan maakte het log vijf keer kleiner en de trigger trager.

De opzet

PostgreSQL 17.10 in een wegwerpcontainer, standaardinstellingen, mediaan van drie metingen. Een tabel customers met 200.000 rijen verdeeld over 100 klanten, zeven kolommen, waaronder 200 tekens aan notities. Eén generieke audittabel voor alle klanten:

create table audit_log (
  id bigint generated always as identity primary key,
  tenant_id int not null,
  table_name text not null,
  row_id bigint not null,
  action text not null,
  changed_at timestamptz not null default clock_timestamp(),
  changed_by text,
  old_row jsonb,
  new_row jsonb
);
create index on audit_log (tenant_id, changed_at);

De trigger haalt de klant uit de rij zelf en de gebruiker uit het verzoek, current_setting('app.user_id', true). Elke meting draaide in een transactie die we daarna terugdraaiden, met een VACUUM vooraf, zodat elke variant met dezelfde gegevens begon. Deze tijden zijn gemeten als superuser, voordat we row-level security op de audittabel zetten. De wijzigingen van één rij draaiden in een lus op de server, dus de getallen bevatten geen netwerktijd en geen commits — in een echt verzoek komen die erbij, en is het aandeel van de trigger kleiner.

Hoeveel trager maakt een audittrigger een UPDATE?

2,1 tot 2,6 keer zo traag voor wijzigingen van één rij in deze opzet, en 3,6 tot 6,4 keer voor een bulkwijziging:

Trigger20.000 wijzigingen van één rijEén UPDATE van 100.000 rijenAuditlog na de bulkwijzigingjsonb per wijziging
geen595 ms586 ms——
rijtrigger, volledige oude en nieuwe rij1.237 ms2.579 ms93,6 MB750 bytes
rijtrigger, alleen gewijzigde kolommen1.552 ms3.747 ms18,8 MB48 bytes
statementtrigger met transitietabellen, volledige rijen1.288 ms2.104 ms93,6 MB750 bytes

De absolute getallen zeggen meer dan de verhoudingen. De trigger met volledige rijen voegde (1.237 − 595) / 20.000 = 32 microseconde toe aan elke wijziging van één rij. Een webverzoek dat één klant wijzigt, merkt dat niet. Bij de bulkwijziging wordt het zichtbaar: een import van 100.000 rijen ging van 0,6 naar 2,6 seconde, en schreef 94 MB auditlog voor een wijziging van één kolom.

Rijtrigger of statementtrigger?

Voor bulkwijzigingen de statementtrigger. Een rijtrigger draait één keer per rij; een statementtrigger draait één keer per opdracht en ziet alle gewijzigde rijen tegelijk via transitietabellen — “row sets that include all of the rows inserted, deleted, or modified by the current SQL statement”, in de woorden van de documentatie:

create trigger customers_audit
  after update on customers
  referencing old table as old_rows new table as new_rows
  for each statement execute function audit_stmt();

De functie schrijft dan alle auditrijen in één insert … select uit new_rows gekoppeld aan old_rows. Bij de wijziging van 100.000 rijen kostte dat 2.104 ms tegen 2.579 ms voor de rijtrigger, een vijfde minder. Bij wijzigingen van één rij was hij iets trager, 1.288 tegen 1.237 ms, omdat hij elke keer de transitietabellen voor één rij opbouwt.

Twee beperkingen uit de documentatie om te kennen voordat je hem kiest: transitietabellen werken alleen bij een AFTER-trigger, en een UPDATE-trigger die ze gebruikt mag geen kolommen opsommen, dus je kunt hem niet beperken tot update of credit_limit.

Volledige rij of alleen de gewijzigde kolommen?

Dat is een ruil tussen opslag en rekenwerk. De versie die alleen opslaat wat veranderde, vergelijkt de oude en nieuwe rij sleutel voor sleutel:

select jsonb_object_agg(key, o -> key), jsonb_object_agg(key, value)
  into d_old, d_new
from jsonb_each(n)
where o -> key is distinct from value;
if d_new is null then return null; end if;  -- niets veranderd

Dat bracht de omvang van de jsonb per wijziging van 750 naar 48 bytes, en het auditlog na de bulkwijziging van 93,6 naar 18,8 MB — vijf keer kleiner. Het maakte ook elke wijziging duurder: 3.747 tegen 2.579 ms voor de bulkwijziging, omdat de vergelijking voor elke rij draait. Onze versie slaat ook wijzigingen over die niets veranderden; een WHEN (old.* is distinct from new.*)-clausule op de trigger doet hetzelfde voor de versie met volledige rijen.

Welke past, hangt af van wat je met het log doet. Een klant die wil zien wat er veranderde, heeft genoeg aan het verschil; een rij terugzetten zoals hij op een bepaalde datum was, gaat makkelijker met volledige rijen. Brede rijen met één vaak wijzigende kolom — een last_seen_at, een teller — pleiten sterk voor het verschil.

Hoe houd je de ene klant uit het auditlog van de andere?

Met dezelfde row-level security als de rest van de gegevens, en zonder UPDATE- of DELETE-rechten. We gaven de applicatierol alleen SELECT en INSERT op de audittabel, en twee beleidsregels:

alter table audit_log enable row level security;
alter table audit_log force row level security;
create policy audit_read on audit_log for select
  using (tenant_id = nullif(current_setting('app.tenant', true), '')::int);
create policy audit_write on audit_log for insert
  with check (tenant_id = nullif(current_setting('app.tenant', true), '')::int);

De trigger draait als de gebruiker die de wijziging deed, dus zijn insert gaat door dezelfde controle. Met klant 1 als context schreef een wijziging van drie rijen in customers van klant 1 drie auditrijen, met changed_by gevuld vanuit het verzoek. Klant 2 zag 0 rijen in het auditlog. Een UPDATE of DELETE op het log als applicatierol faalde met permission denied for table audit_log. Het log is dan voor de applicatie alleen aan te vullen — een superuser kan het nog steeds wijzigen en de eigenaar van de tabel kan de bescherming uitzetten, dus kopieer het voor alles wat in een geschil overeind moet blijven naar een plek waar de databaserol van de applicatie niet bij kan.

Een migratie of script dat over klanten heen werkt, heeft per klant de klantcontext nodig of een rol met BYPASSRLS; zo’n rol komt ook langs het auditbeleid, en de trigger blijft zijn wijzigingen vastleggen — zo liepen onze metingen hierboven. De controle bij het invoegen heeft nog een gevolg. Schrijft code ooit een auditrij voor een andere klant dan de huidige context, dan faalt de hele wijziging in plaats van dat hij onder de verkeerde klant wordt vastgelegd. Dat is dezelfde garantie die we voor de gegevens zelf hebben gemeten in row-level security in PostgreSQL: waar tenants lekken.

Wat we niet hebben gemeten

Inserts en deletes, een gepartitioneerde audittabel (maandpartities maken van opschonen een DROP in plaats van een DELETE, zoals we voor klantgegevens maten in het indexenartikel), een koude cache, de kosten van het log per klant teruglezen, en auditing via een extensie zoals pgAudit, die opdrachten naar het serverlog schrijft in plaats van rijen naar een tabel. Alle tijden komen van één machine met een warme cache; lees ze als ordes van grootte.

Bronnen: de PostgreSQL 17-documentatie over CREATE TRIGGER (transitierelaties, statement- en rijtriggers), gelezen op 1 oktober 2026. Alle metingen zijn van onszelf, in 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