TSM - Este reactivitatea cheia spre succes?

Darius Haș - Java Software Engineer @ Accesa


Fie că începem să vorbim despre concepte abstracte, precum algoritmii optimi pentru traversarea unui graf sau sortarea unei liste, fie că explorăm situații concrete, cum ar fi soluții software pentru gestionarea tranzacțiilor bancare sau implementarea unei platforme de e-commerce, care să permită plasarea fără cusur a comenzilor online, certitudinea este că ne confruntăm cu o provocare omniprezentă în lumea noastră, a programatorilor: performanța. În rândul aplicațiilor web și a sistemelor distribuite există nenumărate tehnici de sporire a performanței, precum adăugarea redundanței în rândul serviciilor noastre prin scalare orizontală, paralelizare și procesare distribuită, monitorizare sau trasare a execuției apelurilor primite.

Există, totuși, riscul de a ne concentra prematur în mod excesiv asupra acestor aspecte? Putem oare să atingem o performanță mai bună a serviciilor noastre înainte de a le avea în vedere? Mai avem loc pentru îmbunătățire?

În mod evident, nu există un răspuns definitiv, dar putem să convenim că nu avem o soluție unică care să asigure performanța absolută în toate scenariile. Totuși, în acest articol, îmi propun să explorez acest subiect și să identific potențiale direcții de îmbunătățire, introducând în primă instanță o paradigmă de design diferită pentru aplicațiile web - abordarea reactivă.

Desigur, nu trebuie să mă credeți pe cuvânt când spun că programarea reactivă poate reprezenta o soluție valoroasă în îmbunătățirea performanței serverelor web. Pentru aceasta, am să încerc în cele ce urmează să prezint o argumentație solidă cu privire la afirmația mea. Cum? Mă voi rezuma la ce știu să fac cel mai bine și voi implementa două servere web REST: unul reactiv și unul blocant, bazat pe o abordare tradițională, care urmărește modelul "one thread per request", pe care le voi compara ulterior cu scopul de a arăta diferențe, asemănări, avantaje și carențe ale manierei reactive față de abordarea clasică, punând accentul pe un context cu un număr mare de utilizatori simultani, care efectuează operații intensive din punct de vedere I/O.

Reactivitatea. Cuvânt la modă sau o nouă paradigmă cu mult potențial?

Principiile reactivității

Conceptul de reactivitate s-a bucurat de o atenție sporită în ultima vreme, însă pentru a-l defini, este nevoie să avem în vedere principii, concepte sau valori fundamentale de design ale soluțiilor software menite să asigure natura reactivă a unui serviciu.

Toate aceste concepte contribuie la definirea reactivității. Ca paradigmă de programare ce vine în ajutorul implementării unui sistem reactiv, putem vorbi de programarea reactivă, care se concentrează la rândul ei pe gestionarea evenimentelor și a schimbărilor de stare într-un mod eficient, bazându-se pe ideea de procesare a acestora într-un mod asincron, fără blocare (non-blocking). Precum orice altă paradigmă de programare, ea constituie o temelie în implementarea soluțiilor software, iar în cazul nostru, a celor caracterizate de reactivitate.

De ce am alege să construim un sistem reactiv?

Reprezintă o întrebare cât de validă. Scopul este de a avea sisteme scalabile, decuplate, ușor extensibile și cu o latență cât se poate de mică, iar noi am vrea să credem că știm cum să facem asta, fiindcă am făcut-o de multe ori fără să avem în vedere reactivitatea. Sunt însă aspecte care nu sunt de neglijat, când vine vorba de serviciile reactive, aspecte ce ar putea să justifice alegerea ei în anumite scenarii, printre care:

Librăriile reactive din ecosistemul Java

În cele ce urmează, intenționez să rămân în sfera mea de competențe și să vă prezint anumite librării care pot fi utilizate în dezvoltarea unor servicii reactive în Java. Scopul este, bineînțeles, nu să reinventăm roata, ci să înțelegem ce soluții robuste și mature există. Astfel, printre cele mai cunoscute librării sau frameworkuri reactive se numără:

