O Postgres consegue substituir o Redis como um cache?
Hoje, decidi perguntar para a galera do Twitter qual era o primeiro serviço de message queue que vinha a cabeça deles. Para a minha surpresa, uma das respostas foi: Postgres.
Eu abri o link e fiquei surpreso não apenas pela possibilidade de usar Postgres como um Message Broker:
“Use Postgres as a message queue with SKIP LOCKED instead of Kafka (If you only need a message queue)”. — Stephan Schmidt
Mas também, com a possibilidade de usar Postgres como um cache no lugar do Redis:
“Use Postgres for caching instead of Redis with UNLOGGED tables and TEXT as a JSON data type. Use stored procedures or do as I do, use ChatGPT to write them for you, to add and enforce an expiry date for the data just like in Redis”. — Stephan Schmidt
E a razão pela qual eu fiquei tão surpreso foi porque durante minha jornada com o Redis, uma das coisas que eu frequentemente ouvir algumas pessoas falarem (da Redis) era que o "Redis é uma base de dados e portanto deveria ser sua base de dados primária."
E na verdade isso faz sentido. Redis é uma base de dados completa que por acaso funciona muito bem como um cache. E a razão pela qual ela funciona tão bem como um cache é porque ela é rápida. Muito rápida. Ao ponto de realizar milhões de operações em um único segundo.
E assim... ler que o Postgres, a minha base de dados relacional favorita, poderia agora substituir o Redis, a minha base de dados não relacional favorita, meio que virou meu mundo de cabeça para baixo. Afinal de contas, eu deveria substituir o Redis com o Postgres ou o Postgres com o Redis?
Mas antes mesmo de considerar essa questão, eu queria entender: É mesmo uma boa ideia usar o Postgres como um cache? Ele realmente consegue substituir o Redis? É isso que eu quero descobrir hoje.
O artigo que foi compartilhado comigo, e que eu descobri depois que estava bombando no Twitter, tinha sido escrito pelo Stephan Schmidt.
O Stephan não só defende a ideia de substituir o Redis pelo Postgres, na verdade ele defende a ideia de substituir tudo com o Postgres. De acordo com ele, ao fazer isso, estaríamos removendo e complexidade e aumentando a nossa habilidade de evoluir mais rapidamente.
“Just Use Postgres for Everything (How to reduce complexity and move faster)” — Stephan Schmidt
Entretanto, ele não poderia ser o único defendendo a ideia de substituir o Redis, e na verdade, algumas outras pessoas estavam fazendo a mesma coisa:
Mas primeiramente, por que eu gostaria de substituir o Redis pelo Postgres?
O Stephan já nos deu duas razões: menos complexidade e mudanças mais rápidas. Mas tem mais?
Usar o Postgres como um cache não é a escolha mais comum, mas existem certos cenários em que isso possa fazer sentido. Vamos dar uma olhada neles:
Uma Tech Stack Unificada
Postgres é uma das base de dados mais populares por aí. Ela é gratuita, open source, e provavelmente você já a está utilizando na sua aplicação.
Usá-lo como um cache pode simplificar sua tech stack ao reduzir a necessidade de gerenciar e manter multiplas base de dados.
Interface Familiar
O Postgres supporta queries complexas e também indexação, o que tornaria mais fácil retornar e transformar dados de forma avançada diretamente na camada de cache. Usar SQL para na camada de cache também é vantajoso se sua equipe já é fluente em SQL.
Custo
Em alguns casos, o custo benefício em usar um Postgres que já existe para cache ao invés de implementar uma solução de cache separada pode ser maior. Principalmente em ambientes onde o orçamento para infraestrutura é limitado.
O que eu deveria esperar de um cache?
Serviços de cache tradicionais, como o Redis, vêm como uma série de funcionalidades que melhoraram a performance e a escalabilidade das nossas aplicações. Para entender se o Postgres poderia realmente substituir o Redis, nós também precisamos entender quais funcionalidades são essas. Vamos dar uma olhada em alguns aspectos chaves que deveríamos esperar de um serviço de cache:
Performance
O objetivo principal de um serviço de cache é melhorar a performance das nossas aplicações ao tornar o acesso aos nossos dados mais rápido.
Soluções de cache de alta performance podem lidar processos de alto throughput e fornecer tempos de resposta abaixo de um milissegundo, acelerando significativamente os processos de acesso aos dados.
Expiração
Ao configurar tempo de expiração (TTL) para os dados em cache, podemos ter a certeza de que dados desatualizados são automaticamente removidos do cache depois um período específico. Ter essa garantia é um aspecto essencial de um serviço de cache.
Eviction
Serviços de cache geralmente armazenam seus dados na memória, que geralmente é mais limitada. Por isso, configurar uma política de eviction permite remover automaticamente os dados menos utilizados para abrir espaço para novos dados.
Key-value storage (Armazenamento chave-valor)
A maioria dos serviços de cache armazenam dados no formato key-value (chave-valor). Isso permite que os dados sejam acessados de forma rápida, tornando mais fácil o armazenamento e o acesso aos dados frequentemente utilizados mais eficiente.
Em resumo, o que esperamos de um serviço de cache é que ele permitar que acessemos nossos dados de modo mais rápido e que ele retorne nossos dados ainda atualizados o mais rápido possível.
Como converter o Postgres num cache?
De acordo com o Martin Heinz, no seu blog [You Don’t Need a Dedicated Cache Service — PostgreSQL as a Cache], o Postgres oferece quase tudo que foi mencionado na seção anterior deste artigo.
Tanto o Stephan quanto o Martin dizem que podemos transformar o Postgres num cache usando UNLOGGED tables. Quase todos os exemplos que vou mostrar daqui para a frente foram criados pelo Martin na sua publicação.
Unlogged tables e Write Ahead Log
Unlogged tables no Postgres é uma forma de prevenir que tabelas específicas escrevam no WAL (Write Ahead Log).
WAL, por sua vez, garante que todas as alterações feitas na base de dados sejam gravadas antes de serem realmente escritas nos arquivos da base de dados. Isso ajuda a manter a integridade dos dados, espepcialmente num evento de crash ou uma falha de energia.
Curiosidade: O Redis oferece um mecanismo similar chamado Append Only File (AOF), que não só é um mecanismo para persistir seus dados, mas também funciona de maneira similar, registrando todas as operações executadas no Redis. Para usar o Redis como nossa base de dados primária, nós desligamos o AOF, enquanto para usar o Postgres como um cache, nós desligamos (em tabelas específicas), o WAL.
Desligar o WAL significa melhorar a performance
Toda vez que um dado é modificado, o Postgres escreve essa mudança tanto no WAL quanto nos seus arquivos. Naturalmente, isso dobra o número de operações necessárias.
Além disso, para garantir que toda transação comitada é fisicamente escrita no disco, o WAL é desenhado para forçar um flush no disco (fsync). E flushes frequentes no disco impactam a performance já que adicionam latência enquanto esperam que o disco reconheça que os dados foram escritos de forma segura.
Mas também signfica abrir mão da persistência
O que precisamos realmente saber sobre unlogged tables é que elas não são persistentes.
O Postgres usa o WAL para reproduzir e aplicar quaisquer alterações que foram feita desde o último checkpoint. Se não temos esse registro, a base de dados não consegue ser restaurada a um estado consistence ao reproduzir os registros do WAL. De qualquer forma, isso é meio que esperado de um cache, não é mesmo?
CREATE UNLOGGED TABLE cache (
id serial PRIMARY KEY,
key text UNIQUE NOT NULL,
value jsonb,
inserted_at timestamp);
CREATE INDEX idx_cache_key ON cache (key);
Expiration com Stored Procedures
Tanto o Martin quanto o Stephan dizem que expiração pode ser implementada com o uso de stored procedures. E é aqui que as coisas começam a ficar um pouco complexas.
CREATE OR REPLACE PROCEDURE expire_rows (retention_period INTERVAL) AS
$$
BEGIN
DELETE FROM cache
WHERE inserted_at < NOW() - retention_period;
COMMIT;
END;
$$ LANGUAGE plpgsql;
CALL expire_rows('60 minutes'); -- This will remove rows older than 1 hour
A verdade é que a maioria das aplicações hoje em dia não dependendem mais de stored procedures. Muitos desenvolvedores inclusive são contra o uso excessivo de stored procedures hoje em dia.
Geralmente, o uso de stored procedures é desencorajado por que queremos evitar que lógica de negócio acabe vazando para dentro das nossas base de dados. Para além disso, conforme o número de stored procedures cresce, gerenciá-las e entende-las pode se tornar uma tarefa difícil.
Além do mais, nós também precisamos chamar essas stored procedures regularmente. E para isso, precisamos usar uma extensão chamada pg_cron.
Depois de instalar nossa extensão, ainda precisamos criar nossos schedulers:
-- Create a schedule to run the procedure every hour
SELECT cron.schedule('0 * * * *', $$CALL expire_rows('1 hour');$$);
-- List all scheduled jobs
SELECT * FROM cron.job;
A complexidade está aumentando, não está?
Eviction com Stored Procedures
O Stephan nem sequer menciona eviction enquanto Martin diz que isso é opcional já que expiration manteria o tamanho do cache baixo.
Mas se você quiser implementar uma solução para eviction, ele sugere que você adicione uma coluna chamada last_read_timestamp a tabela e rode outra stored procedure de vez em quando para alcançar uma política de eviction LRU (last recently used).
CREATE OR REPLACE PROCEDURE lru_eviction(eviction_count INTEGER) AS
$$
BEGIN
DELETE FROM cache
WHERE ctid IN (
SELECT ctid
FROM cache
ORDER BY last_read_timestamp ASC
LIMIT eviction_count
);
COMMIT;
END;
$$ LANGUAGE plpgsql;
-- Call the procedure to evict a specified number of rows
CALL lru_eviction(10); -- This will remove the 10 least recently accessed rows
Redis offers eight types of eviction policy out of the box. Precisa de outra política de eviction? Pede pro ChatGPT.
E a performance?
Performance é o que mais importa nesse caso, não é? Afinal, o que realmento buscamos com um caching service é acessar nossos dados de um modo mais rápido.
Greg Sabino Mullane mandou muito bem escrevendo o seu artigo [PostgreSQL Unlogged Tables — Look Ma, No WAL!] comparando a performance de tabelas UNLOGGED e LOGGED no Postgres. Seus dados mostram que a performance de escrita nas tabelas de escrita é duas vezes mais rápida do que a mesma operação na tabela LOGGED. Especificamente:
Unlogged Table:
• Latency: 2.059 ms
• TPS: 485,706168
Logged Table:
• Latency: 5.949 ms
• TPS: 168,087557
Mas e a performance de leitura?
E aqui está a pegadinha. A estratégia de otimização do Postgres depende de shared buffers.
Shared buffers armazenam dados frequentemente acessados e índices diretamente na memória, o que permite que eles sejam rapidamente acessados e se reduza a necessidade de leituras do disco. Melhorando assim a performance das queries e o acesso de dados tanto das tabelas logged quanto das unlogged.
É verdade que as tabelas unlogged podem viver nesses buffers, mas elas também podem, e irão, ser escritas no disco se crecerem muito e o espaço na memória for limitada. Portanto, tabelas unlogged melhoram primeiramente a velocidade de escrita, não de leitura.
Para provar, eu conduzi um rápido experimento utilizando o pgbench. Você pode ver como eu fiz aqui.
E os resultados mostram que a performance tanto das tabelas logged quanto das unlogged são, de fato, muito similares. Ler dos dois tipos de tabela levou mais ou menos 0,650 ms na média. Especificamente:
Unlogged Table:
• Latency: 0,679 ms
• TPS: 14.724,204
Logged Table:
• Latency: 0,627 ms
• TPS: 15.946,025
Esse resultado reenforça que tabelas unlogged primeiramente melhoram a performance de escrita. Para operaçoes de leitura, não é evidente que as tabelas unlogged tenham uma melhora na performance, já que tanto as tabelas logged, quanto unlogged se beneficiam de modo similar da estratégia de cache e otimização do Postgres.
Como a performance se compara ao Redis?
Em conjunto ao benchmark do Postgres, eu também conduzi um experimento com o Redis. Veja os resultados aqui. Os resultados do Redis mostram uma vantagem significativa na performance em tanto em termos de leitura quanto de escrita:
Leitura:
• Latency (p50): 0,095 ms
• Requests per second (RPS): 892.857,12
Escrita:
• Latency (p50): 0,103 ms
• Requests per second (RPS): 892.857,12
A comparação das performances nos mostram que o Redis tem um desempenho melhor do que o Postgres tanto nas operações de leitura quanto de escrita:
O Redis alcança uma latência de 0,095 ms, que é aproxidamente 85% mais rápida do que a latência de 0.679 ms observada na tabela unlogged do Postgres.
O Redis também lida com um request rate muito maior com 892.857,12 requests por segundo comparada as 15.946,025 transações por segundo do Postgres.
E quando falamos de operações de escrita, como ficou evidente pelo maior throughput e a menor latência, podemos ver que o Redis também entrega uma performance superior.
E se eu rodasse Postgres na memória?
Durante a revisão desse artigo, um colega da Xebia, [Maksym Fedorov], disse:
"E se tabelas unlogged fossem criadas num tablespace que corresponde ao memory mapped file? Acredito que veríamos números completamente diferentes.
Para testar isso, eu conduzi benchmarks com os dados do Postgres persistidos na memória. Surpreendemente não houveram melhroas nos resultados. O benchmark demonstrou:
Leitura:
• Latency: 0.652 ms
• Requests per second (TPS): 15.329,776954
Depois de uma pesquisa mais longa, eu entendi que apesas dos dados serem persistidos na RAM, acessá-los pelo shared buffers do Postgres ainda causa custos adicionais. Esses custos vêm do gerenciamento de locks e outros processos internos necessários para a integridade dos dados e acesso simultâneo.
O Postgres sempre verifica se os dados estão no shared buffers primeiro. Se não estiverem, ele copia os dados do tmpfs para o shared buffers antes de retorná-los ao cliente mesmo quando a base de dados é persistida na RAM.
Eu deveria substituir o Redis pelo Postgres?
Baseado nesse estudo, se você precisa de um serviço de caching para melhorar a performance de escrita, o Postgres pode ser otimizado utilizando unlogged tables. Entretanto, apesar das tabelas unlogged oferecerem uma melhor performance de escrita, elas ainda são significativamente menos performáticas em comparação com o Redis.
A maior razão para se utilizar um serviço de cache é melhorar o tempo de acesso aos dados. Tabelas unlogged não melhoram a performance de leitura enquanto o Redis é excelente com operações extremamente rápidas.
Para além disso, o Redis ajuda a prevenir que um grande volume de queries de baixo custo sejam enviadas para a sua base de dados, um benefício que tabelas unlogged não conseguem oferer. O Redis também oferece funcionalidades nativas como expiration, políticas de eviction, e mais, que são complexas quando implementadas no Postgres.
Embora gererenciar o Postgres possa parecer mais fácil para aguns, converter o Postgres em um cache não traz as vantagens de um serviço de cache dedicado. Ao mesmo tempo, o Redis é fácil e prazerozo de aprender e utilizar.
Para uma maior performance e simplicidade, escolher um serviço de cache verdadeiro como o Redis é a escolha certa.
Espero que tenham curtido essa história! Eu curti muito escreve-la e fazer toda essa pesquisa!
Um agradecimento especial ao Maksym Fedorov, João Paulo Gomes, and Hernani Fernandes pela revisão desse artigo.