Remova o Gargalo, Não Ande Devagar Embaixo Dele
Depois que o incidente de throttling no SNS Subscribe foi estabilizado ao longo de sete releases, o serviço estava saudável e o design estava inalterado. Tudo que havíamos entregue era uma forma de viver polidamente embaixo de uma cota de 100 chamadas por segundo que a AWS documenta como não ajustável, e que eles temporariamente reduziram para um dígito porque o nosso padrão de uso estava afetando outros clientes na região.
Então a pergunta deixou de ser como andar em ritmo melhor. Escrevi um ADR, e a primeira coisa útil que ele fez foi recusar a pergunta que eu levei.
A pergunta que eu levei era a errada
O que eu realmente queria perguntar era se deveríamos migrar para Kafka ou RabbitMQ. É a forma que o problema apresenta: um sistema de fan-out sob carga, e brokers são o que se compra para fan-out.
Escrever isso me forçou a notar que o SNS fazia três trabalhos separáveis para nós, e um broker substitui exatamente um deles:
| Papel | Substituível por Kafka, RabbitMQ ou SQS |
|---|---|
| Registro de inscrições e roteamento do fan-out | sim |
| Token do dispositivo para endpoint de plataforma, credenciais, feedback de endpoint desabilitado | não |
| Entrega de fato para APNs e FCM | não |
"Migrar para Kafka" portanto não pode significar "substituir o SNS". Significa construir o nosso próprio fan-out e a nossa própria entrega para Apple e Google, e depois opcionalmente colocar um broker no meio. O broker é o componente menor e mais opcional da mudança que eu pensava estar propondo.
Esse reenquadramento resolveu a decisão praticamente sozinho, porque uma vez que o broker é separado, a pergunta interessante é se o fan-out nos pertence. E os dados já eram nossos.
As proporções
| Medida | Valor |
|---|---|
| Dispositivos habilitados | 4.976 |
| Tópicos SNS em produção | 25.297 |
| Taxa de eventos no pico | ~1,2 / seg |
| Taxa aceita de inscrição sob o limite protetivo | ~7-9 / seg |
Operávamos 25.297 peças de estado de roteamento do lado da AWS, cerca de cinco por dispositivo, para atender 4.976 dispositivos e aproximadamente um evento por segundo. Os dados de roteamento já estavam no nosso Postgres: r10_subscription é escrita pelo mesmo código que chama Subscribe, e o backfill a lê para descobrir o que falta. A inscrição no SNS era uma segunda cópia de uma tabela que já possuíamos, guardada num sistema onde escrever uma linha custava uma chamada de API com rate limit.
Quatro opções
As opções eram: endurecer o cliente e manter a topologia (o que acabáramos de entregar); construir o nosso próprio fan-out com FCM e APNs diretos; isso mais um broker; ou Kafka especificamente como backbone de eventos da plataforma.
A segunda foi escolhida, em estágios, com o broker adiado e Kafka rejeitado.
É a única opção que remove o motivo em vez de acomodá-lo. Subscribe para de aparecer na arquitetura, então o teto do control plane deixa de existir como conceito em vez de se tornar um número que ajustamos. Também não precisa de infraestrutura nova, o que importa mais aqui do que importaria em outro lugar: o time são dois engenheiros de backend já operando ECS, Postgres, Valkey e SQS. Adquirir uma quinta dependência com estado para resolver um problema de cota é uma troca que eu teria que justificar por anos.
O modelo de custo se inverte, e é esse o ponto
| inscrever | publicar | |
|---|---|---|
| SNS | caro: N chamadas de API com rate limit, pode falhar parcialmente | barato: uma chamada |
| Fan-out próprio | barato: um INSERT, atômico, sem throttle possível |
mais trabalho: uma query e N envios |
Mover custo de inscrever para publicar parece um movimento lateral. Está correto aqui por causa de onde a capacidade realmente está. Inscrições são em rajada, atreladas a partidas começando, com rate limit, e são o que quebrou. Publicações rodam a 1,2 por segundo, quatro centésimos de um por cento da cota de publicação. Uma query de fan-out nessa taxa é nada.
Há um efeito de segunda ordem que eu não antecipei e que hoje considero o argumento mais forte do documento. A mudança não torna a falha menos provável, ela remove a falha. Um usuário que se inscreve um segundo antes de um gol recebe aquele gol, porque a query de fan-out no momento da publicação lê a mesma tabela que a inscrição acabou de escrever. No design antigo aquele usuário tinha que esperar a inscrição ser aceita por uma API externa, com rate limit, individualmente falível, antes que qualquer evento pudesse alcançá-lo.
A linha que de fato decidiu
O requisito de produto é que todo usuário consiga se inscrever numa partida e começar a receber notificações. Isto é como aquilo parecia em produção no dia 31 de agosto, depois de tudo que havíamos entregue para estabilizar:
{"msg":"match_subscribed","status":"partial_failure",
"subscription_count":34,"subscription_failed":2,"duration_ms":9347}
Um usuário esperou 9,3 segundos e terminou com 2 das suas 36 inscrições faltando. Ele receberia escanteios e não gols, e nada dizia a ele ou a nós quais duas se foram.
Nenhum ajuste de rate limit corrige isso, e essa é a parte que vale generalizar. Enquanto inscrever custa N chamadas externas que podem falhar independentemente, dentro da requisição de um usuário, a falha parcial permanece disponível a todo momento. Você pode torná-la mais rara. Não pode torná-la impossível. O único jeito de removê-la é fazer com que a requisição pare de depender de N chamadas externas, que é o que um único INSERT faz.
Adiando o broker, rejeitando Kafka
O broker está adiado em vez de rejeitado, e a razão honesta é que ele resolve um problema real que não é este. Nosso pool de workers em processo é um canal com capacidade igual a workers vezes dez. Qualquer coisa enfileirada nele é perdida num crash ou deploy. Essa é uma lacuna genuína de durabilidade. A 1,2 eventos por segundo também não é urgente, e adotar um broker sem também possuir o fan-out não muda nada sobre o incidente que acabáramos de ter.
Se adotarmos um, deve ser SQS, que já está na plataforma para outro serviço, é gerenciado, e não custa atenção operacional. É o mais lento dos três em latência, somando talvez 30 a 100 milissegundos, e isso é irrelevante contra um orçamento de produto medido em segundos.
Kafka está rejeitado. Ele ganha o próprio custo através de replay, event sourcing e múltiplos consumidores independentes em alto throughput, e não precisamos de nenhum dos três. A cerca de uma mensagem por segundo, um cluster com partições, consumer groups e política de retenção é um passivo operacional permanente comprado por capacidades que não estão nos requisitos. RabbitMQ é honestamente uma forma melhor para esta carga do que Kafka: ack por mensagem, dead letter queue, e filas de prioridade que expressariam "partidas ao vivo primeiro" nativamente em vez da ordenação que escrevemos à mão. Ainda é um broker para operar, e o SQS já está lá.
O que tornou isso viável
A razão pela qual esta é uma migração em estágios e não uma reescrita é que a maior parte das partes difíceis já existia.
internal/apns é um cliente APNs direto e completo: HTTP/2 para a Apple, autenticação JWT ES256, headers de tópico. Está em produção há um tempo servindo Live Activity, que é o push de maior frequência que enviamos. internal/worker já constrói o JSON completo da mensagem FCM v1, porque o SNS quer aquele payload repassado. E as três tabelas que a query de fan-out precisa são as tabelas que já consultamos.
O único componente genuinamente ausente era um sender FCM: OAuth2 com uma service account, e depois uma requisição HTTP por token. É no máximo uma semana de trabalho, e é trabalho com um contrato bem documentado do outro lado.
O que assumimos
Escrever os pontos negativos importou tanto quanto a decisão.
O ciclo de vida do token passa a ser nosso, e é o principal risco de toda a mudança. Hoje o SNS guarda o token num platform endpoint, marca como desabilitado quando a Apple ou o Google recusam, e reporta isso num log de feedback que já consumimos. Depois, nós guardamos o token e temos que interpretar cada recusa por conta própria, decidindo quais significam que o dispositivo se foi e quais significam que estamos sendo throttled. Errar isso apaga dispositivos de usuários reais, e é a mesma classe de falha do incidente: silenciosa, e invisível em todo dashboard.
Retries, backoff e tratamento de falha parcial de lote também passam a ser nossos, todos hoje gratuitos pelo SDK.
E dois caminhos de entrega coexistem durante a migração, então um bug pode se esconder na diferença entre eles. Esse é mitigado entregando por tipo de notificação e comparando os dois, o que acabou sendo mais subtil do que parece.
O que eu levei disso
A pergunta "qual broker" quase sempre chega antes da pergunta "qual desses trabalhos é de fato o problema". Separar a dependência nos papéis que ela cumpre para você é barato e mudou completamente a minha resposta.
Uma cota que não pode ser levantada é uma propriedade do design, não da operação. Se a única alavanca é sentar mais embaixo dela, então a capacidade escala com rotatividade e não com tráfego, e cada dispositivo novo a aperta.
E quando um requisito é declarado como absoluto (todo usuário consegue se inscrever e começar a receber), verifique se o design atual consegue satisfazê-lo em princípio e não no caso comum. O nosso não conseguia, e a falha parcial de 9,3 segundos estava nos logs há um tempo parecendo um soluço operacional em vez do requisito sendo violado.
Parte de Removendo um Gargalo, sobre o incidente de throttling no SNS Subscribe e a migração para entrega direta via FCM.