Scenariu în lumea reală. Crearea unui serviciu reactiv REST folosind

Java, Spring WebFlux și Spring Data R2DBC

E momentul oportun să trecem la practică, astfel urmând să vedem cum putem implementa un server reactiv folosind Java 17, Spring WebFlux și Spring Data R2DBC, o componentă ce oferă integrarea driverelor reactive cu baze de date relaționale. În cazul serviciului nostru, vom folosi unul specific pentru PostgreSQL. Vom observa că serverul pe care va rula serviciul nostru va fi Netty, specializat în manipularea apelurilor HTTP asincrone. Înainte să trecem prin pașii necesari implementării unui serviciu HTTP, care nu blochează apelantul până la finele procesării, aș vrea să trec în revistă scenariul pe care îl vom avea în vedere atât în construcția serviciului, cât și în compararea acestuia cu un serviciu REST blocant, clasic, care urmărește un model de tip "one thread per request".

Așadar, vom urmări să implementăm un server pentru o instituție bancară, capabil să înregistreze noi conturi, tranzacții și chiar să caute anumite tranzacții într-un interval dat sau să calculeze soldul dintr-un cont în baza istoricului tranzacțiilor înregistrate. Tot în acest capitol, vom putea urmări cum putem trata și propaga diferite excepții personalizate, astfel încât să evidențiem API-ul reactiv prin care putem manipula fluxurile de date.

Urmează câțiva pași necesari în dezvoltarea serverului reactiv care facilitează efectuarea unor operații de tip CRUD a tranzacțiilor bancare.

Adăugarea dependințelor

Pe lângă dependințele de Spring Webflux și Spring Data R2DBC, am adăugat atât o dependință către Lombok pentru cod șablon, fiindcă recordurile din Java nu se pretează pe entități, cât și pentru Flyway, pentru scripturi de migrare.

dependencies {
    implementation 'org.springframework.boot:spring-
     boot-starter-data-r2dbc'

    implementation 'org.springframework.boot:spring-
    boot-starter-webflux'

    implementation 'org.flywaydb:flyway-core'
    implementation 'org.projectlombok:lombok:1.18.28'
    runtimeOnly 'org.postgresql:r2dbc-postgresql'
    implementation 'org.postgresql:postgresql'

    compileOnly 'org.projectlombok:lombok:1.18.28'
    annotationProcessor 'org. 
    projectlombok:lombok:1.18.28'

    testImplementation 'org.springframework.
    boot:spring-boot-starter-test'

    testImplementation 'io.projectreactor:
    reactor-test'
}

Definirea modelelor

În serviciul construit, vom avea două modele: Account și Transaction. Relația dintre acestea este una de tip one-to-many.

Mai jos, putem observa o posibilă implementare a entităților și a relației acestora cu adnotări specifice Spring Data R2DBC.

@Getter
@Setter
@NoArgsConstructor
@Table("account")
public class Account {
    @Id
    private Long id;
    @Column("account_number")
    private String accountNumber;
    @Column("account_holder")
    private String accountHolder;
    @Transient
    @JsonIgnore
    @MappedCollection(idColumn = "account_number")
    private List transactions;
}

@Data
@NoArgsConstructor
@Table(name = "transaction")
public class Transaction {
    @Id
    Long id;
    @Column("account_number")
    String accountNumber;
    @Column("date")
    LocalDate transactionDate;
    @Column("transaction_details")
    String transactionDetails;
    @Column("processed_date")
    LocalDate processedDate;
    @Column("withdrawal_amount")
    Double withdrawalAmount;
    @Column("deposit_amount")
    Double depositAmount;
}

Definirea unui repository reactiv pentru apeluri la baza de date

