Implementazione del Throttling in Java

Quando si implementano dei servizi, specialmente se web, uno dei problemi che deve essere affrontato a livello architetturale è la definizione del limite complessivo di richieste che il back-end è in grado di elaborare per unità di tempo. Questo è spesso misurato con il parametro TPS (Transaction per Second).  In alcuni casi, il sistema potrebbero anche avere un limite fisico di dati che possono essere trasferiti in byte, e allora si parla di BPS (Byte per Second).

Quando diversi client inviano contemporaneamente richieste all’applicazione è possibile che il numero di tali richieste superi il limite fisico imposto dal parametro TPS. Conseguentemente, nel migliore dei casi, le richieste andrebbero in time-out oppure non verrebbero neppure prese in carico dal back-end dell’applicazione, ma nei casi peggiori, potrebbero provocare un faul del sistema e la sua completa indisponibilità. Per tale motivo è spesso utile implementare una politica di throttling, che consiste, sostanzialmente, nel limitare il rate delle richieste servite dal servizio, terminando o accodando tutte le richieste in eccesso.

Gestire il throttling è quindi essenziale se ci si vuole assicurare che l’applicazione continui a garantire la propria disponibilità e prestazioni accettabili, anche in caso di carichi di lavoro particolarmente elevati.

In questo articolo vediamo come il throttling possa essere gestito a livello applicativo, considerando una semplice applicazione Spring Boot di esempio. Tale applicazione espone un unico servizio REST /hello-world, gestito dal seguente Controller, in cui un oggetto AtomicInteger è utilizzato per generare un id associato alla richiesta:

Guava RateLimiter

Il Progetto Guava contiene molte delle core libraries usate da Google nei progetti Java. Queste librerie coprono molti aspetti: dalle collection, alla gestione della cache, dalla programmazione concorrente, al processamento delle stringhe e così via. In particolare nell’ambito della gestione della concorrenza Guava espone una classe RateLimiter che consente di implementare il throttling in pochi semplici passi.

Innanzitutto è necessario includere la dipendenza a Guava che, ad esempio, con Maven significa includere la seguente <dependency>:

L’implementazione del throttling richiede innanzitutto di istanziare un oggetto di tipo RateLimiter, specificando il rate che si intende supportare. Tale rate è definito in termini di  permits per second , che potremmo tradurre come  autorizzazioni al secondo . Il significato associato alle permit dipende dall’uso che se ne fa. In generale N permit per second significa che il servizio è in grado di gestire N richieste al secondo, ma l’interpretazione che se ne da può essere indifferentemente transazioni o byte.

La classe dispone di diversi  metodi factory create() per la sua istanziazione, il più semplice dei quali riceve in input il solo parametro permitsPerSecond:

Nel momento in cui il servizio è invocato, la prima azione che deve essere eseguita è quella di verificare la disponibilità delle risorse (o permit) necessarie a poter processare la richiesta. A tale scopo la classe RateLimiter dispone di un metodo acquire() in cui è possibile specificare il numero di permit necessari. Applicare questi concetti al nostro controller di esempio, significa eseguire l’acquire tra le prime istruzioni del metodo sayHello() e comunque, prima della parte computazionalmente più onerosa del metodo:

L’effetto dell’acquire() è quello di verificare che il rate delle invocazioni del metodo sia compatibile con quello dichiarato in fase di istanziazione della classe RateLimiter. In caso contrario l’esecuzione viene bloccata fino a quando la richiesta può essere eseguita. Il metodo restituisce il numero di secondi di sospensione che sono stati necessari per garantire il rate richiesto o zero se non vi è stato alcun blocco.

La classe rende disponibile anche un metodo tryAcquire() (con diverse signature) che consente di evitare il blocco o di uscirne dopo un periodo di tempo prefissato. In tale caso il metodo restituisce un boolean con cui indica se l’acquire ha avuto successo o se invece è semplicemente scaduto il timeout.

Interpretazione del Rate Limit

Il corretto funzionamento del throttling dipende da come viene interpretato il concetto di permit offerto dalla classe RateLimiter. Ad esempio se intendiamo supportare 100 transazioni al secondo ed ogni invocazione del metodo conta come 1 transazione, allora possiamo semplicemente assegnare 100 permitsPerSecond in fase di istanziazione della classe e consumare 1 permits ad ogni acquisizione. Ma posso ottenere lo stesso risultato anche specificando 1000 permitsPerSeconded acquisendone 10 ad ogni invocazione.

Questo ragionamento è molto utile in quei casi in cui la scelta dei due parametri non è così immediata. Supponiamo, ad esempio, di voler consentire un massimo di 3 transazioni al minuto. Questo può essere ottenuto indifferentemente considerando un permitsPerSecond pari a 3/60 (3 transazioni / 60 secondi in un minuto) ed acquisendo 1 permits ad invocazione, oppure considerando un permitsPerSecond pari ad 1 ed acquisendo 20 permits ad invocazione. Acquisire 20 permits infatti ha come effetto che nessuna altra richiesta possa essere servita prima dei successivi 20 secondi.

