Microservices: Det er ikke (kun) størrelsen der er vigtigt, det er (også) hvordan du bruger dem – Del 3

Del 1 – Microservices: Det er ikke (kun) størrelsen der er vigtigt, det er (også) hvordan du bruger dem
Del 2 – Microservices: Det er ikke (kun) størrelsen der er vigtigt, det er (også) hvordan du bruger dem
Del 4 – Microservices: Det er ikke (kun) størrelsen der er vigtigt, det er (også) hvordan du bruger dem

Micro services: Det er ikke (kun) størrelsen der er vigtigt, det er (også) hvordan du bruger dem – Del 2 diskuterede vi endnu en gang problemerne med at bruge (synkron) 2 vejs kommunikation mellem distribuerede (micro) services. Vi diskuterede også hvordan koblings problemerne ved 2 vejs kommunikation, kombineret med mikro services, reelt resulterer i, at vi har genopfundet distribuerede objekter. Vi diskuterede også hvordan kombinationen af 2 vejs kommunikation og manglenreliable messaging og transaktioner medfører kompleks kompensations logik i tilfælde af fejl.
Efter en genopfriskning af de 8 fejlslutninger om distribueret computing undersøgte vi et alternativ til 2 vejs kommunikationer mellem services. Vi tog udgangspunkt i Pat Hellands “Life Beyond Distributed Transactions – An Apostate’s Opinion” (PDF format) der tager det standpunkt at Distribuerede transaktioner ikke er en løsning for koordinering af opdateringer mellem services. Vi diskuterede hvorfor distribuerede transaktioner er problematiske.

I følge Pat Helland skal vi finde løsningen på vores problem ved at kigge på:

  1. Hvordan vi opsplitter vores data/services
  2. Hvordan vi identificerer vores data/services
  3. Hvordan vi kommunikerer mellem vores data/services

Del 1. og 2. blev behandlet i Micro services: Det er ikke (kun) størrelsen der er vigtigt, det er (også) hvordan du bruger dem – Del 2 og kan opsummeres:

  • Vores data skal samles i klumper kaldet entiteter eller aggregates (i DDD terminologi).
  • Hver aggreate er unikt identificerbar ud fra en ID (kan f.eks. være en UUID/GUID).
  • Disse aggregates skal være afgrænset i størrelse, således at de efter en transaktion er konsistente
  • Tommelfinger reglen er: 1 usecase = 1 transaktion = 1 aggregate.

I denne blog post vil vi kigge nærmere på punkt 3  “Hvordan vi kommunikerer mellem vores data/services”

Hvordan bør vi kommunikere mellem vores data/services?

Som vi har gennemgået flere gange før, medfører 2 vejs (synkron) kommunikation mellem vores services, hård kobling og andre ubehageligheder:

  • Det medfører kommunikations mæssig kobling (data og logik ligger ikke altid i samme service)
    • Dette medfører også kontraktuel, data og funktions mæssig kobling samt høj latens tid (pga. netværks kommunikation)
  • Lagdelt kobling (persistens ligger ikke altid i samme service)
  • Temporal kobling (vores service kan ikke fungere hvis den ikke kan kommunikere med de services den afhænger af)
  • Det, at vores service afhænger af andre services underminerer dens autonomi og gør den mere skrøbelig
  • Alt dette medfører også behovet kompleks kompensations logik pga. manglen på reliable messaging og transaktioner.
Genbrugelige services, 2 vejs (synkron) kommunikation og kopling

Genbrugelige services, 2 vejs (synkron) kommunikation og kopling

Hvis løsningen ikke er synkron kommunikation, så må svaret vel være asynkron kommunikation?

Ja, men det afhænger af …… 🙂
Inden vi dykker ned i detaljerne om hvad det afhænger af, så lad os først kigge på karakteristika for synkron og asynkron kommunikation:

kommunikations former

 

 

 

 

 

 

 

 

 

 

Ud fra disse karakteristika kan vi kort kategorisere kommunikations formerne som følger:

Synkron kommunikation er 2 vejs kommunikation

synchronous-communication

 

 

 

 

 

 

Det kommunikations mønster, der er visualiseret i tegningen ovenfor, kaldes Request/Response og er samtidigt den typiske implementations fundament for Remote Procedure Calls (RPC).
I dette mønster sender en Afsender (Consumer) en Request besked til en Modtager (Provider). Mens Modtageren processerer request beskeden kan Afsenderen principielt ikke foretage sig andet end at vente* på at den modtager et Response eller en fejl (* her vil nogle nok pointere at consumeren kan benytte sig af asynkrone platform features således at den f.eks. kan udføre flere kald i parallel, etc. Dette løser desværre ikke den temporale kobling mellem afsender og modtager. Afsenderen kan typisk ikke vente særligt længe og ikke fortsætte sit arbejde FØR den har modtaget sit Response fra modtageren). Det typiske eksekverings flow for Request/Response eller RPC er visualiseret i tegningen nedenfor.

