Escolhendo a tecnologia de fila com Postgres
(adriano.fyi)- Antes de adotar um sistema de filas separado, se for possível montar uma fila de tarefas em segundo plano apenas com o Postgres que já está em uso, dá para aproveitar bastante a simplicidade operacional e a familiaridade da equipe
- Desde o Postgres 9.5,
NOTIFY/LISTENeFOR UPDATE SKIP LOCKEDresolvem juntos tanto a notificação de novas tarefas quanto a prevenção de processamento duplicado entre workers - Redis, Kafka, RabbitMQ e Amazon SQS são opções poderosas, mas uma nova dependência adiciona modos de falha e custo de aprendizado aos ambientes de desenvolvimento, testes e operação
- Filas com Postgres também não são solução para tudo: push/pop pode ser mais lento do que no Redis, e pode haver custos operacionais como um banco de dados ou servidor de filas separado e
VACUUMmais frequente - O código de tarefas em segundo plano deve ser abstraído para não ficar preso ao backend de fila, e é mais seguro considerar outra tecnologia de fila apenas quando a tecnologia familiar não conseguir atender aos requisitos
Como uma fila com Postgres funciona
- A tecnologia de fila com Postgres é composta por dois elementos
- pub/sub para anunciar e receber novas tarefas
- bloqueio de linhas para impedir que vários workers processem a mesma tarefa
- As duas funcionalidades são fornecidas por padrão desde o Postgres 9.5, lançado em 2016
- Usando
NOTIFYeLISTENjuntos, é possível adicionar pub/sub à aplicação FOR UPDATE SKIP LOCKEDaplica bloqueio de linha nos registros que correspondem à condição e ignora os registros que já estão bloqueados- Aplicado a registros de tarefas, isso permite criar uma consulta de processamento de fila como
SELECT * FROM jobs ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1
- Aplicado a registros de tarefas, isso permite criar uma consulta de processamento de fila como
SKIP LOCKEDfornece uma visão “inconsistente” dos dados, mas essa característica é útil em filas- Tarefas que já estão sendo processadas não ficam visíveis para outros workers por causa do bloqueio de linha
- Esse comportamento permite exclusão mútua distribuída
- Ao usar
NOTIFYpara avisar os workers que estão emLISTENsobre novas tarefas, é possível montar tanto processamento periódico em lote quanto processamento em tempo real
Como o Redis virou o padrão para tarefas em segundo plano
- Decidir como processar tarefas de longa duração é uma escolha técnica cheia de trade-offs
- As opções de fila e message broker mais usadas incluem
- Redis: armazenamento de dados em memória e backend de várias bibliotecas de tarefas em segundo plano
- Apache Kafka: plataforma distribuída de streaming de eventos mantida pela Apache Foundation
- RabbitMQ: apresentado como um message broker amplamente distribuído
- Amazon SQS: SaaS da Amazon que oferece filas altamente escaláveis
- No tópico background jobs do GitHub, as 5 bibliotecas mais populares são todas baseadas em Redis
- O Redis armazena dados em memória, então inserção e leitura são rápidas, e oferece API de pub/sub e estruturas de dados como
listeset, o que combina bem com a implementação de filas - A escalabilidade do Redis faz dele a escolha padrão para muitos desenvolvedores, e padrões têm muita força em decisões tecnológicas
Critérios a avaliar antes da escalabilidade
- A obsessão do setor de tecnologia com “scale” pode empurrar para segundo plano simplicidade, facilidade de manutenção e redução da carga cognitiva dos desenvolvedores
- Muitas aplicações não exigem escala no nível de Google, Facebook ou Uber
- Ao iniciar um projeto ou negócio, em vez de otimizar apenas para escalabilidade desde o começo, vale priorizar primeiro estes critérios
- tecnologias que a equipe conhece bem
- tecnologia boa o suficiente para atender às necessidades dos usuários
- a solução que melhor combina com a capacidade da equipe e exige menos esforço
- O Postgres também escala bem na prática, mas não é o sistema especializado em filas que mais escala para esse caso de uso
- Como software de uso geral, o Postgres faz várias coisas razoavelmente bem, e fila é uma delas
Julgando pela “tecnologia entediante”
- Ao escolher uma tecnologia de fila, a pergunta mais importante é qual tecnologia já está em uso e é bem compreendida hoje
- Uma tecnologia que já é usada e bem conhecida adiciona menos peso à stack de software
- Se você já usa um banco de dados relacional e esse banco é o Postgres, faz sentido considerar uma fila com Postgres antes de outro software
- “Tecnologia entediante” não é uma lista fixa; ela muda de acordo com as tecnologias já usadas no momento
- Em aplicações centradas em troca de mensagens, RabbitMQ pode ser a tecnologia entediante
- Em aplicações centradas em cache, Redis pode ser a tecnologia entediante
- Em aplicações com muitos dados relacionais, Postgres pode ser a tecnologia entediante
- Introduzir Redis, Kafka, RabbitMQ ou SQS do zero apenas para tarefas em segundo plano aumenta bastante o custo
- uma nova dependência de sistema é adicionada aos ambientes de desenvolvimento, testes e produção
- no futuro, Developer, DBA e SRE precisarão conhecer os modos de falha e as configurações do novo sistema
- será necessário conhecimento de recuperação de falhas, diagnóstico de problemas e monitoramento de desempenho
- unknown unknowns que a equipe ainda não conhece continuam sendo um risco
Os custos e os critérios de escolha de uma fila com Postgres
- Tecnologia entediante não é solução mágica, e o Postgres não é exceção
- Ao escolher uma fila com Postgres, você ganha familiaridade, modos de falha conhecidos e diluição de custos, mas pode pagar os seguintes preços
- push/pop em fila com Postgres é consideravelmente mais lento do que no Redis
- em vez de um único banco de dados relacional, pode ser necessário um banco da aplicação e outro banco para a fila
- pode ser necessário um servidor de banco de dados separado para escalar tarefas em segundo plano de forma independente
VACUUMmais frequente pode ser necessário, gerando custo de desempenho
- Nenhum entre Postgres, Redis, Kafka, RabbitMQ e SQS deveria virar um padrão automático
- Escolha de tecnologia é um conjunto contínuo de trade-offs, e deve ser julgada conforme a equipe e as exigências da aplicação
- Em caso de dúvida, pode-se aplicar o critério: “considere alternativas apenas quando ficar demonstrado que a tecnologia entediante não consegue atender aos requisitos”
Uma estrutura de tarefas em segundo plano que permite trocar a fila
- O código de processamento de tarefas em segundo plano não deve depender de uma tecnologia de fila específica
- À medida que a aplicação cresce, tecnologias como memcached ou Redis podem ser adicionadas conforme necessário, e com o tempo também podem se tornar “tecnologias entediantes” familiares para a equipe
- Ao abstrair a tecnologia de fila, o usuário pode escolher a fila adequada para o trabalho
- Entre as bibliotecas de tarefas em segundo plano mais populares no GitHub, com exceção de Hangfire, nenhuma oferece uma rota de saída para tecnologias de fila além do Redis
- Em uma estrutura assim, trocar a fila exige reescrever o código da aplicação
- Neoq é uma biblioteca para Go criada com essa filosofia
- o backend de fila pode usar memória, Postgres ou Redis
- o usuário pode inicializar com outro backend de fila sem mudar o código da aplicação
- as implementações em memória e Postgres são nativas, e a implementação Redis usa asynq
- Redis, Kafka, RabbitMQ e SQS podem ser excelentes tecnologias, mas nem sempre são a fila certa para aquele trabalho ou o nível adequado de complexidade
1 comentários
Opiniões do Hacker News
Até agora, já construí sistemas distribuídos de jobs três vezes, e a regra prática que recomendo há anos é: “projete para 10 vezes a escala atual”
Se são 70 requisições por segundo, pense em 700; se você precisa de 20 servidores de jobs em lote, dimensione para 200. Mesmo uma startup que cresce 100% ao ano estará 8 vezes maior em 3 anos, então há tempo para reescrever conforme cresce
No primeiro sistema que criei, evitei SQL por causa de “escalabilidade”, mas sofri bastante ao encontrar muitos casos de exceção que exigiam integridade transacional
Nos dois sistemas distribuídos de jobs que construí depois, usei PostgreSQL como coordenador e, literalmente, estruturei tudo em torno de
SELECT FOR UPDATE SKIP LOCKED. Um deles controla normalmente 350 workers, e o outro aplica prioridades complexas a milhares de jobsAmbos vão aguentar sem problemas até chegar a uma receita anual de vários milhões de dólares; por exemplo, o sistema com 350 workers poderia escalar para cerca de 2.000 CPUs com pequenos ajustes
Tecnologias de escala massiva parecem bem baratas até você perceber que precisa de transações. A partir desse momento, imitar semântica transacional em cima de um armazenamento com consistência eventual vira um pesadelo de engenharia
Portanto, é preciso sentar e fazer as contas. Se a empresa faturar US$ 100 milhões por ano, quão grande o sistema distribuído precisará ser, se é fácil obter uma instância PostgreSQL que aguente essa carga, ou se sharding por cliente basta; se for possível, vale considerar seriamente PostgreSQL. Cem pequenas coisas ficam mais fáceis
A solução em SQL costuma ser mais simples, consumir menos recursos computacionais e ser mais fácil de operar
Mas, para fazê-la funcionar, é preciso conhecer bem o banco de dados a ponto de saber que recursos como
SELECT FOR UPDATE SKIP LOCKEDexistem. Hoje, como muitos engenheiros crescem em ambientes onde ORMs pesados bloqueiam a capacidade real do DBMS, esse conhecimento está ficando bastante raroO sistema precisa suportar a carga de pico e, se você não sabe qual é o pico, deve colocar uma margem de segurança ou incluir uma forma de descartar ou adiar jobs quando necessário
Tudo é uma troca; você deve otimizar apenas o que precisa ser otimizado, e a capacidade de julgar o que entra nesse grupo é uma característica de bons engenheiros
Se isso é novidade para você, acabou de obter, lendo o HN durante o expediente, o valor de uma semana inteira; vale escrever em um papel e colar no seu cartão do banco
Em vários projetos, optei por uma abordagem mais simples: usar uma tabela comum e
SELECT FOR UPDATE SKIP LOCKED, algo que funciona diretamente em ORMs/frameworks de DSL de consulta de todas as linguagenshttps://www.pgcasts.com/episodes/the-skip-locked-feature-in-...
Não é “escala web”, mas, na minha experiência, escala facilmente até milhares de jobs em background
Usei essa abordagem sem problemas em trabalhos de uma organização grande. Não precisa de deploy especial nem de nova infraestrutura; basta subir algumas threads de worker dentro da aplicação. Também dá para ter uma thread que reinicialize jobs abandonados
Porém, em 3 anos isso nunca aconteceu de fato; tudo ficava dentro de
try/catch, e, se falhasse, era colocado de volta na fila, além de a aplicação Java ser muito estáveldelete from taskwhere task_id in( select task_idfrom taskorder by random() -- use tablesample for better performancefor updateskip lockedlimit 1)returning task_id, task_type, params::jsonb as params[1] https://taylor.town/pg-task
Você disse “milhares de jobs em background”, mas filas normalmente devem ser avaliadas pela Lei de Little, falando em taxas como a taxa média de entrada de jobs por segundo e a duração média dos jobs. O número bruto, sozinho, não significa muita coisa
No começo, um
UPDATE ... SETingênuo até funciona, mas pega locks demais. Usar uma subconsultaSELECTnoUPDATEou tornar o lock de retirada mais eficiente comSELECT FOR UPDATE SKIP LOCKEDpode ajudar, mas, no fim, as consultas de retirada podem bloquear os locks umas das outras e travar a filaNesse momento, dá para desligar a entrada para deixar o DB respirar, mas a entrada perdida causa perda de dados, e geralmente o problema central é que as retiradas bloqueiam umas às outras
Também dá para shardear às pressas a tabela de jobs para evitar locks, mas isso é frágil para distribuir entre vários workers e pode levar à perda de dados. Você poderia descartar uma parte aleatória dos jobs, mas, obviamente, isso é perda de dados
Essas opções são extremamente estressantes em produção e muito difíceis de recuperar sem redesenhar do zero. A menos que você tenha apenas alguns clientes e tenha certeza de que ficará para sempre no nível de dezenas de jobs por segundo, depois de passar por isso pessoalmente, eu escolho uma tecnologia de filas de verdade em vez de banco de dados sempre que possível
Vejo com frequência críticas do tipo “engenheiros copiam a infraestrutura da FAANG para parecerem descolados, mesmo que ela não tenha nada a ver com suas necessidades”, mas acho que, em grande parte, isso é um problema de conhecimento e documentação.
Se você quiser seguir a FAANG ou outras startups e criar uma arquitetura baseada em filas infinitamente escalável, há dezenas de guias, tutoriais e white papers de alta qualidade que permitem configurar soluções escaláveis como Redis e SQS em poucas horas.
O custo de manutenção é mais alto, mas dá para começar copiando e colando comandos, código e configurações de fontes respeitadas.
Por outro lado, se você quiser usar
NOTIFYdo PostgreSQL e pesquisar “SQLALchemy notify listen postgres”, só aparecem algumas perguntas sem resposta no Stack Overflow e alguns GitHub Gists sem contexto.Eu realmente gostaria de experimentar essa abordagem em um projeto paralelo, mas não tenho folga para gastar 2 ou 3 dias descobrindo tudo sozinho.
Por fora, parece uma escolha entre “simples, mas não escala, ou seja, usar só PostgreSQL” e “complexo, mas escala, ou seja, Redis/SQS etc.”; nesse caso, vira a narrativa de que eu escolhi uma escalabilidade desnecessária porque fiquei cego por tecnologias legais e pela FAANG.
Mas, quando se consideram também os guias e materiais disponíveis, as opções reais ficam mais próximas de “complexo e com escalabilidade imprevisível, porque eu não conheço a implementação nem as armadilhas” versus “simples e escalável, o jeito que todo mundo realmente faz”. Nesse cenário, a escolha do engenheiro que segue a FAANG parece muito mais racional.
NOTIFY, basta ler https://www.postgresql.org/docs/current/sql-notify.html, sem precisar de grandes habilidades de busca.Por exemplo, com um PostgreSQL sob baixa carga, migrações de schema, adição de novas constraints, análises etc. se tornam quase triviais.
Ao usar SQS, Cassandra etc., você ganha escalabilidade/disponibilidade, mas, quando descobre que o design original não se encaixa, mudar leva muito mais tempo. É o caso de quando o negócio pede para adicionar uma constraint como “usuários do tipo foo não podem combinar simultaneamente o valor bar”.
Dá para implementar sem PostgreSQL, mas, especialmente quando mudanças são necessárias, não é fácil nem simples.
Por isso, para manter flexibilidade, acho melhor usar PostgreSQL; quando você sabe que algo não vai mudar, aí sim usar outra coisa junto por cima do PostgreSQL. Claro que, nesse caso, há overhead adicional de infraestrutura e manutenção.
No fim, é sempre um trade-off, e é preciso saber o que trocar, quando e pelo quê.
Não acho ideal que todo mundo implemente dentro da própria aplicação um worker de tarefas em background sob medida, seja com backend PostgreSQL ou com outra fila.
Tarefas em background têm muitas sutilezas e detalhes de implementação fáceis de errar, então normalmente é melhor que uma biblioteca ou framework dedicado e mais abrangente cuide disso.
Se nem toda aplicação Rails tivesse Sidekiq/Active Jobs e cada uma implementasse seu próprio worker em background, é bem provável que apps Rails tivessem uma reputação muito pior em termos de confiabilidade.
Basta escolher a solução que traz o maior benefício sem se afastar demais do objetivo principal.
Já vi uma startup em que os desenvolvedores gastavam cerca de 80% do tempo brigando com ferramentas e infraestrutura. O runway era de 3 meses e, hoje, só sobrou um grande buraco no fim dele. Ainda me dá arrepios só de pensar.
Parece faltar respeito pela complexidade.
A maior desvantagem de usar PostgreSQL como barramento publish/subscribe com
LISTEN/NOTIFYé queLISTENé um recurso de sessão e, portanto, não combina com pooling de conexões por instrução.Se for seguir esse caminho, é melhor usar advisory locks. Outros locks explícitos impõem uma carga maior ao banco de dados, mas advisory locks foram projetados intencionalmente para serem muito leves.
Meu exemplo favorito de implementação é o que, que foi portado para várias linguagens.
NOTIFY/LISTENé que, quando há problema, é difícil de diagnosticar.Recentemente também tivemos de parar de usar porque, depois de algum tempo, todos os
NOTIFY/LISTENtravavam, e só reiniciar o banco de dados resolvia https://dba.stackexchange.com/questions/325104/error-could-n...A Skype usou o PostgreSQL como fila com um pequeno plugin, anos atrás, para processar todos os CDRs. Não sei se ainda usa, mas, pelos padrões de 10 anos atrás, era “escala web” e simplesmente funcionava bem enquanto, na internet, as pessoas discutiam que usar banco de dados como fila era um antipadrão
Ter transações é bastante conveniente
https://wiki.postgresql.org/wiki/SkyTools
Como usei bastante no trabalho, também fiz algumas apresentações sobre esse tema no Sydpy. Se você já opera e dá suporte bem ao PostgreSQL, é útil
Mas, hoje em dia, eu provavelmente usaria uma fila dedicada e evitaria o RabbitMQ a todo custo
Mesmo que haja preocupação com aprisionamento ao fornecedor, as operações básicas de uma fila são, na prática, algo pequeno como push/pop, então também é relativamente fácil escrever de um jeito que permita migrar se necessário
Se você colocou a fila dentro do banco de dados só porque já operava um banco de dados, essa lógica é uma faca de dois gumes. Não é algo a comemorar, mas agora a bagunça da fila pode derrubar também o armazenamento principal de dados
Uma das maiores vantagens de usar PostgreSQL como fila da aplicação é que todos os trabalhos assíncronos agendados se beneficiam da transacionalidade
Por exemplo, se houver uma alteração complexa no backend após o cadastro de um usuário, que envia um e-mail, e depois de inserir a tarefa de envio de e-mail uma parte posterior da mesma transação falhar e fizer rollback, essa tarefa de e-mail nem entra na fila em primeiro lugar
No caso de e-mail, se você falhar esperando a confirmação de envio, precisa escolher entre fazer a transação falhar e aceitar a possibilidade de e-mail duplicado, ou seguir em frente e aceitar a possibilidade de perda do e-mail
A grande vantagem aparece em caminhos de código que modificam o DB de forma assíncrona. Como o consumo do trabalho e a atualização do DB ficam dentro da mesma transação, a semântica de exatamente uma vez pode ser tratada de forma totalmente transacional
No PostgreSQL, mantenho uma tabela de log de transações que contém o evento disparado e o
pg_current_xact_id()daquela transação. Também daria para usar oxminembutido da linha, mas aí é preciso se preocupar com wraparound de transações. Inserir nessa linha dispara umNOTIFYUm processo em segundo plano roda repetidamente e seleciona, da tabela de transações, todas as linhas com IDs de transação entre o
xminda execução anterior e opg_snapshot_xmin(pg_current_snapshot())atual. Ele mapeia esses eventos para trabalhos e os envia ao SQS, registra oxmine então espera o próximoNOTIFYcomLISTENEm uma fila no PostgreSQL, é verdade que, se você inserir uma tarefa de envio de e-mail e depois a transação falhar, o e-mail não entra na fila
Código de banco de dados que precisa ser totalmente separado vale a pena ser separado em transações diferentes. Por outro lado, se o usuário não foi criado no DB, talvez você também não queira enviar o e-mail de cadastro, então depende da situação
Em filas baseadas em Redis, ou praticamente qualquer outra fila, você rapidamente acaba encontrando situações em que um trabalho que assume que um registro existe no banco de dados é executado antes de a transação ser commitada. O código para corrigir isso costuma ficar estranho e complexo
O que gosto no Kafka é que ele é simplesmente um log somente de acréscimo, e os clientes essencialmente só mantêm offsets
Conceitualmente, é muito simples de entender, é persistente e bastante resiliente a falhas, porque você pode voltar para qualquer offset e reler
Infelizmente, por sua natureza distribuída, o Kafka traz complexidade suficiente para que, na maioria dos casos de uso, acabe não valendo a pena
Pessoalmente, eu gostaria que existisse algo parecido, mas mais fácil de operar. Em um único nó, provavelmente conseguiria processar centenas, talvez milhares, de eventos por segundo, e seria bem interessante sem a complexidade distribuída
Em tese, também dá para fazer isso com PostgreSQL: basta nunca apagar as linhas. Talvez essa seja a resposta
Se você não precisa do conjunto completo de recursos, como consumer groups e partições, a configuração dos consumidores também fica muito mais simples
No Windmill usamos exatamente essa abordagem. O Windmill é uma alternativa open source ao Retool e uma ferramenta mais próxima de um Airflow moderno, e rodamos benchmarks todos os dias
Em uma instância razoável do GitHub CI, executando um worker do Windmill e o PostgreSQL em contêineres, o benchmark processa 1.200 trabalhos por segundo. Ao adicionar workers, escala de forma estável até 5.000 trabalhos por segundo
Estamos avaliando o uso do Citus para ultrapassar a barreira de 5.000 trabalhos por segundo em uma instância multi-tenant
https://github.com/windmill-labs/windmill/tree/benchmarks
Na época em que o volume era de cerca de 10 mensagens por segundo, usamos PostgreSQL para algumas filas e ele até escalou bastante. Mas, sendo sincero, configurar SQS ou outras stacks de fila na AWS, GCP ou Azure é fácil demais; elas foram criadas para esse propósito e já trazem coisas como DLQ embutidas
Não vejo motivo para escolher esse caminho tendo que se preocupar com a possibilidade de esse sistema desabar e afetar até o estado do restante do banco de dados
Gosto do princípio de “use a ferramenta mais simples”, mas às vezes parece que os engenheiros vão longe demais e acabam ficando só com a ferramenta mais burra, cheia de ressalvas ambíguas, mesmo quando a alternativa mainstream é relativamente barata e simples
Ao armazenar o estado dos jobs no banco de dados, dá para consultá-lo de uma forma conveniente. Mesmo que não mostre o estado real com precisão, isso ajuda a analisar falhas em operação. Especialmente porque a maioria das filas de jobs simplesmente apaga os registros processados
E, se você tornar todos os jobs em background idempotentes, um modelo como “enviar uma mensagem para a fila de jobs mandando processar este job” quase sempre é seguro
Quando se depende apenas da fila de mensagens, muitas vezes fica difícil entender o que está acontecendo quando surgem problemas de desempenho. Por exemplo, o RabbitMQ pode informar o tamanho da fila, mas tem pouca ou nenhuma capacidade de inspecionar os dados internos
Pode-se prever que alguns softwares nunca vão ultrapassar a capacidade do PostgreSQL e, mesmo que ultrapassem, é possível projetá-los para facilitar a migração para outro serviço
E talvez você queira criar de forma simples, dentro do PostgreSQL, um sistema de jobs com transações
Usamos o Oban, do Elixir, para processar centenas de milhares a milhões de jobs por dia dentro do PostgreSQL
Ter semântica transacional em torno de jobs em background, como agendar um e-mail apenas quando uma transação é bem-sucedida, é extremamente conveniente
É preciso fazer um pouco de tuning no
autovacuum, mas, depois de ajustado, funcionou muito bem para nós