Utilizando PostgreSQL como cache distribuído

Usar o PostgreSQL como cache distribuído deixou de ser uma ideia exótica e passou a ser uma opção oficial no ecossistema .NET. Durante muitos anos, Redis e Memcached foram a escolha padrão para essa camada, mas a biblioteca Microsoft.Extensions.Caching.Postgres reabriu essa discussão.

A pergunta central é direta: é viável colocar o cache dentro do mesmo banco que a aplicação já usa? Neste artigo, você vai entender os fundamentos, a configuração, o comportamento interno e os resultados práticos de desempenho dessa abordagem.

Por que considerar o PostgreSQL como cache distribuído

Um cache distribuído é um cache compartilhado por vários servidores da aplicação. Em geral, ele roda como um serviço externo, separado das instâncias que o consultam.

Segundo a documentação do ASP.NET Core, os dados de um cache distribuído permanecem coerentes entre as requisições de vários servidores. Além disso, eles sobrevivem a reinícios e deploys, porque não ficam na memória local de cada instância.

Por isso, a escolha do backend tem peso arquitetural. Cada novo serviço na infraestrutura traz custo de operação, monitoramento, segurança e atualização.

Nesse contexto, o PostgreSQL como cache distribuído aparece como alternativa para quem já tem o banco em produção. Em vez de adicionar um Redis só para cache, a equipe reaproveita uma peça que já conhece e já opera.

Se você quer revisar os tipos de cache antes de seguir, vale ler o artigo sobre cache em APIs e quando usar cada tipo. Ele ajuda a situar o cache distribuído entre as outras estratégias.

PostgreSQL além do modelo relacional

O PostgreSQL se consolidou como um dos bancos de dados mais versáteis do mercado. Além do modelo relacional tradicional, ele oferece suporte maduro a vários tipos de carga.

  • Documentos JSON e JSONB.
  • Operações vetoriais por meio da extensão pgvector.
  • Cargas analíticas e transacionais no mesmo motor.
  • Estruturas otimizadas para escrita e leitura de alta performance.

Dentro desse cenário, faz sentido usar o banco também como camada de cache. Isso vale especialmente quando a equipe não quer introduzir infraestrutura adicional.

Na prática, o PostgreSQL como cache distribuído aproveita recursos nativos do banco. Um deles, as tabelas sem registro no log de escrita, faz toda a diferença no desempenho, como você verá adiante.

A biblioteca Microsoft.Extensions.Caching.Postgres

A Microsoft publicou uma implementação oficial de cache distribuído baseada em PostgreSQL, compatível com a interface IDistributedCache. O código fica no repositório Azure/Microsoft.Extensions.Caching.Postgres no GitHub, e o pacote está publicado no NuGet.

Com ela, aplicações .NET usam um banco PostgreSQL como backend de cache de forma transparente. Ou seja, o código da aplicação trata o cache exatamente como trataria um Redis ou um SQL Server.

A documentação oficial do ASP.NET Core já lista o pacote ao lado dos provedores para Redis, SQL Server, Azure Cosmos DB e NCache. Embora a biblioteca seja recente, ela já tem versões estáveis e documentação suficiente para uso em ambientes reais.

O pacote suporta .NET 6, .NET 8, .NET 9 e .NET 10, além de .NET Standard 2.0 e 2.1 e .NET Framework 4.8.1. Dessa forma, ele atende tanto aplicações modernas quanto projetos legados.

Os principais recursos da biblioteca são poucos e bem definidos.

  • Implementação oficial de IDistributedCache.
  • Criação automática de schema e tabela, controlada pela opção CreateIfNotExists.
  • Suporte opcional ao Write-Ahead Logging, controlado pela opção UseWAL.
  • Controle de expiração absoluta e deslizante dos itens.
  • Compatibilidade com o HybridCache como cache de segundo nível.

Como configurar o PostgreSQL como cache distribuído

O primeiro passo é instalar o pacote no projeto. Para isso, use o .NET CLI.

dotnet add package Microsoft.Extensions.Caching.Postgres

Em seguida, defina as opções no appsettings.json. A biblioteca permite controlar a string de conexão, o schema, a tabela, o uso do WAL e os tempos de expiração.

