Notícias8 min de leitura

PostgreSQL 18 na prática: I/O assíncrono, UUIDv7 e o que testar antes do upgrade

Como medir o I/O assíncrono com io_method e io_uring, quando usar uuidv7() como chave primária, e as armadilhas de upgrade: checksums por padrão, estatísticas preservadas e fim do MD5.

  • postgresql
  • banco-de-dados
  • io-uring
  • uuid
  • linux
  • performance
  • upgrade

O PostgreSQL 18, lançado em setembro de 2025, trouxe a mudança interna mais profunda em anos: o banco deixou de depender só de leituras síncronas e do readahead do sistema operacional. Veio junto uma lista de mudanças menores que afetam o dia a dia: uuidv7() nativo, skip scan em índices B-tree, OLD e NEW no RETURNING, e alguns padrões novos que podem travar um pg_upgrade se você não souber deles.

Se você ainda está no 16 ou no 17, este texto é o que eu testaria antes de subir de versão, com os comandos para medir em vez de acreditar em número de anúncio.

I/O assíncrono: o que mudou

Até a versão 17, cada processo do PostgreSQL pedia um bloco de 8 KB ao kernel e esperava a resposta. Para sequential scans, o banco contava com o readahead do Linux para adiantar leituras. Em disco local rápido, funcionava. Em disco de rede, como os volumes das nuvens, cada leitura carrega a latência da rede, e esperar uma por vez desperdiça o hardware.

A versão 18 introduz um subsistema de AIO, controlado pelo parâmetro io_method:

io_method Como funciona Plataforma
worker (padrão) Processos auxiliares (io_workers, 3 por padrão) fazem as leituras em paralelo Todas
io_uring Usa a interface assíncrona do kernel Linux, sem processos intermediários Linux, com build que inclui liburing
sync Comportamento antigo, síncrono Todas

Nesta primeira versão, o AIO cobre leituras: sequential scans, bitmap heap scans e vacuum. Escrita continua pelo caminho tradicional. O parâmetro effective_io_concurrency, que controla quantas leituras podem ficar em voo, também teve o padrão aumentado.

O anúncio oficial fala em ganhos de até 3× em leitura. O "até" importa: o ganho aparece em armazenamento com latência alta e em consultas que leem muito do disco. Com o conjunto de dados inteiro no shared_buffers ou no cache do sistema operacional, quase não há diferença, porque não há I/O para paralelizar.

Como medir na sua carga

Não confie no número de ninguém, inclusive o meu. Monte o teste com a sua carga.

Confira o que está ativo:

SHOW io_method;
SHOW io_workers;
SHOW effective_io_concurrency;

Crie uma tabela maior que a memória disponível para o banco, para forçar leitura de disco:

CREATE TABLE leitura_teste AS
SELECT g AS id, md5(g::text) AS payload, now() - (g || ' seconds')::interval AS criado_em
FROM generate_series(1, 50000000) AS g;

VACUUM ANALYZE leitura_teste;

Para cada valor de io_method, reinicie o banco (o parâmetro exige restart), limpe o cache do sistema operacional e rode a mesma consulta:

sudo systemctl restart postgresql
sync && echo 3 | sudo tee /proc/sys/vm/drop_caches
EXPLAIN (ANALYZE) SELECT count(*) FROM leitura_teste WHERE payload LIKE 'ab%';

No 18, EXPLAIN ANALYZE já inclui BUFFERS por padrão. Compare o tempo total e a linha shared read: é ela que mostra quanto veio do disco. Rode cada configuração algumas vezes e descarte a primeira.

Enquanto a consulta roda, a view pg_aios mostra as operações de I/O assíncrono em andamento. É útil para confirmar que o AIO está de fato sendo usado.

Antes de ligar io_uring

  • O binário precisa ter sido compilado com suporte a liburing. Pacotes das distribuições e do repositório PGDG costumam ter; builds customizadas, nem sempre. Se não tiver, o banco recusa o valor.
  • Em container, o perfil seccomp padrão do Docker bloqueia as syscalls de io_uring. O banco sobe, mas o io_uring não funciona. Esse é um dos poucos casos em que afrouxar o seccomp é o caminho, e vale fazer só com um perfil customizado que libera exatamente essas syscalls.
  • Algumas distribuições e ambientes gerenciados desligam io_uring no kernel por política de segurança. Nesses casos, worker é a escolha.

Na minha leitura, worker já entrega a maior parte do ganho, funciona em qualquer lugar e é o padrão por um motivo. io_uring é o ajuste fino para quem controla o host.

UUIDv7: chave primária que não espalha escrita

A função uuidv7() gera identificadores no formato padronizado pelo RFC 9562. Os primeiros 48 bits são um timestamp em milissegundos, o resto é aleatório.

CREATE TABLE pedido (
    id uuid PRIMARY KEY DEFAULT uuidv7(),
    cliente_id bigint NOT NULL,
    total numeric(12, 2) NOT NULL
);

SELECT uuid_extract_timestamp(id) FROM pedido LIMIT 1;

