Forsvarer MongoDB: – Håper dette er en øyeåpner for noen

Utvikler Vegar Vikan mener altfor mange avskriver MongoDB når de skal velge database.

Vegar Vikan i Sopra Steria mener mange er for raske til å avskrive MongoDB. 📸: MongoDB / Sopra Steria
Vegar Vikan i Sopra Steria mener mange er for raske til å avskrive MongoDB. 📸: MongoDB / Sopra Steria Vis mer

"Dersom dataen din har relasjoner, bør du bruke en relasjonsdatabase!"

Dette argumentet blir brukt altfor ofte for å utelukke MongoDB som mulig lagringsplatform. Men er det rett? Utelukkes MongoDB så fort du har relasjoner mellom data?

Jeg vil si nei.

All data inneholder en eller annen form for relasjoner. Spørsmålet er hvordan en velger å representere disse. Der en i relasjonsdatabaser gjerne ønsker å ‘normalisere’ dataen mest mulig, er det én gylden regel som gjelder for dokumentdatabaser:

"Data som leses sammen bør lagres sammen."

Normalt sett leser en data mange ganger oftere enn en skriver data, og en har som regel høyere krav til ytelse når en leser enn når en skriver. Flere av normaliseringsreglene for relasjonsdatabaser er imidlertid laget for å øke fleksibilitet og ytelse ved skriving av data, og de medfører at en må ‘sy sammen’ dataen igjen hver gang en leser vha en JOIN operasjon.

En bil lagret i en relasjonsdatabase.
En bil lagret i en relasjonsdatabase. Vis mer
Samme bil lagret i en dokumentdatabase.
Samme bil lagret i en dokumentdatabase. Vis mer

Tidligere hadde ikke MongoDB noe tilsvarende JOIN, og det er nok derfor mange tenker at MongoDB ikke er egnet. Jeg ønsker imidlertid å vise at en stort sett aldri trenger JOIN - og om du trenger det, ja så har MongoDB hatt operasjoner for dette helt tilbake til 2015.

Embedding

Det er ett mønster som brukes mer enn noe annet når en modellerer data for MongoDB: “Embedding”. Eller “inkludering” om du vil.

Der en i en relasjonsdatabase velger å ta ut noe av dataen og lagre det i en annen tabell, velger en i dokumentdatabasen å lagre denne daten i samme dokument. Ta for eksempel det klassiske eksemplet Ordre og Ordrelinje. I stedet for å lagre dette i to tabeller, vil en i MongoDB lagre dette i samme dokument:

{
    ordreNummer: "10002",
    ordreDato: ISODate("2023-01-01T00:00:00Z"),
    ordreLinjer: [
        {
            produktKode: "Q2-C2",
            produktNavn: "Keychron Q2 QMK Custom Mechanical Keyboard",
            antall: 1,
            pris: 1290
        }
    ]
}

Noen vil kanskje reagere på at vi her har inkludert både produktKode og produktNavn i ordre-dokumentet. Dette vil jo si at vi dupliserer produktnavnet i alle ordre der produktet er kjøpt i stede for å ‘joine inn’ navnet fra produkt-dokumentet.

I dokumentdatabaser er en ofte ikke like redd for å duplisere data. Noen ganger kan det bety noe ekstra jobb dersom produktnavnet skal oppdateres, men dersom en oppdaterer et produktnavn ønsker en sjelden at dette skal få tilbakevirkende kraft. Når en ordre er effektuert må den få bestå med de produktnavnene som eksisterte på det tidspunktet. Samme gjelder andre ting som for eksempel kundens adresse. En endrer ikke en gammel utsendt ordre dersom kunden senere flytter.

Selv om en ønsker å lagre alt som ett helt dokument, betyr ikke det at en alltid må lese og skrive hele dokumentet. MongoDB har godt med operasjoner for å oppdatere eller lese deler av ett dokument. Her er noen eksempler på hvordan en kan jobbe med ordredokumentet over.

