4

Postei uma solução em PHP, a comunidade sugeriu Go, e eu fui atrás: o "Drop-in Buffer" de webhooks agora é um binário único

Há alguns dias publiquei aqui como parei de derrubar meu n8n com rajadas de webhooks do WhatsApp usando ~20 linhas de PHP e SQLite: Como parei de derrubar o meu n8n com webhooks do WhatsApp.

A ideia era simples: em vez do n8n tomar porrada de centenas de webhooks simultâneos, um script na frente recebe tudo, joga numa fila no SQLite e responde 200 na hora. O n8n vai buscando os lotes no tempo dele.

O que eu não esperava é que os comentários fossem melhorar tanto a solução:

  • O @plugandship achou três furos sérios: o banco ficava na pasta pública (qualquer um podia baixar as conversas), o cliente podia receber resposta em dobro e os picos geravam "database is locked". Já corrigi tudo lá no post original.
  • O @Oletros deixou uma sugestão que ficou na minha cabeça: depois de validar o PoC em PHP, talvez migrar pra Go fosse interessante.

Então fui pesquisar. E fiz.

Por que Go faz sentido aqui?

Eu nunca tinha usado Go a sério, então fui entender o que ele traz pra esse problema específico:

  • Um binário só. Você compila e sobe um arquivo executável. Não precisa de PHP-FPM, Apache nem extensão nenhuma no servidor.
  • Goroutines. Cada requisição roda numa "thread leve" que custa poucos KB. Segurar milhares de conexões simultâneas é o que o Go faz de melhor.
  • Processo que fica de pé. O PHP nasce e morre a cada requisição (o que é ótimo pra isolamento). Já o Go fica rodando, então dá pra ter uma goroutine de faxina limpando a fila em segundo plano, sem precisar de cron.
  • SQLite sem dor de cabeça. Usei o driver modernc.org/sqlite, escrito em Go puro. Não precisa de CGO nem de compilador C, e compila pra Linux direto do Windows.

Aproveitei pra resolver um problema que ficou pendente

O n8n não tem nó nativo de SQLite. Na versão PHP, o n8n precisava enxergar o arquivo do banco, o que complica quando ele roda em outro container ou servidor.

Na versão Go, o próprio serviço vira a fila completa:

EndpointQuem chamaO que faz
POST /webhookEvolution APIGrava a mensagem e responde 200 na hora
POST /fila/pegar?limite=50n8n (a cada minuto)Devolve lotes travados pra fila e reserva os próximos 50
POST /fila/{id}/processadon8nMarca a mensagem como concluída
POST /fila/{id}/erron8nMarca a mensagem como falha

Com isso o n8n só faz HTTP Request e nunca toca no arquivo do banco.

O código

Tudo cabe num arquivo main.go:

package main

import (
	"context"
	"crypto/subtle"
	"database/sql"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"strconv"
	"syscall"
	"time"

	_ "modernc.org/sqlite" // driver SQLite em Go puro: sem CGO, compila pra qualquer lugar
)

var (
	db    *sql.DB
	token = os.Getenv("TOKEN")
)

const schema = `
CREATE TABLE IF NOT EXISTS fila (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    payload    TEXT NOT NULL,
    status     TEXT NOT NULL DEFAULT 'pendente',
    locked_at  DATETIME,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_fila_status ON fila(status);`

func main() {
	addr := getenv("ADDR", ":8080")
	dbPath := getenv("DB_PATH", "./dados/buffer_webhooks.sqlite")
	if token == "" {
		log.Fatal("defina a variável de ambiente TOKEN")
	}

	if err := os.MkdirAll(filepath.Dir(dbPath), 0o750); err != nil {
		log.Fatal(err)
	}

	var err error
	db, err = sql.Open("sqlite", "file:"+dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
	if err != nil {
		log.Fatal(err)
	}
	// Uma única conexão: todas as escritas passam em fila indiana,
	// então "database is locked" deixa de existir dentro do processo.
	db.SetMaxOpenConns(1)
	if _, err := db.Exec(schema); err != nil {
		log.Fatal(err)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("POST /webhook", auth(receber))
	mux.HandleFunc("POST /fila/pegar", auth(pegarLote))
	mux.HandleFunc("POST /fila/{id}/{status}", auth(marcar))

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	go faxina(ctx)

	srv := &http.Server{Addr: addr, Handler: mux, ReadTimeout: 10 * time.Second}
	go func() {
		<-ctx.Done()
		srv.Shutdown(context.Background()) // termina as requisições em andamento antes de sair
	}()

	log.Printf("buffer ouvindo em %s", addr)
	if err := srv.ListenAndServe(); err != http.ErrServerClosed {
		log.Fatal(err)
	}
}

// POST /webhook: recebe da Evolution, grava e responde na hora.
func receber(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 10<<20)) // até 10MB (mídia em base64)
	if err != nil || len(body) == 0 {
		http.Error(w, "payload inválido", http.StatusBadRequest)
		return
	}
	if _, err := db.Exec(`INSERT INTO fila (payload) VALUES (?)`, string(body)); err != nil {
		log.Printf("erro ao gravar: %v", err)
		http.Error(w, "erro ao gravar", http.StatusInternalServerError) // a origem reenvia
		return
	}
	writeJSON(w, map[string]string{"status": "ok"})
}

