Development

Il benchmark dei protocolli RPC

Abbiamo effettuato una doppia esecuzione per eseguire il benchmark dei protocolli RPC e trovare una soluzione ideale per la nostra architettura di microservizi.

February 15 2016

La piattaforma Infobip non è un enorme blocco monolitico In un esempio tipico, l'architettura di microservizi consiste in una varietà di piccoli servizi che rappresentano le diverse parti della piattaforma. Ogni singolo servizio ha un unico scopo ben definito, e si occupa solamente di raggiungere questo scopo nel miglior modo possibile.

Utilizzando diversi servizi di piccole dimensioni, gestire l'intera piattaforma diventa più semplice. Ogni servizio può essere aggiornato in qualsiasi momento senza interrompere gli altri. I servizi possono essere (e di fatto sono) scritti nei linguaggi di programmazione più adatti a risolvere il problema di cui si occupano. Le parti della piattaforma che gestiscono notevoli volumi di dati possono essere scalate in modo indipendente e senza alcun effetto sulle parti soggette a volumi inferiori. Un'architettura di microservizi offre tutti questi vantaggi, e rende ottimale l'esperienza di sviluppo sulla piattaforma Infobip.

Tuttavia, nessun servizio è un'isola a sé stante. Se ogni piccola parte ha una diversa responsabilità, i servizi che funzionano in sincronia hanno bisogno di poter comunicare in qualche modo. Il lancio di una campagna SMS sul nostro Portale Infobip sarà gestito dal servizio dedicato alle campagne. Se la campagna include gruppi o contatti di clienti, questi verranno ottenuti da un secondo servizio dedicato alla gestione dei contatti. Inoltre, la pianificazione degli invii verrà gestita da un ulteriore servizio, così come l'inoltro, l'addebito e l'effettivo invio dei messaggi agli abbonati mobile.

Questione di scelte

I nostri servizi comunicano tra loro tramite diversi protocolli RPC (Remote Procedure Call). Nel corso degli anni ne abbiamo testati vari, ma al momento ne utilizziamo solamente tre:

  • Il buon vecchio HTTP/1.1. Quasi tutti i linguaggi di programmazione includono diverse implementazioni client e server, e questo lo rende una buona possibilità per la nostra piattaforma poliglotta.  Solitamente inviamo tramite questo protocollo un JSON semplice, così è semplice eseguire il debug dei servizi (abbiamo bisogno solamente di un browser web o un client REST).
  • Il nostro secondo protocollo è un altro HTTP, ma questa volta invia oggetti serializzati Java avanti e indietro. Era questa l'opzione predefinita per far comunicare due servizi scritti in Java, dal momento che entrambi potevano leggere e scrivere questo tipo di dati in modo nativo
  • MML, abbreviazione di Machine-to-Machine-Language, è il nostro protocollo RPC interno, pensato per garantire un'elevata velocità effettiva. In alcuni aspetti è simile ad HTTP/2, che ancora non esisteva quando abbiamo iniziato a lavorare a MML. Utilizza una connessione TCP permanente tra client e server, e consente ai client di bilanciare il carico tra i server e se necessario di eseguire il failover. MML utilizza Typed JSON come payload: sembra un normale JSON, ma ciascun oggetto è contrassegnato da un tipo Java. Per questo motivo, MML può essere utilizzato solamente tra servizi Java

Avendo a disposizione tre diversi modi di comunicare, scegliere quello giusto può rivelarsi complicato. Ogni protocollo ha i suoi vantaggi e svantaggi in termini di prestazioni, possibilità di debug e interoperabilità con servizi non Java. Si suppone che MML garantisca le migliori prestazioni, ma limita la comunicazione a soli servizi Java. Se sono coinvolti altri linguaggi di programmazione rimane un'unica opzione, il semplice JSON su HTTP.

Avere tre soluzioni allo stesso problema è tutt'altro che ideale: qualsiasi tentativo di migliorare la comunicazione tra servizi diversi deve essere effettuato tre volte per ciascun protocollo, e ognuno ha delle peculiarità che rendono difficile implementare le stesse funzioni per tutti i protocolli. L'ideale sarebbe utilizzare un unico protocollo in grado di offrire alte prestazioni e la possibilità di eseguire il debug in modo semplice e di essere utilizzato da diversi linguaggi di programmazione. In altre parole, la botte piena e la moglie ubriaca.