Dersom du ønsker å teste ut eksemplene nedenfor kan du gjøre dette i en MongoDB Playground jeg har gjort klar. Dessverre støtter den ikke å ha flere eksempler i én playground, men alle eksemplene nedenfor skal kunne kopieres inn i Query-feltet og kjøres.

// Hente hele ordren
db.ordre.find({ ordreNummer: "10002" });

// Hente ordren, men ikke ordrelinjene
db.ordre.find({ ordreNummer: "10002" }, { ordreLinjer: 0 });

// Legg til ett produkt
db.ordre.update(
  { ordreNummer: "10002" },
  {
    $push: {
      ordreLinjer: {
        produktKode: "PT21-1",
        produktNavn: "PBTFANS POCO",
        antall: 1,
        pris: 1849,
      },
    },
  }
);

// Fjern ett produkt
db.ordre.update(
  { ordreNummer: "10002" },
  { $pull: { ordreLinjer: { produktKode: "Q2-C2" } } }
);

// Oppdater `antall`-feltet for produktet `Q2-C2`
db.ordre.update(
  { ordreNummer: "10002" },
  { $inc: { "ordreLinjer.$[element]": 1 } },
  { arrayFitlers: [{ "element.produktKode": "Q2-C2" }] }
);

Dette er bare noen helt enkle eksempler, og for deg som er vant med å lese SQL er det kanskje uvant. Har du erfaring med JavaSript vil du imidlertid enkelt kunne lese koden, vil jeg tro.

Alle funksjonskallene i eksemplene starter med ett query document. I vårt tilfelle bruker vi samme query document i alle eksemplene, og det sier kort og greit at vi er ute etter ett dokument der ordreNummer er "10002".

I update-eksemplene har vi også med ett update document – ett dokument som spesifiserer hvordan vi ønsker å oppdatere dokumentet vårt. I eksemplene skiller update-dokumentet seg fra query-dokumentet ved at det har noen properties som starter på $: $push, $pull, $set. Dette er update operators, og det finnes en god del av disse. Det finnes også query operators som vi kunne brukt i update-dokumentet vårt om vi hadde behov for det. For eksempel kunne vi brukt $gt for å finne en ordre der ordredato er større enn en gitt dato, eller $size for å finne ordre der det er 0 ordrelinjer.

«For deg som er vant med å lese SQL er det kanskje uvant.»

I det andre find-eksemplet har vi i tillegg til query-dokumentet gitt inn ett projection document. En bør aldri lese mer data fra databasen enn en faktisk har bruk for. Mongo lar deg velge om du vil spesifisere hvilke felter som skal utelates, eller å spesifisere hvilke felter som skal med. Det er også operatorer for å begrense hvilke elementer fra arrays som skal med.

I det siste update-eksemplet har vi ett tredje parameter, et options document der vi har lagt med ett arrayFilter. Når en oppdaterer arrays kan dette gjøres enkelt så lenge en vet posisjonen til elementet en ønsker å oppdatere. Vet en ikke det, kan en bruke ett arrayFilter for å søke etter rett element.

Lookups

Noen ganger rekker det ikke med ‘embeddings’. For alt kan jo ikke pakkes sammen i ett stort dokument.

Som allerede nevnt fikk MongoDB sin egen ‘JOIN’ en god stund tilbake. Men for å få tilgang til denne må en ta i bruk find-funksjonens kraftige storebror: The Aggregation Pipeline.

I tillegg til de enklere CRUD (Create-Read-Update-Delete)-funksjonene, finnes det en aggregate-funksjon. Denne tar inn en pipeline som består av en liste av aggregation stages. Resultatet av ett steg blir input til neste steg, og det finnes en hel rekke steg for å filtrere, sortere, manipulere og kalkulere over dokumentene i et collection.

Så – la oss si at vi nå har TO collections. Ett for ordre og ett for ordrelinjer.