Folosind Spring Data RDBC (Relational Reactive Database Connectivity), putem construi un ReactiveCrudRepository, unde avem posibilitatea de a adăuga metode noi, care vor fi implementate prin reflexie, lucru întâlnit și în alte soluții din suita Spring Data. Principala diferență este dată de natura reactivă a acestuia, astfel tipul de obiecte returnate vor fi Mono sau Flux.

@Repository
public interface TransactionRepository extends ReactiveCrudRepository {
    Flux findAllBy(Pageable pageable);
    Flux findAllByAccountNumber(
     String accountNumber, Pageable pageable);

    Mono deleteAllByAccountNumber(
     String accountNumber);
    Flux 
     findAllByAccountNumberAndTransactionDateBetween(
      String accountNumber, LocalDate after, 
      LocalDate before, Pageable pageable);

    Mono countByAccountNumber(
      String accountNumber);

    Mono 
     countByAccountNumberAndTransactionDateBetween(
      String accountNumber, LocalDate after, 
      LocalDate before);
}

Serviciul Reactiv

În cele ce urmează, vom observa implementarea unei metode de salvare a unei tranzacții în care înlănțuim mai multe operații pe baza de date într-o manieră reactivă, pentru a demonstra puterea API-ului de care dispunem. Putem observa cum accesăm elementul conținut într-un observabil de tip Mono printr-un flatMap, după care salvăm tranzacția. Ba mai mult, returnăm o excepție customizată în cazul în care contul aferent numărului de cont pentru care urmărim să salvăm o tranzacție nu există.

@Transactional
public Mono createTransaction(Transaction transaction) {
  return accountRepository
  .findByAccountNumber(transaction.getAccountNumber())
  .flatMap(account -> transactionRepository
    .save(transaction))
  .switchIfEmpty(Mono.error(
    new AccountNotFoundException()));
}

Expunerea unui endpoint în mod reactiv

În final, putem ajunge la implementarea unei metode REST de tip POST aferente metodei din serviciul prezentat anterior.

Observăm multe elemente comune cu o implementare clasică pentru un serviciu rest blocant (precum clasicele adnotări @PostMapping și @RequestBody), însă partea interesantă vine de la modul în care prin acest Fluent API al Reactor Core-ului putem manipula anumite excepții aruncate și putem returna diferite coduri de status HTTP.

@PostMapping
public Mono> 
  createTransaction(@RequestBody Transaction 
    transaction) {
    return transactionService
      .createTransaction(transaction)
      .then(Mono.just(ResponseEntity.ok().build()))
      .onErrorResume(AccountNotFoundException
        .class, error -> Mono.just(
        ResponseEntity.notFound().build()));
}

Event-loop vs. Thread per request. Când este important să devenim reactivi?

Este, din punctul meu de vedere, cea mai importantă întrebare privind sistemele reactive, pentru că acestea vin la pachet cu costuri suplimentare atât în momentul dezvoltării, cât și în timpul încercărilor de a găsi și fixa posibilele probleme survenite.

Pentru a putea argumenta implementarea unui serviciu reactiv, propun un studiu de caz în care urmăresc să compar două servicii REST, unul reactiv și unul blocant în două ipostaze diferite. Scenariul este același, avem un serviciu capabil să efectueze operații CRUD cu tranzacții bancare, conturi bancare și poate chiar să agrege niște date (spre exemplu, calcularea unui sold din tranzacțiile actuale).

De menționat aici este faptul că am folosit JMeter pentru crearea unor apeluri în masă ce simulează utilizarea serviciului într-o manieră concomitentă de către un număr de utilizatori, JProfiler pentru profilarea serviciilor și pgAdmin pentru a colecta anumite metrici legate de conexiunea cu baza de date.

Acestea fiind spuse, pentru a avea parte de cel mai mare grad de izolare, rezumăm discuția la testarea a două servicii aflate în containere de Docker, fără a lua în considerare aspecte precum scalarea orizontală prin redundanță, load-balancing sau alte îmbunătățiri ce ne-ar putea ajuta să avem un sistem mai "pregătit pentru producție".