Avevamo bisogno di un modo di confrontare benchmark gli uni con gli altri per scoprire quale fosse "il migliore" e in quali situazioni. Abbiamo dato alle prestazioni la massima priorità: possiamo sempre creare degli strumenti per il debug dei protocolli o scrivere un'implementazione nostra per un certo linguaggio, se non è già disponibile, ma se un protocollo offre prestazioni ridotte, non c'è nulla da fare.

Inoltre, avevamo bisogno di un benchmark. Scriverne uno in grado di fare qualche chiamata in un loop e misurare la velocità effettiva è semplice. Ma commettere degli errori in grado di portare il benchmark a misurare fattori a cui non siamo interessati o di distorcerne i risultati rendendoli inutili è ancora più semplice.

Scrivere un (buon) benchmark

Due dei tre protocolli che utilizziamo sono limitati al linguaggio Java; scrivere l'intero benchmark in Java sembrava quindi una buona scelta. Tuttavia, scrivere un benchmark eseguibile su Java Virtual Machine (JVM) senza creare problemi non è semplice. Ci sono moltissimi dettagli da tenere d'occhio:

  • A JVM occorre del tempo per profilare e ottimizzare il codice generato, ed eseguire le misurazioni prima del completamento dell'ottimizzazione non ha senso.
  • JVM profila e ottimizza il codice per l'effettivo modello di utilizzo. Se eseguiamo il benchmark di due protocolli l'uno dopo l'altro, il risultato del secondo potrebbe essere peggiore solamente perché JVM ha già specializzato il codice per il primo. Per impedire che accada, ogni benchmark va eseguito con un nuovo processo
  • Nel caso dei benchmark a thread multipli, è necessario prestare particolare attenzione ad assicurarsi che tutti i thread inizino e finiscano la misurazione allo stesso momento.
  • E tutti questi non sono neanche la metà dei potenziali problemi! Fortunatamente, alcuni strumenti possono aiutarci a risolverli. Uno di questi è Java Microbenchmark Harness (JMH), scritto dagli sviluppatori di JVM (possiamo ragionevolmente presumere che sappiano cosa stanno facendo).

Scrivere benchmark con JMH è facile.Dobbiamo scrivere solamente il codice che vogliamo effettivamente misurare, e JMH penserà a tutto il resto: eseguire il codice in loop per misurare la velocità effettiva, eseguire lo spinning dei vari processi per ciascun benchmark, dare a JVM il tempo di "riscaldarsi" e ottimizzare il codice e tutti gli altri dettagli, tanto noiosi quanto importanti.

Lo scheletro del nostro benchmark ha un aspetto simile:

public class ProtocolBenchmark {
 
    @Param({"http_json", "http_java", "mml"})
    private String protocol;
 
    @Param({"0", "10", "100"})
    private int payloadSize;
 
    @Setup
    public void setup() {
        // Esegue lo spin-up del server e dei client
    }
 
    @Benchmark
    @Threads(1)
    public Object threads_1() {
        // Esegue una chiamata RPC
        // Verrà richiamato da un unico thread (client)
    }
 
    @Benchmark
    @Threads(32)
    public Object threads_32() {
        // Esegue una chiamata RPC
        // Verrà richiamato da un unico thread (client)
    }

}

JHM è particolarmente flessibile, e può essere adattato a vari scenari. Le opzioni che si sono rivelate più utili per il nostro benchmark sono state i parametri e il numero di thread. I parametri ci consentono di riutilizzare lo stesso benchmark in scenari diversi. La logica del benchmark non prevede alcuna differenza nella misurazione di protocolli diversi o nell'utilizzo di payload di più dimensioni, così i parametri ci consentono di evitare il copia e incolla delle parti di codice. Modificando il numero di thread in esecuzione allo stesso tempo, inoltre, possiamo misurare la reazione dei protocolli quando aumenta il numero di client concomitanti.