type item struct {
	ID      int64           `json:"id"`
	Payload json.RawMessage `json:"payload"`
}

// POST /fila/pegar?limite=50: o n8n chama isso a cada minuto.
// Devolve lotes travados pra fila e reserva os próximos N numa única transação.
func pegarLote(w http.ResponseWriter, r *http.Request) {
	limite, err := strconv.Atoi(r.URL.Query().Get("limite"))
	if err != nil || limite < 1 || limite > 500 {
		limite = 50
	}

	tx, err := db.Begin()
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	defer tx.Rollback()

	// Use um tempo MAIOR que o pior caso de duração do seu lote.
	if _, err := tx.Exec(`
		UPDATE fila SET status = 'pendente', locked_at = NULL
		WHERE status = 'processando' AND locked_at < datetime('now', '-10 minutes')`); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	rows, err := tx.Query(`
		UPDATE fila SET status = 'processando', locked_at = CURRENT_TIMESTAMP
		WHERE id IN (SELECT id FROM fila WHERE status = 'pendente' ORDER BY id LIMIT ?)
		RETURNING id, payload`, limite)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	lote := []item{}
	for rows.Next() {
		var it item
		var p string
		if err := rows.Scan(&it.ID, &p); err != nil {
			rows.Close()
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		if json.Valid([]byte(p)) {
			it.Payload = json.RawMessage(p) // o n8n já recebe o objeto, sem JSON.parse
		} else {
			it.Payload, _ = json.Marshal(p)
		}
		lote = append(lote, it)
	}
	rows.Close()
	if err := tx.Commit(); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	writeJSON(w, lote)
}

// POST /fila/{id}/processado ou /fila/{id}/erro: o n8n marca cada mensagem ao terminar.
func marcar(w http.ResponseWriter, r *http.Request) {
	status := r.PathValue("status")
	if status != "processado" && status != "erro" {
		http.Error(w, "status deve ser processado ou erro", http.StatusBadRequest)
		return
	}
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		http.Error(w, "id inválido", http.StatusBadRequest)
		return
	}
	res, err := db.Exec(`UPDATE fila SET status = ? WHERE id = ? AND status = 'processando'`, status, id)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if n, _ := res.RowsAffected(); n == 0 {
		http.Error(w, "mensagem não encontrada ou não está em processamento", http.StatusNotFound)
		return
	}
	writeJSON(w, map[string]string{"status": status})
}

// Uma goroutine que roda pra sempre e apaga o histórico antigo (LGPD + arquivo enxuto).
func faxina(ctx context.Context) {
	t := time.NewTicker(time.Hour)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			res, err := db.Exec(`DELETE FROM fila WHERE status = 'processado' AND created_at < datetime('now', '-7 days')`)
			if err != nil {
				log.Printf("faxina: %v", err)
				continue
			}
			if n, _ := res.RowsAffected(); n > 0 {
				log.Printf("faxina: %d mensagens antigas removidas", n)
			}
		}
	}
}

// Aceita o token no header X-Token ou em ?token= (pra origens que não mandam header).
func auth(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		t := r.Header.Get("X-Token")
		if t == "" {
			t = r.URL.Query().Get("token")
		}
		if subtle.ConstantTimeCompare([]byte(t), []byte(token)) != 1 {
			http.Error(w, "não autorizado", http.StatusUnauthorized)
			return
		}
		next(w, r)
	}
}

func writeJSON(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(v)
}

func getenv(k, def string) string {
	if v := os.Getenv(k); v != "" {
		return v
	}
	return def
}

