# Change data capture

## Hva det er

Change data capture, som regel forkortet til CDC, betyr å lese endringene i en
database og sende dem videre til andre systemer etter hvert som de skjer. Hver
innsetting, oppdatering og sletting blir en hendelse som noe annet kan reagere
på: en rapportkopi som holder seg oppdatert, en søkeindeks, en annen tjeneste
som må vite at en ordre er lagt inn. Databasen der endringen skjer, forblir den
autoritative kilden, og de andre systemene følger etter.

Det finnes grovt sett to måter å finne endringene på. Den enkle er å spørre: med
noen minutters mellomrom kjører en spørring etter rader der tidsstempelet for
siste oppdatering er nyere enn forrige kjøring. Den andre er å lese databasens
egen oversikt over hva den har endret. PostgreSQL fører den oversikten i
write-ahead-loggen (WAL), og dokumentasjonen beskriver prinsippet bak: endringer
i datafilene skrives først når postene som beskriver dem, er skrevet til
permanent lagring. Fordi loggen allerede inneholder hver endring i den
rekkefølgen den skjedde, gir den en fullstendig historikk uten at tabellene
trenger å leses i det hele tatt. Det er den andre tilnærmingen, loggbasert CDC,
man som regel mener med uttrykket i dag.

I PostgreSQL heter mekanismen logisk dekoding. Dokumentasjonen beskriver den som
prosessen som trekker ut alle varige endringer i tabellene til en database i et
sammenhengende format som kan leses uten kjennskap til databasens indre
tilstand. Tre deler virker sammen. Loggen på serveren må inneholde nok detaljer,
og det styres av innstillingen `wal_level`: standardverdien, `replica`, holder
for å kopiere data mellom servere, mens `logical` legger til det dekodingen
trenger. Et replikeringsspor (replication slot) står for én strøm av endringer
fra én database, som spilles av for en klient i den rekkefølgen de ble gjort, og
sporet husker hvor langt klienten har kommet. En output-plugin omsetter de
interne postene i loggen til det formatet klienten ønsker.

De fleste skriver ikke klienten selv. Debezium beskriver seg selv som en samling
kildekonnektorer for Apache Kafka Connect, der hver enkelt leser endringer fra
en bestemt database gjennom databasens egne CDC-funksjoner. Konnektoren for
PostgreSQL leser enten gjennom `pgoutput`, som Debezium-dokumentasjonen omtaler
som den innebygde output-pluginen i PostgreSQL, eller gjennom `decoderbufs`, en
plugin som Debezium-fellesskapet vedlikeholder. Debezium kan også kjøre som et
bibliotek i en JVM-applikasjon eller som Debezium Server, som sender
endringshendelsene videre til systemer som Amazon Kinesis, Google Cloud Pub/Sub
og Apache Pulsar.

## Hvorfor det er viktig

Polling ser billigere ut, og for en liten tabell som sjelden endres, stemmer det
ofte. Debezium-dokumentasjonen lister opp hva lesing av loggen får til som
polling ikke gjør. Den fanger hver endring, der en spørring bare ser tilstanden
til raden i det øyeblikket den kjører, så en rad som ble endret tre ganger
mellom to kjøringer, bare dukker opp én gang. Den fanger slettinger, der en
slettet rad rett og slett mangler i neste resultat. Den krever ingen ekstra
kolonne i datamodellen, som et «Last Updated»-felt som all kode som skriver til
tabellen, må huske å fylle ut. Den kan ta med den tidligere tilstanden til raden
og metadata, for eksempel hvilken transaksjon som gjorde endringen. Og den
leverer endringene med svært liten forsinkelse uten den CPU-belastningen som
hyppig polling gir: for PostgreSQL og MySQL ligger forsinkelsen ifølge Debezium
i millisekundområdet.

Det andre argumentet er at databasen og meldingene som andre systemer mottar,
sier det samme. Debezium nevner dobbeltskriving (dual writes) som den andre
tilnærmingen den sammenligner seg med: applikasjonen lagrer en endring i
databasen sin og sender deretter en melding om den. Stopper prosessen mellom de
to stegene, er databasen og meldingsstrømmen uenige, og ingenting registrerer
hvem av dem som tar feil. CDC fjerner den andre skrivingen. Applikasjonen lagrer
én gang, og meldingen avledes av det som faktisk ble lagret. Outbox-mønsteret
går et steg videre: applikasjonen skriver selve meldingen i en outbox-tabell i
den samme transaksjonen som endringen, og CDC publiserer radene i den tabellen.
Outbox Event Router fra Debezium beskriver formålet som å unngå inkonsistens
mellom den egne tilstanden til en tjeneste og hendelsene som andre tjenester
mottar.

