Implementación de Limitación de Tasa con Ventana Deslizante Usando Sorted Sets de Redis

El algoritmo se fundamenta en dos operaciones principales:

  1. Conteo de solicitudes dentro del período activo.
  2. Aplicación de la regla de limitación.

Las estructuras Sorted Sets de Redis, donde cada elemento tiene una puntuación (score), son ideales para modelar esto. El score puede representar la marca de tiempo de la solicitud, mientras que el value actúa como identificador único. La clave (key) puede derivarse del recurso (URI), dirección IP o identificador de usuario.

El comando básico para registrar una solicitud es:

ZADD clave_recurso marca_tiempo identificador_solicitud

La siguiente implementación en Java ilustra la lógica:

public boolean permisoConcedido(String claveRecurso) {
    ZSetOperations<String, String> operacionesSortedSet = plantillaRedis.opsForZSet();
    long marcaActual = System.currentTimeMillis();
    long inicioVentana = marcaActual - duracionVentana;

    operacionesSortedSet.removeRangeByScore(claveRecurso, 0, inicioVentana);
    Long conteoActual = operacionesSortedSet.zCard(claveRecurso);

    if (conteoActual >= limiteMaximo) {
        return false;
    }

    String idUnico = generarIdUnico();
    operacionesSortedSet.add(claveRecurso, idUnico, marcaActual);
    plantillaRedis.expire(claveRecurso, duracionVentana, TimeUnit.MILLISECONDS);
    return true;
}

Para garantizar la atomicidad en entornos concurrentes, se encapsulan las operaciones en un script Lua:

local clave = KEYS[1]
local marca_actual = tonumber(ARGV[1])
local tamano_ventana = tonumber(ARGV[2])
local umbral = tonumber(ARGV[3])

redis.call('ZREMRANGEBYSCORE', clave, 0, marca_actual - tamano_ventana)
local conteo = redis.call('ZCARD', clave)

if conteo >= umbral then
    return '0'
else
    redis.call('ZADD', clave, marca_actual, marca_actual)
    return '1'
end

Una implementaicón completa integrando el script sería:

@Service
public class LimitadorVentanaDeslizante {

    @Autowired
    private StringRedisTemplate plantillaRedis;

    private final long duracionVentana = 60000;
    private final int limiteMaximo =13;

    public boolean verificarPermiso(String clave) {
        String scriptLua = "local clave = KEYS[1]\n" +
                          "local marca_actual = tonumber(ARGV[1])\n" +
                          "local tamano_ventana = tonumber(ARGV[2])\n" +
                          "local umbral = tonumber(ARGV[3])\n" +
                          "redis.call('ZREMRANGEBYSCORE', clave, 0, marca_actual - tamano_ventana)\n" +
                          "local conteo = redis.call('ZCARD', clave)\n" +
                          "if conteo >= umbral then\n" +
                          "    return '0'\n" +
                          "else\n" +
                          "    redis.call('ZADD', clave, marca_actual, marca_actual)\n" +
                          "    return '1'\n" +
                          "end";

        DefaultRedisScript<String> script = new DefaultRedisScript<>(scriptLua, String.class);
        long marcaActual = System.currentTimeMillis();

        String resultado = plantillaRedis.execute(script,
                Collections.singletonList(clave),
                String.valueOf(marcaActual),
                String.valueOf(duracionVentana),
                String.valueOf(limiteMaximo));

        return "1".equals(resultado);
    }
}

Para aplicar la limitación de menera declarativa en endpoints de Spring, se puede definir una anotación personalizada:

@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LimitacionTasa {
    long ventanaSegundos() default 60;
    long umbral() default 10;
}

Un aspecto (Aspect) intercepta los métodos anotados:

@Aspect
@Component
public class AspectoLimitacionTasa {

    @Autowired
    private StringRedisTemplate plantillaRedis;

    @Before("@annotation(limitacion)")
    public void validarTasa(JoinPoint puntoUnion, LimitacionTasa limitacion) {
        long ventanaMs = limitacion.ventanaSegundos() * 1000;
        long umbral = limitacion.umbral();

        ServletRequestAttributes atributos = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest solicitud = atributos.getRequest();
        String uri = solicitud.getRequestURI();
        String idUsuario = obtenerIdUsuario(solicitud);
        String claveRedis = "limite:" + idUsuario + ":" + uri;

        ZSetOperations<String, String> operaciones = plantillaRedis.opsForZSet();
        long marcaActual = System.currentTimeMillis();
        long inicioVentana = marcaActual - ventanaMs;

        operaciones.removeRangeByScore(claveRedis, 0, inicioVentana);
        Long conteo = operaciones.zCard(claveRedis);

        if (conteo >= umbral) {
            throw new RuntimeException("Tasa de solicitudes excedida");
        }

        operaciones.add(claveRedis, String.valueOf(marcaActual), marcaActual);
        plantillaRedis.expire(claveRedis, ventanaMs, TimeUnit.MILLISECONDS);
    }
}

Finalmente, la anotación se utiliza en un controaldor:

@RestController
@RequestMapping("/api")
public class ControladorEjemplo {

    @LimitacionTasa(ventanaSegundos = 30, umbral = 5)
    @GetMapping("/recurso")
    public String obtenerRecurso() {
        return "Respuesta exitosa";
    }
}

Este enfoque proporciona un control de tasa robusto y distribuido. Para escenarios más complejos, se recomienda explorar herramientas especializadas como Alibaba Sentinel.

Etiquetas: Redis Sorted Sets Spring AOP Lua Scripts Rate Limiting

Publicado el 8-23 12:12