Skip to content
Sete camadas de cache em uma aplicação Rails, do navegador ao banco de dados

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.

Publicado em 14 min de leitura

Também disponível em English

Compartilhar

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:

  1. O navegador tem a página fresca: zero pedidos. Se está velha, ele envia If-None-Match.
  2. O Varnish tem: servida da RAM com o ETag correto, o Rails nunca roda.
  3. O Nginx tem, ou tem velha enquanto o Puma reinicia: servida, possivelmente com um refresh em segundo plano.
  4. 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.
  5. A partial do post e cada partial de comentário vêm do fragment cache. Só o comentário mais novo renderiza.
  6. Esses fragmentos vivem no Solid Cache no mesmo Postgres que o Kamal já gerencia.
  7. O cálculo de tempo de leitura e posts relacionados vem do Redis, calculado quinze minutos atrás por outra pessoa.
  8. O Postgres vê um único SELECT pela linha do post, porque o fresh_when precisava do updated_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

  1. Rails Guides: Caching with Rails guides.rubyonrails.org
  2. MDN: HTTP caching developer.mozilla.org
  3. Varnish Cache: Built-in VCL varnish-cache.org
  4. varnish-modules (vmod-xkey) github.com
  5. Nginx: ngx_http_proxy_module (proxy_cache) nginx.org
  6. rails/actionpack-action_caching github.com
  7. rails/jbuilder github.com
  8. rails/solid_cache github.com
  9. petergoldstein/dalli github.com
  10. 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.