Referensiell integritet på tvers av subgrafer
FS GraphQL API består av en supergraf med flere subgrafer. Hver subgraf eier sine egne data, men utad er alt ett sammenhengende system. Det betyr at data i én subgraf ofte refererer til data som eies av en annen – et utdanningstilbud i opptakssystemet viser for eksempel til en utdanningsinstans som eies av utdanningsregisteret.
Denne siden beskriver hvordan vi sikrer referanseintegritet på tvers av subgrafene, hvorfor vi har valgt logisk replikering mellom Postgres-databasene som mekanisme, og hvilke prinsipper som gjelder når data deles. Den er ment for deg som utvikler en subgraf og trenger å referere til eller bygge på data som eies av andre.
Bakgrunn og begreper
Solide, sammenhengende informasjonsmodeller har vært garantien for langsiktighet og kontinuitet i løsningene våre helt fra begynnelsen. Vi prioriterer høy datakvalitet og krever at databasene har referanseintegritet og skranker som ivaretar dette – også når referansene går på tvers av delsystemer.
Sentrale begreper:
- Eier (autoritativ kilde) – subgrafen som er hovedkilde for en gitt type data. All oppretting, endring og sletting skjer hos eieren.
- Referanseintegritet – databasens garanti for at dersom du refererer til noe, så finnes det du refererer til. Internt i en database håndheves dette med fremmednøkler.
- Logisk replikering – den innebygde mekanismen i Postgres for å kopiere endringer i utvalgte tabeller fortløpende fra én database til en annen.
Utfordringen er at fremmednøkler bare virker innenfor én database. Skal opptaksdatabasen kunne garantere at et utdanningstilbud peker på en utdanningsinstans som faktisk finnes, må identitetene til utdanningsinstansene finnes lokalt i opptaksdatabasen. Det er dette hullet logisk replikering tetter: eieren publiserer et minimalt utvalg av sine data, og mottakeren abonnerer på dem og kan definere fremmednøkler mot dem.
Prinsipper for deling av data
Følgende prinsipper gjelder for all replikering av data mellom subgrafer:
- Hver subgraf eier sine egne data. All endring av data skjer via subgrafen som eier dem.
- Replikerte data er skrivebeskyttet hos mottakeren. Mottakerens applikasjon skal aldri kunne skrive til replikerte tabeller, og dette skal håndheves med rettigheter i databasen.
- Repliker så lite som mulig. Bare de tabellene, kolonnene og radene som trengs for å dekke behovet. Postgres støtter kolonnelister og radfiltre i publikasjoner, så minimeringen kan håndheves teknisk – ikke bare som en intensjon.
- Entitetstabeller replikeres for referanseintegritet. Identitetene til entiteter du refererer til, replikeres til egne tabeller slik at du kan definere fremmednøkler mot dem og modellere videre normalisert.
- Denormalisering kun ved dokumentert behov. Data utover identiteter kan replikeres der det er nødvendig for ytelse, typisk for å bygge søkeindekser. Behovet skal dokumenteres.
- Replikerte data videreeksponeres ikke. Data du har fått replikert skal ikke tilgjengeliggjøres i din subgraf – det er eierens subgraf som eksponerer dem i supergrafen. Trenger konsumenter dataene sammen, løses det med føderering.
- All replikering dokumenteres. Hva som replikeres, hvorfor, og hvem som er eier.
Hvorfor vi løser dette i databaselaget
Vi har valgt å bruke databasens egne mekanismer – fremmednøkler og logisk replikering – fremfor å sjekke referanser i applikasjonskode eller synkronisere via egne integrasjonsjobber. Det følger av samme erfaring som ligger bak resten av arkitekturen:
- Databasen står seg bedre over tid enn applikasjonskoden. Informasjonsmodellen for domenet vårt endrer seg saktere enn teknologien rundt. En fremmednøkkel gir en garanti som gjelder uansett hvilken applikasjon eller kodevei som skriver til databasen.
- Integritetssjekker i applikasjonskode er utilstrekkelige. De kan omgås, de må dupliseres i hver kodevei, og de kan ikke garantere noe om data som allerede ligger i databasen.
- Logisk replikering er innebygd i Postgres. Vi slipper å utvikle og forvalte egne synkroniseringsjobber med tilhørende feilhåndtering, gjenkjøring og overvåking – Postgres håndterer innledende kopi, fortløpende endringer og gjenopptak etter avbrudd.
Vi er samtidig ærlige på kostnaden: replikering innfører en kobling i databaselaget, i tillegg til supergrafen. Koblingen er intern og avgrenset – den brukes bare for felles kjernebasisdata (som organisasjon og utdanningsinstans), ikke som en generell integrasjonskanal.
Selv om referansetabeller i teorien kan holdes synkronisert ved hjelp av hendelser (på lik linje med andre konsumenter, og dermed i tråd med API-først-prinsippet), velger vi likevel replikering internt. Dette følger av at:
- Samtidighetskravet til integritet er strengere for våre egne brukere enn for integrasjonspartnerne våre. Replikeringen har typisk mindre enn et sekunds etterslep. Dette er et samtidighetskrav arkitekturen vår for hendelser bevisst ikke innfrir.
- Hvilke kolonner vi bruker som primærnøkler, er et internt anliggende. Velger vi å skjule dem med
@inaccessible, er de heller ikke tilgjengelige gjennom hendelser – da er replikering den eneste måten å dele dem på.
Rett mekanisme for rett behov
Føderering og replikering er ikke konkurrerende mekanismer – de henger tett sammen og utfyller hverandre. Sett fra konsumentens side er det supergrafen som er ansvarlig for å hente data, og data hentes alltid fra den autoritative kilden. Replikeringen virker i databaselaget og sørger for at identitetene subgrafene refererer til, er gyldige – som er nettopp det fødereringen bygger på. Se Samspillet med fødereringen.
Replikering er altså ikke en generell integrasjonskanal, men databaselagets virkemiddel for integritet og ytelse. API-først-strategien gjelder fortsatt: forretningsdata utveksles via supergrafen. Bruk denne tabellen for å finne rett mekanisme:
| Behov | Mekanisme |
|---|---|
| Vise data fra et annet delsystem i APIet | Føderering i supergrafen – ingen kopi |
| Garantere at en referanse peker på noe som finnes | Replikert entitetstabell + fremmednøkkel |
| Søk eller ytelse som krever oppdaterte data fra andre | Replikering for denormalisering |
| Reagere på forretningshendelser i andre delsystemer | Hendelser via supergrafen |
Merk skillet mellom de to siste: en søkeindeks trenger dataene fortløpende og maskinelt – det er et databasebehov. En forretningsprosess som skal reagere på at noe har skjedd, er et API-behov og skal bruke hendelsene som eierens subgraf eksponerer.
Eksterne med samme behov holder identitetene synkronisert via hendelsesspørringene supergrafen eksponerer. Å tilby replikering utad ville bety å åpne databasene og forvalte en langt større flate. Dette anser vi som verken forsvarlig eller ønskelig.
Slik fungerer logisk replikering
Logisk replikering i Postgres følger en publiser/abonner-modell:
- Eieren oppretter en publikasjon (
CREATE PUBLICATION) som angir hvilke tabeller – og eventuelt hvilke kolonner og rader – som tilbys. - Mottakeren oppretter et abonnement (
CREATE SUBSCRIPTION) som kobler seg til eierens database, kopierer eksisterende data (innledende synkronisering), og mottar deretter alle endringer fortløpende. - Endringene leses fra eierens transaksjonslogg (WAL) via en replikeringsslot, som sørger for at ingen endringer går tapt selv om mottakeren er nede en periode.
Tabellene må finnes på forhånd hos mottakeren, med samme skjemakvalifiserte navn som hos eieren og kompatible kolonner – replikeringen kobler tabeller ut fra fullt navn, og tabelldefinisjoner kopieres ikke automatisk. Replikeringen er alltid enveis: fra eier til mottaker, aldri tilbake.
Dette skjer fra database til database, under applikasjonene. Ingen applikasjonskode eller pods er involvert i selve replikeringen, og ingen påloggingsinformasjon for databasen distribueres ut til applikasjonsinstansene – tilkoblingen mot eieren ligger i abonnementet hos mottakerdatabasen. Det infrastrukturen må tillate, er at mottakerdatabasen når eierdatabasen over nettet. Koblingen går alltid én vei: abonnenten kobler seg til utgiveren, aldri motsatt.
To egenskaper ved mekanismen former angrepsflaten: koblingene er enkeltvise og enveis – en mottaker snakker bare med den ene eieren den abonnerer på – og en replikeringsrolle gir lesetilgang, aldri skrivetilgang. Den autoritative dataen kan altså ikke endres utenfra; i verste fall er det snakk om innsyn, ikke integritetsbrudd.
Bygg videre på dette når du setter opp replikeringen:
- Lås koblingen i nettverkslaget (
pg_hbaog security groups), slik at den bare kan gå mellom de to avtalte partene. Da er en stjålet legitimasjon verdiløs andre steder fra. - Bruk en dedikert rolle per par, og gi den bare
SELECTpå de publiserte tabellene.
Hvor mye en slik rolle får lese, og hvilke verter den får koble seg fra, hører til nettverks- og rolleoppsettet. Slike oppskrifter for ekte miljøer er ennå ikke skrevet, se Status og videre arbeid.
Detaljene – konfigurasjon, roller, nettverk og feilsøking – dekkes i egne veiledninger, se Status og videre arbeid.
Mønster: entitetstabeller for referanseintegritet
Dette er hovedmønsteret, og det du normalt skal bruke. Mottakeren abonnerer på en minimal entitetstabell – i utgangspunktet bare identitetene – og definerer fremmednøkler mot den.
Eieren legger entitetstabellen i sitt eget domeneskjema – utdanningsregisteret har valgt
utdanning, så tabellen heter utdanning.utdanningsinstans. Siden replikeringen kobler
tabeller ut fra skjemakvalifisert navn, må mottakeren bruke nøyaktig samme navn: navnerommet
følger eieren.
Alle kolonnene som inngår i entitetens @key, må replikeres. Det er disse kolonnene
fødereringen bruker til å knytte entiteten sammen på tvers, og som fremmednøkkelen står på (se
Samspillet med fødereringen). Om det er en naturlig nøkkel eller en
surrogatnøkkel, er eierens modellvalg; kravet mønsteret stiller, er at nøkkelen er stabil. For
utdanningsinstans er identiteten utdanningsinstansnr – de organisasjonstildelte kodene kan endre
seg over tid, mens nummeret står fast.
Hos eieren (utdanningsregisteret) publiseres i utgangspunktet bare nøkkelkolonner:
create publication opptak_utdanningsinstans
for table utdanning.utdanningsinstans (utdanningsinstansnr);
Hos mottakeren (opptak) opprettes tabellen med samme skjemakvalifiserte navn, og abonnementet kobles opp:
create schema utdanning;
create table utdanning.utdanningsinstans (
utdanningsinstansnr bigint primary key
);
create subscription utdanningsregisteret_utdanningsinstans
connection '<tilkoblingsstreng til utdanningsregisterets database>'
publication opptak_utdanningsinstans;
Deretter kan opptaksdatabasen modellere normalisert videre, med full referanseintegritet:
create table opptak.utdanningstilbud (
opptakstype_kode text not null,
opptak_kode int not null,
utdanningsinstansnr bigint not null
references utdanning.utdanningsinstans,
-- ...
primary key (opptakstype_kode, opptak_kode, utdanningsinstansnr)
);
Ved skriving garanterer databasen nå at et utdanningstilbud aldri kan opprettes eller endres til å referere til en utdanningsinstans som ikke finnes hos eieren. En klient kan hente gyldige verdier på forhånd, men subgrafen som tar imot mutasjonen, kan ikke uten videre stole på at den gjør det – og skal heller ikke måtte slå opp hos utdanningsregisteret for hver skriving. Garantien må ligge der skrivingen lander, og den lokale fremmednøkkelen legger den akkurat der. (Mekanismen beskytter derimot ikke mot at eieren senere sletter en rad du refererer til – det hullet lukkes av konvensjonen om logisk sletting, ikke av replikeringen selv, se Sletting hos eieren.) Den replikerte nøkkelkolonnen inngår direkte i primærnøkkelen til utdanningstilbudet. Den normaliserte modelleringen fortsetter altså rett oppå den replikerte entitetstabellen. Entiteter med sammensatte naturlige nøkler håndteres på samme måte: publiser alle nøkkelkolonnene og definer en tilsvarende sammensatt fremmednøkkel – veiledningen viser den varianten i praksis.
Beregnede kolonner (GENERATED ALWAYS AS (…) STORED) kan først publiseres fra Postgres 18.
Trengs en beregnet kolonne hos mottakeren på 17, kan samme beregning defineres
lokalt på toppen av replikerte grunnkolonner.
Opptak har i dag et eget utdanning-skjema med lokalt modellerte og lokalt vedlikeholdte kopier
av flere av utdanningsregisterets tabeller. Den nyeste utdanningstilbud-modellen
(opptak.utdanningstilbud_v2) refererer i tillegg til utdanningsregisterets ID-er som ren tekst
(utdanningsinstans_ekstern_id) – uten fremmednøkkel. Det er nettopp disse to mellomløsningene
mønsteret skal erstatte: replikerte tabeller i stedet for manuelt vedlikeholdte kopier, og
fremmednøkler i stedet for ukontrollerte ID-strenger. Overgangen må også håndtere at
utdanning-navnerommet i opptak i dag er opptatt av de lokale kopiene.
Mønsteret med å legge eksterne identiteter i egne tabeller med fremmednøkler mot dem, har vi brukt lenge. Det nye er at logisk replikering holder disse tabellene vedlike automatisk, i stedet for at hver tjeneste må gjøre det selv.
Samspillet med fødereringen
I supergrafen bruker vi Apollo Federation, der entiteter deles mellom subgrafer ved hjelp av
@key-direktivet.
For at dette skal fungere, må alle subgrafer bruke samme verdi for @key-feltene for den samme
entiteten – det er slik supergrafen vet at det er snakk om den samme utdanningsinstansen på
tvers.
Her faller referanseintegriteten og fødereringen naturlig sammen: de replikerte
nøkkelkolonnene er nettopp de feltene som inngår i @key. Da blir det én gjennomgående linje
gjennom alle lagene – replikert kolonne = fremmednøkkel = @key-felt.
Replikeringen vet selv ingenting om Node-ID-er. Den flytter rå nøkkelkolonner mellom databasene;
Node-ID-en settes først sammen i graflaget hos eieren, når entiteten eksponeres for konsumenten.
En mottaker trenger derfor aldri å kjenne eierens typeId eller koding for å holde referansene
synkronisert – den forholder seg bare til nøkkelkolonnene. Det er nettopp derfor vi kobler
entiteten på @key(fields: "utdanningsinstansnr") og ikke på den innkodede id-en. Nøkkelen
hører hjemme i databasen, og databasen er laget som står støtt over tid: informasjonsmodellen og
valget av primærnøkler endrer seg sakte og har lang levetid. Node-ID-en følger derimot av hvordan
vi har lagt opp GraphQL-APIet. Ved å feste referanseintegriteten til nøkkelen forankrer vi den i
det mest varige vi har.
At nøkkelen er stabil, betyr likevel ikke at vi er låst til den. Kolonnene bærer felles, lesbar betydning på tvers av databasene, så en primærnøkkel lar seg endre selv når den er delt mellom flere – tungt, men gjennomførbart. Med en ugjennomsiktig ID uten felles betydning ville det samme vært langt vanskeligere, for ingen kan lese ut av verdien hva den peker på.
Eieren (utdanningsregisteret) deklarerer kolonnen som @key. Skal ikke nøkkelkolonnene være en del
av det offentlige APIet, kan de skjules med
@inaccessible
slik at feltene ikke eksponeres for konsumentene. Det klientvendte, globale id-et lever
naturligvis videre uavhengig, generert fra @node som før:
type Utdanningsinstans implements Node
@node(typeId: "10150", keyColumns: ["utdanningsinstansnr"])
@key(fields: "utdanningsinstansnr") {
id: ID! @nodeId
utdanningsinstansnr: BigInt! @inaccessible
# ...
}
Opptak refererer til den samme nøkkelen. Referansestubben trenger verken @node eller id – bare
selve nøkkelen den peker på:
type Utdanningstilbud {
# ...
utdanningsinstans: Utdanningsinstans
}
type Utdanningsinstans
@key(fields: "utdanningsinstansnr", resolvable: false) {
utdanningsinstansnr: BigInt!
}
Legg merke til at stubben ikke gjentar @inaccessible. @inaccessible virker på det
sammenslåtte feltet i supergrafen: markerer eieren nøkkelen som utilgjengelig, utelates den fra
hele API-skjemaet – «even if that definition is also present in other subgraphs»
(Apollos referanse).
Det er derfor nok at eieren setter @inaccessible; referansesubgrafene deklarerer bare feltet
så @key er oppfylt. Tilgjengelighet er en egenskap ved entiteten i den samlede grafen, ikke ved
den enkelte subgrafen.
Merk også at eiersiden må deklarere denne nøkkelen som en resolvable @key for at referansen
skal kunne slås opp – gjerne i tillegg til @key(fields: "id"), siden en entitet kan ha flere
nøkler. Om utdanningsinstansnr i det hele tatt skjules med @inaccessible, er eierens
modellvalg: er nummeret et forretnings-ID som konsumentene har nytte av, kan det stå åpent; skal
det holdes internt, skjuler eieren det.
Fordi fremmednøkkelen hindrer opptak i å lagre en referanse som ikke finnes hos eieren, kan hver referanse opptak sender fra seg, alltid slås opp. Selve innholdet i utdanningsinstansen henter supergrafen fra utdanningsregisteret – den autoritative kilden. Slik utfyller de to hverandre: replikeringen gir integritet og gyldige nøkler i databaselaget, mens fødereringen gir konsumentene dataene fra eieren. Konsumenten ser aldri de replikerte dataene, bare den sammenhengende grafen.
Mønster: denormalisering for søk
Noen behov lar seg ikke løse med føderering og identiteter alene. Et godt eksempel er søk: opptakssystemet skal tilby gode søk etter utdanningstilbud, men mye av informasjonen om utdanningsmulighetene tilbudene gjelder for, eies av utdanningsregisteret. Et slikt søk krever en søkeindeks bygget på oppdaterte data fra begge, og det kan ikke løses med API-kall i spørringsøyeblikket uten at ytelsen blir uakseptabel.
Da tillater vi replikering av dataene som trengs for å bygge indeksen:
- Repliker bare kolonnene og radene indeksen faktisk trenger, med kolonneliste og eventuelt radfilter i publikasjonen.
- De replikerte tabellene er kilde for indeksen, ikke en del av mottakerens egen datamodell. Denormaliseringen – materialiserte visninger, indekstabeller eller lignende – gjøres lokalt hos mottakeren, oppå de replikerte tabellene.
- Prinsippene gjelder fortsatt fullt ut: skrivebeskyttelse, ikke videreeksponer eierens data som dine egne, og dokumenter behovet.
Dersom du er i tvil om behovet kvalifiserer, start med føderering og mål ytelsen. Replikering for denormalisering er unntaket som skal begrunnes – ikke utgangspunktet.
Konsekvenser du må ta høyde for
Replikering gir oss fremmednøkler på tvers av subgrafer, men den endrer ikke at det er to selvstendige databaser. Følgende egenskaper må du kjenne til og ta høyde for.
Replikerte data er ferskvare
Replikeringen er asynkron. Normalt er forsinkelsen under et sekund, men ved store endringsvolumer eller avbrudd kan mottakeren ligge etter. Ta høyde for at replikerte data kan være marginalt utdaterte, og bruk aldri replikerte data i prosesser som krever et transaksjonskonsistent øyebliksbilde på tvers av databasene.
Fremmednøkler håndheves ikke under selve replikeringen
Endringer som kommer inn via et abonnement, tas imot med session_replication_role = replica.
Det betyr at fremmednøkkelsjekker ikke kjøres når replikerte rader skrives hos mottakeren –
bare lokale skriveoperasjoner sjekkes. I praksis er den viktigste konsekvensen knyttet til
sletting, se nedenfor.
Sletting hos eieren
Dersom eieren sletter en rad som mottakeren har fremmednøkler mot, vil slettingen bli replikert uten at mottakerens fremmednøkler stopper den – og mottakeren sitter igjen med referanser til noe som ikke lenger finnes. Dette må håndteres med avtalte sletteregler mellom eier og mottaker. Utgangspunktet vårt er at entiteter som andre kan referere til, ikke slettes fysisk, men markeres som utgåtte (logisk sletting). Fysisk sletting – for eksempel av personvernhensyn – må koordineres med mottakerne.
Skjemaendringer replikeres ikke
Logisk replikering kopierer data, ikke DDL. Endrer eieren skjemaet for en publisert tabell, kan replikeringen stoppe hos mottakerne inntil de har gjort tilsvarende endring. Å publisere en tabell er derfor en kontrakt: eieren varsler mottakerne om skjemaendringer på publiserte kolonner i god tid, på samme måte som vi håndterer endringer i GraphQL-skjemaene. Ved å publisere med eksplisitt kolonneliste kan eieren fritt endre alt som ikke er publisert.
Replikering er en forpliktelse for eieren
Replikeringsslotten hos eieren holder på transaksjonslogg helt til mottakeren har fått endringene. Er en mottaker nede over tid, hoper loggen seg opp hos eieren og kan i verste fall fylle disken. Både eier og mottaker må derfor overvåke replikeringen – forsinkelse, stoppede abonnementer og slot-størrelse. Hva som skal overvåkes og hvordan, dekkes i veiledningene.
Regler og konvensjoner
- Navnerommet følger eieren – og må avtales. Replikeringen kobler tabeller ut fra
skjemakvalifisert navn, så mottakeren må bruke nøyaktig samme skjema- og tabellnavn som eieren.
Tjenestene våre organiserer databasene i domeneskjemaer, så en replikert
utdanning.utdanningsinstanslegger beslag påutdanning-navnerommet også hos mottakeren. Har mottakeren allerede egne tabeller i et skjema med samme navn – slik opptak har i dag – må navnerommet ryddes som del av overgangen til replikering. - Skrivebeskyttelse håndheves med rettigheter. Mottakerens applikasjonsrolle gis bare
SELECTpå replikerte skjemaer. Bare abonnementets eier skal kunne skrive. - Navn identifiserer motparten. Publikasjoner navngis etter mottaker og formål (for eksempel
opptak_utdanningsinstans), abonnementer etter eier og formål (for eksempelutdanningsregisteret_utdanningsinstans). Da er det alltid tydelig ved feilsøking hvem som er i andre enden. Konvensjonen gjelder nye publikasjoner og abonnementer for formålet beskrevet her – utdanningsregisterets eksisterendedbz_publicationer Debeziums standard og tjener hendelsesloggen, se Status og videre arbeid. - Dokumentasjonen bor hos mottakeren. Det er mottakeren som har behovet, og som dokumenterer hva som replikeres og hvorfor, med lenke fra eierens dokumentasjon.
Status og videre arbeid
Vi bruker Postgres 17 og 18, der logisk replikering er moden funksjonalitet. Mekanismen er
allerede i drift hos oss, men til et annet formål: utdanningsregisteret har en publikasjon
(dbz_publication) som mater en Debezium-basert hendelseslogg (debezium.database_hendelse).
Den publiserer hele tabeller og følger ikke konvensjonene i dette dokumentet – de gjelder
entitetsreplikering mellom subgrafer. Databasene våre kjører på RDS, der logisk replikering
aktiveres med rds.logical_replication-parameteren og rds_replication-rollen.
Kompetansen på logisk replikering er foreløpig begrenset internt, så vi bygger den opp gjennom veiledninger:
Start med Kom i gang med logisk replikering i Postgres – en praktisk innføring der du utforsker hele mekanismen i et lokalt lekemiljø.
Mønsteret er nå under innføring for første gang: opptak abonnerer på entitetstabeller fra utdanningsregisteret. Hva som replikeres og hvorfor er dokumentert i Replikering fra utdanningsregisteret til opptak.
Oppskrifter for å tilby data for replikering fra egen subgraf og abonnere på data fra andre i ekte miljøer, kommer under Veiledninger.
SIS-subgrafen kjører på Oracle og er ikke omfattet av mekanismen beskrevet her. Prinsippene for eierskap og deling gjelder like fullt; hvordan data fra SIS gjøres tilgjengelig for replikering, avklares som en del av moderniseringsarbeidet.