Synchronous communication flow

Synkron kommunikations flow

Som det ses af tegningen er der en stærk kobling mellem afsender og modtager. Afsenderen kan ikke udføre sit arbejde hvis Modtageren er utilgængelig. Denne form for kobling kaldes temporal kobling eller runtime kobling og er noget vi bør minimere mellem services.

 Asynkron kommunikation er 1 vejs kommunikation

asynchronous-communication

 

 

 

 

 

 

Med asynkron kommunikation, sender Afsenderen (Sender) en besked til en Modtager (Receiver) over en transport kanal (Channel). Afsender venter kortvarigt på at kanalen bekræfter modtagelsen af beskeden (set med afsenderens øjne sker afsendelsen af beskeden til kanalen typisk synkront) hvorefter Senderen kan fortsætte sit arbejde. Dette er essensen af en vejs kommunikation og det typiske eksekverings flow er visualiseret nedenfor:

Asynchronous communication flow

Asynkron kommunikations flow

Asynkron kommunikation kaldes også ofte for messaging. Transport kanalerne i asynkron kommunikation er ansvarlige for at modtage beskeden fra afsender og for at levere beskeden til modtageren (eller modtagerne). Transport kanalen overtager, så at sige, ansvaret for besked udvekslingen. Transport kanaler kan både være simple (f.eks. understøttet med Sockets ala 0MQ) eller avancerede distribuerede løsninger med durable Queues/Topics (f.eks. vha. ActiveMQ, HornetQ, MSMQ, Kafka, etc.). I forbindelse med messaging og asynkron kommunikation snakker man tit om forskellige garantier som den pågældende kanal tilbyder: Garanteret aflevering og Garanteret besked rækkefølge.

Hvorfor er asynkron kommunikation så ikke hele løsningen?

Det er det reelt også, men er desværre ikkenemt som asynkront versus synkront. Det integrations mønstret der benyttes mellem vores services der afgør den reelle koblings grad. Er der tale om ægte asynkron 1 vejs kommunikation så er vi i mål mht. de fleste tilfælde.

Udfordringen er nemlig at 2 vejs kommunikation kommer i flere afskygninger:

Jeg har flere gange set projekter der benyttede Request / Reply (synkron over asynkron) for at sikre at deres services ikke var temporalt koblede. Djævlen er som altid i detaljen.
Set fra Afsenderen (Consumer) er der, for de fleste anvendelser af Request/Reply, ikke stor forskel på graden af temporal kobling mellem RPC, Request/Response eller Request/Reply, da de alle er varianter af 2 vejs kommunikations, der tvinger vores Afsender til at må ventesvar før den kan fortsætte:

RPC - Request-Response vs Request-Reply

Så hvad er konklusionen?

Konklusionen er, at 2 vejs kommunikation mellem services er roden til mange problemer og specifikt til problemer der ikke bliver mindre af at vi gør vores services mindre (microservices).
Vi har set at asynkron kommunikation kan bryde den temporale kobling mellem vores services, men KUN hvis det foregår som ægte 1-vejs kommunikation.

Vores løsning :)

Vores løsning 🙂

Spørgsmålet er så, hvordan designer man services (eller microservices) der som udgangspunkt kun behøver asynkron 1 vejs kommunikation mellem hinanden (kommunikation mellem UI og services er en anden sag, hvilket vi snart kommer ind på)?

I næste blog post vil vi kigge på hvordan vi kan splitte vores services op og hvordan de kan kommunikere med hinanden via asynkron 1 vejs kommunikation.

Appendiks over besked garantier

Garanteret aflevering

Garanteret aflevering dækker over sandsynligheden for at en besked fra en Afsender vil blive modtaget af Modtageren.

At Most Once

Med denne afleveringsgaranti vil en Modtager modtage en besked 0 eller 1 gang. Afsenderen garanterer at beskeder kun afsendes én gang. Såfremt modtageren ikke er tilgængelig eller i stand til at gemme data relateret til beskeden (f.eks. pga. en fejl), vil beskeden ikke blive gensendt.

At Least Once

Med denne afleveringsgaranti bliver beskeder modtaget 1 eller flere gange (altså mindst én gang). Beskeden vil blive gensendt indtil at kanalen har modtaget en kvittering for modtagelse fra Modtageren, hvorfor beskeden kan blive modtaget mere end én gang. Manglende kvittering kan f.eks. skyldes at modtageren ikke er tilgængelig eller fejler. Gentagen aflevering af samme besked kan håndteres ved at gøre modtagerens håndtering af beskederne idempotent (idempotens beskriver den kvalitet for en operation, hvor resultat og tilstand ikke ændrer sig, selv om operationen bliver udført mere end 1 gang).