Por que isso importa: com UUIDv4, totalmente aleatório, cada INSERT cai numa página aleatória do índice B-tree da chave primária. Em tabela grande, isso significa ler páginas do disco para inserir, sujar muitas páginas diferentes no checkpoint e gerar mais WAL. Com UUIDv7, IDs novos são sempre maiores que os antigos, e as inserções se concentram no fim do índice, como acontece com um bigserial.

Você fica com as vantagens do UUID, que são gerar o ID fora do banco, não expor contagem e mesclar dados de várias origens, sem o custo de escrita do v4.

O custo que ninguém menciona: o UUIDv7 carrega o momento da criação. Qualquer pessoa que veja o ID sabe quando o registro foi criado, com precisão de milissegundo. Para ID de pedido interno, tanto faz. Para ID exposto em URL pública de algo em que o momento de criação é sensível, como cadastro de usuário ou documento, pode ser um vazamento. Nesses casos, UUIDv4 continua sendo a escolha, ou um ID público separado da chave primária.

Outras mudanças que valem o upgrade

Skip scan em B-tree. Um índice em (status, criado_em) agora pode servir uma consulta que filtra só criado_em, se status tiver poucos valores distintos. O planejador "pula" entre os valores de status. Antes, esse caso exigia um segundo índice só em criado_em.

OLD e NEW no RETURNING. Dá para ver o antes e o depois no mesmo comando, sem trigger nem consulta extra:

UPDATE produto
SET preco = preco * 1.1
WHERE categoria = 'cabos'
RETURNING id, old.preco AS preco_anterior, new.preco AS preco_novo;

Isso simplifica auditoria. Quem já precisou de trigger só para registrar "valor antigo e novo", como no texto sobre triggers no PostgreSQL disparando APIs, vai reconhecer o alívio.

Colunas geradas virtuais. GENERATED ALWAYS AS (...) agora é virtual por padrão: calculado na leitura, sem ocupar disco. A limitação na versão 18 é que colunas virtuais não podem ser indexadas. Se você precisa de índice, declare STORED explicitamente.

Restrições temporais. Chaves primárias e únicas aceitam WITHOUT OVERLAPS em um intervalo, o que impede, por exemplo, duas reservas da mesma sala em períodos sobrepostos, direto no esquema. Exige a extensão btree_gist.

OAuth 2.0. O banco pode autenticar usando tokens de um provedor de identidade, via um módulo validador. É o começo de tirar senha de banco das mãos das pessoas.

Armadilhas de upgrade

Checksums de dados agora são padrão. O initdb do 18 ativa checksums por padrão. O pg_upgrade exige que o cluster antigo e o novo tenham a mesma configuração. Se o seu cluster antigo não tem checksums, o que é o caso da maioria criada com os padrões antigos, o upgrade falha. Saída: criar o cluster novo com initdb --no-data-checksums, ou ativar checksums no antigo antes com pg_checksums (exige o banco parado, e leva tempo proporcional ao tamanho).

Estatísticas preservadas. Até o 17, depois de um pg_upgrade o banco ficava sem estatísticas do planejador até alguém rodar ANALYZE em tudo. Durante esse tempo, planos ruins. O 18 preserva as estatísticas básicas. As estendidas (CREATE STATISTICS) ainda precisam ser recriadas, e o vacuumdb ganhou uma opção para processar só o que falta:

vacuumdb --all --analyze-in-stages --missing-stats-only

MD5 descontinuado. Senhas MD5 ainda funcionam, mas geram aviso e vão sair numa versão futura. Migre para SCRAM:

SET password_encryption = 'scram-sha-256';
\password nome_do_usuario

E troque md5 por scram-sha-256 no pg_hba.conf. Confira antes se todos os drivers das aplicações suportam SCRAM; drivers muito antigos não suportam.

pg_upgrade --swap. Um modo novo que move os diretórios de dados em vez de copiar ou criar links. É o mais rápido, mas o cluster antigo deixa de ser utilizável. Tenha backup testado antes.

Checklist de upgrade

  • Conferir checksums do cluster atual (SHOW data_checksums;) e decidir entre --no-data-checksums ou pg_checksums.
  • Rodar pg_upgrade --check numa cópia antes do dia.
  • Planejar vacuumdb --analyze-in-stages --missing-stats-only logo após o upgrade.
  • Migrar senhas MD5 para SCRAM e testar todos os clientes.
  • Medir io_method = worker contra sync com a carga real; testar io_uring só se o host permitir.
  • Revisar extensões: todas precisam ter versão compatível com o 18 instalada no servidor novo.
  • Para tabelas novas com UUID, preferir uuidv7(), exceto quando o timestamp embutido for um problema.

Em resumo

O PostgreSQL 18 não muda o SQL que a sua aplicação escreve, mas muda como o banco conversa com o disco. Para quem roda em nuvem, medir io_method com a carga real é o ajuste de maior retorno. Para modelagem nova, uuidv7() resolve o velho dilema entre UUID e chave sequencial, com uma ressalva de privacidade. E, no upgrade, o novo padrão de checksums é o detalhe que mais derruba planos que funcionavam nas versões anteriores.

Fontes

Adelson de Bittencourt Junior

Desenvolvedor full-stack em Criciúma, SC, com passagem por suporte de telefonia VoIP e redes. Escreve sobre redes, segurança, infraestrutura e bancos de dados. Sobre o site · Enviar correção