Como Lidar com Tráfego Sazonal: Algoritmos Leaky Bucket e Token Bucket

API7.ai

January 5, 2023

OpenResty (NGINX + Lua)

Nos artigos anteriores, aprendemos sobre otimização de código e design de cache, que estão intimamente relacionados ao desempenho geral do aplicativo e merecem nossa atenção. No entanto, em cenários reais de negócios, também precisamos considerar o impacto do tráfego repentino no desempenho. O tráfego repentino aqui pode ser normal, como tráfego de notícias urgentes, promoções, etc., ou tráfego anormal, como ataques DDoS.

O OpenResty agora é usado principalmente como uma camada de acesso para aplicativos Web, como WAFs e gateways de API, que precisam lidar com o tráfego repentino normal e anormal mencionado. Afinal, se você não conseguir lidar com tráfego repentino, os serviços de back-end podem facilmente ser sobrecarregados, e o negócio não responderá adequadamente. Então, hoje, veremos maneiras de lidar com tráfego repentino.

Controle de Tráfego

O controle de tráfego é uma funcionalidade essencial para WAF (Web Application Firewall) e gateways de API. Ele garante que os serviços upstream possam funcionar adequadamente, direcionando e controlando o tráfego de entrada por meio de alguns algoritmos, mantendo assim o sistema saudável.

Como a capacidade de processamento do back-end é limitada, precisamos considerar vários aspectos, como custo, experiência do usuário e estabilidade do sistema. Não importa qual algoritmo seja usado, ele inevitavelmente causará o atraso ou até a rejeição de solicitações de usuários normais, sacrificando parte da experiência do usuário. Portanto, precisamos controlar o tráfego enquanto equilibramos a estabilidade do negócio e a experiência do usuário.

Na verdade, há muitos casos de "controle de tráfego" na vida real. Por exemplo, durante o período de viagens do Ano Novo Chinês, o fluxo de pessoas fica congestionado em estações de metrô, trens, aeroportos e outros centros de transporte, porque a capacidade de manuseio desses veículos é limitada. Portanto, os passageiros devem esperar em fila e entrar na estação em lotes para garantir sua segurança e a operação regular do tráfego.

Isso naturalmente afeta a experiência do passageiro, mas, no geral, garante a operação eficiente e segura do sistema. Por exemplo, se não houvesse filas e lotes, mas todos fossem permitidos entrar na estação em massa, o resultado seria que todo o sistema entraria em colapso.

Voltando à tecnologia, por exemplo, vamos supor que um serviço upstream seja projetado para lidar com 10.000 solicitações por minuto. Nos horários de pico, se não houver controle de fluxo no ponto de entrada e a pilha de tarefas atingir 20.000 por minuto, o desempenho de processamento desse serviço upstream pode degradar para talvez apenas 5.000 solicitações por minuto e continuar a se deteriorar, talvez levando à indisponibilidade do serviço. Esse não é o resultado que queremos ver.

Os algoritmos comuns de controle de tráfego que usamos para lidar com esse tipo de tráfego repentino são o algoritmo do balde furado e o algoritmo do balde de tokens.

Algoritmo do Balde Furado

Vamos começar olhando para o algoritmo do balde furado, que visa manter uma taxa constante de solicitações e suavizar picos de tráfego. Mas como isso é alcançado? Primeiro, veja a seguinte abstração conceitual da introdução do algoritmo do balde furado na Wikipedia.

algoritmo do balde furado

Podemos imaginar o tráfego do cliente como água fluindo de um cano com uma taxa de fluxo incerta, às vezes rápida e às vezes lenta. O módulo de processamento de tráfego externo, que é o balde que recebe a água, tem um buraco no fundo para vazamento. Essa é a origem do nome do algoritmo do balde furado, que tem os seguintes benefícios:

Primeiro, independentemente de o fluxo para o balde ser um fiozinho ou uma enchente monstruosa, é garantido que a taxa de água que sai do balde seja constante. Esse tráfego constante é amigável para serviços upstream, que é o objetivo do controle de tráfego.

Segundo, o balde em si tem um certo volume e pode acumular uma certa quantidade de água. Isso equivale a solicitações de clientes que podem ser enfileiradas se não puderem ser processadas imediatamente.

Terceiro, a água que excede o volume do balde não será aceita pelo balde, mas fluirá para fora. A metáfora correspondente aqui é que, se houver muitas solicitações de clientes que excedam o comprimento da fila, uma mensagem de falha será retornada diretamente ao cliente. Nesse momento, o lado do servidor não consegue lidar com tantas solicitações e a fila se torna desnecessária.

Então, como esse algoritmo deve ser implementado? Vamos pegar a biblioteca resty.limit.req que vem com o OpenResty como exemplo. É um módulo de limite de taxa implementado pelo algoritmo do balde furado. Vamos falar mais sobre ele no próximo artigo. Hoje, começaremos com uma breve olhada nas seguintes linhas de código, que são as principais:

local elapsed = now - tonumber(rec.last)
excess = max(tonumber(rec.excess) - rate * abs(elapsed) / 1000 + 1000,0)
if excess > self.burst then
    return nil, "rejected"
end
-- return the delay in seconds, as well as excess
return excess / rate, excess / 1000

Vamos ler essas linhas de código brevemente. Onde elapsed é o número de milissegundos entre a solicitação atual e a última, e rate é a taxa que definimos por segundo. Como a menor unidade de rate é 0,001 s/r, o código implementado acima precisa ser multiplicado por 1000 para calculá-lo.