Exactly Once

Denne afleveringsgaranti sørger for at beskeden modtages præcist én gang. Såfremt modtageren ikke er tilgængelig eller ikke er i stand til at gemme data relateret til beskeden (f.eks. pga. en fejl), vil beskeden blive gensendt indtil en kvittering på modtagelse er blevet modtaget. Forskellen fra ”At least once” er, at leveringsmekanismen er styret gennem en koordinerende protokol, der sikrer at duplikerede beskeder bliver ignoreret.

Nogle af de protokoller der benyttes til implementere Exactly Once afleveringsgaranti er WS-ReliableMessaging og forskellige implementationer af 2 Phase Commit.

En anden måde at opnå den samme egenskab på,  er at benytte idempotente operationer i kombination med At Least Once garantien.

Implementering af Idempotence kræver næsten altid, at der er en unik identifier/message ID på hver besked, som Modtager kan udnytte til at verificere om besked allerede er modtaget og behandlet. Den unikke identifier kan f.eks. være en GUID eller timestamp. Enkelte operationer kan dog opfylde idempotence uden unik identifier f.eks. Sletning af en Aggregate.

Garanteret beskedrækkefølge

Garanteret beskedrækkefølge, også kendt som ”In order delivery” sikrer at beskeder modtages i sammen rækkefølge som de er afsendt. Garanteret beskedrækkefølge kan kombineres med ovenstående afleverings garantier.

Hvor garanteret aflevering har fokus på aflevering af den enkelte besked, beskæftiger garanteret beskedrækkefølge sig med koblingen eller relationen mellem flere beskeder.

Udfordringer omkring beskedrækkefølge opstår, hvis et eller flere af nedenstående forhold gør sig gældende for kanalen mellem Afsender og Modtager:

  • Der er flere veje gennem kanalen (multipath) f.eks. introduceret på grund af clustering, load balancer og anden fejltolerance indbygget i netværk og infrastruktur.
  • Dead letter queues. Hvis en besked placeres i en Dead Letter queue pga. problemer med at aflevere den giver det en udfordring med hvordan denne fejl skal håndteres og hvad der skal ske med efterfølgende beskeder ind til den fejlede besked er afleveret.
  • Clustering hos Afsender eller Modtager giver den udfordring at beskeder ikke afleveres til kanalen i den rigtige rækkefølge eller at beskeder afleveres i den rigtige rækkefølge, men ikke behandles af modtager i samme rækkefølge.

