DevGuideTK
← Trilha Backend

Particionamento

Como dividir os dados entre máquinas e o custo de escolher mal a chave de partição.

O código desta página foi executado nas cinco linguagens. As medições do particionamento no PostgreSQL 18 e a simulação de pontos quentes estão no nível Conceito.

Hashing consistente, em cada linguagem

Cada versão monta um anel com 100 posições por servidor e devolve o servidor de uma chave com uma busca binária. O hash é o mesmo nas cinco (os primeiros 8 bytes do SHA-256, como inteiro sem sinal), para que todas coloquem cada chave no mesmo servidor. O programa distribui 100 mil chaves em 4 servidores, acrescenta um quinto e conta quantas chaves mudaram, no anel e com hash % N.

import crypto from "node:crypto";

// Os primeiros 8 bytes do SHA-256, como inteiro sem sinal: o mesmo valor em qualquer linguagem
const hash = (texto: string) =>
  crypto.createHash("sha256").update(texto).digest().readBigUInt64BE(0);

class Anel {
  private pontos: { posicao: bigint; servidor: string }[] = [];
  private virtuais: number;

  // Cada servidor ocupa várias posições no anel (nós virtuais), para dividir melhor as chaves
  constructor(servidores: string[], virtuais = 100) {
    this.virtuais = virtuais;
    for (const s of servidores) this.adicionar(s);
  }

  adicionar(servidor: string) {
    for (let v = 0; v < this.virtuais; v++) {
      this.pontos.push({ posicao: hash(`${servidor}#${v}`), servidor });
    }
    this.pontos.sort((a, b) => (a.posicao < b.posicao ? -1 : a.posicao > b.posicao ? 1 : 0));
  }

  // A chave vai para o primeiro ponto do anel a partir do hash dela, dando a volta no fim
  servidorDe(chave: string): string {
    const h = hash(chave);
    let ini = 0, fim = this.pontos.length;
    while (ini < fim) {
      const meio = (ini + fim) >> 1;
      if (this.pontos[meio].posicao < h) ini = meio + 1; else fim = meio;
    }
    return this.pontos[ini % this.pontos.length].servidor;
  }
}

// Teste: 100 mil chaves em 4 servidores, e quantas mudam ao entrar um quinto
const chaves = Array.from({ length: 100_000 }, (_, i) => `cliente:${i}`);
const anel = new Anel(["shard-0", "shard-1", "shard-2", "shard-3"]);
const antes = chaves.map((c) => anel.servidorDe(c));
const contagem: Record<string, number> = {};
for (const s of antes) contagem[s] = (contagem[s] ?? 0) + 1;
anel.adicionar("shard-4");
const mudouAnel = chaves.filter((c, i) => anel.servidorDe(c) !== antes[i]).length;
const mudouModulo = chaves.filter((c) => hash(c) % 4n !== hash(c) % 5n).length;
console.log("por servidor:", JSON.stringify(contagem));
console.log(`ao entrar o 5º servidor: anel ${mudouAnel} chaves mudaram, módulo ${mudouModulo}`);

O resultado, nas cinco linguagens

shard-0shard-1shard-2shard-3Mudaram no anelMudaram com % N
Nas cinco24.59124.37724.21126.82118.93679.982

As cinco versões colocaram cada chave no mesmo servidor, com o mesmo resultado. Isso importa na prática: se a aplicação em Go e o job em Python roteiam as mesmas chaves, eles precisam concordar sobre onde cada chave está. O hash precisa ser o mesmo em todo lugar e estável entre execuções: o hash() de textos do Python, que muda a cada execução, não serve, e os hashes próprios de cada linguagem (como o hashCode() do Java) não coincidem entre si. Um hash criptográfico ou um padrão bem definido, como o usado aqui, resolve.

O que muda de uma linguagem para outra

Hash como inteiro sem sinalAchar o próximo ponto do anel
TypeScriptreadBigUInt64BE devolve bigintBusca binária num vetor ordenado
Pythonint.from_bytes(..., "big")bisect.bisect_left
Gobinary.BigEndian.Uint64sort.Search
Javalong tem sinal: Long.compareUnsigned e Long.remainderUnsignedTreeMap.ceilingEntry, com comparador sem sinal
C#BinaryPrimitives.ReadUInt64BigEndianBusca binária numa lista ordenada

