Ir para o conteúdo
Trilha
Capítulos

Aprender

Dados e cache

De onde vem o dado, por quanto tempo vale guardar a resposta e o que a derruba — com cache.Do, tags e cache.Once.

O Trilha não tem ORM, não tem repositório e não tem opinião sobre o seu banco: a página chama o seu código, e o seu código chama o que você usa. O que o framework traz é a parte que sempre se escreve à mão e sempre se escreve errado — guardar uma resposta por um tempo, e jogá-la fora quando ela deixa de ser verdade.

import "github.com/emersonjoe/trilha/cache"

O cache é seu, não do framework#

Não existe app.Cache(). Você cria, você diz de que tamanho ele fica, e você o guarda onde mora o código que o enche — em geral o pacote que consulta o banco:

// internal/eventos/eventos.go
var Cache *cache.Cache

// app/setup.go
func Setup(a *trilha.App) error {
	eventos.Cache = cache.New(cache.Options{
		Name:       "eventos",
		MaxEntries: 500,
		Metrics:    a.Metrics(),
	})
	return nil
}

MaxEntries tem padrão (10 000) e não tem como dizer "sem limite". Cache sem teto é vazamento de memória que leva uma semana para aparecer: toda chave que alguém consegue inventar — um termo de busca, um filtro na query string — vira uma entrada que nunca sai. Batendo no teto, a entrada usada há mais tempo é despejada.

Do: o valor, ou o jeito de buscá-lo#

cache.Do é o pacote inteiro em uma chamada. Ele devolve o que está guardado, ou executa a sua função e guarda o que ela devolveu:

func Proximos(ctx context.Context) ([]Evento, error) {
	return cache.Do(ctx, Cache, cache.Key{
		Name: "eventos:proximos",
		TTL:  5 * time.Minute,
		Tags: []string{"eventos"},
	}, func(ctx context.Context) ([]Evento, error) {
		return db.Proximos(ctx)
	})
}
Campo da KeyO que é
Nameo endereço do valor; nomes iguais são a mesma entrada
TTLpor quanto tempo ele vale; 0 (ou menos) é sem prazo
Tagsrótulos para invalidar em lote depois

O nome é uma decisão, não um detalhe: tudo o que muda a resposta tem de estar nele. Uma lista que depende da página e de quem está logado é posts:pagina:2:usuario:42, e não posts — chave de cache que esquece o usuário é como o dado de um vai parar na tela do outro.

O erro volta para quem chamou e não é guardado para ninguém. A requisição seguinte tenta de novo.

Uma busca de cada vez#

No instante em que uma chave quente vence, todas as requisições que a queriam chegam juntas e todas vão ao banco. O Do não deixa: a primeira executa a função, as outras esperam por ela e leem a mesma resposta. É uma busca por chave, não importa quantas requisições estejam na fila atrás dela.

O que derruba#

Tempo é o jeito fraco de invalidar — cinco minutos de lista errada são cinco minutos de alguém lendo um post que já foi apagado. O jeito forte é avisar:

func Criar(ctx context.Context, e Evento) error {
	if err := db.Inserir(ctx, e); err != nil {
		return err
	}
	Cache.Invalidate("eventos")
	return nil
}

Invalidate derruba toda entrada que carrega aquela tag, seja qual for o nome, e devolve quantas derrubou. Delete(nomes...) derruba por nome, e Clear() esvazia tudo.

Ponha a chamada ao lado da escrita, nunca ao lado da leitura. Um cache é invalidado por quem mudou o dado — o código que lê não tem como saber que alguma coisa mudou.

Once não é o cache#

O layout quer saber quem está logado. O cabeçalho quer o mesmo. Dois componentes dentro da página também. O cache.Once responde à pergunta uma vez por requisição:

func Layout(c *trilha.Ctx, children h.Node) (h.Node, error) {
	usuario, err := cache.Once(c, "usuario", func() (*usuarios.Usuario, error) {
		return usuarios.Buscar(c.Context(), auth.From(c).Subject)
	})
	…
}