Nel caso in cui il rate limit è invece definito in byte valgono le stesse considerazione. La sola differenza è che, almeno in generale, ilpermitsPerSecond sarà impostato al numero massimo di byte gestibili al secondo, mentre l’acquire sarà eseguito su un numero di permits pari ai byte consumati dalla richiesta. Se immaginiamo, ad esempio, che il metodo riceva in input un file da elaborazione, la dimensione di tale file potrebbe essere proprio il numero di permits consumati:

Test di Esecuzione

Completiamo il servizio hello-world considerando un rate limit di 3 transazioni al minuto ed inserendo alcuni messaggi di log necessari a comprendere il comportamento del metodo:

Per eseguire un test utilizziamo il seguente comando shell, che concatena 4 istruzioni curl, il cui effetto è quello di inviare  4 richieste contemporanee:

Dall’analisi dei log riportati di seguito notiamo che la prima richiesta ricevuta, che nel nostro caso è la quarta, viene immediatamente servita, mentre le altre 3 vengono servite rispettivamente dopo circa 20, 40 e 60 secondi.

A completamento consideriamo invece si utilizzare il metodo tryAcquire() con un timeout si 1 secondo. Come ci aspettiamo tutte le richieste, tranne la prima, vengono droppate.

Spring Boot Throttling

Limitatamente al modo Spring Boot esiste la libreria open source weddini, che consente di gestire il throttling in modo dichiarativo mediante annotation.

A differenza di Guava, questa libreria è più rigida nella gestione del throttling, infatti opera terminando, e quindi rifiutando, tutte le richieste che sopraggiungono dopo il superamento del rate limit configurato. In tali casi l’eccezione ThrottlingException viene lanciata e restituita al richiedente ed il metodo non viene neppure eseguito.

La configurazione della politica di throttling avviene utilizzando una semplice annotazione: @Throttling(), con cui annotare il metodo che implementa il servizio. Attraverso diversi parametri è poi possibile definire, non solo il rate limit richiesto, ma anche le politiche con cui applicare il throttling.

Prima di scendere nel dettaglio della configurazione, applichiamo la libreria al semplice servizio hello word visto sopra. Per farlo dobbiamo innanzitutto importare il repository github e la relativa dipendenza:

Il controller per la gestione del servizio sarà quindi implementato in modo analogo a quanto visto, con la sola differenza che, attraverso l’annotazione @Throttling(), il controllo del rate limit è demandato completamente alla libreria:

Si noti che in assenza di parametri  il rate limit di default è di una richiesta al secondo , quindi se invochiamo il servizio con le stesse 4 istruzioni curl che abbiamo visto sopra, solamente la prima verrà presa in carico, mentre le altre verranno droppate. Il chiamante riceverà quindi una eccezione ThrottlingException attraverso il seguente json:

Configurazione del Throttling

Per la configurazione della politica di throttling si consideri che di base l’annotazione ammette i seguenti tre parametri:

  • Il throttling type che può assumere diversi valori, come descritto di seguito.
  • Il rate limit che può assumere valori interi.
  • Il timeUnit ovvero l’unità di misura in cui è definito il limit e che può assumere i valori definiti nella classe java.util.concurrent.TimeUnit ovvero: giorni, ore, minuti, secondi ma anche microsecondi, millisecondi e nanosecondi.

A questi parametri se ne aggiungono altri in funzione del valore assunto dal parametro type, come descritto nei paragrafi seguenti.

Remote Address

E’ l’implementazione di default in cui il rate limit è applicato per indirizzo IP di provenienza della richiesta, per intenderci quello ottenuto da HttpServletRequest#getRemoteAddr().

Spring Expression Language (SpEL)

Attraverso una espressione SpEL è possibile prendere in considerazione i parametri del metodo invocato come discriminante per l’applicazione del throttling.

Http Cookie Value

Il throttling può essere applicato in funzione del valore assunto da un cookie presente nella request e recuperato con il metodo HttpServletRequest#getCookies():

Http Header Value

Anche gli header http recuperati dalla request mediante HttpServletRequest#getHeader() possono essere utilizzati come discriminanti per il throttling.

User Principal Name

Infine il throttling può essere discriminato in funzione dell’utente che è autenticato nella richiesta e che si ottiene mediante HttpServletRequest#getUserPrincipal().getName().

Considerazioni Finali

E’ importante sottolineare che la classe RateLimiter offerta da Guava è sicuramente più flessibile in quanto non utilizza i concetti di remote address, cookie, etc, per limitare le richieste, come fatto dalla libreria weddini. Questo però implica che per implementare una qualsiasi politica di throttling più restrittiva è necessario realizzare del codice ad-hoc. Ad esempio il seguente metodo implementa il throttling per remote address:

Una seconda importante differenza è che Guava è in grado sia di accodare le richieste che di rifiutarle. Il solo inconveniente è che non è possibile specificare il numero di richieste massimo che è possibile mantenere in coda. Questo perchè comunque una richiesta in coda impiega risorse di sistema e dal punto di vista dell’availability dell’applicazione potrebbe comprometterla.

Codie Sorgente

Il codice sorgente del progetto è disponibile qui throttling.