Skip to content
This page has been auto-translated and may contain errors.View in English

Token bucket

Todos los algoritmos vistos hasta ahora tratan una ráfaga como un problema. Pero muchas ráfagas son completamente normales: una página que carga seis cosas a la vez, o un cliente que estuvo inactivo un minuto y ahora tiene trabajo pendiente.

El token bucket permite esas ráfagas a propósito. Los tokens se acumulan a un ritmo constante, cada solicitud gasta uno, y un cliente que ha estado en silencio tiene algunos guardados.

Hojas y orugas

Una planta sostiene como máximo cinco hojas y crece tres al día. Cada oruga que llega se come una hoja, y una oruga que no encuentra hoja disponible no sobrevive.

DíaHojas que crecenOrugasResultadoHojas restantes
132Ambas comen1
23, entonces 4 en total54 comen, 1 no0
330No pasa nada3
42, con tope de 50No pasa nada5

Tres ideas en esa tabla:

  • Una hoja es un token, y una solicitud gasta uno.
  • Los tokens llegan a un ritmo fijo, sin importar si alguien los está pidiendo o no.
  • La planta no puede sostener más de su capacidad, así que el día cuatro crece dos en lugar de tres.

La capacidad es lo que permite la ráfaga. Un bucket lleno puede gastarse todo de una vez, así que un cliente que ha estado en silencio puede enviar cinco solicitudes en un instante y luego tiene que esperar a que crezcan más.

El ritmo de recarga define el promedio a largo plazo. La capacidad define qué tan grande puede ser la ráfaga que toleras.

JunoHojas y orugas El día cuatro vale la pena detenerse a pensarlo. Normalmente crecerían tres hojas, pero solo crecen dos, porque la planta ya está en cuatro y no puede sostener seis.

Ese tope es la razón por la que una ráfaga tiene un límite de tamaño. Sin él, un cliente inactivo por una hora regresaría pudiendo enviar de golpe todo lo acumulado en esa hora.

JunoHojas y orugas Son dos parámetros y cada uno cumple una función distinta. El ritmo de recarga es el throughput sostenido que estás dispuesto a servir. La capacidad es cuánto puede ahorrar un cliente y gastar de una sola vez.

Entonces un bucket de 10 que se recarga con 6 cada 3 segundos promedia 2 por segundo y tolera 10 de golpe. Mismo promedio, pero una sensación muy distinta a uno de 2 que se recarga con 2 cada segundo.

JunoHojas y orugas La capacidad también representa tu peor escenario, y debe dimensionarse según lo que el endpoint realmente pueda absorber. Un bucket de 10,000 puede promediar bien y aun así lanzar 10,000 solicitudes en un solo instante, que es el número que verá tu base de datos.

El otro costo es el estado: un bucket por cliente significa guardar un conteo de tokens y una marca de tiempo de la última recarga por cada cliente que hayas visto, lo cual es más que el contador de una ventana fija y menos que la lista de marcas de tiempo de un log deslizante.

Las implementaciones en producción suelen prescindir por completo del temporizador y calcular los tokens de forma perezosa al momento de leer, a partir del tiempo transcurrido desde la última recarga. No hay ningún proceso en segundo plano, y los buckets de clientes inactivos no cuestan nada hasta que vuelven a aparecer.

Construyendo el bucket

El estado son dos números, y la lógica son dos decisiones.

js
class TokenBucket {
  constructor(capacity, refillRate, refillInterval) {
    this.capacity = capacity
    this.refillRate = refillRate
    this.refillInterval = refillInterval
    this.tokens = capacity          // empieza lleno
    this.secondsSinceLastRefill = 0
  }

  processRequests(numRequests) {
    this.secondsSinceLastRefill += 1

    if (this.secondsSinceLastRefill >= this.refillInterval) {
      this.tokens = Math.min(this.capacity, this.tokens + this.refillRate)
      this.secondsSinceLastRefill = 0
    }

    const accepted = Math.min(numRequests, this.tokens)
    const rejected = numRequests - accepted

    this.tokens -= accepted

    return { accepted, rejected }
  }
}

Las dos llamadas a Math.min son las que hacen el trabajo real.

La primera limita la recarga a la capacidad, así que los tokens nunca superan el tope. La segunda limita las aceptaciones a los tokens disponibles, así que una solicitud de nueve tokens contra un bucket que tiene solo uno acepta uno y rechaza ocho.

Usar >= en lugar de == en la verificación de recarga es deliberado. Si alguna vez se salta un tick, una comparación exacta se saltaría la recarga y el bucket nunca volvería a llenarse.

JunoConstruyendo el bucket Empezar el bucket lleno es una decisión, y una decisión amable. Un cliente nuevo puede actuar de inmediato en lugar de esperar a que aparezcan sus primeros tokens.

Empezar vacío sería más estricto y haría que la primera visita de cualquiera se sintiera como si algo estuviera roto.

JunoConstruyendo el bucket Fíjate en que es posible atender parcialmente una ronda: nueve solicitudes contra un solo token da como resultado una aceptada y ocho rechazadas, en lugar de rechazar todo el lote.

Eso importa para cómo debería responder un cliente. Parte de su trabajo tuvo éxito, así que reintentar todo duplicaría la parte que ya se procesó.