O Java foi o único que exigiu cuidado com o sinal: metade dos hashes de 64 bits vira número negativo num long, e uma comparação comum colocaria o anel na ordem errada.

Partições no PostgreSQL

A tabela do teste, dividida em 16 partições por hash:

CREATE TABLE pedidos (
  id         bigint NOT NULL,
  cliente_id int NOT NULL,
  criado_em  timestamptz NOT NULL,
  total      numeric(12, 2) NOT NULL
) PARTITION BY HASH (cliente_id);

-- 16 partições: o resto da divisão do hash por 16 escolhe cada uma
DO $$ BEGIN
  FOR i IN 0..15 LOOP
    EXECUTE format('CREATE TABLE pedidos_p%s PARTITION OF pedidos
                    FOR VALUES WITH (MODULUS 16, REMAINDER %s)', i, i);
  END LOOP;
END $$;

-- A chave primária precisa incluir a chave de partição
ALTER TABLE pedidos ADD PRIMARY KEY (id, cliente_id);

O EXPLAIN mostra quantas partições cada consulta lê. No teste, WHERE cliente_id = 4242 leu uma; WHERE id = 123456 leu as 16.

A carga dos dados do teste encontrou um erro comum ao gerar valores: 1 + (g * 7919) % 200000, com g inteiro até 8 milhões, estourou o tipo int (integer out of range) antes do resto da divisão. A conversão g::bigint resolveu.

Lista de revisão

Necessidade

  • Um servidor só, com índices, réplicas, cache e partições locais, já não basta?
  • As gravações ou o volume de dados são o limite, e não leituras ou consultas lentas?

Chave

  • As consultas mais frequentes contêm a chave de partição?
  • A chave espalha as gravações, e não é a data de criação?
  • O que é lido e alterado junto fica na mesma parte?
  • Há um plano para os clientes gigantes?

O que cruza partes

  • Os identificadores são gerados fora do banco, ou incluem a chave de partição?
  • A unicidade global tem uma tabela ou um serviço próprio?
  • Relatórios sobre todas as partes vão para um banco analítico?

Crescimento

  • O roteamento usa partições lógicas fixas ou hashing consistente, e não hash % N?
  • O hash é estável e igual em todas as linguagens e serviços que roteiam?

Prompts para aprofundar

Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.

  • Implementar o roteamento de partes

    Quero implementar na aplicação o roteamento de cada registro para a parte certa.
    
    Pergunte qual linguagem eu uso, quantas partes existem e se elas podem crescer.
    
    Vamos em etapas:
    1. A função de hash estável, igual em todos os serviços.
    2. O mapa de partições lógicas para servidores, ou o anel de hashing consistente.
    3. O acesso ao banco certo a partir da chave.
    4. O que fazer com consultas que não têm a chave.
    5. Testes que confirmem a distribuição e quantas chaves mudam ao crescer.
  • Particionar uma tabela no PostgreSQL

    Quero particionar uma tabela grande no PostgreSQL, no mesmo servidor.
    
    Pergunte qual tabela, o volume, as consultas e se o objetivo é retenção por data ou dividir a carga.
    
    Me ajude com:
    1. Escolher faixa, hash ou lista, e a chave.
    2. Criar a tabela particionada e as partições.
    3. As chaves primárias e índices, com a restrição da chave de partição.
    4. Migrar os dados da tabela antiga.
    5. Conferir com EXPLAIN que as consultas leem só as partições necessárias.
  • Revisar um desenho de particionamento

    Vou colar abaixo o desenho de particionamento de um sistema (esquema, chave, roteamento e consultas principais). Revise.
    
    Verifique, e para cada item diga se está correto, incorreto ou ausente, citando o trecho:
    1. O particionamento entre máquinas é necessário, ou um servidor bastaria?
    2. A chave está nas consultas frequentes e espalha as gravações?
    3. Há risco de ponto quente por data ou por cliente gigante?
    4. Identificadores e unicidade funcionam entre partes?
    5. Há transações ou junções entre partes?
    6. O roteamento cresce sem mover quase todos os dados?
    
    Para cada problema, descreva o efeito em produção e mostre a correção.
    
    Desenho:
    [cole aqui]