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:
| Endpoint | Quem chama | O que faz |
|---|---|---|
POST /webhook | Evolution API | Grava a mensagem e responde 200 na hora |
POST /fila/pegar?limite=50 | n8n (a cada minuto) | Devolve lotes travados pra fila e reserva os próximos 50 |
POST /fila/{id}/processado | n8n | Marca a mensagem como concluída |
POST /fila/{id}/erro | n8n | Marca 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 comoprocessandoe 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ável | Padrão | Pra quê |
|---|---|---|
TOKEN | (obrigatória) | Protege todos os endpoints |
ADDR | :8080 | Porta |
DB_PATH | ./dados/buffer_webhooks.sqlite | Onde fica o banco (fora de pasta pública!) |
O fluxo no n8n ficou assim
- Schedule Trigger a cada 1 minuto
- HTTP Request →
POST https://seu-dominio/fila/pegar?limite=50com headerX-Token - Split Out no array retornado
- Seu processamento (Gemini, RAG, resposta no WhatsApp...)
- HTTP Request →
POST https://seu-dominio/fila/{{ $json.id }}/processado(ou/errono 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!