Eseguire il benchmark

È arrivata l'ora di eseguire il nostro piccolo benchmark! In realtà, abbiamo implementato due esecuzioni in condizioni diverse.

Nella prima esecuzione abbiamo eseguito sia server che client sulla stessa macchina, e abbiamo stabilito una comunicazione tramite interfaccia localhost. Questo scenario ci è stato utile a misurare il sovraccarico puro del protocollo in assenza di fattori esterni per interferire con le misurazioni.

Nella seconda esecuzione abbiamo utilizzato i protocolli in condizioni più realistiche. Abbiamo eseguito il server su una macchina diversa da quella dei client, e li abbiamo messi in comunicazione su una rete reale. Nel caso dei protocolli HTTP, tutto il traffico è stato indirizzato tramite HAProxy (è così che lo utilizziamo realmente nell'ambiente di produzione). Dato che MML supporta il bilanciamento del carico sul lato server, il client può essere connesso direttamente al server senza passare per un intermediario.

Abbiamo fatto girare il lato client del benchmark su un certo numero di client, e ognuno ha dovuto eseguire ripetutamente una chiamata in remoto con un dato payload, quindi abbiamo atteso che il server rimandasse indietro lo stesso payload. Il payload in questione era un semplice elenco di oggetti, e le dimensioni corrispondevano al numero di oggetti nell'elenco.

Al numero dei client e alle dimensioni del payload sono stati assegnati dei parametri per coprire i pattern di comunicazione tipici dei nostri servizi. Le misurazioni sono state eseguite con 1, 32, 64 e 128 client contemporaneamente e con payload da 0 (elenco vuoto), 10 e 100 elementi.

Risultati

Dopo aver completato un'esecuzione, JHM genera dei dati tabulari contenenti le misurazioni per ciascuna combinazione di parametri. Per semplificare il confronto, abbiamo scelto di visualizzare i risultati come semplici grafici a barre.

Nel caso della comunicazione su localhost abbiamo ottenuto risultati come questi:

Engineering

Per i payload piccoli e medi, il protocollo MML è uscito senza dubbio vincitore, indipendentemente dal numero di client contemporanei. Quando però siamo passati a payload di grandi dimensioni, ovvero quando i protocolli basati su HTTP sono diventati quasi uguali ad MML in termini di velocità effettiva, il risultato è cambiato. È anche interessante osservare che l'utilizzo di oggetti serializzati Java è in realtà più lento dell'utilizzo di JSON. Sembra che Jackson, la libreria JSON che utilizziamo, sia ottimizzata meglio per il nostro caso d'uso.

Nella seconda esecuzione su rete reale abbiamo ottenuto questi risultati:

Engineering

MML è risultato ancora il migliore per payload piccoli e medi, ma il margine si è ridotto. Nel caso dei payload più grandi, inoltre, i grafici si sono capovolti. Il trasferimento di JSON semplice su HTTP è stato infatti notevolmente migliore rispetto a quello di MML. La causa di questo risultato va probabilmente ricercata in Typed JSON: tutti i tipi di tag che aggiunge aumentano notevolmente il numero di byte da inviare via cavo, causando così una diminuzione della velocità effettiva.

Conclusioni

Cosa abbiamo appreso da questo esperimento? Il nostro protocollo interno funziona perfettamente con payload piccoli o medi, ma non è più una buona scelta se i servizi inviano richieste e risposte di dimensioni maggiori. D'altra parte, l'utilizzo di JSON su HTTP offre discrete prestazioni ed è utilizzabile da servizi scritti in qualsiasi lingua (il che è decisamente un punto a suo favore). In più, l'utilizzo della serializzazione Java su HTTP è stato inferiore in tutti gli scenari testati, e abbiamo dunque deciso che non utilizzeremo più questo protocollo.

A conti fatti, non c'è un chiaro vincitore. Stiamo pensando di ottimizzare JSON su HTTP, o forse di creare un prototipo di soluzione basata su HTTP/2 e vedere come va. C'è ancora molto lavoro da fare: torna a trovarci!

By Hrvoje Ban, Software Engineer