Nada guardado aqui sobrevive à resposta, e é exatamente esse o ponto. Ele não recebe *Cache, nem TTL, nem tag, porque não há o que vencer: o valor morre com a requisição que o criou. Use Once quando a alternativa é passar um valor por seis assinaturas de função, e Do quando a resposta é a mesma para todo mundo.

Não troque um pelo outro. O nome de um usuário no Do sob a chave "usuario" é o nome desse usuário servido para a próxima pessoa que abrir a página.

Vendo funcionar#

Com Options.Metrics, quatro séries aparecem no /metrics, rotuladas pelo nome do cache:

trilha_cache_hits_total{cache="eventos"} 1043
trilha_cache_misses_total{cache="eventos"} 61
trilha_cache_evictions_total{cache="eventos"} 0
trilha_cache_entries{cache="eventos"} 61

Acertos sobre acertos mais erros é a taxa de acerto — abaixo de 50 % o TTL está curto demais ou a chave carrega algo que não devia. Despejos subindo é teto baixo demais: o cache está jogando fora justamente o que iam lhe pedir.

O cache que o navegador guarda#

O cache de cima poupa ao servidor uma ida ao banco. Este poupa à rede uma resposta inteira: o navegador já tem a página e só pergunta se ela mudou.

func Page(c *trilha.Ctx) (h.Node, error) {
	p, ok := trilha.Use[*posts.Store](c).Get(c.Param("slug"))
	if !ok {
		return nil, trilha.ErrNotFound
	}
	c.CacheControl("private, no-cache")
	if c.ETag(p.Atualizado.UTC().Format(time.RFC3339Nano)) {
		return nil, nil // a cópia do navegador está em dia: 304, sem corpo
	}
	c.SetTitle(p.Title)
	return view(p), nil
}

ETag escreve a etiqueta e diz se a requisição já a trazia. Quando diz que sim, o 304 já foi escrito: devolva nil, nil — um corpo ali seria jogado fora. LastModified faz o mesmo com uma data, e CacheControl escreve o cabeçalho como você digitou. no-cache não quer dizer "não guarde"; quer dizer "guarde, mas me pergunte antes de reusar", que é justamente o que provoca o 304.

A etiqueta é uma versão do dado, não um hash da página — e a Trilha não calcula uma por você. Toda resposta carrega um nonce novo de CSP, então um hash do HTML nunca bateria duas vezes. Serve qualquer coisa que se mexa quando o dado se mexe: updated_at, um número de revisão, os ids do que foi renderizado.

Uma etiqueta que esquece quem está lendo é o mesmo defeito de uma chave de cache que esquece o usuário. Se a página muda com o visitante, ponha isso na etiqueta ou não mande nenhuma.

Os arquivos em static/ já fazem isso sozinhos: a impressão digital do ?v= é a ETag deles, então a segunda visita custa um 304 e nenhum byte.

Desafio#

A página de detalhe do evento vai ao banco a cada visita. Guarde-a por uma hora, com uma tag que permita ao Salvar derrubar só aquele evento e outra que derrube a seção inteira.

Mostrar solução

Tags são uma lista, então uma entrada pode pertencer a mais de um grupo:

func Buscar(ctx context.Context, slug string) (Evento, error) {
	return cache.Do(ctx, Cache, cache.Key{
		Name: "evento:" + slug,
		TTL:  time.Hour,
		Tags: []string{"eventos", "evento:" + slug},
	}, func(ctx context.Context) (Evento, error) {
		return db.Buscar(ctx, slug)
	})
}

func Salvar(ctx context.Context, e Evento) error {
	if err := db.Salvar(ctx, e); err != nil {
		return err
	}
	// O evento mudou: a página dele, e toda lista em que ele aparece.
	Cache.Invalidate("evento:"+e.Slug, "eventos")
	return nil
}