Concorrência Não É Throughput
O backfill de inscrições do nosso serviço de notificações rodava 50 goroutines contra uma API da AWS com cota de 100 chamadas por segundo. Ao lado da constante que definia o número de workers havia um comentário explicando que isso deixava folga sob a cota.
Estava ali há meses. Parecia algo que alguém tinha pensado a respeito. Estava errado por mais de uma ordem de magnitude, e é a razão pela qual as notificações push pararam por 48 horas.
Uma contagem e uma taxa são unidades diferentes
Concorrência é quantas operações estão em voo ao mesmo tempo. Throughput é quantas completam por unidade de tempo. Elas são relacionadas, e a relação não é "mais ou menos o mesmo número".
Para uma chamada que leva 30 milissegundos, um worker emitindo uma após a outra faz cerca de 33 por segundo. Cinquenta workers fazendo isso fazem cerca de 1.650 por segundo. A cota era 100.
Você obtém isso da Lei de Little sem derivar nada: throughput é concorrência dividida por latência. No momento em que a latência melhora, o throughput sobe enquanto a contagem de workers fica exatamente onde estava. Então um limite de concorrência não é um rate limit, e não é nem um proxy estável para um. O nosso derivava junto com o tempo de resposta da API que estava chamando.
O que torna o erro fácil é que os dois números são inteiros pequenos que soam como capacidade. Cinquenta e cem parecem pertencer ao mesmo eixo. Não pertencem, e nenhuma quantidade de encarar a constante revela isso, porque o termo que falta é a latência, que não está no arquivo.
O limiter que já tínhamos não teria ajudado
A parte desconfortável é que este serviço já tinha um rate limiter, internal/ratelimit, e ele já estava conectado ao serviço de inscrições. Só não estava conectado ao caminho que gerava 99% da carga.
Exceto que não teria mudado muito se estivesse, o que levei mais tempo para ver.
AdaptiveRateLimiter.Wait dorme um atraso por chamada e retorna. Não há contabilidade compartilhada: cada chamador espera o próprio atraso, de forma independente. Então com um atraso de 50 milissegundos e um chamador você tem 20 chamadas por segundo. Com 50 chamadores você tem 1.000 chamadas por segundo, porque todos dormem concorrentemente.
Um sleep por chamada é um mecanismo de ritmo para um chamador serial. Sob paralelismo o throughput dele é concorrência / atraso, que é a mesma fórmula que quebrou o pool de workers. O limiter tinha o mesmo bug que o código que ele deveria proteger.
Essa é uma forma geral que vale reconhecer. Se o estado do seu limiter vive na goroutine chamadora, ele não pode impor nada global. Um teto precisa ser algo pelo qual todos os chamadores competem.
Como um teto realmente é
A correção foi um token bucket, no mesmo pacote, com uma instância compartilhada por todos os workers. Tokens reabastecem a uma taxa definida, um chamador pega um ou espera, e a contabilidade fica no bucket em vez de no chamador.
Escrevi em vez de adicionar golang.org/x/time/rate, que é uma dependência defensável e teria sido a escolha óbvia num dia normal. Este não era um dia normal: produção estava quebrada há dois dias e eu não queria que a correção carregasse uma mudança de go.mod para dentro de uma release que ninguém teria tempo de revisar com cuidado. Um token bucket são quarenta linhas. git diff go.mod go.sum voltou vazio, que era o ponto.
Provar isso também exigiu um momento de reflexão. O teste natural é afirmar que o limiter foi chamado, o que não prova nada sobre a taxa. O critério que escrevemos em vez disso roda 50 goroutines tentando adquirir todas de uma vez, por uma janela medida, e afirma que a quantidade que passou nunca excede o teto naquela janela. É um limite inferior de tempo em vez de uma asserção de mock, o que o deixa mais lento e um pouco menos agradável, e é a única versão que teria pegado o bug original.
Reconhecer throttling é um problema por si só
Um teto impede que você exceda a cota que você acha que tem. Não faz nada quanto à cota que você realmente tem, que no nosso caso a AWS reduziu para um dígito no meio do incidente.
Para isso você precisa ouvir a recusa e desacelerar. O que significa classificar erros, e os erros são mais confusos do que a documentação sugere. A AWS retornou todos estes para a mesma condição:
Throttling: Rate exceeded, o documentadoThrottlingException, de um caminho de código diferenteexceeded maximum number of attempts, 3, ... api error Throttling, onde o SDK já tentou três vezes e embalou o original
E estes, que não são throttling e não podem ser tratados como tal:
Endpoint is disabled, significando que o dispositivo se foiTopic does not exist, significando que o nosso próprio estado está errado
O classificador reaproveita aws-sdk-go-v2/aws/retry, já uma dependência direta, então a verificação é tipada em vez de comparação de string. Testar isso exigiu um erro de API tipado falso, e em vez de trazer smithy-go para um teste declarei um tipo local com um método ErrorCode() string, que é tudo que a interface pede.
Duas regras saíram disso que eu aplicaria em qualquer lugar hoje:
Throttling nunca é motivo para apagar um token. Não diz nada sobre o dispositivo, apenas sobre a sua taxa. Tínhamos um caminho de poda de dispositivos que podia ser alcançado a partir de uma resposta throttled, o que teria apagado dispositivos de usuários reais para punir o nosso próprio ritmo.
Throttling também nunca é motivo para abrir um circuit breaker. Um breaker existe para parar de martelar uma dependência que está quebrada. Um throttle significa que a dependência está bem e você está rápido demais: a resposta correta é desacelerar e continuar, não abrir o circuito e parar. Esse precisou de uma mudança em circuitbreaker.Config.IsFailure, porque o nosso breaker vinha contando throttles como falhas e abrindo neles, o que convertia um problema de ritmo numa indisponibilidade do caminho inteiro.
Um teto por processo não é um teto
A última peça é a que eu teria errado se o incidente não tivesse deixado óbvio.
O serviço roda mais de uma task. Um token bucket num processo limita aquele processo. Dois processos com o mesmo teto produzem o dobro da taxa, e a cota é por conta.
Então o teto foi para o Redis: um token bucket com chave por operação, retornando uma dica de espera para que um chamador que não pode prosseguir saiba quanto tempo dormir em vez de girar. Três detalhes nisso acabaram importando mais que o bucket em si.
Ele falha aberto para o teto local. Se o Redis estiver indisponível, o limiter cai de volta para o bucket em processo em vez de se recusar a trabalhar. Um rate limiter que se torna uma indisponibilidade quando o store pisca é um problema pior que o que ele resolve, e o estado de fallback é reportado na linha de log, então "estamos rodando degradados" é visível em vez de inferido.
O orçamento é cobrado por tentativa HTTP, através de middleware do SDK, não por chamada lógica. Essa é a que eu mais gosto. Se você cobra um token quando o seu código chama Subscribe, e o SDK internamente tenta três vezes, você gastou três unidades de cota e contou uma. Enganchar a cobrança na cadeia de middleware faz com que a coisa contada seja a coisa que a cota conta.
E os números em vigor são reportados em vez de assumidos: a taxa aplicada no momento, o consumo contra ela, e se o fallback local está ativo. Durante a recuperação, com o limite protetivo da AWS em vigor, aquela linha de log era a única forma de distinguir entre "estamos no ritmo correto" e "estamos no ritmo correto contra o número errado".
O orçamento que só contava vitórias
Mais uma coisa do mesmo arquivo, porque é a mesma categoria de erro.
Cada execução do backfill tinha um teto de quantas inscrições criaria. Ele contava sucessos. Quando toda chamada falha, nada incrementa, então o teto nunca é alcançado e a execução percorre o conjunto inteiro de candidatos: até 500 tópicos vezes 1.000 dispositivos de tentativas, todas falhando, todas alimentando a espiral.
Um orçamento que conta conclusões é um orçamento que desaparece sob falha. Conte tentativas. O recurso que você está protegendo é gasto independentemente de a chamada ter funcionado.
O que eu levei disso
Throughput é concorrência dividida por latência, e só dois desses três aparecem no seu código. Se um comentário raciocina sobre uma taxa a partir de uma contagem de workers, a latência que ele assumiu é que está fazendo o trabalho, e ela não está escrita em lugar nenhum.
Um limiter cujo estado é por chamador não pode impor uma taxa global, não importa como se chame. Verifique onde vive a contabilidade antes de confiar nele.
Cobre o limiter onde as requisições realmente saem, que geralmente é mais baixo do que você pensa. Retries internos do SDK gastam cota real.
E um throttle é um sinal de ritmo, não uma falha. Tudo abaixo do seu tratamento de erros precisa saber a diferença: breakers, jobs de poda, alarmes, e a própria flag de sucesso da execução.
Parte de Removendo um Gargalo, sobre o incidente de throttling no SNS Subscribe e a migração para entrega direta via FCM.