
Todo sistema web chega ao ponto em que o usuário não pode esperar: o e-mail de boas-vindas, a miniatura da foto, o relatório que leva dois minutos para gerar. Se isso roda dentro da requisição HTTP, a página trava, o timeout estoura e uma falha no SMTP vira erro 500 na cara do cliente. A saída é uma fila de tarefas: a API registra o trabalho e responde na hora, e processos separados executam o serviço em segundo plano, com nova tentativa quando algo falha. No mundo Go, a biblioteca mais usada para isso é o Asynq, que usa o Redis como broker e vem com um painel web, o Asynqmon. Este guia monta um exemplo completo, testado de verdade contra Redis e Valkey em contêineres, e termina com deploy em systemd e métricas no Prometheus.
O que é o Asynq
O Asynq é uma biblioteca Go, licença MIT, criada por Ken Hibino. O modelo é simples:
- o cliente (
asynq.Client) grava a tarefa numa fila no Redis; - o servidor (
asynq.Server) puxa tarefas das filas e abre uma goroutine por tarefa, até o limite deConcurrency; - um ServeMux roteia cada tarefa pelo tipo (
email:boas-vindas,imagem:miniatura), do mesmo jeito que onet/httproteia URLs.
Uma tarefa é só um tipo (string) mais um payload em bytes, normalmente JSON. Como o estado inteiro fica no Redis, você escala rodando mais cópias do worker em outras máquinas, sem coordenação extra. Se você está chegando agora na linguagem, vale ler a história da linguagem Go e o guia de instalação do Go no Linux.
Arquitetura e recursos
O que o Asynq entrega pronto, com o nome da opção na API:
- Entrega pelo menos uma vez (at-least-once): se o worker morre no meio, a tarefa volta para a fila. Por isso o handler precisa ser idempotente.
- Filas com prioridade:
Queues: map[string]int{"critical": 6, "default": 3, "low": 1}divide o tempo em 60/30/10% quando todas têm trabalho (prioridade ponderada). ComStrictPriority: true, a fila de menor prioridade só anda quando as de cima estão vazias. - Retries com backoff: o padrão é
MaxRetry25 e atraso exponencial (a fórmula do Sidekiq, n4 + 15 s + um valor aleatório). Tudo ajustável por tarefa e porRetryDelayFunc(wiki: Task Retry). Um erro embrulhado comasynq.SkipRetrypula as tentativas. - Tarefas agendadas:
ProcessIn(30*time.Second)ouProcessAt(t)deixam a tarefa no estadoscheduledaté a hora. - Tarefas periódicas: o
asynq.Schedulerenfileira tarefas por expressão cron ou@every(wiki: Periodic Tasks). - Deduplicação:
Unique(ttl)recusa uma segunda tarefa com o mesmo tipo, payload e fila enquanto a primeira não for processada com sucesso ou o TTL não expirar, e devolveErrDuplicateTask.TaskID("...")dá um ID fixo à tarefa e recusa repetição comErrTaskIDConflict(wiki: Unique Tasks). - Agregação em grupos: tarefas enfileiradas com
Group("nome")ficam emaggregatinge umGroupAggregatoras junta numa só, controlado porGroupGracePeriod,GroupMaxDelayeGroupMaxSize. Serve, por exemplo, para mandar um único e-mail com dez notificações (wiki: Task aggregation). - Timeout e deadline:
Timeout(d)(padrão de 30 minutos) eDeadline(t)cancelam ocontext.Contextdo handler (wiki: Timeout and Cancelation). - Tarefas arquivadas: quem esgota as tentativas ou devolve
SkipRetryvai paraarchived, onde fica disponível para inspeção e reprocessamento manual pela CLI ou pelo painel. - Retenção:
Retention(24*time.Hour)guarda a tarefa concluída comocompleted, útil para auditoria.
O caminho de uma tarefa, do produtor até o painel:

