Blog

UUIDv7, ULID ou Snowflake: identificadores que não sabotam seu índice.

Escolher o tipo do ID é uma decisão de cinco segundos no começo do projeto e uma migração de duas semanas depois. Vale gastar quinze minutos.

O padrão que quase todo mundo adota é UUIDv4. Aleatório, sem coordenação, gerado no cliente ou no servidor, sem colisão prática. É uma boa escolha por vários motivos e tem um problema específico que só aparece com volume.

O problema do UUIDv4

UUIDv4 é aleatório. Cada novo registro cai numa posição imprevisível do índice.

O índice B-tree do Postgres guarda dados ordenados em páginas. Inserção sequencial vai sempre pra última página, que já está na memória. Inserção aleatória toca uma página qualquer, provavelmente fria, que precisa vir do disco, ser modificada e voltar.

Com poucas linhas, indiferente. Com dezenas de milhões e escrita intensa, você vê inchaço de índice, fragmentação e uma queda de throughput que ninguém consegue atribuir a nada em particular. O sintoma é “o banco ficou lento com o tempo”, que é o tipo de diagnóstico que não leva a lugar nenhum.

Tem um segundo problema, menos técnico e igualmente real: UUIDv4 não tem ordem. Você não consegue ordenar por ID, não consegue paginar por cursor sem uma coluna extra, e não consegue olhar dois IDs e saber qual veio antes.

As alternativas ordenáveis

UUIDv7 coloca um timestamp em milissegundos nos 48 bits mais significativos e preenche o resto com aleatoriedade. É UUID de verdade, cabe no tipo uuid do Postgres, e é ordenável por tempo. Virou padrão em 2024 e é a escolha que eu faço hoje por default.

ULID é a mesma ideia com codificação diferente: 26 caracteres em Base32, sem hífen, ordenável, com boa legibilidade. A desvantagem é que não é UUID nativo, então em Postgres você guarda como uuid convertido ou desperdiça espaço num char(26).

Snowflake é o modelo do Twitter: 64 bits divididos em timestamp, identificador da máquina e sequência. Cabe num bigint, é o menor de todos e o mais rápido de indexar. O custo é operacional: cada nó gerador precisa de um identificador único, e você tem que garantir isso na infraestrutura.

BIGSERIAL ainda é a coisa mais rápida que existe, e eu não usaria como identificador público. Sequência exposta entrega volume de negócio (“eles têm 4.312 clientes”), convida a enumerar recursos e vaza informação de graça.

A comparação que interessa

Tamanho Ordenável Gerar no cliente Vaza informação
BIGSERIAL 8 bytes sim não volume e ordem
UUIDv4 16 bytes não sim nada
UUIDv7 16 bytes sim sim momento da criação
ULID 16 bytes sim sim momento da criação
Snowflake 8 bytes sim com coordenação momento e nó gerador

Repare na última coluna. UUIDv7 expõe quando o registro foi criado, o que é irrelevante em quase todo sistema e é problema em alguns. Se o momento de criação for informação sensível, e existem casos onde é, volte pro v4 e aceite o custo de índice.

O padrão que eu uso

Chave primária ordenável interna, identificador público separado quando fizer sentido.

create table invoice (
  id          uuid primary key default uuid_generate_v7(),
  public_id   text unique not null,   -- 'inv_9f2b...', o que vai pra fora
  ...
);

O prefixo no identificador público é a melhor pequena decisão de API que existe. inv_ para fatura, cus_ para cliente, pay_ para pagamento. Suporte olha um ID no chamado e sabe do que se trata. Um ID colado no campo errado falha na hora, com mensagem clara, em vez de retornar 404 misterioso.

Stripe faz assim, e a experiência de depurar integração deles é notavelmente melhor que a média por causa disso.

Se você já está em UUIDv4

Não migre por migrar. Migrar chave primária com relacionamento é caro e arriscado.

Migre se você tem tabela com dezenas de milhões de linhas, escrita constante, e sintoma medido de inchaço de índice. Comece pelas tabelas de evento e log, que são as que mais crescem e as que menos têm relacionamento apontando pra elas.

Nas tabelas novas, use v7 desde o começo. Conviver com dois formatos é perfeitamente aceitável e bem mais barato que uma migração grande.

Um detalhe que morde

Se a sua chave primária for ordenável por tempo, paginação por cursor fica trivial:

select * from invoice
where id > $ultimo_id
order by id
limit 50;

Sem offset, sem pular registro quando alguém insere no meio, sem a degradação de offset grande. É um ganho que aparece em toda listagem do sistema e que não estava no radar quando você escolheu o tipo do ID.

Sobre índice e o que acontece quando ele fica grande demais, escrevi em índices no Postgres.

← Voltar pro blog