PostgreSQL 18: A Evolução Definitiva do Banco de Dados Que Está Transformando Performance, Observabilidade e Desenvolvimento Backend

Published on: 2026-01-25
Post image
pt postgresql-18 postgres-18 postgresql-18-features postgresql-18-performance postgresql-18-observability postgresql-18-developers postgresql-merge postgresql-json_table postgresql-logical-replication postgresql-pg_stat_io postgresql-indexing

As versões mais recentes do PostgreSQL mudaram o tipo de trabalho que costuma dar mais desgaste em sistemas reais: menos improviso, menos “gambiarras” e mais recursos padrão, com comportamento previsível. Entre as versões 14 e 18, várias melhorias deixaram de ser apenas “novidades” e passaram a afetar diretamente o dia a dia de desenvolvimento, performance e manutenção.

O que chama atenção nesse período não é uma única função revolucionária, mas a soma de pequenos avanços que removem atritos antigos. Em vez de forçar reescritas, essas versões tornam soluções antes complexas em algo mais direto, legível e correto. A seguir, ficam organizados os tópicos centrais que sustentam essa mudança, com exemplos práticos e códigos completos.

Panorama do PostgreSQL moderno (14 a 18) e o que realmente muda

Entre as versões 14 e 18, o PostgreSQL ganhou recursos que aproximam o uso diário do que a linguagem SQL sempre prometeu: expressividade com consistência. Muitas equipes ainda trabalham como se estivessem em versões antigas, mantendo padrões de consulta e manutenção que já não são necessários. O resultado costuma ser mais código na aplicação, mais risco de concorrência e mais dificuldade para diagnosticar gargalos.

Uma forma útil de entender essa evolução é separar “o que era feito por necessidade” do “que agora existe como recurso nativo”. Antes, boa parte do trabalho exigia contornar limitações com CTEs complexas, funções auxiliares e lógica condicional fora do banco. Agora, vários desses casos têm soluções mais diretas, reduzindo margem de erro em concorrência e facilitando auditoria e revisão de código.

MERGE e a padronização do UPSERT sem surpresas

O termo UPSERT descreve a ideia de “inserir se não existir; atualizar se existir”. Durante muito tempo, isso era feito com INSERT ... ON CONFLICT ou com lógica condicional na aplicação, o que funcionava bem em casos simples, mas podia ficar confuso em cenários com múltiplas regras. O comando MERGE trouxe uma forma padrão de expressar isso, alinhada ao SQL e mais fácil de ler.

Além de reduzir complexidade, o MERGE melhora a clareza do que acontece em cada caso, o que ajuda em revisão de mudanças e em raciocínio sob concorrência. Em operações em lote, a intenção fica explícita: quando há correspondência, atualiza; quando não há, insere. Isso diminui a quantidade de “ramificações” no código da aplicação e evita soluções com múltiplas etapas vulneráveis a condições de corrida.

O exemplo abaixo mostra um MERGE típico, usando uma tabela de estágio para sincronizar dados por e-mail.

MERGE INTO users u
USING staging_users s
ON u.email = s.email
WHEN MATCHED THEN
  UPDATE SET
    name = s.name,
    updated_at = NOW()
WHEN NOT MATCHED THEN
  INSERT (email, name, created_at)
  VALUES (s.email, s.name, NOW());

Ingestão em massa: COPY, paralelismo e desenho de pipeline

Ingestão é a carga de grandes volumes de dados, como eventos, logs, integrações e importações. O comando COPY continua sendo um dos caminhos mais eficientes para inserir dados, mas ele é, por natureza, uma operação de sessão única. O avanço prático está no desenho do processo: ao dividir a carga em partes e executar múltiplas sessões em paralelo, o total pode cair drasticamente sem sobrecarregar um único processo do banco.

O ponto central é trocar “um cano gigante” por “vários canos menores” com divisão intencional, normalmente por partição, por faixa de chave ou por recorte temporal. Isso funciona especialmente bem quando há particionamento, que é a técnica de dividir uma tabela grande em partes menores gerenciadas pelo próprio banco. Ao final, consolidam-se operações de manutenção como estatísticas e checagens, evitando repetir trabalho para cada lote.

