1. O que significa "identidade" em software
Toda entidade em um sistema de software precisa ser distinguível das demais. Um pedido, um usuário, uma transação financeira. A forma como essa distinção é feita afeta diretamente a capacidade do sistema de escalar, de manter dados consistentes e de evoluir ao longo dos anos. Quando falamos em "identidade" no contexto de bancos de dados e arquitetura de sistemas, estamos falando do mecanismo pelo qual garantimos que cada registro pode ser localizado, referenciado e diferenciado de todos os outros de maneira inequívoca.
Existe uma diferença importante entre identidade lógica e identidade técnica. A identidade lógica é aquela que faz sentido para o domínio do negócio. O CPF de uma pessoa, o número de um pedido, o código de rastreamento de uma encomenda. A identidade técnica é aquela que o sistema utiliza internamente para organizar e acessar dados. O inteiro auto-incrementado na coluna id de uma tabela do PostgreSQL, por exemplo, é uma identidade técnica. Ele não carrega significado de negócio, mas é a forma pela qual o banco de dados localiza aquele registro de maneira eficiente.
Na maior parte dos projetos, a escolha do identificador é feita nos primeiros dias e nunca mais revisitada. Um SERIAL no PostgreSQL ou um AUTO_INCREMENT no MySQL resolve o problema por meses ou anos. A complexidade aparece quando o sistema precisa crescer, quando os dados precisam ser distribuídos entre múltiplos bancos, quando APIs expõem esses identificadores para o mundo externo. Nesse momento, aquela decisão que parecia trivial se transforma em uma restrição arquitetural difícil de reverter.
"Já passamos pelo processo doloroso de aumentar o número de bits usados para armazenar IDs de tweets antes. Não é de surpreender que seja difícil quando você tem mais de 100.000 codebases diferentes envolvidas." (tradução do autor)
— Twitter Engineering, "Announcing Snowflake" (2010)
A experiência do Twitter ilustra bem esse ponto. Mudar o formato de um identificador que já permeia centenas de milhares de componentes é uma operação de altíssimo risco e custo. É por isso que entender as opções, os trade-offs e as implicações de cada estratégia de identificação é uma competência arquitetural que merece atenção proporcional ao impacto que carrega.
Este artigo percorre a história e a evolução das estratégias de geração de identificadores, começando pelo cenário mais simples (um banco de dados relacional com inteiros sequenciais) e avançando até os desafios de sistemas distribuídos com milhões de escritas por segundo. Ao longo do caminho, cada conceito é construído a partir do anterior, de forma que a complexidade cresce junto com o entendimento.
2. O mundo simples: monólitos e inteiros auto-incrementais
Como bancos relacionais geram IDs sequenciais
A forma mais antiga e mais difundida de gerar identificadores em bancos de dados relacionais é delegar ao próprio banco a responsabilidade de atribuir um número a cada nova linha inserida. No MySQL, isso é feito com a propriedade AUTO_INCREMENT na definição da coluna. No PostgreSQL, a mesma funcionalidade é oferecida pelos tipos SERIAL e BIGSERIAL, que internamente criam uma sequence (um objeto do banco que mantém um contador atômico).
-- PostgreSQL
CREATE TABLE pedidos (
id BIGSERIAL PRIMARY KEY,
cliente_id BIGINT NOT NULL,
valor NUMERIC(10,2) NOT NULL,
criado_em TIMESTAMP DEFAULT now()
);
-- MySQL
CREATE TABLE pedidos (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
cliente_id BIGINT UNSIGNED NOT NULL,
valor DECIMAL(10,2) NOT NULL,
criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);Quando uma nova linha é inserida, o banco de dados incrementa o contador e atribui o próximo valor disponível. Esse processo é atômico, o que significa que mesmo com múltiplas transações concorrentes inserindo dados na mesma tabela, cada uma recebe um valor distinto. O banco garante unicidade dentro daquela tabela sem que a aplicação precise se preocupar com isso.
As garantias que o banco oferece
Essa abordagem oferece três garantias. O banco impede que dois registros compartilhem o mesmo valor de primary key, pois o contador só avança. A obtenção do próximo valor e a inserção do registro acontecem dentro da mesma transação, garantindo atomicidade. E porque os valores são estritamente crescentes, a ordem dos IDs reflete (de maneira aproximada) a ordem em que os registros foram criados.
Essa terceira propriedade é particularmente útil para debugging e operações do dia-a-dia. Se você sabe que o pedido com ID 5.000 foi criado antes do pedido com ID 5.001, consegue raciocinar sobre a sequência de eventos sem precisar consultar timestamps adicionais. Em análises rápidas, em queries ad-hoc, na leitura de logs, essa ordenação natural economiza tempo.
Por que a indexação funciona tão bem
Para entender por que IDs sequenciais têm melhor performance em banco de dados, é necessário entender como os bancos organizam dados fisicamente em disco. Bancos relacionais não gravam registros individualmente, um por um. Eles agrupam registros em unidades chamadas páginas (ou blocos), com tamanho fixo: tipicamente 8 KB no PostgreSQL e 16 KB no MySQL. Quando o banco precisa ler ou gravar um registro, ele opera sobre a página inteira que o contém. O que determina a performance não é o número de registros acessados, mas o número de páginas que precisam ser lidas ou gravadas no disco.
Os bancos de dados relacionais mais utilizados (PostgreSQL, MySQL com InnoDB) organizam o índice da primary key em estruturas chamadas B-trees (ou variantes como B+trees). Uma B-tree é uma árvore balanceada composta por páginas: as folhas (leaf pages) contêm os dados reais ou ponteiros para eles, e os nós internos contêm chaves que guiam a navegação até a folha correta. Quando as chaves inseridas são sequenciais, as novas entradas sempre vão para a extremidade direita da árvore, modificando apenas a última página de dados, chamada de rightmost leaf page. As páginas anteriores permanecem intactas.
Esse padrão de inserção é chamado de append-only e tem dois efeitos positivos. O banco mantém a rightmost leaf page no buffer cache (a região de memória onde páginas de disco ficam armazenadas para acesso rápido), porque é sempre essa página que recebe as novas inserções. A taxa de cache hit para escritas se aproxima de 100%. Além disso, não ocorrem page splits.
Um page split acontece quando uma página fica completamente cheia e chega uma nova chave que deveria ser inserida nela. O banco precisa dividir a página em duas, mover metade das chaves para a nova página e atualizar os ponteiros nos nós pai da B-tree para refletir a nova estrutura. Toda essa operação é registrada no WAL. O resultado imediato é que as duas páginas passam a ter em média 50% de ocupação, desperdiçando espaço e reduzindo a eficiência do buffer cache. Com inserções sequenciais esse problema não ocorre: a rightmost leaf page só é dividida quando fica completamente cheia, e a nova página passa a receber todas as inserções seguintes, chegando rapidamente a 100% de ocupação novamente. Com inserções aleatórias, como as de UUID v4, cada nova chave vai para uma posição imprevisível na árvore, forçando page splits distribuídos por todo o índice e fragmentando-o progressivamente.
A documentação do PostgreSQL descreve esse comportamento no contexto de índices B-tree, observando que inserts sequenciais são o caso mais favorável para essa estrutura de dados (PostgreSQL Documentation, "B-Tree Indexes"). O MySQL InnoDB apresenta comportamento similar, já que a primary key define a ordem física dos dados no disco (clustered index), e inserções sequenciais evitam a reorganização de páginas.
A simplicidade operacional
Além da performance, há um ganho operacional difícil de quantificar mas fácil de perceber. Quando alguém reporta um problema com o "pedido 47832", é trivial encontrá-lo. Um SELECT * FROM pedidos WHERE id = 47832 resolve. Quando dois sistemas precisam se comunicar sobre um registro, basta trocar um número. Quando um time de suporte precisa investigar um incidente, consegue ordenar eventos pela coluna id e acompanhar a sequência cronológica.
Essa simplicidade não deve ser subestimada. Sistemas que optam por identificadores mais complexos antes de precisar deles pagam um custo cognitivo diário em cada operação de debugging, em cada query exploratória, em cada conversa entre times. A complexidade tem seu lugar, mas deve ser adotada quando há uma necessidade para isso.
3. Primeiras fraturas: quando o monólito começa a escalar
O limite de um único escritor
A arquitetura de um monólito com um único banco de dados funciona bem até certo ponto. Quando o volume de leituras cresce, a estratégia mais comum é adicionar read replicas. Essas réplicas recebem uma cópia assíncrona dos dados e respondem queries de leitura, aliviando a carga do banco primário. Nesse cenário, a geração de IDs continua centralizada no banco primário, e as réplicas apenas refletem os IDs que ele gerou.
Um INSERT com auto-increment percorre um caminho específico dentro do banco antes de qualquer dado chegar ao disco. Quando uma transação insere um registro com auto-increment, a primeira coisa que acontece é a aquisição de um lock exclusivo no contador de sequência. Esse lock garante que nenhuma outra transação obtenha o mesmo valor. Com o próximo ID em mãos, o banco registra a operação no WAL (Write-Ahead Log), um arquivo sequencial em disco que serve de diário de bordo. Só então a alteração é aplicada nas páginas do buffer cache, a memória compartilhada onde as páginas do banco vivem enquanto estão "quentes". A escrita em disco propriamente dita é assíncrona: um processo de background (o bgwriter no PostgreSQL, o InnoDB buffer pool flusher no MySQL) drena periodicamente as páginas modificadas para o armazenamento persistente. O que parece simples no papel envolve, portanto, sincronização entre threads, escrita sequencial obrigatória em disco (o WAL) e contenção em memória compartilhada.
O problema aparece quando o volume de escritas cresce. Em um banco relacional convencional, todas as escritas passam por uma única instância (o "primary" ou "master"). Essa instância tem limites de hardware, de throughput de disco, de capacidade de processamento. Quando o volume de inserts atinge dezenas de milhares por segundo, a contenção no lock do auto-increment pode se tornar mensurável. Cada transação precisa adquirir esse lock, incrementar o contador e liberá-lo antes que a próxima transação avance. Em volumes moderados esse ciclo é imperceptível. Em volumes altos, as transações começam a se enfileirar esperando o lock, e o que era microssegundos vira milissegundos acumulados no p99 de latência.
Sharding e a quebra da sequencialidade
A primeira estratégia para escalar escritas é o sharding (particionamento horizontal), onde os dados são distribuídos entre múltiplos bancos de dados. Cada shard contém um subconjunto dos dados, e a aplicação decide para qual shard direcionar cada operação com base em alguma chave de particionamento (por exemplo, o user_id módulo o número de shards).
É nesse ponto que os IDs sequenciais começam a falhar. Se dois shards independentes geram IDs usando seus próprios contadores auto-incrementais, ambos vão produzir IDs 1, 2, 3, 4, e assim por diante. Haverá colisões (dois registros distintos com o mesmo ID, o que corrompe a integridade referencial e pode sobrescrever dados). Uma solução é configurar cada shard com um offset e um incremento diferente. Por exemplo, com dois shards, o primeiro gera IDs ímpares (1, 3, 5, 7...) e o segundo gera IDs pares (2, 4, 6, 8...). Essa abordagem funciona, mas perde a propriedade de ordenação temporal (o ID 3 do shard 1 pode ter sido criado depois do ID 4 do shard 2) e dificulta a adição de novos shards (reconfigurar os offsets é uma operação delicada).
"A solução típica que funciona para um único banco de dados — simplesmente usar o recurso nativo de chave primária auto-incremental do banco — deixa de funcionar quando os dados estão sendo inseridos em vários bancos ao mesmo tempo." (tradução do autor)
— Instagram Engineering, "Sharding & IDs at Instagram" (2012)
O Instagram enfrentou exatamente essa situação. Com mais de 25 fotos e 90 likes por segundo sendo processados, a equipe precisou fragmentar os dados entre múltiplos servidores PostgreSQL. O auto-increment nativo deixou de ser viável como estratégia de geração de IDs.
Ticket Servers: a primeira gambiarra elegante
Uma abordagem intermediária, criada antes da era dos sistemas distribuídos massivos, foi o conceito de Ticket Server, popularizado pelo Flickr em 2006. A ideia é ter um (ou dois, para alta disponibilidade) bancos de dados dedicados exclusivamente a gerar IDs. Esses bancos não armazenam dados do negócio, apenas mantêm contadores.
-- Esquema do Ticket Server do Flickr
CREATE TABLE Tickets64 (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
stub CHAR(1) NOT NULL DEFAULT '',
PRIMARY KEY (id),
UNIQUE KEY stub (stub)
) ENGINE=MyISAM;
-- Para obter um novo ID:
REPLACE INTO Tickets64 (stub) VALUES ('a');
SELECT LAST_INSERT_ID();O Flickr operava dois Ticket Servers com offsets intercalados para evitar um ponto único de falha. A equipe de engenharia do Flickr descreveu essa solução como um exemplo do princípio de design que eles adotavam internamente, que era usar a solução mais simples que resolvesse o problema. Os servidores de tickets entraram em produção na sexta-feira 13 de janeiro de 2006, e serviam mais requisições do que nunca com as máquinas praticamente ociosas em todas as métricas (Flickr Engineering, "Ticket Servers: Distributed Unique Primary Keys on the Cheap", 2010).
A limitação é que ainda existe um componente centralizado. Se os Ticket Servers ficam indisponíveis, nenhum serviço consegue gerar novos IDs. Para o Flickr, esse trade-off era aceitável. Para sistemas que precisam de geração de IDs completamente descentralizada, era preciso algo diferente.
O vazamento de informação
Há um problema de segurança que muitas vezes só é percebido tarde demais. IDs sequenciais vazam informação. Se o seu sistema expõe IDs em URLs (como /api/usuarios/1234), um atacante pode inferir quantos registros existem no sistema, qual a taxa de crescimento e, mais importante, pode enumerar todos os registros simplesmente incrementando o número.
Essa classe de vulnerabilidade é conhecida como IDOR (Insecure Direct Object Reference) e foi listada no OWASP Top 10 desde 2007. O OWASP descreve que essa vulnerabilidade surge quando aplicações usam input fornecido pelo usuário para acessar objetos diretamente, sem verificação adequada de autorização (OWASP, "Insecure Direct Object Reference Prevention Cheat Sheet").
Um exemplo documentado e amplamente discutido envolve a rede social Parler. A plataforma utilizava identificadores sequenciais para posts, o que permitiu a extração em massa de terabytes de dados públicos simplesmente iterando sobre os IDs (Wikipedia, "Insecure direct object reference").
IDs sequenciais expostos em APIs ou URLs são um vetor de ataque que exige controles de acesso rigorosos independentemente de qualquer outra medida. E a escolha do identificador, como o caso do Parler ilustra, tem implicações de segurança que vão além da camada de dados.
4. O salto para sistemas distribuídos
Por que serviços independentes não podem depender de um gerador central
Quando uma aplicação monolítica é decomposta em microservices, cada serviço passa a ter seu próprio banco de dados (o padrão "database per service"). Nesse modelo, não existe mais um único banco que gera todos os IDs. Cada serviço precisa ser capaz de gerar identificadores de maneira independente, sem coordenação com os demais.
Essa necessidade surge de um princípio arquitetural dos microservices, que é o acoplamento fraco (loose coupling). Se todos os serviços dependem de um gerador central de IDs, esse componente se torna um ponto único de falha e um gargalo de performance. Cada inserção em qualquer serviço exigiria uma chamada de rede ao gerador, adicionando latência e introduzindo um modo de falha compartilhado.
O Teorema CAP e a consistência eventual
Em sistemas distribuídos, o Teorema CAP (formulado por Eric Brewer em 2000 e provado formalmente por Gilbert e Lynch em 2002) estabelece que um sistema distribuído pode oferecer, simultaneamente, no máximo duas das três propriedades a seguir: Consistência (todos os nós veem os mesmos dados ao mesmo tempo), Disponibilidade (toda requisição recebe uma resposta) e Tolerância a Partições (o sistema continua operando mesmo com falhas de rede entre nós).
Na prática, partições de rede são inevitáveis, então a escolha recai entre favorecer consistência ou disponibilidade. A maioria dos sistemas distribuídos modernos opta por consistência eventual (eventual consistency), onde os dados convergem para um estado consistente ao longo do tempo, mas podem apresentar divergências temporárias.
Essa escolha impacta diretamente a geração de IDs. Em um sistema com consistência eventual, dois nós podem gerar IDs "ao mesmo tempo" sem saber da existência um do outro. Se ambos geram o mesmo ID, haverá um conflito que pode corromper dados. Portanto, o mecanismo de geração de IDs precisa garantir unicidade mesmo sem coordenação, o que é uma restrição forte que elimina a viabilidade de contadores centralizados em cenários de alta disponibilidade.
O que precisamos de um identificador distribuído
Um identificador adequado para sistemas distribuídos precisa atender a um conjunto de requisitos que os cenários anteriores deixaram claro.
Precisa ser globalmente único, sem depender de coordenação entre nós. Precisa ser gerado localmente em cada serviço, sem chamadas de rede. Idealmente, precisa ser ordenável por tempo, para preservar a capacidade de raciocinar sobre a sequência de eventos. E precisa funcionar bem com as estruturas de indexação dos bancos de dados, para não degradar a performance de leitura e escrita.
Essas são as restrições que motivaram o desenvolvimento de formatos de identificadores como UUID, ULID, KSUID e Snowflake, cada um fazendo trade-offs diferentes para atender cenários distintos.
O eixo que organiza todos os formatos: onde a coordenação acontece?
Há uma pergunta que organiza todos os formatos: onde é colocado o custo de garantir que dois registros nunca compartilhem o mesmo identificador?
No BIGINT auto-increment, esse custo está no lock do banco de dados. Cada transação que precisa de um ID precisa adquirir o contador, incrementá-lo e liberá-lo. O banco serializa cada inserção que necessita de um identificador novo, e o preço é contenção sob carga alta de escrita. A coordenação acontece em cada operação.
O Ticket Server do Flickr desloca a coordenação para fora do banco principal, mas a mantém centralizada. Um serviço dedicado possui o contador. O custo migra da contenção no banco de aplicação para uma chamada de rede a um serviço separado. O ponto único de falha persiste, apenas mudou de lugar.
O UUID v4 elimina a coordenação em tempo de execução transferindo a unicidade para o espaço probabilístico. Nenhum componente precisa se comunicar com nenhum outro. Cada gerador produz 122 bits aleatórios e confia que a probabilidade de colisão é negligenciável em qualquer sistema realista. O preço é a ausência de ordenação: geração aleatória significa que não há correlação temporal entre o identificador e o momento de criação.
O Snowflake divide o problema em dois tempos. Os machine IDs exigem coordenação uma vez (atribuir a cada worker um número distinto, tipicamente via Zookeeper ou configuração manual), mas a geração de IDs em si não exige mais nenhuma coordenação depois disso. Dentro de cada máquina, um contador local incrementa a cada milissegundo. O custo da coordenação é pago no momento do deploy, não no momento da requisição.
UUID v7, ULID e KSUID ocupam uma posição diferente: usam o tempo como coordenador grosso e a aleatoriedade como desambiguador fino. Nenhum componente se comunica com nenhum outro no momento da geração. O timestamp nos bits mais significativos fornece ordenação monotônica entre máquinas diferentes (de forma aproximada, dentro da precisão da sincronização de relógio), e os bits aleatórios nos bits menos significativos tratam da colisão quando dois identificadores são gerados no mesmo milissegundo em máquinas distintas. O risco residual é o clock drift: se o relógio de uma máquina retrocede, a garantia de ordenação quebra.
Essa pergunta (onde a coordenação acontece?) é uma lente que classifica qualquer formato de identificador, inclusive os que serão criados depois deste artigo. Ela também prediz os trade-offs antes de examinar a implementação: quanto mais distante da coordenação em tempo de execução, maior a capacidade de distribuição, mas maior também a dependência de garantias probabilísticas ou da confiabilidade dos relógios físicos.
5. UUID: a primeira resposta universal
O conceito e a história
UUID (Universally Unique Identifier) é um formato de identificador de 128 bits padronizado originalmente pela RFC 4122 em 2005, com raízes em trabalhos anteriores da Apollo Computer e do DCE (Distributed Computing Environment) nos anos 1980. A ideia é gerar identificadores que sejam únicos globalmente sem a necessidade de um registro central ou coordenação entre as partes que os geram.
Um UUID tem 128 bits de comprimento e é representado como uma string hexadecimal de 36 caracteres no formato 8-4-4-4-12:
550e8400-e29b-41d4-a716-446655440000A RFC 9562 define o layout do UUID com a notação xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx, onde M identifica a versão e N identifica a variante. No exemplo acima, o caractere 4 na posição de M indica que é um UUID v4, e o a na posição de N (em hexadecimal, a = 1010 em binário, cujos dois bits mais significativos são 10) confirma a variante RFC 4122. Diferentes versões utilizam diferentes estratégias para garantir unicidade.
As versões que importam
Nem todas as versões de UUID são equivalentes. As três mais relevantes para a discussão de sistemas modernos são a v1, a v4 e a v7.
O UUID v1 combina um timestamp de alta resolução (intervalos de 100 nanossegundos desde 15 de outubro de 1582) com o endereço MAC da máquina que gerou o identificador. Essa versão garante unicidade de maneira determinística, mas expõe informações sobre a infraestrutura (o endereço MAC) e, por uma peculiaridade do layout dos bits, não ordena lexicograficamente por tempo. A RFC 9562, publicada em maio de 2024, aborda essa questão de privacidade e layout em versões mais novas.
O UUID v4 é a versão mais amplamente utilizada. Ele consiste em 122 bits aleatórios (os 6 restantes são usados para indicar versão e variante). A probabilidade de colisão é extremamente baixa. Para ter 50% de chance de uma colisão, seria necessário gerar aproximadamente 2.71 × 10^18 UUIDs (o que é computacionalmente inviável na maioria dos cenários de produção). Essa propriedade torna o UUID v4 a escolha padrão para geração de IDs descentralizada, onde cada nó pode gerar UUIDs de maneira independente com confiança de que não haverá duplicatas.
O UUID v7, padronizado pela RFC 9562 em maio de 2024, é a evolução mais recente e mais relevante para uso como primary key em bancos de dados. Ele incorpora um timestamp Unix de 48 bits (precisão de milissegundo) nos bits mais significativos, seguido por bits aleatórios. Isso faz com que UUIDs v7 sejam monotonicamente crescentes ao longo do tempo e lexicograficamente ordenáveis. É a versão que resolve o maior problema do UUID v4 como chave primária, que discutiremos a seguir.
O problema de performance que ninguém te contou
UUID v4 é excelente para garantir unicidade sem coordenação. Mas quando usado como primary key em um banco de dados relacional, ele cria um problema de performance que só se manifesta conforme a tabela cresce.
O problema está na aleatoriedade. Cada novo UUID v4 é completamente aleatório, o que significa que cada nova inserção vai para uma posição aleatória na B-tree do índice. Em vez de inserir sempre no final (como acontece com inteiros sequenciais), o banco precisa localizar a página correta em algum ponto arbitrário da árvore, potencialmente fazendo I/O de disco para trazer uma página que não está no cache.
Quando a página de destino está cheia, ocorre um page split: o banco divide a página em duas e redistribui as chaves. Page splits são operações caras em termos de I/O e geram registros adicionais no WAL (Write-Ahead Log), o mecanismo que o banco usa para garantir durabilidade. Um artigo do site Better Stack sobre UUID v7 no PostgreSQL 18 observa que índices com inserções aleatórias tendem a ocupar em média 69% da capacidade das páginas (em vez de próximo de 100% com inserções sequenciais), desperdiçando espaço em disco e reduzindo a eficiência do cache (Better Stack, "UUID v7 in PostgreSQL 18").
O impacto é mensurável. A Buildkite, uma plataforma de CI/CD, documentou sua migração de UUIDs aleatórios para UUIDs com ordenação temporal em um blog post de 2023. Junto com outras otimizações realizadas em um período de seis semanas, a empresa observou uma redução de 50% na taxa de geração do WAL do banco de dados primário (Buildkite Engineering, "Goodbye to sequential integers, hello UUIDv7!", 2023).
"Com essa mudança (junto com várias outras ao longo de um período de 6 semanas), observamos uma redução de 50% na taxa de geração do WAL (Write Ahead Log) do banco de dados primário." (tradução do autor)
— Buildkite Engineering (2023)
O gráfico abaixo ilustra conceitualmente a diferença entre inserções sequenciais e aleatórias em uma B-tree:
graph LR
subgraph "Inserção Sequencial (INT / UUID v7)"
A1[Página 1
IDs 1-100
🟢 Cheia] --> A2[Página 2
IDs 101-200
🟢 Cheia] --> A3[Página 3
IDs 201-250
🟡 Inserindo aqui]
end
subgraph "Inserção Aleatória (UUID v4)"
B1[Página 1
🔴 Split!] --> B2[Página 2
🔴 Split!]
B3[Página 3
🟡 69% uso] --> B4[Página 4
🟡 69% uso]
B5[Página 5
🔴 Split!] --> B6[Página 6
🟡 69% uso]
endO efeito se acumula. Em tabelas pequenas (menos de 100 mil registros), o índice inteiro cabe no buffer cache e a diferença é imperceptível. Conforme a tabela cresce e o índice não cabe mais na memória, cada inserção aleatória tem alta probabilidade de causar um I/O de disco, e a degradação se torna exponencial.
O custo de armazenamento e legibilidade
Além da performance de indexação, UUID v4 tem outras desvantagens práticas. Com 128 bits (16 bytes em formato binário, ou 36 caracteres na representação textual), ele ocupa o dobro do espaço de um BIGINT de 64 bits. Esse custo é multiplicado por cada índice que referencia a coluna, por cada foreign key em tabelas relacionadas, por cada entrada no WAL. Para ter a escala concreta: uma tabela com 100 milhões de registros, dois índices secundários e três tabelas com foreign key apontando para ela acumula cerca de 800 MB em referências com BIGINT. O mesmo esquema com UUID v4 chega a 1,6 GB. A diferença dobra se as foreign keys forem armazenadas como texto (36 caracteres) em vez de BINARY(16). Esse excesso de armazenamento pressiona o buffer cache, aumenta o volume de dados transferidos entre disco e memória e reduz a efetividade do cache para as páginas de dados.
A legibilidade também sofre. Comparar 550e8400-e29b-41d4-a716-446655440000 com 42 durante um debugging às 3 da manhã é uma experiência que qualquer engenheiro que já passou por isso pode relatar. UUIDs são difíceis de memorizar, de comunicar verbalmente e de digitar em queries manuais.
6. A evolução: IDs ordenáveis por tempo
A percepção de que a aleatoriedade do UUID v4 era prejudicial para a performance de bancos de dados motivou o desenvolvimento de formatos alternativos que combinam unicidade global com ordenação temporal. Três se destacam: ULID, KSUID e UUID v7.
ULID: Universally Unique Lexicographically Sortable Identifier
O ULID foi proposto como uma especificação aberta em 2016 (disponível em github.com/ulid/spec). Ele tem 128 bits de comprimento, o mesmo que um UUID, e é composto por dois componentes: 48 bits de timestamp (milissegundos desde o epoch Unix) seguidos por 80 bits de aleatoriedade.
01ARZ3NDEKTSV4RRFFQ69G5FAV
|-------||----------------|
Timestamp Aleatoriedade
48 bits 80 bitsA representação textual usa Crockford's Base32, resultando em 26 caracteres (contra 36 do UUID). Essa codificação é case-insensitive, não contém caracteres especiais (é URL-safe) e ordena lexicograficamente da mesma forma que a representação binária. ULIDs gerados em momentos diferentes são naturalmente ordenados por tempo, e ULIDs gerados no mesmo milissegundo são diferenciados pela parte aleatória.
A compatibilidade binária com UUID é uma vantagem prática. Como ambos têm 128 bits, um ULID pode ser armazenado em uma coluna do tipo UUID no PostgreSQL sem modificação no schema. Essa propriedade facilita migrações incrementais.
KSUID: K-Sortable Unique Identifier
O KSUID foi desenvolvido pela equipe do Segment (hoje Twilio Segment) quando enfrentaram a necessidade de ordenar identificadores por tempo para viabilizar o arquivamento de logs no Amazon S3. O blog post da Segment descreve que a equipe começou usando UUID v4, e após algumas semanas surgiu a necessidade de ordenar esses identificadores temporalmente. A motivação inicial era agrupar mensagens por faixas de tempo para arquivamento, e os UUIDs aleatórios resultariam em dispersão aleatória sem nenhuma propriedade de agrupamento natural (Segment Engineering, "A Brief History of the UUID", 2017).
"Após algumas semanas, surgiu a necessidade de ordenar esses identificadores por tempo. [...] Os UUIDs existentes teriam resultado em dispersão aleatória das mensagens, sem nenhuma propriedade de agrupamento natural." (tradução do autor)
— Segment Engineering (2017)
O KSUID difere do ULID em alguns aspectos. Ele tem 160 bits (20 bytes): 32 bits de timestamp com resolução de 1 segundo e 128 bits de payload aleatório. A representação textual usa Base62, resultando em 27 caracteres alfanuméricos. O epoch personalizado (13 de maio de 2014) permite mais de 100 anos de vida útil.
O espaço de aleatoriedade de 128 bits é maior que o do ULID (80 bits) e do UUID v4 (122 bits), o que torna colisões ainda mais improváveis. Segundo o repositório oficial no GitHub, trilhões de KSUIDs foram gerados nos sistemas de produção do Segment sem colisões observadas (segmentio/ksuid, GitHub).
UUID v7: o padrão que unifica tudo
O UUID v7, padronizado pela RFC 9562 em maio de 2024, incorpora as lições aprendidas com ULID, KSUID e outros formatos time-ordered. Ele coloca um timestamp Unix de 48 bits (precisão de milissegundo) nos bits mais significativos, seguido por bits aleatórios nos 74 bits restantes (descontados os 6 bits de versão e variante).
A vantagem decisiva do UUID v7 sobre ULID e KSUID é que ele é um UUID padrão. Isso significa que bibliotecas, frameworks, bancos de dados e ferramentas que já suportam o tipo UUID funcionam com UUID v7 sem modificação. O PostgreSQL 18 (previsto para setembro de 2025) inclui suporte nativo com a função uuidv7() (PostgreSQL Documentation, "UUID v7 Support in PostgreSQL 18").
A tabela a seguir compara as características dos três formatos:
| Característica | ULID | KSUID | UUID v7 |
|---|---|---|---|
| Tamanho (bits) | 128 | 160 | 128 |
| Timestamp (bits) | 48 | 32 | 48 |
| Resolução temporal | milissegundo | segundo | milissegundo |
| Aleatoriedade (bits) | 80 | 128 | ~74 |
| Representação textual | 26 chars (Base32) | 27 chars (Base62) | 36 chars (hex UUID) |
| Compatível com UUID nativo | sim (binário) | não | sim (nativo) |
| Padronizado por RFC | não | não | sim (RFC 9562) |
Para novos projetos que começam hoje, UUID v7 é a recomendação mais segura na maioria dos cenários. Ele oferece a combinação de padrão aberto, amplo suporte de ferramentas, ordenação temporal e compatibilidade com o ecossistema UUID existente.
O problema que UUID v7 cria em bancos distribuídos
UUID v7 resolve o problema de performance em B-trees tornando os identificadores monotonicamente crescentes. Essa mesma propriedade cria um problema diferente em bancos como DynamoDB, Cassandra e CockroachDB.
Nesses sistemas, os dados são particionados entre nós usando uma partition key. Escritas e leituras são roteadas ao nó responsável pelo intervalo de chaves que contém aquele valor. Quando a partition key é monotonicamente crescente, como o UUID v7 é (os 48 bits de timestamp ocupam os bits mais significativos), todas as escritas em qualquer janela de tempo são direcionadas ao mesmo nó. Um nó recebe toda a carga de escrita enquanto os demais ficam ociosos. Isso é um hot partition, e é uma das causas mais comuns de throttling em sistemas baseados em DynamoDB.
A documentação da AWS descreve o problema diretamente: "Se a sua tabela tem um número muito grande de itens mas a aplicação está enviando todo o seu tráfego para um único valor de partition key, você pode receber um ProvisionedThroughputExceededException mesmo que o throughput médio da tabela esteja dentro dos limites provisionados." (AWS Documentation, "Best Practices for Designing and Using Partition Keys Effectively", tradução do autor). A causa é sempre a mesma: concentração de acesso em um subconjunto de partições, independentemente de quanta capacidade a tabela tem em agregado.
A ironia é precisa: a propriedade que torna o UUID v7 excelente para a B-tree do PostgreSQL (todas as inserções chegam ao final da rightmost leaf page, maximizando a localidade do cache e eliminando page splits) é a mesma propriedade que o torna um problema como partition key em bancos distribuídos (todas as inserções chegam ao mesmo nó, sobrecarregando-o). B-trees se beneficiam de escritas que sempre vão para o mesmo lugar. Tabelas de hash distribuídas se beneficiam de escritas que se espalham por lugares diferentes. Nenhum formato de identificador satisfaz as duas propriedades simultaneamente.
Quatro respostas arquiteturais existem para esse problema, cada uma com trade-offs distintos.
Usar UUID v7 como sort key em vez de partition key é a mais natural. No DynamoDB, a chave primária composta consiste em uma partition key e uma sort key opcional. Usar um atributo de domínio (tenant ID, tipo de entidade, região) como partition key e UUID v7 como sort key distribui as escritas por tenant enquanto preserva a ordenação temporal dentro de cada partição. É o padrão recomendado para workloads de append-heavy no DynamoDB e funciona bem quando o particionamento por domínio é natural.
O prefixo de hash distribui as escritas artificialmente: adicionar alguns bits derivados de algum atributo do registro ao início do UUID v7 antes de usá-lo como partition key espalha o tráfego entre partições enquanto preserva ordenação relativa dentro de cada bucket. O custo é a perda da ordenação global entre partições. Para reconstruir uma sequência globalmente ordenada, as leituras precisam fazer merge-sort dos resultados de todas as partições.
Aceitar UUID v4 para tabelas com escrita intensiva em bancos distribuídos e usar uma coluna de timestamp separada para ordenação é a opção de menor esforço em sistemas legados. Restaura a distribuição aleatória e portanto a utilização uniforme das partições, ao custo de perder a informação temporal embutida no identificador.
Partition keys baseadas em time buckets — como truncamento por data ou hora — combinadas com UUID v7 como sort key limitam o problema do hot partition à duração do bucket e expiram partições antigas naturalmente em workloads de séries temporais.
A consequência prática para decisões arquiteturais: UUID v7 como chave primária em banco relacional (PostgreSQL, MySQL com InnoDB) é o padrão correto para sistemas novos na maioria dos cenários. UUID v7 como partition key no DynamoDB ou como clustering key no Cassandra exige design deliberado para evitar hot partitions. A escolha adequada depende das estruturas internas de dados do banco de destino, não do formato do identificador isoladamente.
7. Snowflake: geração coordenada em escala do Twitter
A origem do problema
Em 2010, o Twitter enfrentava uma transição arquitetural significativa. A empresa estava migrando de MySQL como seu principal armazenamento online para o Cassandra (um banco distribuído sem suporte nativo a geração de IDs sequenciais) e MySQL com sharding horizontal. A equipe precisava de um sistema que gerasse dezenas de milhares de IDs por segundo com alta disponibilidade, e que esses IDs fossem aproximadamente ordenados por tempo e coubessem em 64 bits.
A motivação para 64 bits era prática. A equipe já havia passado pela experiência dolorosa de aumentar o tamanho dos IDs de tweets, e com mais de 100 mil codebases diferentes consumindo a API do Twitter, qualquer mudança no formato dos IDs era extremamente custosa. UUIDs de 128 bits foram considerados, mas descartados por esse motivo (Twitter Engineering, "Announcing Snowflake", 2010).
A estrutura do ID
A solução foi o Snowflake, um serviço open source que gera IDs de 64 bits compostos por três partes:
0 | 00000000 00000000 00000000 00000000 00000000 0 | 00000 00000 | 000000000000
| 41 bits | 10 bits | 12 bits
| Timestamp (ms) | Machine ID | SequenceO timestamp ocupa 41 bits e representa milissegundos desde um epoch personalizado (4 de novembro de 2010, 01:42:54 UTC para o Twitter). Com 41 bits, o sistema suporta aproximadamente 69 anos de timestamps antes de esgotar. O Machine ID ocupa 10 bits (frequentemente dividido em 5 bits para datacenter e 5 bits para worker), permitindo até 1.024 geradores simultâneos. A Sequence ocupa 12 bits e é um contador que reseta a cada milissegundo, permitindo até 4.096 IDs por milissegundo por máquina.
O resultado é que cada máquina pode gerar IDs de maneira independente (sem coordenação de rede a cada geração), e os IDs são naturalmente ordenados por tempo com a garantia de que tweets publicados dentro de um segundo terão IDs em proximidade no espaço de IDs. A equipe do Twitter descreveu esse nível de ordenação como k-sorted, onde k é de no máximo 1 segundo.
Adaptações por outras empresas
O formato Snowflake foi adotado e adaptado por diversas empresas. O Discord utiliza uma versão com epoch em 1 de janeiro de 2015. O Instagram criou uma variação que executa a lógica diretamente dentro do PostgreSQL usando PL/pgSQL, eliminando a necessidade de um serviço separado.
A solução do Instagram é particularmente interessante para quem opera com PostgreSQL. A equipe delegou a geração de IDs para cada tabela dentro de cada shard, usando uma function PL/pgSQL que combina timestamp (41 bits), shard ID (13 bits) e uma sequence auto-incrementada módulo 1024 (10 bits). Com essa distribuição, cada shard pode gerar 1.024 IDs por milissegundo.
-- Função de geração de ID do Instagram (simplificada)
CREATE OR REPLACE FUNCTION insta5.next_id(OUT result bigint) AS $$
DECLARE
our_epoch bigint := 1314220021721;
seq_id bigint;
now_millis bigint;
shard_id int := 5;
BEGIN
SELECT nextval('insta5.table_id_seq') %% 1024 INTO seq_id;
SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000) INTO now_millis;
result := (now_millis - our_epoch) << 23;
result := result | (shard_id << 10);
result := result | (seq_id);
END;
$$ LANGUAGE PLPGSQL;A equipe do Instagram avaliou o Snowflake do Twitter mas decidiu que a complexidade adicional de operar um serviço separado não se justificava para o cenário deles. Nas palavras do blog post de engenharia, a equipe buscou soluções simples e fáceis de entender, pois essa abordagem era uma das razões pelas quais o Instagram conseguia escalar com poucos engenheiros (Instagram Engineering, "Sharding & IDs at Instagram", 2012).
O problema do clock drift
O calcanhar de Aquiles do Snowflake (e de qualquer formato baseado em timestamp) é a dependência de relógios sincronizados. Se o relógio de uma máquina está adiantado e o NTP (Network Time Protocol) corrige o atraso movendo o relógio para trás, o Snowflake pode gerar IDs com timestamps no passado, violando a propriedade de ordenação.
O repositório original do Snowflake no GitHub aborda isso explicitamente: se o relógio se move para trás, o Snowflake se recusa a gerar IDs até que o tempo ultrapasse o último timestamp utilizado. Essa é uma medida de proteção contra IDs duplicados, mas significa que o serviço fica temporariamente indisponível para aquele worker. A recomendação é usar NTP configurado para nunca retroceder o relógio (apenas diminuir a velocidade de avanço até convergir), uma prática descrita como "slew mode" na documentação do NTP (twitter-archive/snowflake, GitHub).
O problema se intensifica em escala global. O NTP em redes locais converge para precisões de 1 a 10 milissegundos, mas em conexões WAN entre continentes a incerteza típica fica entre 80 e 200 milissegundos. Em arquiteturas active-active, onde dois datacenters em regiões diferentes aceitam escritas simultaneamente, essa imprecisão significa que a garantia de ordenação do Snowflake é regional, não global. Dois events gerados ao mesmo tempo na Europa e na Ásia terão timestamps determinados pelos relógios locais de cada região. Se esses relógios divergem por 150ms, a sequência global de IDs não reflete a sequência real de eventos. Para sistemas que dependem da ordenação dos IDs como proxy de causalidade (por exemplo, feeds, logs de auditoria, processamento de eventos), essa propriedade deve ser entendida com clareza: o Snowflake garante ordem dentro de um worker, e aproximadamente dentro de um datacenter sincronizado, mas não garante ordem global em deployments multi-região. O Google Spanner ataca esse problema com o TrueTime, uma API que encapsula o intervalo de incerteza do relógio com garantias de hardware (relógios atômicos e receptores GPS em cada datacenter). O Spanner usa esses limites de incerteza para adiar commits até ter certeza da ordem global. Mas essa infraestrutura não está disponível para a maioria das organizações, e mesmo dentro da oferta gerenciada (Cloud Spanner), o modelo de programação muda significativamente.
8. Estratégias arquiteturais para geração de IDs
Banco vs. aplicação
Uma decisão arquitetural frequentemente subestimada é onde a geração do ID acontece. Quando o banco gera o ID (via auto-increment ou function), a aplicação precisa fazer um INSERT e depois recuperar o ID gerado (usando RETURNING no PostgreSQL ou LAST_INSERT_ID() no MySQL). O ID só existe após a escrita no banco.
Quando a aplicação gera o ID (usando UUID v4, ULID, KSUID ou qualquer formato que não dependa do banco), o ID está disponível antes da escrita. Isso habilita padrões como "create the ID, publish an event, then write to the database" que são comuns em arquiteturas event-driven e sistemas com CQRS (Command Query Responsibility Segregation).
A geração na aplicação também simplifica operações de batch. Se você precisa inserir 10 mil registros, pode gerar todos os IDs de antemão, preparar os dados completos e fazer a inserção em lote, sem uma round-trip ao banco para cada ID.
Client-side vs. server-side
Em APIs, o ID pode ser gerado pelo cliente (a aplicação que chama a API) ou pelo servidor (a API que processa a requisição). A geração client-side tem uma vantagem interessante: idempotência. Se o cliente gera o ID e envia na requisição, e a requisição falha por timeout, o cliente pode reenviar a mesma requisição com o mesmo ID. O servidor pode detectar a duplicata e retornar o resultado da primeira execução em vez de criar um registro duplicado.
Esse padrão é particularmente valioso em sistemas de pagamento e em qualquer contexto onde operações duplicadas causam prejuízo. A desvantagem é que o cliente precisa usar um formato de ID seguro contra colisões (como UUID v7), e o servidor precisa confiar que o cliente não vai enviar IDs malformados ou intencionalmente duplicados.
IDs opacos com prefixo: o padrão Stripe
A Stripe popularizou um padrão de design de IDs que combina um prefixo semântico com um sufixo aleatório. Cada tipo de objeto tem um prefixo distinto: cus_ para clientes, pi_ para payment intents, sub_ para assinaturas.
pi_3LKQhvGUcADgqoEM3bh6pslE
cus_MNlbRsTWfvcJ01
sub_df987gds98fgA Stripe utiliza esse padrão desde 2012. Antes disso, os identificadores de objetos se pareciam com UUIDs tradicionais sem prefixo. Contas criadas antes de 2012 ainda têm IDs nesse formato antigo (Paul Asjes, "Designing APIs for humans: Object IDs", Stripe Engineering Blog).
A vantagem prática é imediata. Quando um engenheiro vê pi_3LKQhvGUcADgqoEM em um log, sabe instantaneamente que se trata de um payment intent, sem precisar consultar nenhum outro sistema. Quando um serviço recebe cus_MNlbRsTWfvcJ01 onde esperava um payment intent, pode rejeitar a requisição antes mesmo de consultar o banco.
A Clerk, uma empresa de autenticação, adotou o mesmo padrão combinando prefixos ao estilo Stripe com KSUIDs do Segment como parte geradora do ID (Clerk Engineering, "Generating sortable Stripe-like IDs with Segment's KSUIDs", 2021). Esse tipo de composição mostra que estratégias de geração de IDs podem (e frequentemente devem) ser combinadas.
IDs públicos vs. internos
Uma prática comum em sistemas maduros é manter dois identificadores para cada entidade. O ID interno (um BIGINT auto-incrementado, por exemplo) é usado para joins, foreign keys e operações internas do banco. O ID externo (um UUID v7, ULID ou ID com prefixo) é exposto em APIs e URLs.
Essa separação oferece o melhor dos dois mundos. O banco opera com inteiros sequenciais (que são ótimos para indexação e joins), e a API expõe identificadores opacos (que são seguros contra enumeração e não vazam informação sobre o volume de dados).
A Buildkite operava exatamente dessa forma antes de migrar para UUID v7 como chave primária. No blog post de 2023, a equipe descreve que historicamente utilizava chaves primárias sequenciais para indexação eficiente e chaves UUID secundárias para uso externo. A migração para UUID v7 unificou os dois papéis em um único identificador (Buildkite Engineering, 2023).
9. IDs e observabilidade
Em sistemas distribuídos, uma única requisição de usuário pode percorrer dezenas de serviços antes de produzir uma resposta. Rastrear o caminho de uma requisição através desses serviços é o que chamamos de distributed tracing, e os identificadores são a espinha dorsal desse mecanismo.
Correlation IDs e Trace IDs
Um Correlation ID (ou Request ID) é um identificador gerado no ponto de entrada do sistema (geralmente o API Gateway ou o primeiro serviço que recebe a requisição) e propagado para todos os serviços subsequentes. Cada serviço inclui esse ID nos seus logs, permitindo que uma busca por correlation_id = "abc123" retorne todos os logs relacionados àquela requisição específica, independentemente do serviço que os produziu.
Sistemas de tracing como OpenTelemetry, Jaeger e Zipkin utilizam Trace IDs (tipicamente 128 bits) e Span IDs (64 bits) para construir uma árvore de chamadas. O Trace ID identifica a requisição como um todo, e cada Span ID identifica uma operação individual dentro do trace (uma chamada HTTP, uma query ao banco, uma publicação em uma fila).
A escolha do formato do Trace ID importa. O W3C Trace Context standard (que é o padrão adotado pelo OpenTelemetry) utiliza 128 bits representados como 32 caracteres hexadecimais. Esse formato é compatível com UUID, o que facilita o armazenamento e a indexação em bancos de dados. Se o Trace ID for um UUID v7, ele carrega intrinsecamente o timestamp de quando a requisição foi iniciada, o que é informação útil para análise de performance e para queries em sistemas de observabilidade.
Propagação entre serviços
Para que Correlation IDs e Trace IDs funcionem, eles precisam ser propagados entre serviços. Isso acontece via headers HTTP (como X-Request-ID ou traceparent no padrão W3C), via metadados de mensagens em filas (como headers do Kafka ou atributos de mensagens do SQS) e via contexto em chamadas gRPC (metadata).
A falha mais comum nessa cadeia é um serviço que não propaga o ID. Se um serviço intermediário cria uma nova requisição HTTP sem copiar o Correlation ID do request original, o trace é quebrado naquele ponto. Instrumentação automática (como a fornecida pelas bibliotecas do OpenTelemetry) mitiga esse risco, mas não elimina completamente, especialmente em serviços legados ou em integrações com sistemas de terceiros que não suportam os headers de propagação.
10. Problemas reais em produção
Migração de inteiros para UUIDs
Uma das migrações mais temidas em sistemas em produção é a mudança do tipo de primary key de inteiro para UUID (ou similar). A dificuldade não está apenas na alteração da coluna em si, mas em todas as tabelas que referenciam essa coluna via foreign keys, em todas as aplicações que consomem esses IDs, em todas as caches que armazenam esses valores, em todos os índices que precisam ser reconstruídos.
Uma estratégia que reduz o risco é a abordagem dual-write. O processo funciona em fases. Primeiro, adiciona-se uma nova coluna com o novo formato de ID (sem remover a antiga). Segundo, a aplicação passa a escrever em ambas as colunas simultaneamente. Terceiro, um job de backfill preenche a nova coluna para registros antigos. Quarto, os consumidores são migrados para usar a nova coluna. Quinto (e apenas após todos os consumidores terem migrado), a coluna antiga pode ser removida.
Cada uma dessas fases pode levar semanas ou meses em um sistema em produção com muitos consumidores. É por isso que a escolha inicial do formato de ID tem um impacto tão desproporcional: trocar depois é possível, mas é caro. O conceito de arquitetura evolutiva, discutido por Neal Ford, Rebecca Parsons e Patrick Kua em "Building Evolutionary Architectures" (O'Reilly, 2017), enquadra exatamente esse tipo de mudança: modificações incrementais, reversíveis e guiadas por fitness functions que protegem as propriedades que o sistema precisa manter. O dual-write é um padrão evolutivo. Cada fase é independentemente reversível e o sistema nunca para de funcionar. A propriedade que o fitness function protege aqui é a consistência referencial entre as colunas antigas e novas durante a transição.
A experiência da Buildkite com backward compatibility
A Buildkite enfrentou um problema interessante de backward compatibility durante sua migração. Quando experimentaram mudar o version byte dos UUIDs de "4" (v4) para "7" (v7), descobriram que alguns sistemas de clientes que validavam UUIDs rejeitaram os novos IDs por não reconhecerem a versão 7. A solução temporária foi manter o byte de versão como "4" enquanto usavam internamente o layout temporal do v7 (Buildkite Engineering, 2023).
Esse tipo de detalhe é o que torna migrações de formato de ID particularmente traiçoeiras. A mudança no formato dos dados é apenas a parte visível. Os efeitos em validadores, serializadores, caches, filtros e qualquer código que faça suposições sobre a estrutura do ID podem ser surpreendentes.
Debugging com IDs complexos
IDs opacos dificultam o debugging do dia-a-dia. Quando um engenheiro de suporte recebe um report de "algo deu errado com minha compra" e precisa rastrear o problema, ter que trabalhar com 01ARZ3NDEKTSV4RRFFQ69G5FAV em vez de 47832 aumenta a fricção de cada etapa da investigação.
Algumas práticas mitigam esse problema. Garantir que todo ID complexo tenha alguma forma de "shorthand" ou que as ferramentas internas suportem busca por prefixo reduz a fricção operacional imediatamente. Ferramentas de busca que aceitem qualquer formato de ID e redirecionem para o registro correto eliminam a necessidade de o engenheiro saber o formato antes de buscar. O padrão de prefixos ao estilo Stripe, mesmo sem resolver a memorização, elimina pelo menos a ambiguidade sobre o tipo de recurso.
11. Segurança e exposição de IDs
O que cada formato vaza
Conforme discutido na seção 3, IDs sequenciais expostos em interfaces públicas são vulneráveis a enumeração. Mas cada formato tem seu próprio perfil de vazamento de informação, e entender o que cada um revela é o primeiro passo para mitigar o risco.
IDs inteiros sequenciais vazam volume e taxa de crescimento. Um atacante que observa o ID 4312 hoje e o ID 5891 amanhã sabe que o sistema criou 1579 registros em 24 horas. Esse dado é suficiente para estimar receita, base de usuários ou tráfego em sistemas públicos.
UUID v1 vaza informação de infraestrutura. O endereço MAC da máquina geradora está embutido nos últimos 48 bits do identificador, o que pode ajudar um atacante a mapear a topologia de rede interna. Além disso, o timestamp de 100ns presente no UUID v1 não aparece nos bits mais significativos, mas pode ser extraído diretamente da estrutura, revelando com precisão o momento de criação.
UUID v4 e v7, quando gerados com um PRNG (Pseudo-Random Number Generator) fraco (como Math.random() do JavaScript em certas implementações), podem ser parcialmente previsíveis se um atacante tiver acesso a uma sequência de IDs gerados. A RFC 9562 recomenda explicitamente o uso de geradores criptograficamente seguros (CSPRNG) para a parte aleatória dos UUIDs. A diferença prática: um CSPRNG coleta entropia do sistema operacional (via /dev/urandom no Linux, CryptGenRandom no Windows), enquanto um PRNG simples é determinístico a partir de um seed inicial.
Ofuscação não é controle de acesso
O erro mais comum nesse domínio é tratar o formato do ID como um mecanismo de segurança. Um UUID v4 não autentica o usuário que o possui. Ele apenas dificulta a adivinhação. Se a API retorna dados de qualquer UUID válido sem verificar quem faz a requisição, a vulnerabilidade IDOR existe independentemente da aleatoriedade do identificador.
O OWASP é explícito a esse respeito: a defesa contra IDOR requer que toda requisição que acessa um objeto seja validada no servidor para confirmar que o usuário solicitante tem autorização sobre aquele recurso específico, independentemente do formato do identificador (OWASP, "IDOR Prevention Cheat Sheet"). O identificador opaco é a segunda linha de defesa. A primeira é sempre o controle de acesso.
Defense in depth na prática
Com controles de acesso corretamente implementados, o formato do ID oferece uma camada adicional de proteção seguindo o princípio de defense in depth. IDs aleatórios como UUID v4 ou v7 tornam a enumeração computacionalmente inviável. Mesmo que um atacante descobrisse o padrão de geração, a entropia de 122 bits do UUID v4 cria um espaço de busca com aproximadamente 5,3 × 10³⁶ possibilidades. Hashes de IDs internos (usando bibliotecas como Hashids, que geram identificadores curtos e ofuscados a partir de inteiros) oferecem uma alternativa quando a migração completa da primary key não é viável. Tokens temporários com prazo de validade explícito são úteis para URLs de compartilhamento, como os usados pelo Google Docs, onde o acesso deve ser revogável sem alterar a identidade permanente do recurso.
12. Como escolher: um framework de decisão
Não existe um formato de ID que seja ideal para todos os cenários. A escolha depende do contexto arquitetural, dos requisitos de performance, das restrições de compatibilidade e do estágio de evolução do sistema. O diagrama a seguir é um ponto de partida para guiar essa decisão:
flowchart TD
A[Preciso gerar IDs] --> B{O sistema é distribuído?}
B -->|Não| C{Performance de escrita é crítica?}
C -->|Não| D[BIGSERIAL / AUTO_INCREMENT]
C -->|Sim| E{IDs expostos externamente?}
E -->|Não| D
E -->|Sim| F[BIGSERIAL interno + UUID externo]
B -->|Sim| G{IDs precisam caber em 64 bits?}
G -->|Sim| H{Posso operar um serviço de IDs?}
H -->|Sim| I[Snowflake]
H -->|Não| J[Snowflake-like no banco
estilo Instagram]
G -->|Não| K{Preciso de padrão RFC?}
K -->|Sim| L[UUID v7]
K -->|Não| M{Preciso de máxima entropia?}
M -->|Sim| N[KSUID - 160 bits]
M -->|Não| O[ULID - 128 bits]Algumas perguntas adicionais ajudam a refinar a decisão. Qual é o banco de dados? PostgreSQL tem suporte nativo a UUID e terá suporte nativo a UUID v7 na versão 18. MySQL armazena UUIDs como strings ou BINARY(16), e o uso de BINARY com funções de conversão é necessário para performance adequada. DynamoDB e Cassandra trabalham bem com qualquer formato binário, mas se beneficiam de chaves de partição com boa distribuição.
Qual é o volume de escrita esperado? Para menos de mil escritas por segundo em um único banco, BIGSERIAL é suficiente. Para dezenas de milhares de escritas por segundo distribuídas entre múltiplos serviços, UUID v7 ou Snowflake são necessários.
Os IDs serão expostos em APIs? Se sim, evite inteiros sequenciais. Use UUID v7 (se o ecossistema suporta), ULID (se precisa de representação mais curta) ou IDs com prefixo ao estilo Stripe (se a legibilidade por humanos é prioridade).
Existe necessidade de ordenação temporal embutida no ID? Se o sistema usa IDs para paginação baseada em cursor (cursor-based pagination), se precisa ordenar eventos sem consultar campos adicionais, ou se usa arquiteturas event-sourcing, IDs com componente temporal (UUID v7, ULID, Snowflake) são altamente recomendados.
Qual é a tolerância a complexidade operacional? Snowflake exige coordenação de Machine IDs (via Zookeeper, configuração manual ou outro mecanismo). UUID v7, ULID e KSUID não exigem coordenação e podem ser gerados em qualquer nó sem configuração adicional.
13. Comparação técnica consolidada
A tabela a seguir consolida as características dos formatos discutidos neste artigo:
| Característica | BIGINT (auto-inc) | UUID v4 | UUID v7 | ULID | KSUID | Snowflake |
|---|---|---|---|---|---|---|
| Tamanho | 64 bits | 128 bits | 128 bits | 128 bits | 160 bits | 64 bits |
| Ordenação temporal | sim | não | sim | sim | sim | sim |
| Unicidade sem coordenação | não | sim | sim | sim | sim | parcial (machine ID) |
| Performance de indexação (B-tree) | excelente | ruim em escala | excelente | excelente | excelente | excelente |
| Legibilidade humana | alta | baixa | baixa | média | média | média |
| Resistência a enumeração | nenhuma | alta | alta | alta | alta | baixa-média |
| Padronizado por RFC | N/A | RFC 9562 | RFC 9562 | não | não | não |
| Geração descentralizada | não | sim | sim | sim | sim | sim (com machine ID) |
| Informação temporal extraível | não | não | sim | sim | sim | sim |
| Suporte nativo PostgreSQL | sim | sim | sim (v18+) | via UUID type | não | não |
14. Exemplos de implementação
UUID v7 em PostgreSQL 18+
-- PostgreSQL 18 (nativo)
CREATE TABLE eventos (
id UUID PRIMARY KEY DEFAULT uuidv7(),
tipo TEXT NOT NULL,
payload JSONB,
criado_em TIMESTAMP DEFAULT now()
);
-- Inserção: o banco gera o UUID v7 automaticamente
INSERT INTO eventos (tipo, payload)
VALUES ('pedido.criado', '{"pedido_id": 42}');
-- Extrair timestamp do UUID v7 (PostgreSQL 18)
SELECT id, uuid_extract_timestamp(id) AS gerado_em
FROM eventos
ORDER BY id
LIMIT 5;UUID v7 em PostgreSQL pré-18 (via extensão)
-- Instalar a extensão pg_uuidv7
CREATE EXTENSION IF NOT EXISTS pg_uuidv7;
CREATE TABLE eventos (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
tipo TEXT NOT NULL,
payload JSONB
);Snowflake-like em Python
import time
import threading
class SnowflakeGenerator:
"""
Gerador de IDs no estilo Snowflake.
Layout: 1 bit (sinal) + 41 bits (timestamp) + 10 bits (machine) + 12 bits (sequence)
"""
EPOCH = 1288834974657 # Twitter epoch: Nov 04 2010 01:42:54 UTC
MACHINE_BITS = 10
SEQUENCE_BITS = 12
MAX_MACHINE = (1 << MACHINE_BITS) - 1 # 1023
MAX_SEQUENCE = (1 << SEQUENCE_BITS) - 1 # 4095
def __init__(self, machine_id: int):
if machine_id < 0 or machine_id > self.MAX_MACHINE:
raise ValueError(f"machine_id deve estar entre 0 e {self.MAX_MACHINE}")
self.machine_id = machine_id
self.sequence = 0
self.last_timestamp = -1
self.lock = threading.Lock()
def _current_millis(self) -> int:
return int(time.time() * 1000)
def generate(self) -> int:
with self.lock:
timestamp = self._current_millis()
if timestamp < self.last_timestamp:
# Clock drift detectado: aguardar até o timestamp avançar
raise Exception("Clock moved backwards")
if timestamp == self.last_timestamp:
self.sequence = (self.sequence + 1) & self.MAX_SEQUENCE
if self.sequence == 0:
# Esgotou os 4096 IDs neste milissegundo: esperar o próximo
while timestamp <= self.last_timestamp:
timestamp = self._current_millis()
else:
self.sequence = 0
self.last_timestamp = timestamp
return (
((timestamp - self.EPOCH) << (self.MACHINE_BITS + self.SEQUENCE_BITS))
| (self.machine_id << self.SEQUENCE_BITS)
| self.sequence
)
# Uso:
gen = SnowflakeGenerator(machine_id=1)
novo_id = gen.generate()
print(f"ID gerado: {novo_id}")ULID em diferentes linguagens
// JavaScript (com a biblioteca ulid)
import { ulid } from 'ulid';
const id = ulid(); // "01ARZ3NDEKTSV4RRFFQ69G5FAV"# Python (com a biblioteca python-ulid)
from ulid import ULID
id = ULID()
print(str(id)) # "01ARZ3NDEKTSV4RRFFQ69G5FAV"
print(id.timestamp) # timestamp embutido como float
print(id.datetime) # datetime extraído do timestamp// Go (com a biblioteca oklog/ulid)
import "github.com/oklog/ulid/v2"
id := ulid.Make()
fmt.Println(id.String()) // "01ARZ3NDEKTSV4RRFFQ69G5FAV"
fmt.Println(id.Time()) // timestamp em milissegundosIDs com prefixo (estilo Stripe)
import uuid
def generate_prefixed_id(prefix: str) -> str:
"""
Gera um ID com prefixo semântico e sufixo UUID v7 (ou v4 como fallback).
Exemplo: cus_550e8400e29b41d4a716446655440000
"""
# Em produção, usar uma biblioteca UUID v7
raw_id = uuid.uuid4().hex # 32 caracteres hex, sem hífens
return f"{prefix}_{raw_id}"
# Uso:
customer_id = generate_prefixed_id("cus") # cus_550e8400e29b41d4a716446655440000
payment_id = generate_prefixed_id("pay") # pay_7f3b8a2c1d4e5f6a7b8c9d0e1f2a3b4c
order_id = generate_prefixed_id("ord") # ord_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d615. Conclusão: trade-offs inevitáveis e arquitetura como processo
Toda escolha de identificador é um trade-off. Inteiros sequenciais oferecem performance e simplicidade, mas falham em sistemas distribuídos e vazam informação quando expostos. UUIDs v4 oferecem unicidade global sem coordenação, mas degradam a performance de B-trees em escala. UUIDs v7, ULIDs e KSUIDs combinam as melhores propriedades, mas ocupam mais espaço e requerem bibliotecas ou versões de banco mais recentes. Snowflake IDs cabem em 64 bits e oferecem ordenação, mas exigem coordenação de machine IDs e são sensíveis a clock drift.
O erro mais comum não é escolher o formato "errado", mas fazer a escolha sem considerar o futuro do sistema. Um projeto que começa como monólito e evolui para microservices vai precisar de IDs geráveis de forma descentralizada. Uma API interna que é promovida a API pública vai precisar de IDs opacos. Um sistema que funciona bem com 100 mil registros pode se comportar de maneira completamente diferente com 100 milhões.
A abordagem mais pragmática é começar com a solução mais simples que atende aos requisitos conhecidos, mas investir tempo entendendo os cenários de evolução prováveis. Se há qualquer chance de que o sistema será distribuído no futuro, considerar UUID v7 como default desde o início é uma decisão que raramente será lamentada. Se a performance de escrita é a principal preocupação e o sistema é controlado por uma única equipe, Snowflake ou variantes oferecem a melhor relação custo-benefício.
A geração de identificadores é uma decisão arquitetural com implicações de longo prazo em performance, segurança, operabilidade e evolução do sistema. Quanto mais cedo essa decisão recebe a atenção que merece, menor o custo de adaptação quando (não se) as condições mudarem.
Referências
- Twitter Engineering. "Announcing Snowflake." 2010. https://blog.x.com/engineering/en_us/a/2010/announcing-snowflake
- Instagram Engineering. "Sharding & IDs at Instagram." 2012. https://instagram-engineering.com/sharding-ids-at-instagram-1cf5a71e5a5c
- Flickr Engineering. "Ticket Servers: Distributed Unique Primary Keys on the Cheap." 2010. https://code.flickr.net/2010/02/08/ticket-servers-distributed-unique-primary-keys-on-the-cheap/
- Buildkite Engineering. "Goodbye to sequential integers, hello UUIDv7!" 2023. https://buildkite.com/resources/blog/goodbye-integers-hello-uuids/
- Segment Engineering (Twilio). "A Brief History of the UUID." 2017. https://segment.com/blog/a-brief-history-of-the-uuid/
- Paul Asjes (Stripe). "Designing APIs for humans: Object IDs." https://dev.to/stripe/designing-apis-for-humans-object-ids-3o5a
- Clerk Engineering. "Generating sortable Stripe-like IDs with Segment's KSUIDs." 2021. https://clerk.com/blog/generating-sortable-stripe-like-ids-with-segment-ksuids
- IETF. RFC 9562: "Universally Unique IDentifiers (UUIDs)." 2024. https://datatracker.ietf.org/doc/html/rfc9562
- OWASP. "Insecure Direct Object Reference Prevention Cheat Sheet." https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html
- Wikipedia. "Insecure direct object reference." https://en.wikipedia.org/wiki/Insecure_direct_object_reference
- Segment. "segmentio/ksuid" (GitHub repository). https://github.com/segmentio/ksuid
- Twitter. "twitter-archive/snowflake" (GitHub repository). https://github.com/twitter-archive/snowflake
- Better Stack. "UUID v7 in PostgreSQL 18." https://betterstack.com/community/guides/databases/postgresql-18-uuid/
- PostgreSQL Documentation. "B-Tree Indexes." https://www.postgresql.org/docs/current/btree.html