{
  "ConnectionStrings": {
    "PostgresCache": "Host=localhost;Database=app;Username=app;Password=secret;Pooling=true;MinPoolSize=0;MaxPoolSize=100;Timeout=15;"
  },
  "PostgresCache": {
    "SchemaName": "public",
    "TableName": "cache",
    "CreateIfNotExists": true,
    "UseWAL": false,
    "ExpiredItemsDeletionInterval": "00:30:00",
    "DefaultSlidingExpiration": "00:20:00"
  }
}

A tabela abaixo resume cada opção, com os valores padrão informados na página do pacote.

Opção Padrão Função
ConnectionString obrigatória Conexão com o PostgreSQL
SchemaName obrigatória Schema onde a tabela de cache fica
TableName obrigatória Nome da tabela de cache
CreateIfNotExists false Cria schema e tabela quando não existem
UseWAL false Grava a tabela no Write-Ahead Log
ExpiredItemsDeletionInterval 00:30:00 Intervalo da limpeza de itens expirados
DefaultSlidingExpiration 00:20:00 Expiração deslizante padrão dos itens

O repositório também recomenda ativar o pooling de conexões na string de conexão. Por isso, o exemplo acima já inclui Pooling=true e os limites do pool.

Registrando o serviço com AddDistributedPostgresCache

A integração acontece pelo método AddDistributedPostgresCache. A documentação oficial mostra a leitura de cada valor com GetValue, propriedade por propriedade.

var builder = WebApplication.CreateBuilder(args);

// Registra o PostgreSQL como cache distribuído
builder.Services.AddDistributedPostgresCache(options =>
{
    options.ConnectionString = builder.Configuration.GetConnectionString("PostgresCache");
    options.SchemaName = builder.Configuration.GetValue<string>("PostgresCache:SchemaName", "public");
    options.TableName = builder.Configuration.GetValue<string>("PostgresCache:TableName", "cache");
    options.CreateIfNotExists = builder.Configuration.GetValue<bool>("PostgresCache:CreateIfNotExists", true);
    options.UseWAL = builder.Configuration.GetValue<bool>("PostgresCache:UseWAL", false);
});

var app = builder.Build();

Uma alternativa mais enxuta é vincular a seção inteira com Bind. Assim, todas as opções do arquivo chegam ao objeto de configuração de uma só vez.

builder.Services.AddDistributedPostgresCache(options =>
{
    // Vincula todas as opções da seção PostgresCache
    builder.Configuration.GetSection("PostgresCache").Bind(options);
    options.ConnectionString = builder.Configuration.GetConnectionString("PostgresCache");
});

Um ponto relevante observado nos testes é que algumas opções não chegam à biblioteca só porque estão no appsettings.json. Em outras palavras, a presença da seção no arquivo não basta.

Portanto, use Bind ou atribua cada valor de forma explícita, como nos exemplos acima. Caso contrário, a aplicação pode rodar com os padrões da biblioteca sem que você perceba.

Esse comportamento é comum no padrão Options do .NET. O artigo sobre Options Pattern no .NET 10 com validação no startup mostra como detectar configurações ausentes logo na inicialização.

Usando o cache via IDistributedCache

Depois da configuração, o uso é idêntico ao de qualquer outro cache distribuído no .NET. A aplicação injeta a interface IDistributedCache e passa a ler e gravar normalmente.

O fluxo típico tem quatro etapas. Primeiro, você serializa o objeto para JSON; depois, grava com SetStringAsync; em seguida, recupera com GetStringAsync; por fim, desserializa para o tipo esperado.

using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;

public sealed record Product(Guid Id, string Name, decimal Price);

public sealed class ProductCache(IDistributedCache cache)
{
    public async Task SetAsync(Product product, CancellationToken ct = default)
    {
        // Serializa o objeto antes de gravar
        var json = JsonSerializer.Serialize(product);

        var options = new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30),
            SlidingExpiration = TimeSpan.FromMinutes(5)
        };

        await cache.SetStringAsync($"product:{product.Id}", json, options, ct);
    }

    public async Task<Product?> GetAsync(Guid id, CancellationToken ct = default)
    {
        var json = await cache.GetStringAsync($"product:{id}", ct);

        // Retorna nulo quando o item não está no cache
        return json is null ? null : JsonSerializer.Deserialize<Product>(json);
    }
}

Esse modelo garante baixo acoplamento. Com isso, você pode trocar o backend de cache no futuro sem tocar na lógica de negócio.