excess indica o número de solicitações ainda na fila, 0 significa que o balde está vazio, sem solicitações na fila, e burst refere-se ao volume de todo o balde. Se excess for maior que burst, significa que o balde está cheio, e o tráfego que entra será descartado diretamente; se excess for maior que 0 e menor que burst, ele entrará na fila para esperar o processamento, e o excess/rate retornado aqui é o tempo de espera.

Dessa forma, podemos controlar o comprimento da fila de tráfego repentino ajustando o tamanho do burst, enquanto a capacidade de processamento do serviço de back-end permanece inalterada. Mas, é claro, depende do seu cenário de negócios se você informa ao usuário que há muitas solicitações e para tentar novamente mais tarde, ou deixa o usuário esperar por um período mais longo.

Algoritmo do Balde de Tokens

Tanto o algoritmo do balde de tokens quanto o algoritmo do balde furado têm o mesmo objetivo, garantir que os serviços de back-end não sejam sobrecarregados por picos de tráfego, embora os dois não sejam implementados da mesma maneira.

O algoritmo do balde furado usa o IP do endpoint para fazer o controle de tráfego e taxa básica. Dessa forma, a taxa de saída do algoritmo do balde furado de cada cliente é fixa. No entanto, isso apresenta um problema:

Suponha que as solicitações do usuário A sejam frequentes e as solicitações de outros usuários sejam infrequentes. Nesse caso, o algoritmo do balde furado retardará ou rejeitará algumas das solicitações de A, mesmo que o serviço possa lidar com elas naquele momento, mesmo que a pressão geral do serviço não seja muito alta.

É aqui que o balde de tokens se torna útil.

Enquanto o algoritmo do balde furado se preocupa em suavizar o tráfego, o balde de tokens permite que picos de tráfego entrem no serviço de back-end. O princípio do balde de tokens é colocar tokens no balde a uma taxa fixa e continuar colocando-os enquanto o balde não estiver cheio. Dessa forma, todas as solicitações que vêm do endpoint precisam ir ao balde de tokens para obter o token antes que o back-end possa processá-las; se não houver token dentro do balde, a solicitação será rejeitada.

No entanto, o OpenResty não implementa baldes de tokens para limitar o tráfego e a taxa em sua biblioteca. Então, aqui está uma breve introdução ao módulo de limite de taxa baseado em balde de tokens lua-resty-limit-rate, que é de código aberto pela UPYUN, como exemplo:

local limit_rate = require "resty.limit.rate"

-- global 20r/s 6000r/5m
local lim_global = limit_rate.new("my_limit_rate_store", 100, 6000, 2)

-- single 2r/s 600r/5m
local lim_single = limit_rate.new("my_limit_rate_store", 500, 600, 1)

local t0, err = lim_global:take_available("__global__", 1)
local t1, err = lim_single:take_available(ngx.var.arg_userid, 1)

if t0 == 1 then
    return -- global bucket is not hungry
else
    if t1 == 1 then
        return -- single bucket is not hungry
    else
        return ngx.exit(503)
    end
end

Neste código, configuramos dois baldes de tokens: um balde de tokens global e um balde de tokens com ngx.var.arg_userid como key, dividido por usuário. Há uma combinação dos dois baldes de tokens, que tem o seguinte benefício principal:

  • Não é necessário determinar o balde de tokens do usuário se ainda houver tokens no balde de tokens global, e atender o máximo de solicitações repentinas de usuários possível se o serviço de back-end puder funcionar corretamente.
  • Na ausência de um balde de tokens global, as solicitações não podem ser rejeitadas indiscriminadamente, então é necessário determinar o balde de tokens de usuários individuais e rejeitar solicitações de usuários com mais solicitações repentinas. Dessa forma, é garantido que as solicitações de outros usuários não sejam afetadas.

Obviamente, os baldes de tokens são mais flexíveis do que os baldes furados, permitindo situações em que picos de tráfego são passados para serviços de back-end. Mas, é claro, ambos têm seus prós e contras, e você pode escolher usá-los de acordo com sua situação.

Módulo de Limite de Taxa do NGINX

Com esses dois algoritmos explicados, vamos finalmente ver como implementar um limite de taxa no NGINX. No NGINX, o módulo limit_req é o módulo de limite de taxa mais comumente usado, e a seguir está uma configuração simples:

limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;

server {
    location /search/ {
        limit_req zone=one burst=5;
    }
}

Este código pega o endereço IP do cliente como key, solicita um espaço de endereço de memória de 10M chamado one e limita a taxa para 1 solicitação por segundo.

No local do servidor, a regra de limite de taxa one também é referenciada, e o brust é definido como 5. Se a taxa exceder 1r/s, 5 solicitações podem ser enfileiradas simultaneamente, dando uma certa área de buffer. Se brust não for definido, as solicitações que excederem a taxa serão rejeitadas diretamente.

Este módulo do NGINX é baseado em um balde furado, então é essencialmente o mesmo que resty.limit.req no OpenResty, que descrevemos acima.

Resumo

O maior problema do limite de taxa no NGINX é que eles não podem ser modificados dinamicamente. Afinal, você precisa reiniciar o arquivo de configuração após modificá-lo para torná-lo efetivo, o que é inaceitável em um ambiente em rápida mudança. Portanto, o próximo artigo examinará a implementação de limites de tráfego e taxa dinamicamente no OpenResty.

Finalmente, vamos considerar uma questão. Do ponto de vista de WAFs e gateways de API, há uma maneira melhor de identificar o que são solicitações de usuários normais e o que são maliciosas? Porque, para tráfego repentino de usuários normais, podemos rapidamente escalar os serviços de back-end para aumentar a capacidade do serviço, enquanto para solicitações maliciosas, é melhor rejeitá-las diretamente na camada de acesso.

Você é bem-vindo a encaminhar este artigo para seus colegas e amigos para aprender e progredir juntos.