Situação atual dos projetos
Antes de colocar uma dependência em produção, olhe para ela. Conferido no GitHub em 23 de setembro de 2026:
- Asynq: última versão v0.26.0, de 3 de fevereiro de 2026. Ela subiu o mínimo para Go 1.24 e trouxe headers nas tarefas,
--tlsnoasynq dash, usuário de ACL do Redis na CLI (--username) eUpdateTaskPayloadno Inspector. O branchmastertem 25 commits além da tag, com o último em 12 de junho de 2026 (entre eles umBatchEnqueueainda sem release). O repositório tem cerca de 13,7 mil estrelas e mais de 290 issues abertas. O README diz que o projeto é “relativamente estável” e segue em versãov0.x: a API pública ainda pode quebrar entre versões menores. - Go: o README promete suporte às duas últimas versões do Go, e a CI testa 1.24.x e 1.25.x contra
redis:7. Eu compilei com Go 1.27.1 sem ajuste nenhum. - Redis: o README exige Redis 4.0 ou superior e avisa que alguns scripts Lua podem não ser compatíveis com Redis Cluster. Redis Sentinel é suportado.
- Valkey: não há declaração oficial de suporte. A issue #981 reúne relatos de uso em produção com Valkey (e com Dragonfly, usando
--default_lua_flags=allow-undeclared-keys), e o pedido de documentação (#985) segue aberto. No meu teste abaixo, o exemplo inteiro rodou igual no Valkey 8.1. - Asynqmon: aqui a situação é pior. A última release é a v0.7.1, de maio de 2022. O último commit é de julho de 2023, e a imagem
hibiken/asynqmon:0.7.2(tambémlatest) no Docker Hub foi publicada no mesmo dia, sem release correspondente no GitHub. O código depende do Asynq v0.24.1, e a tabela de compatibilidade do README para em “Asynq 0.23.x ↔ Asynqmon 0.7.x”. Na prática, ele leu e administrou sem erro as filas criadas pelo Asynq v0.26.0 no meu teste, mas trate-o como ferramenta parada no tempo, não como produto mantido.
Laboratório: Redis e Valkey em contêiner
Para o teste, subi um Redis 8 e um Valkey 8 numa rede Docker própria, mais o Asynqmon apontando para o Redis. Se o Docker ainda é novidade, comece pela história do Docker.
docker network create filas
docker run -d --name redis --network filas -p 127.0.0.1:6379:6379 redis:8-alpine
docker run -d --name valkey --network filas -p 127.0.0.1:6380:6379 valkey/valkey:8-alpine
docker exec redis redis-server --version
docker exec valkey valkey-server --version
Redis server v=8.10.2 sha=00000000:1 malloc=jemalloc-5.3.0 bits=64 build=6583f6419e33bdeb
Valkey server v=8.1.8 sha=00000000:0 malloc=jemalloc-5.3.0 bits=64 build=288c3332651d2a23
As portas ficam presas em 127.0.0.1: um Redis sem senha exposto na internet é invasão garantida.
O exemplo: módulo Go com produtor, worker e agendador
O módulo tem três binários e um pacote compartilhado:
filas/
├── go.mod
├── tarefas/tarefas.go # tipos, payloads e handlers
└── cmd/
├── produtor/main.go # enfileira as tarefas
├── worker/main.go # asynq.Server + ServeMux
└── agendador/main.go # tarefas periódicas
mkdir filas && cd filas
go mod init exemplo.com/filas
go get github.com/hibiken/asynq@v0.26.0
Tipos de tarefa e handlers
Cada tipo ganha uma função que cria a tarefa e um handler que a executa. O handler da miniatura simula um storage instável: falha nas duas primeiras tentativas e só funciona na terceira. Um arquivo .bmp é erro permanente e vai direto para o arquivo, sem retry.
// Package tarefas define os tipos de tarefa, os payloads e os handlers.
package tarefas
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/hibiken/asynq"
)
// Tipos de tarefa: o ServeMux roteia pelo prefixo, como rotas HTTP.
const (
TipoEmailBoasVindas = "email:boas-vindas"
TipoMiniatura = "imagem:miniatura"
TipoRelatorio = "relatorio:diario"
)
// RedisAddr lê o endereço do Redis/Valkey do ambiente.
func RedisAddr() string {
if a := os.Getenv("REDIS_ADDR"); a != "" {
return a
}
return "127.0.0.1:6379"
}
type EmailPayload struct {
UsuarioID int `json:"usuario_id"`
Email string `json:"email"`
}
type MiniaturaPayload struct {
Origem string `json:"origem"`
Largura int `json:"largura"`
}
func NovaTarefaEmail(id int, email string) (*asynq.Task, error) {
p, err := json.Marshal(EmailPayload{UsuarioID: id, Email: email})
if err != nil {
return nil, err
}
// Opções padrão da tarefa; podem ser sobrescritas no Enqueue.
return asynq.NewTask(TipoEmailBoasVindas, p,
asynq.Queue("critical"), asynq.MaxRetry(5), asynq.Timeout(30*time.Second)), nil
}
func NovaTarefaMiniatura(origem string, largura int) (*asynq.Task, error) {
p, err := json.Marshal(MiniaturaPayload{Origem: origem, Largura: largura})
if err != nil {
return nil, err
}
return asynq.NewTask(TipoMiniatura, p, asynq.MaxRetry(3), asynq.Timeout(2*time.Minute)), nil
}
func HandleEmail(ctx context.Context, t *asynq.Task) error {
var p EmailPayload
if err := json.Unmarshal(t.Payload(), &p); err != nil {
// Payload inválido nunca vai dar certo: não adianta tentar de novo.
return fmt.Errorf("payload inválido: %v: %w", err, asynq.SkipRetry)
}
id, _ := asynq.GetTaskID(ctx)
log.Printf("e-mail de boas-vindas para %s (usuário %d, tarefa %s)", p.Email, p.UsuarioID, id)
return nil
}
func HandleMiniatura(ctx context.Context, t *asynq.Task) error {
var p MiniaturaPayload
if err := json.Unmarshal(t.Payload(), &p); err != nil {
return fmt.Errorf("payload inválido: %v: %w", err, asynq.SkipRetry)
}
if strings.HasSuffix(p.Origem, ".bmp") {
// Erro permanente: vai direto para "archived", sem retry.
return fmt.Errorf("formato não suportado: %s: %w", p.Origem, asynq.SkipRetry)
}
tentativa, _ := asynq.GetRetryCount(ctx)
if tentativa < 2 {
// Simula falha transitória (storage fora do ar) nas duas primeiras tentativas.
return fmt.Errorf("storage indisponível ao ler %s (tentativa %d)", p.Origem, tentativa+1)
}
select {
case <-time.After(3 * time.Second): // "trabalho" pesado
case <-ctx.Done(): // timeout, deadline ou desligamento
return ctx.Err()
}
log.Printf("miniatura %dpx gerada para %s na tentativa %d", p.Largura, p.Origem, tentativa+1)
return nil
}
func HandleRelatorio(ctx context.Context, t *asynq.Task) error {
log.Printf("relatório diário gerado às %s", time.Now().Format("15:04:05"))
return nil
}
Produtor: enfileirar, deduplicar e agendar
O produtor faz o papel da sua API: grava quatro tarefas. O e-mail é enfileirado duas vezes com Unique para mostrar a deduplicação. O banner vai agendado para 30 segundos depois, com ID próprio e retenção de 24 horas.
package main
import (
"errors"
"log"
"time"
"exemplo.com/filas/tarefas"
"github.com/hibiken/asynq"
)
func main() {
client := asynq.NewClient(asynq.RedisClientOpt{Addr: tarefas.RedisAddr()})
defer client.Close()
// 1) E-mail imediato na fila "critical". Unique evita duplicar o envio
// se o cadastro for reprocessado dentro de 1 hora.
for i := 0; i < 2; i++ {
t, _ := tarefas.NovaTarefaEmail(42, "ana@exemplo.com.br")
info, err := client.Enqueue(t, asynq.Unique(time.Hour))
if errors.Is(err, asynq.ErrDuplicateTask) {
log.Printf("duplicada, ignorada: %v", err)
continue
}
if err != nil {
log.Fatal(err)
}
log.Printf("enfileirada: id=%s fila=%s tipo=%s", info.ID, info.Queue, info.Type)
}
// 2) Miniatura na fila "default" (falha 2x e é reprocessada).
t, _ := tarefas.NovaTarefaMiniatura("uploads/foto-42.jpg", 320)
info, err := client.Enqueue(t)
if err != nil {
log.Fatal(err)
}
log.Printf("enfileirada: id=%s fila=%s tipo=%s", info.ID, info.Queue, info.Type)
// 3) Miniatura agendada para daqui a 30 s, na fila "low", com ID próprio.
t, _ = tarefas.NovaTarefaMiniatura("uploads/banner.png", 1200)
info, err = client.Enqueue(t, asynq.ProcessIn(30*time.Second),
asynq.Queue("low"), asynq.TaskID("miniatura-banner"), asynq.Retention(24*time.Hour))
if err != nil {
log.Fatal(err)
}
log.Printf("agendada: id=%s fila=%s estado=%s para=%s",
info.ID, info.Queue, info.State, info.NextProcessAt.Format("15:04:05"))
// 4) Erro permanente: termina em "archived" sem retry.
t, _ = tarefas.NovaTarefaMiniatura("uploads/antiga.bmp", 320)
info, err = client.Enqueue(t)
if err != nil {
log.Fatal(err)
}
log.Printf("enfileirada: id=%s fila=%s tipo=%s", info.ID, info.Queue, info.Type)
}
Worker com ServeMux, retries e desligamento gracioso
O RetryDelayFunc aqui é curto de propósito, para a demonstração caber em um minuto. Em produção, omita o campo e fique com o backoff exponencial padrão. O srv.Run trata sozinho os sinais: SIGTERM ou SIGINT encerram com calma, e SIGTSTP para de puxar tarefas novas sem derrubar o processo.
package main
import (
"context"
"log"
"time"
"exemplo.com/filas/tarefas"
"github.com/hibiken/asynq"
)
func main() {
srv := asynq.NewServer(
asynq.RedisClientOpt{Addr: tarefas.RedisAddr()},
asynq.Config{
Concurrency: 10,
// Prioridade ponderada: 60% critical, 30% default, 10% low.
Queues: map[string]int{"critical": 6, "default": 3, "low": 1},
// Backoff curto só para a demonstração: 5 s, 10 s, 15 s...
// Em produção, omita e use o exponencial padrão.
RetryDelayFunc: func(n int, err error, t *asynq.Task) time.Duration {
return time.Duration(n+1) * 5 * time.Second
},
ErrorHandler: asynq.ErrorHandlerFunc(func(ctx context.Context, t *asynq.Task, err error) {
n, _ := asynq.GetRetryCount(ctx)
max, _ := asynq.GetMaxRetry(ctx)
log.Printf("ERRO %s (retry %d/%d): %v", t.Type(), n, max, err)
}),
// No SIGTERM, espera até 20 s as tarefas em andamento terminarem.
ShutdownTimeout: 20 * time.Second,
},
)
mux := asynq.NewServeMux()
mux.HandleFunc(tarefas.TipoEmailBoasVindas, tarefas.HandleEmail)
mux.HandleFunc(tarefas.TipoMiniatura, tarefas.HandleMiniatura)
mux.HandleFunc(tarefas.TipoRelatorio, tarefas.HandleRelatorio)
// Run bloqueia até receber SIGTERM/SIGINT e então desliga com calma.
if err := srv.Run(mux); err != nil {
log.Fatal(err)
}
}
Agendador de tarefas periódicas
package main
import (
"log"
"time"
"exemplo.com/filas/tarefas"
"github.com/hibiken/asynq"
)
func main() {
// Sem Location, o Scheduler interpreta o cron em UTC.
fuso, err := time.LoadLocation("America/Sao_Paulo")
if err != nil {
log.Fatal(err)
}
s := asynq.NewScheduler(asynq.RedisClientOpt{Addr: tarefas.RedisAddr()}, &asynq.SchedulerOpts{
Location: fuso,
PostEnqueueFunc: func(info *asynq.TaskInfo, err error) {
if err != nil {
log.Printf("falha ao enfileirar periódica: %v", err)
return
}
log.Printf("periódica enfileirada: %s id=%s", info.Type, info.ID)
},
})
// Sintaxe cron (robfig/cron): todo dia às 03:00...
if _, err := s.Register("0 3 * * *", asynq.NewTask(tarefas.TipoRelatorio, nil), asynq.Queue("low")); err != nil {
log.Fatal(err)
}
// ...ou intervalo fixo, útil para testar.
id, err := s.Register("@every 20s", asynq.NewTask(tarefas.TipoRelatorio, []byte(`{"teste":true}`)), asynq.Queue("low"))
if err != nil {
log.Fatal(err)
}
log.Printf("entrada registrada: %s", id)
if err := s.Run(); err != nil {
log.Fatal(err)
}
}
Atenção ao fuso: sem Location, o Scheduler interpreta o cron em UTC. No primeiro teste, sem essa opção, o 0 3 * * * estava marcado para 11h35 dali em diante, ou seja, meia-noite no horário de Brasília. Com America/Sao_Paulo, o log confirma:
2026/09/23 12:24:53 entrada registrada: 4e28eaa1-ddd4-40d5-9dc6-2e96f770e600
asynq: pid=416391 2026/09/23 15:24:53.136229 INFO: Scheduler starting
asynq: pid=416391 2026/09/23 15:24:53.136232 INFO: Scheduler timezone is set to America/Sao_Paulo
asynq: pid=416391 2026/09/23 15:24:53.136241 INFO: Send signal TERM or INT to stop the scheduler
2026/09/23 12:25:13 periódica enfileirada: relatorio:diario id=217bb335-888a-4148-bdb2-121c19f6d8a8
2026/09/23 12:25:33 periódica enfileirada: relatorio:diario id=9d80f7ea-26c1-4e08-b0ee-124579c81e88
2026/09/23 12:25:53 periódica enfileirada: relatorio:diario id=1e43a446-4fc8-41c7-ab44-6285a8e53203
2026/09/23 12:26:13 periódica enfileirada: relatorio:diario id=399dc3e0-5d5d-4407-b10c-f14ba55c9eb3
asynq: pid=416391 2026/09/23 15:26:17.022259 INFO: Scheduler shutting down
asynq: pid=416391 2026/09/23 15:26:17.022798 INFO: Scheduler stopped
Só rode um agendador por ambiente: duas cópias enfileiram cada tarefa periódica duas vezes. Os workers, por outro lado, você replica à vontade.
Rodando o exemplo
Compile, suba o worker e o agendador, pause a fila default pela CLI (para ver as tarefas paradas no painel) e rode o produtor:
go build -o bin/ ./cmd/...
export REDIS_ADDR=127.0.0.1:6379
bin/worker &
bin/agendador &
asynq queue pause default
bin/produtor
2026/09/23 12:24:55 enfileirada: id=542783fc-53f6-48c4-bff3-b3ded2e72681 fila=critical tipo=email:boas-vindas
2026/09/23 12:24:55 duplicada, ignorada: task already exists
2026/09/23 12:24:55 enfileirada: id=3b3008d8-9889-497a-aca8-3dd2cab5f84c fila=default tipo=imagem:miniatura
2026/09/23 12:24:55 agendada: id=miniatura-banner fila=low estado=scheduled para=12:25:25
2026/09/23 12:24:55 enfileirada: id=16d93751-6b19-4ca7-84fa-71cc2a20c306 fila=default tipo=imagem:miniatura
A segunda chamada com Unique voltou task already exists, e o banner ficou em scheduled. Depois de asynq queue unpause default, o log do worker conta a história toda:
asynq: pid=416390 2026/09/23 15:24:53.136156 INFO: Starting processing
asynq: pid=416390 2026/09/23 15:24:53.136186 INFO: Send signal TSTP to stop processing new tasks
asynq: pid=416390 2026/09/23 15:24:53.136187 INFO: Send signal TERM or INT to terminate the process
2026/09/23 12:24:55 e-mail de boas-vindas para ana@exemplo.com.br (usuário 42, tarefa 542783fc-53f6-48c4-bff3-b3ded2e72681)
2026/09/23 12:25:14 ERRO imagem:miniatura (retry 0/3): storage indisponível ao ler uploads/foto-42.jpg (tentativa 1)
2026/09/23 12:25:14 ERRO imagem:miniatura (retry 0/3): formato não suportado: uploads/antiga.bmp: skip retry for the task
asynq: pid=416390 2026/09/23 15:25:14.093162 WARN: Retry exhausted for task id=16d93751-6b19-4ca7-84fa-71cc2a20c306
2026/09/23 12:25:14 relatório diário gerado às 12:25:14
2026/09/23 12:25:23 ERRO imagem:miniatura (retry 1/3): storage indisponível ao ler uploads/foto-42.jpg (tentativa 2)
2026/09/23 12:25:28 ERRO imagem:miniatura (retry 0/3): storage indisponível ao ler uploads/banner.png (tentativa 1)
2026/09/23 12:25:33 relatório diário gerado às 12:25:33
2026/09/23 12:25:33 ERRO imagem:miniatura (retry 1/3): storage indisponível ao ler uploads/banner.png (tentativa 2)
2026/09/23 12:25:36 miniatura 320px gerada para uploads/foto-42.jpg na tentativa 3
2026/09/23 12:25:47 miniatura 1200px gerada para uploads/banner.png na tentativa 3
2026/09/23 12:25:53 relatório diário gerado às 12:25:53
2026/09/23 12:26:11 ERRO imagem:miniatura (retry 0/3): formato não suportado: uploads/antiga.bmp: skip retry for the task
asynq: pid=416390 2026/09/23 15:26:11.619533 WARN: Retry exhausted for task id=16d93751-6b19-4ca7-84fa-71cc2a20c306
2026/09/23 12:26:13 relatório diário gerado às 12:26:13
asynq: pid=416390 2026/09/23 15:26:14.020184 INFO: Stopping processor
asynq: pid=416390 2026/09/23 15:26:14.449245 INFO: Processor stopped
asynq: pid=416390 2026/09/23 15:26:14.449276 INFO: Starting graceful shutdown
asynq: pid=416390 2026/09/23 15:26:14.449296 INFO: Waiting for all workers to finish...
asynq: pid=416390 2026/09/23 15:26:14.449300 INFO: All workers have finished
asynq: pid=416390 2026/09/23 15:26:14.449797 INFO: Exiting
Leia na ordem:
- o e-mail saiu na hora, pela fila
critical, que não estava pausada; - a
foto-42.jpgfalhou duas vezes e deu certo na terceira, com 9 e 13 segundos entre as tentativas (atraso de 5 s e 10 s mais a checagem periódica do Asynq); - a
antiga.bmpfoi paraarchivedna primeira falha, por causa doSkipRetry. O avisoRetry exhaustedé a mensagem que o próprio Asynq usa nesse caso; - o banner agendado para 12:25:25 começou às 12:25:28: o Asynq verifica as tarefas agendadas a cada 5 segundos, então não conte com precisão de segundo;
- às 12:26:11, reprocessei a tarefa arquivada com
asynq task run, e ela voltou ao arquivo, como esperado; - no
SIGTERM, o servidor parou de puxar tarefas, esperou os workers e saiu. Se uma tarefa passar doShutdownTimeout, ela volta para o Redis e roda de novo em outro worker, e daí vem a exigência de idempotência.
Os horários com prefixo asynq: estão em UTC (logger interno da biblioteca), e os outros no horário local.
O mesmo teste no Valkey
Troquei só a variável: REDIS_ADDR=127.0.0.1:6380. Deduplicação, retries, agendamento, arquivamento e periódicas se comportaram igual. A tarefa em retry no resumo abaixo é o banner: encerrei o worker antes da terceira tentativa. A CLI mostra a versão 7.2.4 porque o Valkey se apresenta com essa versão de compatibilidade no INFO:
Task Count by State
active pending aggregating scheduled retry archived completed
--------- --------- --------- --------- --------- --------- ---------
0 0 0 0 1 1 0
Task Count by Queue
critical default low
-------- -------- --------
0 1 1
Daily Stats 2026-09-23 UTC
processed failed error rate
--------- ------ ----------
9 5 55.56%
Redis Info
version uptime connections memory usage peak memory usage
------- ------ ----------- ------------ -----------------
7.2.4 0 days 1 1.70MB 1.71MB
A CLI asynq
A ferramenta de linha de comando fica num módulo separado do repositório:
go install github.com/hibiken/asynq/tools/asynq@latest
Um detalhe: o módulo tools não tem tag própria, então o @latest resolve para um commit do master (no meu caso, de 12 de junho de 2026) que ainda declara dependência do Asynq v0.25.0. Funcionou sem problema contra filas da v0.26.0. Os comandos que mais uso:
asynq stats # visão geral por estado e por fila
asynq queue ls # lista as filas
asynq queue inspect default # detalhes de uma fila
asynq queue pause default # para de entregar tarefas da fila
asynq task ls --queue=default --state=archived
asynq task run --queue=default --id=<ID> # reprocessa uma arquivada
asynq server ls # workers conectados
asynq cron ls # entradas do Scheduler
asynq dash # painel no terminal (TUI)
Para outro servidor, use --uri host:porta, --password, --username e --tls. Saídas reais do laboratório:
$ asynq stats
Task Count by State
active pending aggregating scheduled retry archived completed
--------- --------- --------- --------- --------- --------- ---------
0 0 0 0 0 1 1
Task Count by Queue
critical default low
-------- -------- --------
0 1 1
Daily Stats 2026-09-23 UTC
processed failed error rate
--------- ------ ----------
13 6 46.15%
Redis Info
version uptime connections memory usage peak memory usage
------- ------ ----------- ------------ -----------------
8.10.2 0 days 4 2.32MB 2.38MB
$ asynq task ls --queue=default --state=archived
ID Type Payload Last Failed Last Error
-- ---- ------- ----------- ----------
16d93751-6b19-4ca7-84fa-71cc2a20c306 imagem:miniatura {"origem":"uploads/antiga.bmp","largura":320} Wed Sep 23 12:26:11 -03 2026 formato não suportado: uploads/antiga.bmp: skip retry for the task
Asynqmon: o painel web
O Asynqmon roda como binário, contêiner ou biblioteca embutida na sua aplicação (asynqmon.New(...) devolve um http.Handler). O jeito mais rápido é a imagem Docker, na mesma rede do Redis:
docker run -d --name asynqmon --network filas \
-p 127.0.0.1:8080:8080 \
hibiken/asynqmon:0.7.2 \
--redis-addr=redis:6379 \
--enable-metrics-exporter
Abra http://127.0.0.1:8080. A aba Queues mostra tamanho, memória, latência, processadas e taxa de erro por fila. No print abaixo, a default aparece pausada, com as duas miniaturas pendentes:

Clicando numa fila, você vê as tarefas por estado (active, pending, aggregating, scheduled, retry, archived, completed), com payload e último erro. Dali dá para reexecutar, apagar ou arquivar em lote:

A aba Schedulers lista as entradas periódicas registradas, com o próximo e o último enfileiramento:

O painel não tem autenticação nenhuma: quem acessa pode apagar filas inteiras. Deixe-o preso em 127.0.0.1 e publique por trás de um proxy reverso com login, VPN ou túnel SSH. Para quem só precisa olhar, existe --read-only.
Métricas no Prometheus
Há dois caminhos, ambos conferidos no código:
- Pelo Asynqmon: com
--enable-metrics-exporter, ele expõe/metricscom o estado das filas. Com--prometheus-addr=http://prometheus:9090, ele também consulta o Prometheus e liga a aba de gráficos históricos. - Dentro da sua aplicação: o pacote
github.com/hibiken/asynq/x/metricstem umNewQueueMetricsCollector(inspector)que você registra no seu próprioprometheus.Registry, sem depender do Asynqmon.
A saída real do exportador do Asynqmon no laboratório:
curl -s 127.0.0.1:8080/metrics | grep '^asynq_' | grep default
asynq_queue_latency_seconds{queue="default"} 0
asynq_queue_memory_usage_approx_bytes{queue="default"} 547
asynq_queue_paused_total{queue="default"} 0
asynq_queue_size{queue="default"} 1
asynq_tasks_enqueued_total{queue="default",state="active"} 0
asynq_tasks_enqueued_total{queue="default",state="archived"} 1
asynq_tasks_enqueued_total{queue="default",state="completed"} 0
asynq_tasks_enqueued_total{queue="default",state="pending"} 0
asynq_tasks_enqueued_total{queue="default",state="retry"} 0
asynq_tasks_enqueued_total{queue="default",state="scheduled"} 0
asynq_tasks_failed_total{queue="default"} 4
asynq_tasks_processed_total{queue="default"} 5
E o trecho do prometheus.yml:
scrape_configs:
- job_name: asynq
static_configs:
- targets: ["127.0.0.1:8080"]
Os alertas que valem a pena: asynq_queue_size crescendo sem parar (workers insuficientes ou parados), asynq_queue_latency_seconds alto na fila critical e qualquer aumento de asynq_tasks_enqueued_total{state="archived"}. Se você ainda não tem Prometheus, o guia Monitorando servidores Linux com Prometheus e Node Exporter monta a base.
Deploy: o worker como serviço systemd
O worker é um binário estático, sem runtime, e roda bem com um usuário sem privilégios. O ponto central é o TimeoutStopSec, que precisa ser maior que o ShutdownTimeout do código (20 s). Sem isso, o systemd manda SIGKILL antes de o Asynq devolver as tarefas em andamento.
# /etc/systemd/system/filas-worker.service
[Unit]
Description=Worker Asynq (filas)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=filas
Group=filas
Environment=REDIS_ADDR=127.0.0.1:6379
ExecStart=/usr/local/bin/filas-worker
KillSignal=SIGTERM
TimeoutStopSec=30
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
sudo useradd --system --no-create-home --shell /usr/sbin/nologin filas
sudo install -m 755 bin/worker /usr/local/bin/filas-worker
sudo systemctl daemon-reload
sudo systemctl enable --now filas-worker
journalctl -u filas-worker -f
O agendador ganha uma unit igual, com ExecStart=/usr/local/bin/filas-agendador, em uma máquina só. Para mais comandos do dia a dia, veja Dominando o systemd. Se o seu produtor é uma API Go, a série Vue.js + Go com Echo v5 mostra como montá-la, e a parte 5 cobre binário único, Docker e systemd, um encaixe natural para chamar client.Enqueue dentro dos handlers.
Boas práticas
- Idempotência sempre: a entrega é “pelo menos uma vez”. Grave no banco que o e-mail do usuário 42 já foi enviado e confira antes de enviar de novo.
UniqueeTaskIDevitam duplicar a tarefa na fila, mas não protegem contra reexecução depois de um crash. - Payload pequeno: mande IDs e caminhos, não o arquivo. A imagem fica no storage, e a tarefa leva só
uploads/foto-42.jpg. Payload grande pesa na memória do Redis e em cada leitura. - Filas separadas por perfil: e-mail transacional na
critical, processamento pesado nalow. Se o volume justificar, rode workers dedicados só para a fila pesada (Queues: {"low": 1}) em outra máquina. - Erro permanente sem retry: payload inválido ou formato não suportado nunca vão dar certo. Use
SkipRetrye deixe a tarefa no arquivo para análise. - Respeite o contexto: passe o
ctxdo handler para HTTP, banco e SMTP. É por ele que chegam timeout, deadline e cancelamento. - Redis com persistência e senha: a fila mora no Redis. Ative AOF ou RDB, configure
requirepassou ACL e não o exponha fora da rede interna. Não use a mesma instância como cache commaxmemory-policyde despejo, ou o Redis pode apagar tarefas. - Fixe versões: com a API ainda em
v0.x, leia as notas antes de cada atualização do Asynq.
Conclusão
O Asynq resolve bem o problema clássico de tirar trabalho da requisição: filas com prioridade, retry com backoff, agendamento, cron, deduplicação e desligamento limpo, tudo com um Redis ou Valkey que você provavelmente já tem. A biblioteca segue viva, com release em 2026 e commits recentes. O Asynqmon funciona, mas está parado desde 2023, então use-o como painel interno e confie no Prometheus para alertas. Se um dia o Asynqmon quebrar numa versão nova do Asynq, a CLI e o x/metrics cobrem o essencial.