JunoConstruyendo el bucket Esta versión avanza el tiempo contando rondas, lo cual funciona bien para un juego por turnos pero no para un servidor. El tráfico real no llega en ticks, así que el código en producción guarda una marca de tiempo y calcula la acumulación al momento de leer: tiempo transcurrido multiplicado por el ritmo, sumado al conteo actual, con tope en la capacidad.

Eso también permite que los tokens sean fraccionarios, algo que importa a ritmos bajos. Con acumulación en enteros y un ritmo menor a un token por intervalo, un cliente puede quedar redondeado a cero para siempre y nunca recuperarse.

La concurrencia es la otra diferencia. Dos solicitudes simultáneas que leen this.tokens antes de que cualquiera de las dos escriba serán ambas aceptadas contra el mismo token, la misma carrera de leer-decidir-escribir que dejó pasar 400 solicitudes concurrentes a través de un límite de 100 en un contador de Redis. La verificación tiene que ser atómica.

Los parámetros lo cambian todo

Dos configuraciones, el mismo algoritmo.

Capacidad 10, recarga de 6 cada 3 segundos. Aproximadamente dos por segundo de forma sostenida, con espacio para diez de golpe. Viéndolo segundo a segundo:

RondaSolicitadasAceptadasRechazadasTokens restantes
13307
25502
37701, tras recargar a 8
49180

Generoso. Las ráfagas se absorben, y quedarse sin tokens requiere un esfuerzo deliberado.

Capacidad 8, recarga de 2 cada 10 segundos. Ahora las primeras ocho solicitudes son gratis y luego casi nada lo es. Envía cinco, luego cinco más, y te quedas vacío con ocho segundos de espera para conseguir dos tokens.

Mismo algoritmo, experiencia completamente distinta. Un cliente bajo la segunda configuración tiene que pensar cuándo gastar, porque los tokens son escasos y tardan en volver.

JunoLos parámetros lo cambian todo Vale la pena imaginar mentalmente la segunda configuración antes de seguir leyendo. Ocho tokens, y luego dos más cada diez segundos.

Ese no es un límite de tasa que moldea el tráfico. Es uno que obliga al cliente a racionar cada solicitud, y el algoritmo no cambió en absoluto.

JunoLos parámetros lo cambian todo El intervalo de recarga es el parámetro que la gente configura sin pensarlo, y es el que decide cómo se siente el límite. Seis tokens cada tres segundos y dos cada segundo promedian lo mismo pero se comportan de manera totalmente distinta.

Los intervalos largos significan largas sequías seguidas de un golpe grande. Los intervalos cortos se sienten suaves. Prefiere el intervalo más corto que tu almacenamiento pueda manejar.

JunoLos parámetros lo cambian todo Medido contra un token bucket, 13 solicitudes que llegan de golpe contra un bucket de 10 aceptaron exactamente 10 y rechazaron 3, devolviendo retryAfterMs: 500. Dejado en reposo por 3 segundos, ese mismo bucket acumuló 6 tokens.

Ese retryAfterMs es lo que vale la pena exponer. Un token bucket puede calcular con precisión cuándo llegará el siguiente token, algo que una ventana fija no puede hacer, así que se le puede decir a un cliente exactamente cuánto tiempo esperar en lugar de dejarlo adivinar.

Devuélvelo en Retry-After y un cliente bien comportado deja de bombardearte. Omítelo y su única estrategia será reintentar de inmediato, que es justo lo que intentabas evitar.

Ponlo a prueba

Un bucket con capacidad 5, que se recarga con 2 tokens cada 2 segundos, empezando lleno.

  1. Un cliente envía 5 solicitudes de golpe. ¿Cuántas se aceptan y cuántas quedan?
  2. Dos segundos después envía 3. ¿Qué pasa?
  3. El cliente luego espera 10 segundos. ¿Cuántos tokens tiene?
  4. ¿Cuál es el ritmo sostenido que permite este bucket, y cuál es la ráfaga más grande posible?
Compara tus respuestas

1. Las 5 se aceptan, quedan 0. El bucket empieza lleno y cada solicitud gasta un token. Vaciarlo en un instante es precisamente la ráfaga que la capacidad existe para permitir.

2. Dos aceptadas, una rechazada. Pasaron dos segundos, así que el bucket se recarga con 2. Llegan tres solicitudes contra 2 tokens, así que Math.min(3, 2) acepta dos y la tercera se rechaza.

3. Cinco, no diez. Diez segundos acumularían 10 tokens, pero la capacidad lo limita a 5. Esta es la regla del día cuatro: el tiempo inactivo no se acumula indefinidamente, y eso es lo que impide que un cliente que estuvo mucho tiempo en silencio regrese con una ráfaga enorme.

4. Uno por segundo de forma sostenida, cinco de golpe. Dos tokens cada dos segundos es el promedio a largo plazo, y la capacidad de 5 es la ráfaga instantánea más grande posible.

La pregunta cuatro es el par que vale la pena recordar. El ritmo de recarga y la capacidad responden dos preguntas distintas, y describir un token bucket con un solo número deja fuera una de ellas.

Hacia dónde va esto

El token bucket le permite al cliente decidir cuándo gastar, así que el tráfico que llega a tu servidor sigue siendo desparejo. Acotado, pero desparejo.

Leaky bucket toma el enfoque contrario: acepta la ráfaga y luego la libera a un ritmo constante, de modo que lo que ve el servidor es parejo sin importar cómo llegó.