Număr mic de utilizatori concomitenți. Operații computațional intensive

Pentru această comparație, contextul testelor este prezența unui număr relativ mic de utilizatori concurenți, unde amândouă serviciile sunt nevoite să calculeze rezultate în memorie. Pentru aceasta, am simulat un calcul de total al soldului corespunzător unui cont adunând sumele din istoricul tranzacțiilor. Graficul aferent acestui test este unul deosebit de interesant, fiindcă observăm cum nici timpul de răspuns, nici randamentul nu diferă atât de mult între cele două servicii. O posibilă interpretare ar fi faptul că în aceste considerente, beneficiile unui sistem reactiv nu sunt prezente, iar serviciul blocant poate face față cu brio.

Figura 1. Număr limitat de utilizatori. Teste efectuate cu JMeter

Număr mare de utilizatori concomitenți. Operații I/O intensive

Numărul de utilizatori concurenți reprezintă o problemă, astfel merită să vedem ce se întâmplă când acesta crește considerabil.

Pentru o bună vizualizare, graficul corespunzător acestor teste conține metrici cu privire la timpul mediu de răspuns la un apel și randament în contextul unui număr mare de apeluri HTTP precum crearea de noi tranzacții, adăugarea unor noi conturi și găsirea tuturor tranzacțiilor pentru un anumit interval de timp.

E de notat faptul că fiecare utilizator concurent a făcut trei operațiuni, deci numărul de apeluri este, în fapt, de trei ori mai mare pentru fiecare dintre cazurile testate. Se observă o creștere în timpul de răspuns asemănătoare celei exponențiale în cazul serviciului blocant, unde am experimentat o limită superioară de apeluri ce pot fi procesate de cca. 9.000-10.000 utilizatori concurenți (30.000 apeluri), moment de la care serviciul meu a început să producă erori precum închiderea conexiunii sau - și mai rău - s-a prăbușit complet.

Aflat în antiteză, serverul reactiv se ridică la nivelul așteptărilor în contextul unui număr de utilizatori mare. Avem un randament bun, un timp de răspuns rezonabil și, în urma testelor, am observat o limită de gestionare a utilizatorilor concurenți aproape dublă, 17.000, răspunzând la 54.000 de apeluri concurente cu o rată de eroare inexistentă.

Figura 2. Număr mare de utilizatori.Teste efectuate cu JMeter

Mai jos, am atașat și o captură din timpul testelor cu JMeter, unde putem observa alte metrici interesante, precum rata de eroare, care a rămas preponderent zero, sau chiar și timpul maxim de răspuns în cazul fiecărui apel în parte.

Figura 3. Test simulând 10.000 utilizatori concurenți ai serviciului reactiv

Viziune de ansamblu

Dincolo de compararea metricilor de timp de răspuns sau randament cu JMeter, un alt aspect interesant este evidențiat de profilarea celor două servicii și de monitorizarea bazei de date. Utilizând JProfiler pe tot parcursul testelor, am observat, precum mă așteptam, o diferență mare când vine vorba atât de numărul de threaduri create, cât și a numărului de threaduri active în mod concomitent, respectiv blocate în momentul procesării. Figurile de mai jos evidențiază acest lucru, unde este de notat că vârfurile apărute în diferitele metrici coincid cu momentele de testare cu un număr mare de utilizatori.
Astfel, observăm că arhitectura bazată pe event-loop a serviciului reactiv duce la o existență constantă a unui număr mai mic de threaduri - aproximativ 30 - și a unui constant de threaduri active în momentul procesării, spre deosebire de serviciul blocant, care în momentul testelor cu un număr mare de utilizatori, crește numărul de threaduri existente, majoritatea fiind însă într-o stare de așteptare.
Alte diferențe sunt date de utilizarea CPU-ului, serverul reactiv fiind mai eficient și folosindu-l pentru o perioadă mai mică de timp, dar și de memoria utilizată, care, deși comparabilă, în cazul serviciului reactiv rămâne constantă, indicativ al faptului că acesta are puterea de a procesa într-un flux continuu un volum mare de date.