A ideia geral pode ser resumida como um pipeline com divisão e múltiplos trabalhadores de carga, o que reduz o tempo total mesmo que cada trabalhador continue usando COPY de forma tradicional. Esse modelo também facilita retentativas, porque cada parte é menor e mais fácil de repetir sem reiniciar tudo.

SQL/JSON e JSON_TABLE: projeção limpa de JSON para formato relacional

Dados em JSON são comuns em APIs, eventos e integrações, e no PostgreSQL isso aparece como jsonb, um formato binário otimizado para consulta. Durante anos, “achatar” JSON em colunas exigia combinações de jsonb_array_elements, LATERAL e junções que ficavam difíceis de manter. O recurso JSON_TABLE traz um modelo mais declarativo, aproximando esse tipo de consulta do padrão SQL.

O ganho principal é transformar uma estrutura JSON em uma “tabela virtual” com colunas definidas por caminhos, melhorando legibilidade e, em muitos casos, planos de execução. Em dados semiestruturados, isso ajuda a evitar consultas com muitos passos intermediários e reduz a chance de erros de interpretação. Também facilita o uso de JSON em cenários híbridos, onde parte do dado é relacional e parte chega em payloads variáveis.

O exemplo abaixo projeta uma lista JSON em colunas simples, definindo caminhos para cada campo e índices de array.

SELECT *
FROM JSON_TABLE(
  '[{"id":1,"tags":["sql","json"]},{"id":2,"tags":["nosql"]}]',
  '$[*]' COLUMNS (
    id     INT  PATH '$.id',
    tag_1  TEXT PATH '$.tags[0]',
    tag_2  TEXT PATH '$.tags[1]'
  )
) AS jt;

Replicação lógica: o poder e o contrato que precisa ser respeitado

Replicação lógica é a cópia de mudanças em nível de linhas, permitindo enviar inserções, atualizações e remoções de um banco para outro. Ela é muito útil para distribuir leitura, alimentar sistemas de análise e fazer migrações com menos interrupção. O ponto crítico é entender o “contrato”: os dados fluem automaticamente, mas mudanças de estrutura precisam de disciplina de release.

Ela cobre bem alterações de dados, inclusive com filtros e seleção de tabelas, mas não resolve sozinha problemas de DDL, que é a parte da SQL que define estrutura, como CREATE, ALTER e DROP. Quando a estrutura muda sem coordenação, surge a chamada “deriva de esquema”, na qual publicador e assinante deixam de concordar sobre o formato dos dados. O modelo saudável é tratar o esquema como parte do processo de implantação, com versionamento e execução controlada.

Uma configuração típica envolve criar uma PUBLICATION no servidor que publica mudanças e uma SUBSCRIPTION no servidor que assina e aplica. O trecho abaixo ilustra a forma geral desses comandos, mantendo o foco no padrão e nos conceitos centrais.

-- No banco de origem (publicador)
CREATE PUBLICATION pub_app FOR TABLE users, orders;

-- No banco de destino (assinante)
CREATE SUBSCRIPTION sub_app
CONNECTION 'host=origem port=5432 dbname=app user=replicador password=segredo'
PUBLICATION pub_app;

Observabilidade de I/O com pg_stat_io: diagnóstico que vira ação

Observabilidade é a capacidade de entender o que acontece em produção a partir de métricas e visões internas. Por muito tempo, investigar gargalos de disco no PostgreSQL era um misto de hipóteses e sinais indiretos. Com a visão pg_stat_io, tornou-se mais viável separar leitura e escrita, identificar padrões por tipo de processo e localizar pontos de pressão com mais precisão.

Isso muda o tipo de resposta possível durante incidentes: em vez de suspeitar genericamente de “o disco está lento”, fica mais claro onde há latência e qual perfil de operação está consumindo mais. A análise melhora quando combinada com consultas lentas e estatísticas de tabelas, porque os sintomas passam a apontar para causas mais específicas. A consequência prática é diminuir tentativas cegas de ajuste e priorizar mudanças com maior retorno.

Uma consulta simples pode sumarizar volumes e tempos por tipo de operação, ajudando a diferenciar leitura aleatória, escrita e operações relacionadas a manutenção.

SELECT
  backend_type,
  object,
  context,
  reads,
  read_time,
  writes,
  write_time