Det tredje argumentet er rekkefølgen. Et replikeringsspor leverer endringene i
den rekkefølgen de ble gjort på kildeserveren, samlet etter transaksjonen som
gjorde dem, slik eksemplet nedenfor viser. En konsument som tar dem i bruk i den
rekkefølgen, gjenskaper den samme tilstanden steg for steg. For en ETL-lasting
gjør det den inkrementelle lastingen ærlig: en nattlig jobb kan ta nøyaktig de
endringene som har kommet siden forrige kjøring, slettinger medregnet, i stedet
for å laste hele tabeller på nytt eller stole på en tidsstempelkolonne som én
kodesti glemte å oppdatere.

## Slik ser det ut i praksis

Det minste eksemplet som fungerer, bruker bare PostgreSQL. Det krever `wal_level
= logical` i serverkonfigurasjonen, noe som først trer i kraft etter en omstart,
og et ledig replikeringsspor; `max_replication_slots` er 10 som standard. Økten
nedenfor kjører som superbruker, slik dokumentasjonens eget eksempel gjør, og
bruker `test_decoding`, output-pluginen fra det eksemplet, som skriver ut hver
endring som en tekstlinje.

```sql
-- postgresql.conf: wal_level = logical (trer i kraft etter en omstart)
SELECT * FROM pg_create_logical_replication_slot('shop_cdc', 'test_decoding');

BEGIN;
INSERT INTO orders (id, status) VALUES (42, 'pending');
INSERT INTO outbox (id, event) VALUES (7, 'ordre 42 lagt inn');
COMMIT;

SELECT data FROM pg_logical_slot_peek_changes('shop_cdc', NULL, NULL);
-- BEGIN 10301
-- table public.orders: INSERT: id[integer]:42 status[text]:'pending'
-- table public.outbox: INSERT: id[integer]:7 event[text]:'ordre 42 lagt inn'
-- COMMIT 10301

SELECT data FROM pg_logical_slot_get_changes('shop_cdc', NULL, NULL);
-- de samme fire radene; kallet bruker dem opp, så et nytt kall gir ingen

SELECT pg_drop_replication_slot('shop_cdc');
```

Ordren og outbox-meldingen kommer ut sammen, innenfor transaksjonen som skrev
dem. `pg_logical_slot_peek_changes` leser uten å flytte sporet og gir derfor de
samme radene ved neste kall, mens `pg_logical_slot_get_changes` bruker dem opp.
En `CREATE TABLE` ville bare gitt en tom `BEGIN` og `COMMIT`, fordi logisk
dekoding ikke tar med DDL. Den siste linjen er den viktigste: et spor som blir
glemt, fortsetter å holde på ressursene til serveren.

I produksjon er det Debezium som leser. Første gang konnektoren for PostgreSQL
starter, tar den et konsistent øyeblikksbilde (snapshot) av tabellene den fanger
endringer fra, og strømmer deretter endringer fra punktet der øyeblikksbildet
ble tatt. Standardverdien for `snapshot.mode`, `initial`, tar bare
øyeblikksbildet når det ikke finnes lagrede offsets, så en omstart fortsetter
fra den sist registrerte posisjonen. Hver hendelse har et `before`- og et
`after`-bilde av raden, en `source`-blokk, et tidsstempel og en `op`-kode: `c`
for opprettelse, `u` for oppdatering, `d` for sletting, `r` for en rad lest
under et øyeblikksbilde og `t` for truncate. Etter en sletting sender
konnektoren i tillegg en tombstone, en hendelse med samme nøkkel og verdien
null, slik at log compaction i Kafka kan fjerne de tidligere meldingene med den
nøkkelen.

Begge mønstrene fra denne artikkelen går igjen på selskapets egne sider. Mellom
2022 og 2024
[utviklet selskapet 600 ETL/ELT-pipelines i PL/pgSQL](/nb/portfolio/engineered-600-pl-pgsql-etl-elt-pipelines/)
på tvers av flere utviklings- og produksjonsmiljøer i PostgreSQL, og
inkrementelle lastinger med CDC og tidsstempelbaserte deltaer reduserte til
sammen behandlingstiden med over 60 %. Notatet
[Å selge den siste billetten nøyaktig én gang](/nb/notes/selling-limited-stock-without-overselling/)
bygger et lynsalg rundt en outbox: hvert steg legger meldingen sin i outboxen i
den samme transaksjonen som endringen, change data capture publiserer outboxen,
og fordi en melding kan komme mer enn én gang, er hver konsument idempotent, med
ordren som nøkkel.

## Hvor det går galt