Em uma Minimal API, o uso fica ainda mais direto. O endpoint recebe a dependência e consulta o cache antes de ir ao banco principal.

builder.Services.AddScoped<ProductCache>();

app.MapGet("/products/{id:guid}", async (Guid id, ProductCache cache) =>
{
    var product = await cache.GetAsync(id);
    return product is null ? Results.NotFound() : Results.Ok(product);
});

Se você está começando com Minimal APIs, o artigo sobre Minimal APIs no ASP.NET cobre os fundamentos.

Comportamento interno do PostgreSQL como cache distribuído

Analisar as consultas que a biblioteca envia ao banco revela pontos importantes. Eles explicam tanto o desempenho quanto as limitações do PostgreSQL como cache distribuído.

Criação automática da estrutura

Quando CreateIfNotExists está ativo, a biblioteca cria o schema e a tabela caso eles ainda não existam. Assim, você não precisa de migrations nem de scripts manuais para começar.

Por outro lado, em ambientes com políticas rígidas de banco, o usuário da aplicação pode não ter permissão para criar objetos. Nesse caso, desative a opção e crie a estrutura pelo seu processo de deploy.

Tabelas sem WAL

O Write-Ahead Log é o mecanismo que o PostgreSQL usa para garantir durabilidade. Toda alteração passa primeiro pelo log, e o banco usa esse registro para se recuperar após uma falha.

Quando UseWAL fica desativado, a tabela de cache é criada como UNLOGGED. Segundo a documentação do CREATE TABLE no PostgreSQL, os dados de uma tabela desse tipo não são gravados no WAL, o que torna a escrita bem mais rápida.

Contudo, existe um custo. Após uma queda do servidor ou um desligamento abrupto, o PostgreSQL esvazia automaticamente as tabelas UNLOGGED, e elas também não são replicadas para servidores standby.

Para dados de cache, esse comportamento costuma ser aceitável. Afinal, o cache guarda dados voláteis que sempre podem ser reconstruídos a partir da fonte original.

Upserts automáticos

As gravações usam o upsert do PostgreSQL, com INSERT seguido de ON CONFLICT DO UPDATE. Dessa forma, gravar uma chave que já existe sobrescreve o valor anterior em uma única instrução.

O trecho abaixo é apenas ilustrativo do padrão, não a consulta exata gerada pela biblioteca.

-- Exemplo ilustrativo de upsert em uma tabela de cache
INSERT INTO public.cache (id, value, expires_at)
VALUES ('product:42', '\x7b7d', now() + interval '20 minutes')
ON CONFLICT (id) DO UPDATE
SET value = EXCLUDED.value,
    expires_at = EXCLUDED.expires_at;

Limpeza de itens expirados e expiração deslizante

Um processo periódico remove as entradas expiradas. No entanto, ele não roda a cada escrita, e sim no intervalo definido por ExpiredItemsDeletionInterval, que por padrão é de 30 minutos.

Além disso, as leituras atualizam o tempo de expiração do item quando há expiração deslizante. Com isso, a biblioteca simula o comportamento de um cache ativo, em que itens acessados com frequência permanecem disponíveis.

Desempenho do PostgreSQL como cache distribuído em ambiente local

Os testes a seguir foram executados em ambiente local, com a API e o banco na mesma máquina. Portanto, os números servem como referência relativa, não como garantia para o seu cenário.

Com UseWAL desativado, o tempo médio de resposta ficou em torno de 3,5 ms. Já o throughput chegou a aproximadamente 14.000 requisições por segundo.

Com UseWAL ativado, o tempo médio de resposta subiu para cerca de 4,5 ms. Ou seja, o cache ficou aproximadamente 30% mais lento.

Em troca, os dados persistem caso o PostgreSQL falhe. Esses resultados mostram que as tabelas sem WAL oferecem um ganho significativo, com uma troca aceitável para cenários de cache.

PostgreSQL como cache distribuído em banco remoto

Os mesmos testes também rodaram contra um PostgreSQL remoto, hospedado em uma VPS. Nesse caso, o tempo médio de resposta ficou em torno de 16 ms, com leitura e escrita em desempenho semelhante.

O aumento da latência é esperado, pois cada operação agora atravessa a rede. Ainda assim, os números ficam dentro de um intervalo aceitável para muitas aplicações.