FROM pg_stat_io
ORDER BY (read_time + write_time) DESC;

Compressão: TOAST, escolhas conscientes e custo de CPU versus I/O

Compressão no PostgreSQL existe há muito tempo via TOAST, um mecanismo que move valores grandes para armazenamento auxiliar e pode comprimi-los. O ganho real aparece quando a escolha é intencional: colunas grandes e pouco acessadas podem ser comprimidas para reduzir I/O e espaço, enquanto colunas usadas em caminhos críticos podem ficar mais “magras” para reduzir custo de CPU. Essa separação entre “dado quente” e “dado frio” costuma ser mais importante do que tentar comprimir tudo.

O erro comum é tratar compressão como vitória automática, quando, na prática, há troca entre CPU e disco. Se a aplicação lê o campo com frequência, descomprimir repetidamente pode piorar a latência. Se o campo é raro e pesa muito, reduzir leitura de disco costuma compensar bastante. A estratégia madura é alinhar compressão ao padrão de acesso, não ao tamanho isolado.

O PostgreSQL permite escolher estratégias por coluna em vários casos, e também diferenciar compressão rápida e compressão forte em outros componentes do ecossistema, como rotinas de backup e arquivamento. A lógica principal é manter o caminho de leitura mais comum o mais leve possível, e empurrar custo para partes menos frequentes do sistema.

Indexação: manual, direcionada e baseada em carga real

Índice é uma estrutura que acelera buscas, ao custo de espaço e de escrita mais cara, pois cada alteração pode exigir atualizar índices. Não existe uma “mágica” universal que escolha índices perfeitos, porque cada sistema tem padrões de consulta diferentes. O que funciona de forma consistente é um ciclo disciplinado: observar consultas reais, identificar as mais custosas, verificar o plano de execução e aplicar índices que reduzam trabalho sem criar excesso de manutenção.

Termos importantes aparecem aqui: índice composto usa mais de uma coluna para filtrar e ordenar; índice parcial indexa apenas parte das linhas, quando existe um filtro frequente; índice de cobertura inclui colunas extras para evitar voltar à tabela em algumas consultas. Esses recursos permitem precisão, mas exigem clareza sobre as consultas que realmente importam. Um índice “a mais” pode piorar ingestão e atualizações se não for usado.

O exemplo abaixo mostra três padrões úteis: composto, parcial e com colunas incluídas, mantendo a explicação no próprio design do índice.

-- Índice composto: útil quando filtros usam as duas colunas com frequência
CREATE INDEX idx_orders_customer_created
ON orders (customer_id, created_at);

-- Índice parcial: útil quando a maioria das consultas filtra por status específico
CREATE INDEX idx_orders_pendentes
ON orders (created_at)
WHERE status = 'PENDENTE';

-- Índice com colunas incluídas: pode evitar leituras na tabela em algumas consultas
CREATE INDEX idx_users_email_include
ON users (email)
INCLUDE (name, updated_at);

Autovacuum: previsibilidade, bloat e ajustes por tabela

Autovacuum é o processo automático que remove versões antigas de linhas e atualiza estatísticas, algo essencial no modelo de concorrência do PostgreSQL. No PostgreSQL, atualizações criam novas versões de linhas, e versões antigas ficam até serem limpas, o que pode gerar bloat, isto é, crescimento de espaço e piora de performance por excesso de páginas a ler. Não existe um único parâmetro que “conserta” tudo, porque cada tabela tem ritmo de escrita diferente.

A forma mais estável de lidar com autovacuum é buscar impacto previsível, não impacto zero. Ajustes por tabela ajudam a tratar “tabelas quentes” com limiares menores e “tabelas frias” com limiares maiores, evitando varreduras desnecessárias. O ajuste por custo, chamado de cost-based tuning, permite que o trabalho seja mais distribuído e menos agressivo, reduzindo picos. O objetivo é manter a manutenção como um ruído baixo e constante, em vez de uma crise periódica.

O exemplo abaixo mostra como configurar parâmetros específicos em uma tabela com muita atualização, mantendo o controle local sem alterar o comportamento global do banco.

ALTER TABLE orders SET (
  autovacuum_vacuum_scale_factor = 0.02,
  autovacuum_analyze_scale_factor = 0.01,
  autovacuum_vacuum_threshold = 2000,
  autovacuum_analyze_threshold = 1000
);