ordre: [
    {
        ordreNummer: "10002",
        ordreDato: ISODate("2023-01-01T00:00:00Z"),
    }
],
ordreLinjer: [
    {
        ordreNummer: "10002",
        produktKode: "Q2-C2",
        produktNavn: "Keychron Q2 QMK Custom Mechanical Keyboard",
        antall: 1,
        pris: 1290
    },
    {
        ordreNummer: "10002",
        produktKode: "PT21-1",
        produktNavn: "PBTFANS POCO",
        antall: 1,
        pris: 1849
    }
]

Når vi bruker relasjonsdatabase gjør vi da en left outer join mellom ordre og ordrelinjer. I MongoDB bruker vi ett $lookup-steg i en aggregation pipeline.

db.ordre.aggregate([
  { $match: { ordreNummer: "10002" } },
  {
    $lookup: {
      from: "ordreLinjer",
      localField: "ordreNummer",
      foreignField: "ordreNummer",
      as: "ordreLinjer",
    },
  },
]);

Vi har to steg: Først gjør vi en $match for å finne ordren vi ønsker å jobbe med. Dernest gjør vi en $lookup for å legge til dokumenter fra ordreLinjer inn i et nytt felt ordreLinjer på dokumentet vårt.

Jeg har laget en playground for dette eksemplet også.

«Fra et ytelsesperspektiv er nok ikke det så lurt.»

Kreativiteten leve!

En siste ting jeg har lyst å snakke om er kreativiteten MongoDB åpner for når en skal modellere dataen sin. Mens en i en relasjonsdatabase er forholdsvis begrenset av to dimensjoner og en rekke normaliseringsgrader, har en mange flere valg når en skal bestemme hvordan et dokument skal se ut. Dette krever imidlertid at en har god forståelse både for hvordan en ønsker å bruke dataen sin, men også hvilke begrensninger som ligger i MongoDB når det kommer til for eksempel indeksering.

Ta for eksempel eksemplet med ordre og ordrelinjer. Det har ikke alltid vært like enkelt å jobbe med arrays i dokumenter, og noen ganger har du kanskje ikke behov for å behandle elementene som en liste. La oss si at vi veldig ofte behandler ordrelinjer basert på produktnummeret, men at vi aldri egentlig trenger å søke etter en gitt ordrelinje. Da kan vi velge å modellere ordrelinjene som sub-dokumenter i stedet for array-elementer.

{
    ordreNummer: "10002",
    ordreDato: ISODate("2023-01-01T00:00:00Z"),
    kundeNummer: "20001",
    ordreLinjer: {
         "Q2-C2": {
            produktKode: "Q2-C2",
            produktNavn: "Keychron Q2 QMK Custom Mechanical Keyboard",
            antall: 1,
            pris: 1290
        }
    }
}

Siden MongoDB er ‘skjemaløst’ i den forstand ikke alle dokumenter trenger å være likt utformet, kan vi bruke produktkoden som elementnavn. Dersom vi nå ønsker å finne ut om denne ordren inkluderer produktet “Q2-C2” kan vi bruke operatoren $exists i spørringen vår. Fra et ytelsesperspektiv er nok ikke det så lurt, da det ikke er mulig å lage en indeks som forteller om et virkårlig element finnes i et dokument…

Et helt annet eksempel på kreativitet, som ikke er like utsatt for ytelsesproblematikk, er eksemplet der vi hadde ordre og ordrelinjer i hver sine dokumenter. Dersom vi ikke ønsker å benytte $lookup for å finne alle ordrelinjer som tilhører en gitt ordre kan vi velge å lagre både ordre-dokumentet og alle ordrelinje-dokumentene i samme collection. Noen får kanskje frysninger av en slik tanke, men du ville neppe nølt lenge før du lagret både en Excel-fil og en Word-fil i samme mappe, ville du?

Ved å lagre begge disse dokumenttypene i samme collection kan du finne dem igjen med en enkel find. Siden begge dokumenttypene inneholder ordreNummer vil et søk etter alle dokumenter med et gitt ordreNummer returnere både ordre-dokumentet og alle ordrelinje-dokumentene. Hvis en legger inn en enkel sortering som gjør at ordredokumentet alltid kommer først, er det en smal sak å håndtere dette på klientsiden ved å la første dokument være ‘master’ og resten være ‘details’. Dette er selvfølgelig ting en også kan oppnå som en del av aggregeringspipeline-en.

