
Sete camadas de cache em uma aplicação Rails, do navegador ao banco de dados
TL;DR
Um pedido pode ser respondido em sete pontos antes de chegar ao Postgres: cache do navegador, Varnish, Nginx, action cache, fragmentos Russian Doll, o store do Rails.cache e um cache de objetos no Redis. As três primeiras nem executam Ruby. Comece pelos headers de Cache-Control, depois fragmentos, depois objetos, e só então a borda.
A velocidade de uma aplicação web não é só a velocidade do app server e do banco. Entre o navegador e o SELECT existem sete lugares onde a resposta pode já existir. Este post percorre todos eles, com a configuração Rails de cada um e o que cada um realmente economiza.
Quando um pedido chega a uma aplicação web, o tempo que ele leva não é apenas uma função da velocidade do app server e do banco de dados. Entre o navegador do usuário e o SELECT que produziria a resposta existem pelo menos sete lugares onde essa resposta pode já existir. Cada camada que você adiciona move o ponto de corte para mais cedo no caminho, e quanto mais cedo um pedido é respondido, menos da sua infraestrutura ele toca.
Este post percorre as sete, da mais distante do seu código até a mais próxima do banco, com a configuração Rails de cada uma, o que cada uma realmente economiza e como cada uma quebra.
O caminho que um pedido percorre
Cache do navegador ....... nenhuma rede
|
Varnish .................. borda, responde da RAM, o Rails nunca vê
|
Nginx proxy cache ........ última parada antes do app server
|
Puma / Rails inicia o pedido
|
Action cache ............. resposta inteira, filtros ainda rodam
|
Fragmentos / Russian Doll partials, blocos jbuilder
|
Cache da aplicação ....... Rails.cache, Memcached ou Solid Cache
|
Cache de objetos ......... valores calculados no Redis
|
PostgreSQL
Uma forma útil de pensar nisso: as camadas 1 a 3 respondem o pedido sem executar Ruby. As camadas 4 a 7 executam Ruby, mas pulam parte do trabalho. A diferença de custo entre esses dois grupos é enorme, então a primeira pergunta é sempre se uma resposta pode ser tornada pública e cacheável na borda. Só quando a resposta é não é que as camadas do lado Ruby importam.
1. Cache do navegador
O pedido mais barato é o que nunca sai do dispositivo. Existem dois mecanismos, e o Rails tem um helper para cada.
Frescor. Cache-Control: max-age diz ao navegador para reutilizar a resposta sem perguntar. O Rails expõe isso via expires_in:
class PostsController < ApplicationController
def show
@post = Post.find(params[:id])
expires_in 10.minutes, public: true, stale_while_revalidate: 1.minute
end
end
public: true importa: sem ele o header é private, e todo cache compartilhado adiante (Varnish, Nginx, uma CDN) vai se recusar a armazená-lo. stale_while_revalidate deixa o navegador servir a cópia antiga enquanto busca uma nova em segundo plano.
Validação. Quando o conteúdo precisa estar atual mas raramente muda, envie um ETag e deixe o navegador perguntar "isso mudou?" com If-None-Match. Um 304 Not Modified tem algumas centenas de bytes e pula a renderização:
def show
@post = Post.find(params[:id])
fresh_when @post, public: true
end
fresh_when monta o ETag a partir do cache_key_with_version do registro, então uma mudança em updated_at o invalida. Repare no que ele não pula: o find ainda roda. ETags economizam banda e renderização, não trabalho de banco.
Assets estáticos são a vitória fácil. Propshaft e Sprockets emitem nomes de arquivo com digest, então um app Rails em produção pode servi-los com uma política de um ano e imutável:
# config/environments/production.rb
config.public_file_server.headers = {
"cache-control" => "public, max-age=#{1.year.to_i}, immutable"
}
Na prática uma CDN na frente do app faz isso por você, mas o header precisa nascer no Rails.
Um padrão que vale conhecer: em uma resposta dinâmica sem header explícito, o Rails define Cache-Control: max-age=0, private, must-revalidate. É por isso que Varnish e Nginx não cacheiam nada até você optar por isso em cada action.
2. Varnish
Varnish é um acelerador HTTP que fica na frente da sua camada web e mantém respostas em memória. Um hit é servido em bem menos de um milissegundo e nunca toca o Puma. A linguagem de configuração é VCL, e as duas coisas em que você vai gastar mais tempo são cookies e invalidação.
Cookies primeiro. O VCL embutido do Varnish se recusa a cachear qualquer pedido que carregue um header Cookie e qualquer resposta que carregue Set-Cookie. Um app Rails define o cookie de sessão em quase toda resposta, e protect_from_forgery grava o token CSRF na sessão no momento em que um formulário é renderizado. Então um app Rails padrão é incacheável pelo Varnish até você remover cookies das rotas que quer cachear:
vcl 4.1;
backend rails {
.host = "127.0.0.1";
.port = "8080";
}
sub vcl_recv {
# Public, read-only routes: drop cookies so they can be cached.
if (req.url ~ "^/(en|pt-br)/blog(/|$)" && req.method == "GET") {
unset req.http.Cookie;
}
}
sub vcl_backend_response {
if (bereq.url ~ "^/(en|pt-br)/blog(/|$)") {
unset beresp.http.Set-Cookie;
set beresp.http.xkey = beresp.http.X-Cache-Tags;
}
}
O Varnish respeita s-maxage acima de max-age, então o Rails pode dar aos navegadores um TTL curto e ao Varnish um longo a partir da mesma action:
expires_in 1.minute, public: true, "s-maxage" => 1.hour.to_i
Invalidação é onde o Varnish se paga. Em vez de purgar por URL, marque cada resposta com os objetos de que ela depende e purgue por tag. Com o vmod xkey, o controller define um header:
before_action -> { response.headers["X-Cache-Tags"] = "post-#{params[:id]} posts" }
e um callback after_commit em Post envia um PURGE com xkey-purge: post-42. Toda página cacheada que renderizou o post 42, incluindo o índice e o feed, some em um único pedido.
O que ele não resolve: qualquer coisa personalizada. Se uma página mostra "Olá, Wilbert", você ou move esse fragmento para um fetch do lado do cliente, ou usa Edge Side Includes, ou aceita que a página passa direto por essa camada.
3. Nginx proxy cache
Se o Varnish já está lá, por que Nginx? Duas razões. Muitas stacks terminam TLS no Nginx e não têm Varnish nenhum, e o cache do Nginx é um cache de borda de nó único perfeitamente bom para um app do tamanho do RubyInsights. A segunda é resiliência: proxy_cache_use_stale serve a cópia cacheada quando o Puma está reiniciando ou estourando timeout, o que transforma um soluço de deploy em um não-evento.
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=rails:10m
max_size=1g inactive=60m use_temp_path=off;
server {
location / {
proxy_pass http://puma;
proxy_cache rails;
proxy_cache_key "$scheme$host$request_uri";
proxy_cache_valid 200 301 10m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
proxy_cache_bypass $cookie__rubyinsights_session;
proxy_no_cache $cookie__rubyinsights_session;
add_header X-Cache-Status $upstream_cache_status;
}
}
proxy_cache_lock é a proteção contra estouro de boiada: quando uma chave expira, só um pedido vai ao Puma e os outros esperam por ele. O bypass por $cookie_ mantém sessões logadas fora do cache compartilhado, o mesmo problema que o Varnish resolveu com VCL.
O Nginx honra os headers do upstream. Cache-Control: private, no-store ou um Set-Cookie impedem o armazenamento, então o expires_in ... public: true da camada 1 é o que destrava esta camada também. Uma chamada de expires_in controla três caches.
Quando Varnish e Nginx estão ambos presentes, dê ao Nginx o trabalho de TLS, arquivos estáticos e stale-on-error, e deixe o Varnish ser dono do cache de HTML. Dois caches de página inteira com TTLs independentes tornam a invalidação duas vezes mais difícil sem ganho algum.
4. Action caching
Daqui em diante o pedido chega ao Puma e o Rails inicia a pilha de middleware. Action caching armazena a resposta inteira renderizada de uma action de controller, incluindo o layout, no Rails.cache. Foi removido do framework no Rails 4 e vive na gem actionpack-action_caching, que a organização Rails ainda mantém.
# Gemfile
gem "actionpack-action_caching"
class PostsController < ApplicationController
before_action :authenticate_user!, only: :drafts
caches_action :index, expires_in: 10.minutes,
cache_path: ->(c) { [c.params[:locale], c.params[:page]] }
caches_action :drafts, expires_in: 5.minutes,
cache_path: ->(c) { [:drafts, c.current_user.id] }
end
A diferença para as camadas de borda está em uma linha: filtros before_action ainda rodam. Isso significa que você pode autenticar, autorizar e então servir um corpo cacheado, que é exatamente o que torna drafts seguro para cachear por usuário quando nunca seria seguro no Varnish. O custo é que você paga por roteamento, middleware e filtros em todo hit.
A armadilha é o CSRF. Uma página cacheada que contém um formulário carrega o token de autenticidade de quem a renderizou primeiro. Ou cacheie com layout: false e mantenha formulários fora da action cacheada, ou renderize formulários a partir de uma partial não cacheada.
Avaliação honesta: action caching é a camada que menos uso. Se uma resposta é pública, empurre-a para a borda. Se é por usuário, Russian Doll normalmente dá o mesmo ganho com invalidação mais fina. Action caching cabe no meio-termo: autenticado, idêntico para um papel inteiro, caro de renderizar.
5. Russian Doll com fragmentos e jbuilder
Fragment caching armazena um pedaço renderizado de uma view, indexado pelo model que o produziu. O padrão Russian Doll aninha esses fragmentos de modo que uma mudança em um registro interno invalida só o seu próprio fragmento e os externos que o contêm, enquanto os fragmentos irmãos continuam quentes.
As chaves fazem o trabalho. Desde o Rails 5.2, cache_key é estável (posts/42) e cache_version carrega updated_at, então o store pode sobrescrever no lugar em vez de deixar entradas mortas para trás. O digest do template da view é incorporado automaticamente, então editar a partial a invalida no deploy.
ERB primeiro:
<%# app/views/posts/index.html.erb %>
<%= render partial: "posts/post", collection: @posts, cached: true %>
<%# app/views/posts/_post.html.erb %>
<% cache post do %>
<article>
<h2><%= post.title %></h2>
<% post.comments.each do |comment| %>
<% cache comment do %>
<%= render comment %>
<% end %>
<% end %>
</article>
<% end %>
cached: true em um render de coleção emite um único fetch_multi para a lista inteira em vez de N leituras, o que é a diferença entre esta camada ajudar e esta camada atrapalhar.
O aninhamento só funciona se a chave externa muda quando o registro interno muda:
class Comment < ApplicationRecord
belongs_to :post, touch: true
end
Sem touch: true, editar um comentário deixa o fragmento velho do post no lugar para sempre.
O jbuilder tem as mesmas primitivas para JSON, e elas importam mais lá porque respostas de API tendem a ser maiores e a apanhar mais:
# app/views/api/v1/posts/index.json.jbuilder
json.array! @posts, partial: "api/v1/posts/post", as: :post, cached: true
# app/views/api/v1/posts/_post.json.jbuilder
json.cache! ["v1", post], expires_in: 1.hour do
json.extract! post, :id, :slug, :title, :published_at
json.comments post.comments, partial: "api/v1/comments/comment",
as: :comment, cached: true
end
O prefixo "v1" é um bump manual de versão para quando a forma do JSON muda sem o registro mudar, já que chaves do jbuilder não incluem um digest de template como as chaves do ERB.
Duas coisas que fragmentos não enxergam: current_user e qualquer coisa de params. Se a saída de um fragmento depende de um deles, coloque-o na chave (cache [post, current_user.role]) ou você vai servir a view de um usuário para outro.
6. Cache da aplicação: Memcached ou Solid Cache
As camadas 4 e 5 escrevem no Rails.cache, então o store por trás dele é uma camada por si só. A escolha é entre um daemon em RAM e uma tabela de banco.
Memcached via Dalli é a escolha clássica. RAM pura, multi-get, eviction LRU, escala horizontalmente de forma trivial adicionando nós à lista do cliente. Também é stateless do ponto de vista do app: um nó morrendo só significa misses até ele esquentar.
config.cache_store = :mem_cache_store,
ENV.fetch("MEMCACHE_SERVERS").split(","),
{ expires_in: 1.day, pool: { size: 5 }, compress: true }
Solid Cache é o padrão em apps Rails 8 novos. Ele armazena entradas em uma tabela de banco, tipicamente um banco cache dedicado, e aposta no fato de que armazenamento NVMe é barato e rápido o suficiente para que um cache em disco com uma pegada muito maior vença um cache em RAM que faz eviction o tempo todo. A latência de hit é de alguns milissegundos em vez de submilissegundo, e a capacidade é limitada por disco em vez de RAM.
# config/cache.yml
production:
database: cache
store_options:
max_age: <%= 60.days.to_i %>
max_size: <%= 256.gigabytes %>
namespace: <%= Rails.env %>
config.cache_store = :solid_cache_store
O trade-off é nítido. Memcached vence quando o working set cabe em RAM e você está atrás de respostas de um dígito de milissegundo. Solid Cache vence quando você quer meses de fragmentos retidos, um serviço a menos para operar, e já está em uma stack como Kamal com Postgres como accessory. Em um único VPS, Solid Cache é o padrão certo e Memcached é um daemon a mais sem ninguém para acordar quando ele enche.
Seja qual for a escolha, dê um TTL aos fragmentos mesmo quando chaves versionadas em teoria os manteriam corretos para sempre. Chaves de registros apagados nunca são tocadas, então sem expires_in elas ficam até a eviction.
7. Cache de objetos no Redis
A última camada é código explícito: Rails.cache.fetch em volta de um valor calculado, uma query lenta ou uma chamada a terceiros. É a única camada em que você decide a chave, o TTL e o que vai dentro.
class PostStats
def self.for(post)
Rails.cache.fetch(["post-stats", post], expires_in: 15.minutes,
race_condition_ttl: 10.seconds) do
{
views: post.page_views.count,
reading_time: ReadingTime.estimate(post.body),
related_ids: RelatedPosts.new(post).ids
}
end
end
end
race_condition_ttl é o equivalente do lado Ruby ao proxy_cache_lock: quando a entrada expira sob carga, o primeiro processo a estende brevemente e recalcula enquanto os outros continuam servindo o valor antigo.
Por que Redis para esta camada quando Solid Cache ou Memcached já está configurado? Porque é aqui que estruturas de dados começam a importar. Um contador de views quer INCR, uma lista de "em alta esta semana" quer um sorted set, um rate limiter quer INCR mais EXPIRE. Essas não são leituras de cache; são operações pequenas, duráveis e atômicas, e o Redis as faz sem uma ida ao Postgres.
# config/environments/production.rb
config.cache_store = :redis_cache_store, {
url: ENV.fetch("REDIS_CACHE_URL"),
expires_in: 1.day,
race_condition_ttl: 10.seconds,
error_handler: ->(method:, returning:, exception:) {
Rails.error.report(exception, handled: true, severity: :warning)
}
}
A regra operacional que pega as pessoas: não compartilhe a instância Redis com o Sidekiq. Um Redis de cache deve rodar com maxmemory-policy allkeys-lru para fazer eviction de forma graciosa quando enche. O Redis do Sidekiq precisa ser noeviction, porque fazer eviction de um job enfileirado é perda de dados. Uma instância não pode ser as duas coisas. O error_handler acima é a outra metade dessa regra: uma queda do cache deve degradar para misses, nunca para 500s.
Juntando as sete
As camadas não são alternativas. Um único GET /pt-br/blog/seven-layers-of-cache-rails em uma stack ajustada fica assim:
- O navegador tem a página fresca: zero pedidos. Se está velha, ele envia
If-None-Match. - O Varnish tem: servida da RAM com o
ETagcorreto, o Rails nunca roda. - O Nginx tem, ou tem velha enquanto o Puma reinicia: servida, possivelmente com um refresh em segundo plano.
- O pedido inicia o Rails. Autenticação roda. Para um post público não há action cache; para o preview do admin há, indexado por usuário.
- A partial do post e cada partial de comentário vêm do fragment cache. Só o comentário mais novo renderiza.
- Esses fragmentos vivem no Solid Cache no mesmo Postgres que o Kamal já gerencia.
- O cálculo de tempo de leitura e posts relacionados vem do Redis, calculado quinze minutos atrás por outra pessoa.
- O Postgres vê um único
SELECTpela linha do post, porque ofresh_whenprecisava doupdated_at.
A ordem para construí-las é aproximadamente o inverso da ordem em que são atingidas. Comece com headers de Cache-Control corretos e fresh_when, porque são quase de graça e toda camada adiante depende deles. Adicione fragmentos Russian Doll às views que aparecem no seu APM como renders lentos. Coloque o cache de objetos em volta do que o APM mostra como as queries mais lentas. Só então adicione um cache de borda, porque com os headers certos, Varnish ou Nginx vira quase só configuração.
E meça. Toda camada adiciona um caminho de invalidação, e um cache que você não consegue explicar é um bug que você ainda não encontrou. X-Cache-Status do Nginx, X-Varnish do Varnish e a instrumentação do Rails.cache (cache_read.active_support) no seu APM bastam para ver qual camada respondeu cada pedido, e esse é o dashboard que você quer antes de adicionar a oitava.
Fontes
- Rails Guides: Caching with Rails guides.rubyonrails.org
- MDN: HTTP caching developer.mozilla.org
- Varnish Cache: Built-in VCL varnish-cache.org
- varnish-modules (vmod-xkey) github.com
- Nginx: ngx_http_proxy_module (proxy_cache) nginx.org
- rails/actionpack-action_caching github.com
- rails/jbuilder github.com
- rails/solid_cache github.com
- petergoldstein/dalli github.com
- Redis: Key eviction redis.io
Perguntas frequentes
Preciso das sete camadas em todo app Rails?
Não. Comece com Cache-Control e fresh_when, que são quase de graça, depois fragmentos Russian Doll nas views lentas e Rails.cache.fetch nas queries lentas. Varnish ou Nginx como cache de borda só compensa quando há tráfego público suficiente e os headers já estão corretos.
Por que o Varnish não cacheia nada no meu app Rails?
Porque o Rails envia o cookie de sessão e Cache-Control private por padrão, e o VCL embutido do Varnish não cacheia pedidos com Cookie nem respostas com Set-Cookie. É preciso remover cookies das rotas públicas no VCL e chamar expires_in com public: true na action.
Solid Cache ou Memcached?
Solid Cache em um VPS único ou stack Kamal: capacidade de disco, retenção longa e um serviço a menos. Memcached quando o working set cabe em RAM e cada milissegundo de latência de hit importa, como em APIs de alto volume.
Posso usar o mesmo Redis para cache e Sidekiq?
Não é recomendado. O Redis de cache deve usar maxmemory-policy allkeys-lru para fazer eviction quando enche; o do Sidekiq precisa de noeviction, porque perder um job enfileirado é perda de dados. Use instâncias ou bancos separados.
Action caching ainda existe no Rails 8?
Sim, mas fora do framework, na gem actionpack-action_caching mantida pela organização Rails. Ela cacheia a resposta inteira da action, com os before_action ainda rodando, o que permite cachear conteúdo autenticado por usuário ou por papel.
O que é race_condition_ttl?
Uma opção do Rails.cache.fetch que evita o estouro de boiada: quando uma entrada expira sob carga, o primeiro processo a estende por alguns segundos e recalcula o valor, enquanto os outros continuam servindo o valor antigo em vez de recalcularem todos ao mesmo tempo.
Comentários
Faça login com GitHub para comentar.
Ainda não há comentários.