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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
@Controller public class GuavaHelloWorldController { private Logger logger = LoggerFactory.getLogger(GuavaHelloWorldController.class); private AtomicInteger index = new AtomicInteger(0); @GetMapping("/hello-world") @ResponseBody public String sayHello(@RequestParam(name="name", required=false, defaultValue="Javaboss") String name) { int id = index.incrementAndGet(); logger.debug( "Serving request id " + id ); return "Hello " + name + " from request id " + id; } } |
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>
:
1 2 3 4 5 |
<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>27.1-jre</version> </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
:
1 2 3 4 5 6 7 |
@Controller public class GuavaHelloWorldController { private RateLimiter rateLimiter = RateLimiter.create(PERMITS_PER_SECONDS); .... } |
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:
1 2 3 4 5 6 7 8 9 10 11 |
@GetMapping("/hello-world") @ResponseBody public String sayHello(@RequestParam(name="name", required=false, defaultValue="Javaboss") String name) { int id = index.incrementAndGet(); double slept = rateLimiter.acquire(PERMITS_CONSUMED); logger.debug( "Serving request id " + id ); return "Hello " + name + " from request id " + id; } |
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 permitsPerSecond
ed 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:
1 2 3 4 5 6 7 8 9 |
private RateLimiter rateLimiter = RateLimiter.create(BYTES_PER_SECONDS); @GetMapping("/convert-file") @ResponseBody public void convert(@RequestParam(name="file", required=true) byte[] file) { .... rateLimiter.acquire(file.length); .... } |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
@Controller public class GuavaHelloWorldController { private Logger logger = LoggerFactory.getLogger(GuavaHelloWorldController.class); private static final Double PERMITS_PER_SECONDS = 1d; private static final int PERMITS_CONSUMED = 20; private RateLimiter rateLimiter = RateLimiter.create(PERMITS_PER_SECONDS); private AtomicInteger index = new AtomicInteger(0); @GetMapping("/hello-world") @ResponseBody public String sayHello(@RequestParam(name="name", required=false, defaultValue="Javaboss") String name) { Date start = Calendar.getInstance().getTime(); int id = index.incrementAndGet(); logger.debug( "Serving request id " + id ); double slept = rateLimiter.acquire(PERMITS_CONSUMED); logger.debug( "Acquired resources for request id " + id ); Date end = Calendar.getInstance().getTime(); logger.debug( String.format("Request id %d served in %d ms, after a sleep of %.0f ms to acquire the resources", id, end.getTime()-start.getTime(), slept*1000 ) ); return "Hello " + name + " from request id " + id; } } |
Per eseguire un test utilizziamo il seguente comando shell, che concatena 4 istruzioni curl
, il cui effetto è quello di inviare 4 richieste contemporanee:
1 2 3 4 |
curl http://localhost:8080/hello-world?name=Luca & curl http://localhost:8080/hello-world?name=Matteo & curl http://localhost:8080/hello-world?name=Paolo & curl http://localhost:8080/hello-world?name=Giovanni & |
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.
1 2 3 4 5 6 7 8 9 10 11 12 |
2019-04-19 12:24:23.536 DEBUG 95309 : Serving request id 4 2019-04-19 12:24:23.536 DEBUG 95309 : Serving request id 2 2019-04-19 12:24:23.536 DEBUG 95309 : Serving request id 3 2019-04-19 12:24:23.536 DEBUG 95309 : Serving request id 1 2019-04-19 12:24:23.538 DEBUG 95309 : Acquired resources for request id 4 2019-04-19 12:24:23.539 DEBUG 95309 : Request id 4 served in 2 ms, after a sleep of 0 ms to acquire the resources 2019-04-19 12:24:42.542 DEBUG 95309 : Acquired resources for request id 2 2019-04-19 12:24:42.543 DEBUG 95309 : Request id 2 served in 19006 ms, after a sleep of 18999 ms to acquire the resources 2019-04-19 12:25:02.541 DEBUG 95309 : Acquired resources for request id 1 2019-04-19 12:25:02.542 DEBUG 95309 : Request id 1 served in 39006 ms, after a sleep of 38999 ms to acquire the resources 2019-04-19 12:25:22.539 DEBUG 95309 : Acquired resources for request id 3 2019-04-19 12:25:22.539 DEBUG 95309 : Request id 3 served in 59003 ms, after a sleep of 58999 ms to acquire the resources |
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.
1 2 3 4 5 6 7 8 9 10 11 12 |
2019-04-19 12:33:00.462 DEBUG 95309 : Serving request id 8 2019-04-19 12:33:00.462 DEBUG 95309 : Serving request id 7 2019-04-19 12:33:00.462 DEBUG 95309 : Serving request id 5 2019-04-19 12:33:00.462 DEBUG 95309 : Serving request id 6 2019-04-19 12:33:00.462 DEBUG 95309 : Request 7 dropped 2019-04-19 12:33:00.462 DEBUG 95309 : Acquired resources for request id 8 2019-04-19 12:33:00.462 DEBUG 95309 : Request 5 dropped 2019-04-19 12:33:00.462 DEBUG 95309 : Request 6 dropped 2019-04-19 12:33:00.462 DEBUG 95309 : Request id 5 served in 0 ms 2019-04-19 12:33:00.462 DEBUG 95309 : Request id 6 served in 0 ms 2019-04-19 12:33:00.462 DEBUG 95309 : Request id 8 served in 0 ms 2019-04-19 12:33:00.462 DEBUG 95309 : Request id 7 served in 0 ms |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
<repositories> <repository> <id>spring-boot-throttling-repo</id> <url>https://raw.github.com/weddini/spring-boot-throttling/mvn-repo/</url> <snapshots> <enabled>true</enabled> <updatePolicy>always</updatePolicy> </snapshots> </repository> </repositories> <dependency> <groupId>com.weddini.throttling</groupId> <artifactId>spring-boot-throttling-starter</artifactId> <version>0.0.9</version> </dependency> |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
@Controller public class WeddiniHelloWorldController { private Logger logger = LoggerFactory.getLogger(WeddiniHelloWorldController.class); private AtomicInteger index = new AtomicInteger(0); @GetMapping("/we-hello-world") @Throttling() @ResponseBody public String sayHello(@RequestParam(name = "name", required = false, defaultValue = "Javaboss") String name) { int id = index.incrementAndGet(); logger.debug( "Serving request id " + id ); return "Hello " + name + " from request id " + id; } } |
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:
1 2 3 4 5 6 7 |
{ "timestamp":"2019-04-19T10:55:56.103+0000", "status":429, "error":"Too Many Requests", "message":"Too many requests", "path":"/we-hello-world" } |
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 illimit
e che può assumere i valori definiti nella classejava.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()
.
1 2 3 4 |
@Throttling(type = ThrottlingType.RemoteAddr, limit = 1, timeUnit = TimeUnit.SECONDS) public void sayHello(...) { .... } |
Spring Expression Language (SpEL)
Attraverso una espressione SpEL è possibile prendere in considerazione i parametri del metodo invocato come discriminante per l’applicazione del throttling.
1 2 3 4 |
@Throttling(type = ThrottlingType.SpEL, expression = "#model.userName", limit = 3, timeUnit = TimeUnit.MINUTES) public void sayHello(Model model) { .... } |
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()
:
1 2 3 4 |
@Throttling(type = ThrottlingType.CookieValue, cookieName = "JSESSIONID", limit = 24, timeUnit = TimeUnit.DAYS) public void sayHello(...) { .... } |
Http Header Value
Anche gli header http recuperati dalla request mediante HttpServletRequest#getHeader()
possono essere utilizzati come discriminanti per il throttling.
1 2 3 4 |
@Throttling(type = ThrottlingType.HeaderValue, headerName = "X-Forwarded-For", limit = 10, timeUnit = TimeUnit.HOURS) public void sayHello(...) { .... } |
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()
.
1 2 3 4 |
@Throttling(type = ThrottlingType.PrincipalName, limit = 1, timeUnit = TimeUnit.HOURS) public void sayHello(...) { .... } |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
private int CACHESIZE = 1000; private ConcurrentMap<Object, Object> cache = CacheBuilder.newBuilder().maximumSize(CACHESIZE).build().asMap(); private void checkrateLimiter(HttpServletRequest request) { String key = request.getRemoteAddr(); RateLimiter rateLimiter = (RateLimiter) cache.get(key); if (rateLimiter == null) { rateLimiter = RateLimiter.create( PERMITS_PER_SECONDS ); cache.put(key, rateLimiter); } rateLimiter.acquire(); } |
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.