Collation por coluna: ordenação correta em ambientes multilíngues

Collation é o conjunto de regras de comparação e ordenação de texto, determinando como caracteres são classificados e como “A” se compara com “Á”, por exemplo. Em sistemas com múltiplos idiomas, usar uma única regra para tudo pode gerar ordenações “estranhas” e buscas inconsistentes. A capacidade de definir collation por coluna permite que diferentes campos sigam regras do idioma correspondente, sem truques e sem normalizações artificiais.

O benefício é clareza: cada coluna declara a regra de ordenação que representa seu conteúdo. Isso reduz divergências entre ambientes e evita comportamentos surpreendentes em ORDER BY e em comparações. Também melhora a consistência quando há relatórios e listagens que precisam respeitar expectativas linguísticas diferentes. Em bases com conteúdo internacional, esse ajuste costuma ser pequeno no código e grande na qualidade percebida.

O exemplo abaixo cria uma tabela com títulos em idiomas distintos, cada um com sua collation apropriada.

CREATE TABLE articles (
  id SERIAL PRIMARY KEY,
  title_en TEXT COLLATE "en_US",
  title_de TEXT COLLATE "de_DE"
);

Tipos multirange: lógica mais limpa para períodos múltiplos

Range é um tipo que representa um intervalo, como um período de datas, e o PostgreSQL oferece operadores ricos para isso. O tipo multirange representa uma coleção de intervalos do mesmo tipo, permitindo expressar “vários períodos” de forma nativa. Antes disso, era comum escrever condições com muitos OR, o que tende a piorar legibilidade e, em alguns casos, o trabalho do otimizador de consultas.

Com multirange, a consulta descreve a intenção: verificar interseção entre um período e uma lista de períodos relevantes. O operador && indica interseção, ou seja, se há sobreposição entre intervalos. Isso também abre espaço para melhor uso de índices apropriados, porque a forma da condição fica mais estruturada. Em regras de reserva, disponibilidade e janelas de cobrança, essa modelagem reduz erros de borda em datas.

O exemplo abaixo busca registros cujo período de reserva intersecta com dois intervalos distintos no ano.

SELECT *
FROM bookings
WHERE booking_period && datemultirange(
  daterange('2025-01-01', '2025-02-01', '[]'),
  daterange('2025-06-01', '2025-07-01', '[]')
);

Do “como era” ao “como fica”: redução de improvisos e aumento de previsibilidade

Antes dessas versões, várias soluções comuns dependiam de combinações criativas: UPSERT complexo com múltiplas etapas, JSON achatado com consultas longas, diagnósticos de I/O com suposições e manutenção reativa de bloat. O resultado era um ecossistema com mais código colateral, mais pontos frágeis e mais tempo gasto em investigações. Mesmo quando funcionava, o custo aparecia na manutenção e na evolução do sistema.

O cenário atual favorece uma abordagem mais direta: MERGE para sincronização clara, JSON_TABLE para projeções limpas, pg_stat_io para visibilidade prática e tipos como multirange para expressar regras com menos ruído. O que muda não é apenas performance, mas a qualidade do desenho e a confiança no comportamento sob carga. Com menos improviso, problemas deixam de ser “normais” e passam a ser tratáveis com ferramentas nativas.

Conclusão: o efeito composto das versões 14 a 18

O impacto das versões 14 a 18 não depende de um único recurso, mas da soma de melhorias que tornam o PostgreSQL mais expressivo, observável e previsível. Recursos como MERGE e JSON_TABLE reduzem complexidade do SQL e deslocam menos lógica para a aplicação. Visões como pg_stat_io tornam gargalos mais visíveis e diminuem ajustes baseados em suposição.

Ao mesmo tempo, boas práticas continuam essenciais: indexação permanece um trabalho cuidadoso, autovacuum exige entendimento de padrão de escrita, e replicação lógica funciona melhor quando o esquema é tratado com disciplina. O resultado final é um banco mais “maduro” no uso cotidiano, com menos atrito acumulado e mais ferramentas para sustentar sistemas modernos. Nessa evolução, o ganho mais valioso é tornar rotinas antes tensas em processos previsíveis e consistentes.