Figura 4. Profilarea serviciului blocking

Figura 5. Profilarea serviciului reactiv

O ultimă metrică interesantă a fost cea legată de numărul de tranzacții pe bază de date, evidențiind diferențele masive între driverul reactiv, respectiv cel blocant folosit în servicii. O captură a metricilor din dbAdmin arată faptul că în urma unui test cu 5000 de utilizatori concurenți, numărul de tranzacții pe bază de date pe minut este de două ori mai mare în cazul reactiv.

Figura 6. Tranzacții pe baza de date

Concluzii

În mod cert, paradigma reactivă generează mult interes în jurul ei, dar implică și o schimbare în gândirea, designul, implementarea sau testarea soluțiilor software. Ea se bazează pe asincronicitate, pentru a asigura izolarea componentelor, iar rezultatul este un sistem extensibil, scalabil, capabil să nu se blocheze în timpul execuției pentru a aștepta terminarea procesării. Deși curba de învățare este ceva mai abruptă, am observat cât este de la îndemână folosirea unor librării deja existente pentru a crea soluții reactive, adaptabile, capabile să facă față cu brio uneia dintre cele mai mari încercări ale soluțiilor software: procesarea unui flux mare de date.

În concluzie, pornind de la subiectul nostru, cred că eficiența și performanța vor continua să fie subiecte esențiale în domeniul programării, iar responsabilitatea de a găsi cele mai bune soluții în funcție de fiecare situație specifică cade pe umerii noștri, cum am văzut și în acest articol. Mereu vor exista multiple abordări pentru diferite provocări, dar, dacă ar fi să ne rezumăm la relația dintre reactivitate și performanță, am putea sumariza că aceasta, deși se arată a fi promițătoare, constituie doar o altă armă în arsenalul nostru, nicidecum o soluție menită să ne rezolve problemele făcând abstracție de context.

Gânduri de final

  1. Programarea reactivă este interesantă. Implică, ca orice altă paradigmă, un mod de gândire inedit.

  2. În acest articol, am făcut doar o introducere de suprafață în lumea reactivității. Pentru aprofundare, cartea [2] este o sursă bună.

  3. Nu trăim într-o utopie, în aplicațiile reale apar constrângeri care duc deseori la imposibilitatea implementării unei arhitecturi pe deplin reactivă.

  4. În lumea reactivă, apar multe probleme interesante ce ar merita, la rândul lor, un articol: sincronizarea sau integrarea datelor atunci când e nevoie de o anumită ordonare, probleme de reprocesare a datelor (în cazul comunicării pe mesaje) și chiar probleme de debug. Topicul de depanare a unor probleme este și el deosebit de interesant, iar mecanismul de monitorizare și logare este absolut necesar în cazul sistemelor asincrone care procesează în mod paralel.

  5. Pentru a înțelege arhitectura reactivă a Spring Webflux, o excelentă resursă este [4]. Aici putem vedea vizual cum funcționează mecanismul din spate și de ce nu e nevoie de o armată de threaduri pentru procesare.

  6. Akka este un toolkit atât de complex pentru aplicații distribuite asincrone, încât ar merita un articol separat doar pentru el, dar un loc bun de început este [5].

Referințe

  1. J. Bonér, D. Farley, R. Kuhn, and M. Thompson, "The reactive manifesto",

  2. O. Dokuka and I. Lozynskyi, Hands-on reactive programming in Spring 5: build cloud-ready, reactive systems with Spring 5 and Project Reactor. Packt Publishing Ltd, 2018.

  3. Reactive programming guide

  4. Spring webflux under the hood

  5. Akka introduction