Ikke alltid mulig å gjøre enkelt

Men noen ganger kommer en opp i situasjoner der ting ikke kan gjøres på enkleste måte.

MongoDB har for eksempel en begrensning som sier at ett dokument ikke kan overstige 16 MB. Dersom en ordre er så stor at alle ordrelinjene ikke får plass i disse 16 MB, kan det være en idé å returnere hver ordrelinje som en egen linje. Nå skal det dog sies at det å returnere så mye data fra databasen i én leseoperasjon neppe er det beste uansett….

Men hva om det ikke er ordre og ordrelinjer vi jobber med. La oss si at vi har en blogpost med kommentarer der vi lagrer kommentarene i et array på blogpost-dokumentet. Dette fungerer kanskje fint en stund, men så skriver man en blogpost om MongoDB og data med relasjoner, og dermed sprenges kommentarfeltet!

I dokumentasjonen til MongoDB er det beskrevet et mønster som de kaller Bucketing. Det går ut på å sette en grense for hvor mange elementer en lagrer i ett dokument. Har en flere elementer begynner en enkelt og greit på et nytt dokument. I blog-eksemplet kan vi for eksempel si at vi lagrer 100 kommentarer i hvert dokument. For å få dette til benytter man en kombinasjon av eksemplet vi hadde for å legge til en ny ordrelinje og noe som heter upsert.

Som dere husker tok update-funksjonen et query document som spesifiserer hvilket dokument som skal oppdateres. Dersom det ikke finnes noe dokument som matcher blir ingen ting oppdatert. Men dersom en angir at en ønsker en upsert så vil det opprettes ett nytt dokument i stedet. I update-dokumentet kan en da spesifisere felter som skal settes kun dersom et nytt dokument opprettes.

db.blog.update(
  { "postId": "mongodbposten", antallKommentarer: {$lt: 100}},
  { $inc: { antallKommentarer: 1 },
    $push: { kommentarer: { tekst: "Dette er jo kjempebra!" }}},
    $setOnInsert:
    {  postId: "mongodbposten",
       opprettet: $now
    }
  }
)

De første 100 kommentarene vil bli lagt inn i dokumentet sammen med selve blog-posten. Deretter vil det opprettes et nytt dokument som kun inneholder blogpostens id og en dato, samt den nye kommentaren. Når neste kommentar skal legges til vil dette nye dokumentet matche kriteriet om færre enn 100 kommentarer. Men etter 200 kommentarer vil det igjen bli opprettet ett nytt dokument.

Dette mønsteret har vært spesielt mye brukt ved f.eks. IoT apparater som logger verdier. I stede for å lage tusenvis av bittesmå dokumenter kan en samle opp og lagre for eksempel alle verdier for samme dag i ett dokument. Faktisk har dette vært et så vanlig bruksmønster at MongoDB har fått innebygget støtte for Time Series Data i de seneste versjonene.

Det er ikke uvanlig at en ønsker å vise en blogpost sammen med de f.eks. 10 nyeste kommentarene. Da er ikke 'bucketing' mønsteret det beste, siden blogposten gjerne ligger sammen med de 100 eldste kommentarene i den første 'bøtta', mens de 10 nyeste kommentarene vil befinne seg i den siste 'bøtta'.

Det finnes imidlertid et annet mønster, The Subset Pattern som kan hjelpe deg her. Dette mønsteret brukes når en har en 1-til-mange relasjon hvor en alltid ønsker å ha noen av 'mange'-elementene lett tilgjengelig. Samtidig som en lagrer alle kommentarer som egne dokumenter (eller i 'bøtter'), kan en passe på at de nyeste kommentarene alltid også lagres i blogposten. Dette vil gå litt utover skriveytelsen, men en vil få en høyere leseytelse dersom en antar at en blogpost vises flere ganger enn det kommenteres.

