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_uringnã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-checksumsoupg_checksums. - Rodar
pg_upgrade --checknuma cópia antes do dia. - Planejar
vacuumdb --analyze-in-stages --missing-stats-onlylogo após o upgrade. - Migrar senhas MD5 para SCRAM e testar todos os clientes.
- Medir
io_method = workercontrasynccom a carga real; testario_uringsó 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.