**Ingenting setter tak på hva et spor kan holde på.** Et spor holder på hver bit
av write-ahead-loggen som konsumenten ikke har bekreftet, og
standardinnstillingene i PostgreSQL setter ingen grense for det.
`max_slot_wal_keep_size` er `-1`, så spor kan beholde en ubegrenset mengde logg,
og `idle_replication_slot_timeout` er `0`, noe som slår av ugyldiggjøringen av
spor som står ubrukt. En konnektor som stoppet under en mislykket
produksjonssetting, kan derfor fylle disken lenge etter at alle har glemt den.
Hold øye med `pg_replication_slots` for spor som ikke lenger er aktive, og sett
`max_slot_wal_keep_size` til det disken tåler, vel vitende om at en konsument
som havner lenger bak enn det, kanskje ikke kan fortsette. Veien tilbake går
gjennom et nytt spor, og et nytt spor begynner der loggen står nå: Debezium
advarer om at et spor som slettes og opprettes på nytt, i det stille kan hoppe
over hendelsene i mellomtiden, så ta et nytt øyeblikksbilde etterpå.

**En stille tabell holder loggen tilbake.** Debezium-dokumentasjonen beskriver
en mer lumsk variant. Et spor hører til én database, og konnektoren flytter bare
den bekreftede posisjonen sin fremover når den ser endringer den fanger. Endres
tabellene den fanger, sjelden, mens andre tabeller eller andre databaser på den
samme serveren skriver mye, flytter posisjonen seg nesten ikke, og loggen bak
den fortsetter å vokse. Løsningen er et hjerteslag (heartbeat):
`heartbeat.interval.ms` får konnektoren til å sende heartbeat-hendelser med et
fast intervall, og `heartbeat.action.query` lar den skrive jevnlig til en liten
tabell i databasen den fanger endringer fra. Tabellen må være med i
publikasjonen (publication) som konnektoren leser, slik at sporet flytter seg
fremover også når ingenting annet skjer der.

**Konsumentene går ut fra at hver endring kommer én gang.** Et logisk spor
sender ut hver endring én gang under normal drift, men posisjonen lagres bare
ved sjekkpunkter (checkpoints), så etter et krasj kan det gå tilbake til et
tidligere punkt og sende de siste endringene på nytt. PostgreSQL-dokumentasjonen
overlater konsekvensene til klienten. En konsument som legger hvert beløp til en
løpende sum, teller de endringene to ganger. Gjør hver konsument idempotent: ta
i bruk hver endring som en upsert med primærnøkkelen til raden som nøkkel, eller
registrer hvilke hendelser som allerede er behandlet, slik at en ny levering
ikke endrer noe.

**Oppdateringer og slettinger kommer uten de gamle verdiene.** Hva en hendelse
for en oppdatering eller sletting forteller om den tidligere raden, avhenger av
replica identity for tabellen. Med `REPLICA IDENTITY DEFAULT` inneholder
hendelsene fra Debezium høyst de tidligere verdiene av primærnøkkelen, og for en
tabell uten primærnøkkel sender konnektoren ingen hendelser for oppdateringer
eller slettinger i det hele tatt. `REPLICA IDENTITY FULL` legger alle tidligere
kolonner i `before`, men det koster: PostgreSQL-dokumentasjonen påpeker at det
øker mengden WAL, særlig når mange `UPDATE`- og `DELETE`-setninger kjøres. Gi
hver tabell som fanges, en primærnøkkel, og bytt bare til `FULL` på tabellene
der konsumentene trenger de gamle verdiene.

## Les mer

- [Debezium Features](https://debezium.io/documentation/reference/stable/features.html)
  — hva loggbasert CDC fanger som polling og dobbeltskriving går glipp av, pluss
  øyeblikksbilder, filtre og maskering.
- [Debezium connector for PostgreSQL](https://debezium.io/documentation/reference/stable/connectors/postgresql.html)
  — output-plugins, øyeblikksbilder, formatet på hendelsene, replica identity og
  diskplass til WAL.
- [Logical Decoding Concepts](https://www.postgresql.org/docs/current/logicaldecoding-explanation.html)
  — replikeringsspor, output-plugins, og hvorfor et spor som ingen trenger, bør
  slettes.
- [Logical Decoding Examples](https://www.postgresql.org/docs/current/logicaldecoding-example.html)
  — å opprette et spor, kikke på og bruke opp endringer og slette sporet igjen.
- [Replication (server configuration)](https://www.postgresql.org/docs/current/runtime-config-replication.html)
  — `max_replication_slots`, `max_slot_wal_keep_size` og
  `idle_replication_slot_timeout`.
- [Outbox Event Router](https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html)
  — outbox-mønsteret, og hvordan Debezium sender outbox-rader videre til topics.

<https://engineer.company/nb/glossary/change-data-capture/>