5 comments for “Microservices: Det er ikke (kun) størrelsen der er vigtigt, det er (også) hvordan du bruger dem – Del 3

  1. Det er en god og overskuelig blog post serie, det udstiller problemerne med kommunikation på tværs af services/applikationer.

    Jeg er selv ved at erfare disse problemer på nært hold og ser meget frem til den fjerde post i serien (håber på nogle lavthængende frugter).

    Vi har opsplittet et applikationskompleks i flere applikaitoner af hensyn til vedligehold og afhængigheder.

    Vi benytter messaging mellem applikationerne, men på et format hvor app1 udsender event (publich/subscribe) om at “somethingHappened (id of changed entity)” hvorefter app2 kalder synkront tilbage for at hente den entitet der er ændret på.

    Årsagen til at vi sender en meget tynd event med kun id og vi derefter henter data er at vi slipper for problemer rækkefølge af ændringer (Competing Consumers på samme queue/topic fordelt på forskellige fysiske servere) samt at en entitet ikke nødvendigvis er fast defineret og forskellige subscribers har behov for forskellige delmængder af entiteten for at udføre deres arbejde. Historisk er vores messaging startet på en mainframe platform med meget flydende grænser for hvor meget een bestemt type entitet indeholder hvorfor jeg stadig mener det giver god mening med de simple event typer fra denne platform.

    Nu er vi kommet i den situation at 4 forskellige apps abonnerer på den samme event hvilket resulterer i at app1 får 4x synkrone kald tilbage efter hver event den udsender, hvilket tydeligt kan mærkes generelt på connections (det hele kører XA transaktioner). Dette er dog på en JBoss platform med JMS pub/sub og er for helt nye domæner hvor der er styr på hvad en given entitet indeholder, så der kunne man i princippet godt publisere hele entiteten.

    Men så får vi så hele problemstillingen med rækkefølge, da hver enkelt listener kører i flere tråde på forskellige fysiske servere.

    Nogle gode forslag til hvordan man kan forbedre på det?
    Eksempelvis medsende (stigende) versionsnumre på de entiter der publiseres ved ændringer og så gemme dem i en database (som er det eneste fælles på tværs af de forskellige servere/noder i clusteret) inden man behandler dem. Så onMessage() bare gemmer beskeden i en database og vi så håndterer behandling af beskeder fra databasen på anden vis. Jeg kan sikkert godt lave nogle løsninger der virker, men hvis erfarne arkitekter allerede har gode løsninger på problemet kan man jo ligeså lytte til dem 🙂


    Dann Søjberg

  2. Hej Dann

    Beklager det sene svar, kommentar notificerings mekanismen virker ikke helt stabilt.

    Du beskriver et godt eksempel på data duplikering gennem events, som er et godt mønster at tage i anvendelse når man skal bryde monolitter op. Når man indfører events i en monolit ender events tit op med at blive lidt fattige mht. semantikken. Det bedste man tit kan opnå er C(R)UD events (EntitiyCreated, EntityUpdated, EntityDeleted events), så derfor plejer jeg at anbefale at sende så mange data med som muligt,så længe at hver opdatering ikke fylder for mange kB. På den måde kan modtageren selv afgøre om den ønsker at bruge event’en til noget uden først at skulle lave et synkront kald tilbage. Mange af fordelene ved data duplikering med events kan gå tabt hvis hver service, der lytter, ender med at lave kald tilbage.
    En udfordring med mange monolitter er, at de er centreret omkring databasen og database modellen. Det kan derfor være svært at afgøre hvor meget man skal sende med (her tænker jeg på associationer). Der kan være tilfælde hvor data fylder adskillige MB. I disse tilfælde bør man sende nok data med til at en service kan afgøre om den vil have flere detaljer, hvilket den så kan opnå ved at bruge forskellige læse operationer i servicen.

    Mht. competing consumers så mener jeg ikke “read after event received” løser rækkefølge problemet (idempotens problemet).
    Det er ikke utænkeligt at samme entitet opdateres hurtigt i streg. Hvis to forskellige consumers (C1 og C2), der begge deler samme database med duplikerede data, modtager en EntityUpdated event hver (vi kan kalde dem E1 og E2), vil de hver i sær forsøge at læse seneste data. Med latens tid, transaktions belastning på serveren er det ikke utænkeligt at C1, der har modtaget E1, læser data før den 2. data opdatering bliver udført.
    C1 læser de data der var klar på tidspunkt T1. MENS read transaktionen for C1 (og dermed data for E1) er igang med at blive læst, bliver den anden opdatering udført (her antager jeg isolations niveau READ_COMMITTED der er det mest normale) og E2 bliver afsendt på tidspunkt T2. E2 bliver modtaget af C2 der går igang med at læse de data, der var aktuelle på tidspunktet T2. C2’s læse transaktion går hurtigere end C1’s læse transaktion, så C2 når at opdaterer databasen med data for T2 og afslutter sit arbejde inden C1 bliver færdig.
    Note: Reelt kan overhalende/forsinkede opdateringer også ske i C1 eller i C1/C2’s database, så det løser ikke problemet at hæve isolations niveauet.

    Nu er C1 færdig med at læse data for T1 og den går igang med at opdatere C1/C2’s fælles database med disse data. C1 har nu effektivt overskrevet den opdatering C2 lige havde udført; og dermed er C1 og C2’s fællesdatabase inkonsistent, fordi den indeholder de data der var aktuelle på T1 og ikke T2.

    Den eneste måde at håndtere det på er, som du selv foreslår, at sende et opdaterings timestamp eller sekvensnummer med (i både event’en og i data der evt. bliver læst via en læse service operation); som tillader dig at sørge for at alle opdateringen til C1/C2’s database kan ske idempotent. Sekvensnummeret skal også indgå i C1 og C’2 fælles database for de duplikerede data, således at du blot kan udføre en UPDATE xxx WHERE Id=yyy AND SequenceNo = Tx. Det vil redde dig når opdateringerne sker i forkert rækkefølge.

    /Jeppe

    • Hej Jeppe.
      Tak for dit meget fyldestgørende svar.

      Når jeg nærlæser dit eksempel kan jeg godt se at idempotency ikke er løst ved vores model. Oh gru! Det vil overraske nogle flere end lige mig 🙂

      Alle events afsendes serielt fra vores “monolit” så der vil være en mulighed for at smide timestamps på (fra centralt hold). Entiteterne(‘s relationer) er fra forskellige tider, så der er forskellige “versions” formater, men timestamps burde kunne gøre det. Derudover så er vores entiteter fra en platform med statiske datastrukturer (COBOL) så det med at sende det der er tilstrækkeligt er ikke imiddelbar en farbar vej… der er mange abbonenter der ønsker forskellige data, der samtidigt alle er datostyrede.

      Dvs. jeg tænker noget a’la:
      UPDATE xxx WHERE Id=”id from current event” AND lastProcessedEventTimestamp < "timestamp from current event"

      Endnu engang tak for din hjælp. Jeg er blevet klogere ihvertfald.
      /Dann

Skriv et svar

Din e-mailadresse vil ikke blive publiceret. Krævede felter er markeret med *