DEV Community

Snowflake IDs de 64-bits em Elixir e Ecto: Como Otimizar Índices e Eliminar UUIDs em Sistemas Distribuídos

Ao desenvolver sistemas distribuídos e implementar diversos algoritmos de identificadores únicos — explorando desde UUIDs v0 a v8, CUID2, Nano ID e ULID no projeto UniqueIdsEncyclopediaEx — torna-se evidente que a escolha da chave primária dita o desempenho do banco de dados a longo prazo. O uso generalizado de UUIDs de 128 bits impõe um custo de espaço e sobrecarga de cache em motores como PostgreSQL e SQLite.

A adoção de Snowflake IDs de 64 bits (layout 41-10-12) elimina essa sobrecarga, garantindo ordenação cronológica natural, unicidade sem coordenação central e performance otimizada.

1. O Custo Oculto dos UUIDs na Árvore B-Tree

UUIDs de 128 bits (16 bytes) consomem o dobro de espaço de armazenamento por chave em comparação com inteiros de 64 bits (8 bytes). Em tabelas com dezenas de milhões de registros, essa duplicação esgota rapidamente o cache do buffer pool (shared_buffers no PostgreSQL ou cache_size no SQLite).

Além disso, UUIDs gerados aleatoriamente (v4) causam random page writes, fragmentando índices B-Tree e gerando page splits constantes no disco. Reduzir a chave primária para um inteiro de 64 bits compacta os nós internos da árvore, permitindo que mais índices caibam na memória RAM e acelerando drasticamente as consultas.

2. A Anatomia do Snowflake ID (Layout 41-10-12 com Epoch 2026)

O algoritmo Snowflake compacta três informações essenciais em um número inteiro de 64 bits (sendo 63 bits úteis para evitar estouro de sinal):

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                             Time                              |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          Time (cont.)         |   Worker ID   |   Sequence    |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Enter fullscreen mode Exit fullscreen mode
  • Bit de Sinal (1 bit): Mantido em 0 (garante compatibilidade com BIGINT).
  • Timestamp (41 bits): Milissegundos desde uma época customizada. Fixando a epoch em 1 de janeiro de 2026, garantimos aproximadamente 69 anos de operação contínua.
  • Worker ID (10 bits): Permite até 1.024 nós concorrentes operando isoladamente sem coordenação central.
  • Sequence (12 bits): Suporta a geração de até 4.096 IDs por milissegundo no mesmo nó.

3. Atribuição Determinística de Worker ID nos Desktops

Em arquiteturas Local-First offline, onde múltiplos clientes desktop rodam instâncias locais do banco de dados, atribuir um worker_id de forma centralizada pela rede é inviável.

A solução elegante na BEAM é derivar o worker_id de forma determinística a partir do nome do nó (Node.self()) utilizando a função de hash nativa :erlang.phash2/2:

# Para o nó de desktop local, ex: :"app_desktop_01@localhost"
worker_id = :erlang.phash2(Node.self(), 1024)
Enter fullscreen mode Exit fullscreen mode

O phash2 distribui o hash uniformemente no intervalo de 0 a 1023 (espaço de 10 bits). Assim, cada estação de trabalho offline calcula seu próprio identificador de forma isolada no momento do boot, eliminando colisões de chaves primárias durante a sincronização futura.

4. O Perigo do MAX_SAFE_INTEGER no JavaScript

Um obstáculo comum ao adotar inteiros de 64 bits no backend é o tráfego de dados para o front-end. O JavaScript utiliza a especificação IEEE 754 de ponto flutuante de dupla precisão, o que limita a representação de inteiros seguros ao Number.MAX_SAFE_INTEGER ($2^{53} - 1$, ou 9.007.199.254.740.991).

IDs de 64 bits ultrapassam esse limite e, se trafegados como inteiros nativos no JSON, sofrerão arredondamentos irreversíveis pelo motor V8 do navegador. A solução no ecossistema Phoenix/Ecto é interceptar a serialização globalmente via protocolo Jason.Encoder, convertendo os inteiros gigantes para String:

defimpl Jason.Encoder, for: Integer do
  def encode(value, opts) do
    # Verifica se ultrapassa o limite seguro do JavaScript
    if value > 9_007_199_254_740_991 or value < -9_007_199_254_740_991 do
      Jason.Encode.string(Integer.to_string(value), opts)
    else
      Jason.Encode.integer(value, opts)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

5. Integração com Schemas Ecto

Nos schemas do Ecto, abandonamos o :binary_id (comumente usado para UUIDs) em favor do tipo genérico :integer, que será mapeado automaticamente para colunas BIGINT no banco relacional.

defmodule MeuApp.Documento do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :integer, autogenerate: false}
  @foreign_key_type :integer
  schema "documentos" do
    field :titulo, :string
    timestamps()
  end

  def changeset(documento, attrs) do
    documento
    |> cast(attrs, [:titulo])
    |> validate_required([:titulo])
    |> maybe_assign_snowflake_id()
  end

  defp maybe_assign_snowflake_id(changeset) do
    case get_change(changeset, :id) do
      nil -> put_change(changeset, :id, MeuApp.Snowflake.generate_id())
      _ -> changeset
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Ao combinar essas técnicas, você reduz drasticamente o peso dos índices e o consumo de I/O nas suas instâncias de banco de dados, mantém a ordenação temporal natural das inserções, e garante uma operação resiliente em clientes desconectados que não dependem de coordenação externa para gerar chaves fortes.

Top comments (0)