Os detalhes que mais gostei

  • db.SetMaxOpenConns(1): no PHP, cada requisição era um processo separado brigando pelo SQLite, e por isso dava "database is locked". No Go, todas as requisições compartilham o mesmo processo, então limitei a uma conexão e as escritas entram em fila indiana. O problema simplesmente deixa de existir.
  • Pegar e reservar numa transação só: o UPDATE ... RETURNING (dica do @plugandship) marca como processando e devolve as linhas ao mesmo tempo. Dois disparos do n8n nunca pegam a mesma mensagem.
  • Graceful shutdown: quando você reinicia o serviço, ele termina as requisições em andamento antes de sair, então nenhum webhook fica pela metade.
  • subtle.ConstantTimeCompare: compara o token sem vazar informação pelo tempo de resposta. Eu não conhecia, e foi uma das coisas legais que aprendi pesquisando.

Como rodar

Precisa do Go 1.22 ou superior (por causa das rotas com POST /fila/{id}/... no ServeMux padrão).

go mod init buffer-webhook
go get modernc.org/sqlite
go build -o buffer .
TOKEN=um-token-grande-e-aleatorio ./buffer

Pra compilar no Windows/Mac e subir na VPS Linux:

GOOS=linux GOARCH=amd64 go build -o buffer .

Depois é só copiar o binário pra VPS. Recomendo deixar o serviço atrás do Nginx com HTTPS e rodar pelo systemd pra subir sozinho se a máquina reiniciar.

Variáveis de ambiente:

VariávelPadrãoPra quê
TOKEN(obrigatória)Protege todos os endpoints
ADDR:8080Porta
DB_PATH./dados/buffer_webhooks.sqliteOnde fica o banco (fora de pasta pública!)

O fluxo no n8n ficou assim

  1. Schedule Trigger a cada 1 minuto
  2. HTTP Request → POST https://seu-dominio/fila/pegar?limite=50 com header X-Token
  3. Split Out no array retornado
  4. Seu processamento (Gemini, RAG, resposta no WhatsApp...)
  5. HTTP Request → POST https://seu-dominio/fila/{{ $json.id }}/processado (ou /erro no caminho de falha)

E na Evolution API, a URL do webhook vira https://seu-dominio/webhook?token=SEU_TOKEN.

Então o PHP era ruim?

Não! A versão PHP continua sendo ótima se você já tem um servidor web com PHP rodando: é só jogar o arquivo lá e pronto. A versão Go brilha quando você quer um serviço dedicado, que fica de pé sozinho, consome pouca memória e não depende de nada instalado no servidor.

Ainda não fiz benchmark comparando as duas. Se alguém se animar a testar com carga (k6, wrk, hey...), posta os números nos comentários que eu adiciono aqui!

O que mais me marcou nisso tudo

Sinceramente? Não foi o código. Foi o processo.

Postei uma solução que eu achava pronta. Em poucos dias, a comunidade achou uma falha de segurança que eu não tinha visto, um bug que faria cliente receber resposta duplicada e um gargalo nos picos. Ainda me sugeriu uma linguagem que eu nunca tinha usado e que acabou me ensinando um monte de coisa.

Se eu tivesse guardado a solução só pra mim, ela estaria rodando em produção com o banco exposto. Compartilhar o que a gente faz, mesmo quando parece simples demais, é a melhor revisão de código que existe. E é de graça.

Então fica meu agradecimento de novo ao @Oletros, ao @plugandship e ao @HermesDev. Esse post só existe por causa de vocês. 🙏

E aí, qual vai ser a próxima versão? Rust? 😄 Se tiver sugestão, crítica ou achar mais algum furo, manda nos comentários!

Carregando publicação patrocinada...
1

Um ponto sobre o token: como a Evolution manda ele em ?token=, ele aparece inteiro no access log do Nginx, que por padrão grava a URL completa com a query. E é o mesmo token que libera o /fila/pegar, que devolve as conversas. Então quem conseguir ler aquele log consegue puxar as mensagens da fila.

Eu separaria em dois tokens: um só pro /webhook e outro, no header, pro n8n. Aí se o da URL vazar, no pior caso alguém joga lixo na fila, mas não lê nada.

1

A gravação em receber termina antes da resposta. Se o processo cai nesse intervalo, ou se o 200 não chega, a origem manda de novo e o segundo POST vira outra linha. A tabela só tem o id da fila, sem o identificador do evento.

pegarLote devolve as duas. O locked_at impede dois consumidores na mesma linha. Não junta as duas cópias.