Isso vale especialmente quando a API e o banco estão na mesma região ou no mesmo datacenter. Nesse cenário, a latência de rede cai bastante em relação a uma VPS distante.

Vale lembrar que a própria documentação da Microsoft aponta o Redis como o cache distribuído de melhor desempenho para produção. Então, o PostgreSQL como cache distribuído compete em simplicidade operacional, não em latência mínima.

PostgreSQL como cache distribuído com HybridCache

O HybridCache, introduzido no .NET 9, combina um cache em memória (L1) com um cache distribuído (L2). Quando existe uma implementação de IDistributedCache registrada, ele a usa automaticamente como segundo nível.

Dessa forma, a maior parte das leituras sai da memória local, e só as faltas chegam ao banco. Isso reduz bastante a pressão sobre o PostgreSQL como cache distribuído.

builder.Services.AddDistributedPostgresCache(options =>
{
    builder.Configuration.GetSection("PostgresCache").Bind(options);
    options.ConnectionString = builder.Configuration.GetConnectionString("PostgresCache");
});

// O HybridCache usa o cache do PostgreSQL como segundo nível
builder.Services.AddHybridCache(options =>
{
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(30),
        LocalCacheExpiration = TimeSpan.FromMinutes(5)
    };
});

Além disso, o HybridCache oferece proteção contra stampede. Quando várias requisições pedem a mesma chave ausente, só uma busca o dado na fonte, enquanto as outras aguardam o resultado.

Para entender a camada em memória isoladamente, veja o artigo sobre Memory Cache no ASP.NET.

Quando usar o PostgreSQL como cache distribuído

Usar o PostgreSQL como cache distribuído não substitui por completo soluções especializadas como o Redis. No entanto, a abordagem se mostra viável em alguns cenários bem definidos.

  • Aplicações que já utilizam PostgreSQL.
  • Times que desejam reduzir a complexidade operacional.
  • Ambientes com restrições de infraestrutura.
  • Necessidade de cache simples, sem requisitos extremos de latência.

Reaproveitar o mesmo banco para várias responsabilidades exige cuidado. Por exemplo, um cache com muitas escritas disputa conexões, CPU e disco com as consultas transacionais da aplicação.

Por isso, avalie o volume de dados, os padrões de acesso e o impacto operacional antes de decidir. Uma opção intermediária é usar um banco ou uma instância separada só para o cache, mantendo a mesma tecnologia.

Também considere o tamanho do pool de conexões. Como cada leitura e escrita abre uma operação no banco, um pool mal dimensionado pode virar gargalo antes do próprio PostgreSQL.

Conclusão

O PostgreSQL, aliado à biblioteca Microsoft.Extensions.Caching.Postgres, é uma solução tecnicamente sólida para cache distribuído em aplicações .NET. Os testes mostram que, bem configurado, o desempenho é competitivo e suficiente para muitos cenários.

Embora não seja uma escolha universal, o PostgreSQL como cache distribuído é uma alternativa legítima e bem integrada ao ecossistema. Ele atende principalmente equipes que buscam simplicidade, menor custo operacional e melhor uso da infraestrutura existente.

Como toda decisão arquitetural, a adoção deve seguir medições, contexto e requisitos específicos. Afinal, dogmas técnicos não substituem um bom teste de carga no seu próprio ambiente.

FAQ

O PostgreSQL pode substituir o Redis como cache?

Pode em muitos cenários, principalmente quando a aplicação já usa PostgreSQL e não exige latência mínima. Para cargas extremas, o Redis continua sendo a opção de melhor desempenho.

O que acontece com o cache se o PostgreSQL cair?

Com UseWAL desativado, a tabela é UNLOGGED e o banco a esvazia após uma queda. Com UseWAL ativado, os dados persistem, mas a escrita fica mais lenta.

Preciso criar a tabela de cache manualmente?

Não, desde que CreateIfNotExists esteja ativo e o usuário tenha permissão. Caso contrário, crie o schema e a tabela no seu processo de deploy.

A biblioteca funciona com o HybridCache?

Sim. O HybridCache usa qualquer IDistributedCache registrado como cache de segundo nível, incluindo o do PostgreSQL.

Por que minhas opções do appsettings.json não foram aplicadas?

Algumas opções não são vinculadas só pela presença no arquivo. Use Bind na seção ou atribua cada valor de forma explícita no registro do serviço.