db.kommentarer.insert({
  postId: "mongodbposten",
  tekst: "En ny kommentar om hvor bra MongoDB er!",
});
db.blog.update(
  { postId: "mongodbposten" },
  {
    $inc: { antallKommentarer: 1 },
    $push: {
      kommentarer: {
        $each: [{ tekst: "En ny kommentar om hvor bra MongoDB er!" }],
        $slice: -10,
      },
    },
  }
);

Her har vi brukt en litt annen variant av $push enn i 'bucketing'-eksempelet. Denne gangen har vi angitt $each sammen med et array av kommentarer vi ønsker å legge til, deretter $slice sammen med et tall for å fortelle hvor mange elementer vi totalt ønsker å beholde i kommentarer-lista. Et negativt tall forteller at vi vil beholde de ti siste kommentarene. Et positivt tall ville beholdt de første elementene i stede. Vi kunne også benyttet $sort sammen med $each og $slice dersom vi ikke ønsker å legge til nye elementer sist i listen men i stede sortere på antall likes og beholde kommentarer med flest likes.

Det at vi har et felt på blogposten som forteller hvor mange kommentarer det finnes totalt, i stede for å telle antall kommentarer hver gang vi ønsker å vise blogposten, er også et mønster beskrevet av MongoDB. The Computed Pattern beskriver at dersom du ønsker å vise en kalkulert verdi, så vil det lønne seg å kalkulere denne hver gang en skriver i stede for hver gang en leser - gitt at en leser oftere enn en skriver.

MongoDB er ikke hva det en gang var

Eksemplene i dette innlegget har kanskje vært enkle, og i noen tilfeller litt søkt. Jeg håper likevel det har vært en bitteliten øyeåpner for noen. MongoDB er ikke den beste relasjonsdatabasen som finnes. Men den er ganske god på å lagre data. Også data som henger sammen.

Det har vært stor utvikling på MongoDB de senere år. I tillegg til mye nye funksjoner i selve databasen, tilbys mye ny funksjonalitet gjennom tjenester i MongoDB Atlas - altså MongoDB selskapet sin hostingtjeneste for MongoDB databaser.

I tillegg til joins kan det være interessant for mange å sjekke ut Multi-document ACID transactions og Time series data . Kryptering har sakte men sikkert forbedret seg, fra encryption at rest til client side encryption, til nå sist Queryable encryption .

«Jeg håper likevel det har vært en bitteliten øyeåpner for noen.»

MongoDB Atlas tilbyr ekstra funksjonalitet som applikasjonstjenester tilsvarende Google Firebase. En får autentisering, funksjoner og triggere. En kan legge ett GraphQL api oppå databasen sin. Og en kan sette opp sync mellom mobil og nett slik at applikasjoner på mobilen kan fungere også offline. Alle disse tjenestene er tilgjengelig gjennom Atlas App Services . En annen tjeneste som har fått mye oppmerksomhet i det siste er Atlas Search . For det som var en tjeneste for full-tekst-søk har nå blitt utvidet med støtte for vektor søk. Via integrasjoner med f.eks. OpenAI kan du benytte eksisterende maskinlæringsmodeller til å søke i dine egne ustrukturerte data.

For de som ikke helt klarer å gi slipp på SQL, så finnes det en 'SQL connector' slik at du kan benytte verktøy som Power BI og Tableau. Har jeg imidlertid klart å overbevise deg om at MongoDB er tingen - ja, så finnes det en 'Relational Migrator' som hjelper deg å få relasjonsdatabasen din over i en MongoDB database.

Jeg håper dette har vært nok til å friste deg til å ta en ny titt på MongoDB. Jeg har allerede gitt deg én gyllen regel: “Data som leses sammen bør lagres sammen”. Jeg vil helt til slutt legge til en liten formaning: “Det viktigste er kanskje ikke om det er SQL eller MongoDB som er det beste valget. Det viktigste er at du setter deg inn i de muligheter og begrensninger som gjelder for platformen du ender opp med. Les dokumentasjonen!”

Få også med deg kode24s guide til databaser: