Deduplicação de jobs com Sidekiq e Redis, sem complicação
Um guia prático de SET NX EX, deduplicação, debounce, retries e idempotência em Rails.
Uma fila agendada passou de mil jobs, e quase metade era repetida. Em outro momento, o mesmo tenant gerou cerca de 1,4 mil recomputações equivalentes. O resultado foi CPU e banco ocupados com o mesmo trabalho, enquanto e-mails e integrações esperavam.
A primeira tentativa foi procurar um job igual antes de cada enqueue. Isso exigia varrer filas, jobs agendados e retries. Quanto maior a fila, mais cara ficava a busca. E havia uma corrida: dois processos podiam procurar ao mesmo tempo, não encontrar nada e publicar a mesma tarefa.
A solução em quatro passos
- ▸1. Monte um fingerprint: um identificador curto calculado com SHA-256 a partir da classe do job e de seus argumentos.
- ▸2. Tente reservar uma chave no Redis antes de publicar o job.
- ▸3. Se a reserva já existir, não publique a duplicata.
- ▸4. Se o job terminar com sucesso, libere a chave; se falhar, preserve-a para o retry ou até o TTL expirar.
| Antes | Depois |
|---|---|
| Varrer todas as filas | Consultar uma chave direta no Redis |
| Custo cresce com a fila | Uma operação curta por tentativa |
| Dois processos podem publicar | Só a primeira reserva vence |
| Regra espalhada | Regra no job base |
SET NX EX, palavra por palavra
SET jobs:dedup:<digest> 1 NX EX 5400- ▸SET grava um valor no Redis.
- ▸key é a chave jobs:dedup:<digest>, como uma ficha de reserva para aquele trabalho.
- ▸value é 1 neste exemplo didático: apenas indica que a reserva existe.
- ▸NX significa “somente se não existir”. Só o primeiro produtor recebe OK.
- ▸EX define a expiração em segundos.
- ▸ttl é o tempo de vida da chave. Ele funciona como a validade de uma senha temporária.
O Redis executa essa decisão de uma vez. Não existe intervalo entre “verificar” e “reservar”. TTL significa tempo de vida: quando ele acaba, o Redis remove a chave e evita um bloqueio permanente.
Deduplicação genérica com Active Job
Fingerprint é a assinatura curta que representa “mesma classe e mesmos argumentos”. O exemplo usa callbacks do Active Job e a conexão redis-client entregue por Sidekiq.redis.
require "digest"
require "json"
class DeduplicatedJob < ApplicationJob
DEDUP_TTL = 90.minutes.to_i
before_enqueue :reserve_once, unless: :retrying_existing_job?
after_perform :release_reservation
private
def dedup_key
payload = JSON.generate([self.class.name, arguments])
digest = Digest::SHA256.hexdigest(payload)
"jobs:dedup:#{digest}"
end
def retrying_existing_job?
executions.positive?
end
def reserve_once
acquired = Sidekiq.redis do |connection|
connection.call(
"SET", dedup_key, "1", "NX", "EX", DEDUP_TTL
) == "OK"
end
throw(:abort) unless acquired
rescue RedisClient::Error => error
Rails.logger.warn("dedup unavailable: #{error.class}")
# Fail-open: Redis failed, so enqueue the job anyway.
end
def release_reservation
Sidekiq.redis { |connection| connection.call("DEL", dedup_key) }
rescue RedisClient::Error => error
Rails.logger.warn("dedup release failed: #{error.class}")
end
end
class RefreshProjectionJob < DeduplicatedJob
queue_as :default
sidekiq_options retry: 4
def perform(tenant_id, period)
ProjectionRefresher.call(tenant_id:, period:)
end
end
RefreshProjectionJob.perform_later(42, "2026-08")before_enqueue tenta reservar a chave. Uma duplicata recebe false do Redis e o callback cancela o enqueue. O after_perform só roda após sucesso e libera a reserva. Se perform falhar, a chave permanece durante os retries ou até o TTL. Um retry do próprio Active Job reutiliza o job existente e, por executions, não tenta adquirir a chave outra vez.
Fail-open significa continuar o enqueue quando o Redis está indisponível. Pode haver duplicata, mas o trabalho não é perdido. Isso é adequado quando repetir é menos grave que deixar de executar.
Quando endurecer a solução
O valor constante 1 simplifica o exemplo, mas tem um limite: se o TTL vencer durante um job lento, outro produtor pode criar uma nova reserva e o primeiro job pode apagá-la. Em operações críticas, grave um token aleatório e remova a chave somente se o token ainda for o seu, com uma operação condicional (por exemplo, Lua). Se perder um comando for inaceitável, uma outbox no banco é uma evolução possível.
Deduplicação não é debounce
Deduplicação remove cópias com a mesma classe e os mesmos argumentos. Debounce agrupa vários eventos próximos que pedem o mesmo resultado final, mesmo que os eventos não sejam idênticos.
class RecomputeFinancialsJob < ApplicationJob
WINDOW = 30.seconds
KEY_TTL = 90.seconds.to_i
def self.schedule(tenant_id)
key = "finance:recompute:tenant:#{tenant_id}"
acquired = Sidekiq.redis do |connection|
connection.call("SET", key, "1", "NX", "EX", KEY_TTL) == "OK"
end
return false unless acquired
set(wait: WINDOW).perform_later(tenant_id)
rescue RedisClient::Error => error
Rails.logger.warn("debounce unavailable: #{error.class}")
set(wait: WINDOW).perform_later(tenant_id) # Fail-open
end
def perform(tenant_id)
release_debounce_key(tenant_id)
FinancialsRecalculator.call(tenant_id:)
end
private
def release_debounce_key(tenant_id)
key = "finance:recompute:tenant:#{tenant_id}"
Sidekiq.redis { |connection| connection.call("DEL", key) }
rescue RedisClient::Error => error
Rails.logger.warn("debounce release failed: #{error.class}")
end
endA primeira chamada cria uma chave por tenant e agenda o job para 30 segundos depois. Chamadas seguintes nessa janela são agrupadas. O job apaga a chave no início; assim, eventos que chegarem durante a execução podem agendar uma próxima rodada.
Retries e idempotência ainda importam
Deduplicar o enqueue não garante execução única. Um worker pode concluir o efeito e falhar antes de confirmar o job. Por isso, limite retries conforme o custo e o tipo de erro. Idempotência significa que repetir a operação produz o mesmo resultado correto, sem cobrar, enviar ou gravar duas vezes.
Use proteções no banco e chaves de idempotência em serviços externos quando necessário. O job também deve tolerar uma execução parcialmente concluída.
Limitações para observar
- ▸TTL curto demais deixa duplicatas passarem; longo demais bloqueia trabalho legítimo.
- ▸Existe uma pequena janela entre reservar no Redis e publicar no Sidekiq. O TTL limita o problema, mas não cria uma transação entre os dois.
- ▸Fail-open troca unicidade por disponibilidade quando o Redis falha.
- ▸Argumentos equivalentes precisam gerar o mesmo fingerprint; mantenha seu formato estável.
- ▸A deduplicação reduz publicações repetidas, mas não substitui idempotência nem um mutex.
E a fila que já estava cheia?
Depois de proteger os produtores, faça uma limpeza administrativa única dos jobs antigos: percorra as filas de forma controlada, compare fingerprints e remova duplicatas confirmadas. Não transforme essa varredura em parte permanente do enqueue e não apague jobs em execução.
Checklist curto
- ▸Defina exatamente o que conta como duplicata.
- ▸Escolha e monitore o TTL.
- ▸Decida conscientemente entre fail-open e fail-closed.
- ▸Limite retries e torne o efeito idempotente.
- ▸Meça reservas, duplicatas evitadas e falhas do Redis.
Conclusão
SET key value NX EX ttl troca uma varredura cara por uma ficha de reserva simples e atômica. Use deduplicação para jobs iguais e debounce para agrupar uma onda de eventos. Depois complete a proteção com TTL adequado, retries limitados, idempotência e atenção às janelas de falha.
Referências
- ▸Redis SET: https://redis.io/docs/latest/commands/set/
- ▸Redis distributed locks: https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/
- ▸Sidekiq Best Practices: https://github.com/sidekiq/sidekiq/wiki/Best-Practices
- ▸Sidekiq Error Handling: https://github.com/sidekiq/sidekiq/wiki/Error-Handling
- ▸Sidekiq API: https://github.com/sidekiq/sidekiq/wiki/API
- ▸Rails Guides, Active Job Basics: https://guides.rubyonrails.org/active_job_basics.html
- ▸Ruby Digest::SHA2: https://ruby-doc.org/3.4.1/exts/digest/Digest/SHA2.html
✓ EOF, Israel Santos
← voltar aos artigos