Particionamento e NewSQL
Três anos depois do lançamento, a BoxOffice vende em 30 países. As tabelas orders e tickets crescem centenas de milhões de linhas por ano, o disco do primário é medido em terabytes e a taxa de escrita durante as grandes aberturas de vendas chega perto do que uma máquina consegue absorver. As réplicas não ajudam mais: elas escalam leituras, e toda réplica continua tendo de aplicar todas as escritas. O próximo degrau da escada de escalabilidade é dividir os próprios dados entre máquinas.
O particionamento — também chamado de sharding — divide um conjunto de dados de forma que cada registro pertença a exatamente uma partição (shard), e partições diferentes vivam em nós diferentes. Cada nó cuida apenas da sua parte das leituras, das escritas e do armazenamento. Os nomes variam conforme o produto — shard no MongoDB e no Elasticsearch, region no HBase, tablet no Bigtable, range no CockroachDB, vnode no Cassandra —, mas as perguntas são sempre as mesmas: por qual chave, como distribuir por igual, como encontrar e como mover quando os nós mudam1.
flowchart LR
R["roteador<br/>partição = f(chave)"] -->|"cliente 7"| S1[("shard 1<br/>+ réplicas")]
R -->|"cliente 1042"| S2[("shard 2<br/>+ réplicas")]
R -->|"cliente 98311"| S3[("shard 3<br/>+ réplicas")] O particionamento normalmente é combinado com replicação: cada partição tem seu próprio líder e seus seguidores, então um nó pode ser líder de algumas partições e seguidor de outras.
Particionamento por intervalo de chaves
Atribua a cada partição um intervalo contínuo de chaves, como os volumes de uma enciclopédia: A–F, G–M, N–S, T–Z. Dentro de uma partição, as chaves ficam ordenadas.
- Consultas por intervalo são baratas: "pedidos de 2027-03-01 a 2027-03-07" lê uma ou duas partições.
- Os limites se adaptam aos dados: quando um intervalo fica grande demais, ele é dividido em dois (tablets do Bigtable, regions do HBase e ranges do CockroachDB e do TiDB fazem isso automaticamente).
- Perigo — chaves sequenciais. Se a chave é um timestamp ou um id autoincremental, todas as novas escritas vão para o último intervalo. O clickstream de hoje cai num único nó enquanto os outros ficam ociosos. Corrija prefixando a chave com outra coisa:
(event_id, timestamp)em vez detimestamp.
Particionamento por hash
Aplique uma função de hash à chave e use o hash para escolher a partição. Um bom hash espalha uniformemente até chaves sequenciais.
- Carga equilibrada para escritas e leituras pontuais.
- Consultas por intervalo se espalham: chaves vizinhas caem em partições sem relação entre si, então "pedidos desta semana" precisa consultar todas as partições.
- Chaves compostas recuperam alguma localidade: o Cassandra faz hash apenas da partition key e mantém as linhas ordenadas pelas clustering columns dentro dela —
PRIMARY KEY ((event_id), scanned_at)dá uma distribuição equilibrada entre eventos e intervalos de tempo baratos dentro de um evento.
A implementação ingênua, partition = hash(key) mod N, tem uma falha fatal: quando N muda, quase todas as chaves mudam de partição.
Hashing consistente
O hashing consistente (consistent hashing)2 coloca nós e chaves no mesmo espaço circular de hash (um anel, por exemplo de 0 a 2^64 − 1). Cada chave pertence ao primeiro nó encontrado andando no sentido horário a partir da posição da chave.
Experimente
Comece com 4 nós e 1 nó virtual: adicione um quinto nó e compare os dois primeiros quadros — o hashing consistente move só as chaves do arco do novo nó (aqui cerca de 8%, um arco de tamanho irregular), o mod N cerca de 80%. Depois mude para 64 nós virtuais e veja mais carregado ÷ média cair para perto de 1. Clicar num nó do anel o remove (uma queda): só as chaves dele se movem.
Quando um nó entra, ele assume apenas as chaves entre ele e seu antecessor; quando um nó sai, só as chaves dele vão para o sucessor. Em média, 1/N das chaves se move, o mínimo possível — contra quase todas com mod N.
Com uma única posição por nó, os arcos ficam desiguais e um nó pode ficar com muito mais do que sua parte. Os nós virtuais (vnodes) resolvem isso: cada nó físico ocupa muitas posições no anel (100–256 é comum), então sua fatia total se equilibra na média, e um nó com o dobro da capacidade pode simplesmente ficar com o dobro de vnodes.
A simulação abaixo faz hash de 20.000 chaves com MD5. À esquerda, a porcentagem de chaves que mudam de nó quando o cluster cresce de N − 1 para N nós; à direita, quão desbalanceados ficam 10 nós (nó mais carregado ÷ média) conforme cresce o número de nós virtuais:
Ir de 9 para 10 nós com mod N move cerca de 90% das chaves — num cache distribuído, é o mesmo que despejar o cache inteiro sobre o banco de uma vez. Com hashing consistente, cerca de 10% se move. E com uma única posição por nó, o nó mais carregado leva várias vezes a sua parte justa; algumas centenas de vnodes o aproximam de 1.
O hashing consistente é usado pelo Dynamo e pelo DynamoDB, Cassandra e ScyllaDB, Riak, sharding do Memcached no lado do cliente (o algoritmo ketama), upstreams hash … consistent do Nginx e muitos balanceadores de carga. Vale reconhecer dois parentes: o rendezvous hashing (highest random weight: cada chave escolhe o nó com o maior hash(key, node))3 e o jump consistent hash (sem anel, balanceamento quase perfeito, mas nós só podem ser adicionados ou removidos no final)4.
Número fixo de partições
Uma alternativa mais simples que muitos sistemas usam: crie de antemão muito mais partições do que nós e atribua partições inteiras aos nós. Adicionar um nó significa mover para ele algumas partições inteiras; o mapeamento chave → partição nunca muda.
- Redis Cluster: 16.384 hash slots,
slot = CRC16(key) mod 16384; os slots são movidos entre os nós (veja Redis). - Elasticsearch / OpenSearch: o número de shards primários é fixado na criação do índice.
- Kafka: as partições de um tópico (veja Kafka).
O porém: escolha bem a quantidade. Partições de menos limitam até onde você consegue escalar; partições demais acrescentam overhead por partição.
| Estratégia | Chave → partição | Consultas por intervalo | Adicionar um nó | Usada por |
|---|---|---|---|---|
| Intervalo de chaves | limites ordenados, divididos quando crescem | eficientes | dividir e mover intervalos | Bigtable, HBase, CockroachDB, TiDB, MongoDB (ranged) |
| Hash mod N | hash(k) mod N | espalham para todas | move quase tudo | nada sério — evite |
| Hashing consistente | primeiro nó no sentido horário do anel | espalham para todas | ~1/N das chaves se move | Dynamo, Cassandra, Riak, clientes Memcached |
| Partições fixas | hash(k) mod P, P fixo e grande | espalham para todas | mover partições inteiras | Redis Cluster, Elasticsearch, Kafka, Couchbase |
| Diretório | tabela de consulta chave → shard | depende | atualizar a tabela | sharding sob medida, Vitess (vindexes) |
Escolhendo a chave de partição
A chave de partição é a decisão de maior consequência e a menos reversível de um design com sharding. Uma boa chave tem:
- Alta cardinalidade — muitos valores distintos, para que os dados possam ser cortados em muitos pedaços.
- Distribuição equilibrada tanto dos dados quanto do tráfego entre os valores.
- Localidade das consultas — as consultas mais frequentes e todas as transações tocam uma partição.
| Dado da BoxOffice | Chave candidata | Localidade | Distribuição | Veredito |
|---|---|---|---|---|
orders, tickets | customer_id | "meus pedidos" e o checkout tocam um shard | milhões de clientes, equilibrada | boa; relatórios por evento se espalham → rode-os no data warehouse |
orders | created_at | intervalos de tempo são locais | todas as escritas de hoje num shard | ruim (chave sequencial) |
seats (inventário) | event_id | uma transação de reserva fica num shard | um show no estádio = um shard quente | boa localidade, desequilíbrio perigoso — veja abaixo |
seats | (event_id, section) | uma reserva de assentos de um setor fica local | o show no estádio se espalha por ~40 setores | melhor para grandes eventos |
gate_scans | (event_id, gate) | as leituras recentes do portão são locais | 50 portões espalham a carga | boa |
Pense antes: por que não particionar seats apenas por seat_id?
A distribuição seria perfeita, mas a consulta principal — "assentos disponíveis do evento 42, setor B" — se espalharia por todos os shards, e uma reserva em grupo de 4 assentos vizinhos viraria uma transação entre shards. A localidade da consulta dominante e das transações vale mais do que uma distribuição perfeita.
Partições quentes
Mesmo com um hash perfeito, o tráfego raramente é uniforme. Uma partição quente (hot partition, ou chave quente) recebe muito mais carga do que as outras: a celebridade cujos posts todo mundo lê, o produto em promoção na Black Friday, o show no estádio às 10:00. O hash não consegue espalhar uma carga que mira uma única chave.
Experimente. O cenário sharding espalha uma tabela NoSQL por várias partições. Execute-o com tráfego uniforme e anote a carga por partição; depois deixe a distribuição de chaves desequilibrada (uma chave quente) e veja uma única partição saturar e sua latência subir enquanto as outras ficam ociosas — adicionar partições não ajuda.
Mitigações, da menos à mais invasiva:
| Técnica | Como | Custo |
|---|---|---|
| Cachear as leituras | a página do evento quente é servida pelo cache ou pela CDN, não pelo shard (Cache) | dados desatualizados |
| Limitar e enfileirar as escritas | uma sala de espera admite fãs no ritmo que o shard consegue atender (cenário flash-sale) | os usuários esperam |
| Chave mais fina | (event_id, section) em vez de event_id | consultas sobre o evento inteiro se espalham por ~40 partições |
| Salting de chave / write sharding | acrescente um sufixo 0–9 a uma chave quente (event:42#7); as escritas escolhem um sufixo aleatório, as leituras juntam os dez | as leituras fazem 10× o trabalho |
| Contadores fragmentados (sharded counters) | "assentos restantes" mantido como 10 subcontadores, somados na leitura | leituras aproximadas durante as atualizações |
| Capacidade dedicada | mova a partição quente para um nó só dela; bancos gerenciados podem dividi-la automaticamente (adaptive capacity do DynamoDB) | operação, custo |
BoxOffice: o plano de particionamento do inventário
- Chave de partição
(event_id, section). Um show normal tem 5–10 setores; o show no estádio tem ~40, espalhados por muitos nós. - Reservas e confirmações são transações de uma única partição (todos os assentos de uma reserva em grupo ficam no mesmo setor, por design da interface).
- Os "assentos restantes" da página do evento são um contador fragmentado por setor, agregado a cada segundo no cache — nunca uma linha quente.
- A sala de espera limita as tentativas concorrentes de reserva ao que a partição mais carregada consegue atender.
Índices secundários em dados particionados
Particionar por customer_id deixa "pedidos do cliente 7" rápido. E "pedidos do evento 42"? É preciso um índice secundário, e em dados particionados ele vem em dois sabores:
flowchart TB
subgraph local ["Índice local (particionado por documento)"]
Q1["consulta: event_id = 42"] --> P1["shard 1<br/>+ seu próprio índice"]
Q1 --> P2["shard 2<br/>+ seu próprio índice"]
Q1 --> P3["shard 3<br/>+ seu próprio índice"]
end
subgraph global ["Índice global (particionado por termo)"]
Q2["consulta: event_id = 42"] --> IX["partição do índice<br/>para event_id 42"]
IX --> D1["shard 1"]
IX --> D3["shard 3"]
end | Índice local | Índice global | |
|---|---|---|
| Escrita | atualiza o índice no mesmo shard — barata, transacional | atualiza uma partição de índice em outro lugar — muitas vezes assíncrona |
| Leitura pela coluna indexada | scatter/gather em todos os shards; latência de cauda do mais lento | uma partição de índice e depois só os shards que têm resultados |
| Exemplos | MongoDB, índices secundários do Cassandra, Elasticsearch | índices secundários globais do DynamoDB (eventualmente consistentes), índices do Spanner/CockroachDB (transacionais) |
O scatter/gather é mais perigoso do que parece: uma consulta a 20 shards é tão lenta quanto o mais lento dos 20. A chance de os 20 responderem dentro do próprio p99 é 0,99²⁰ ≈ 82 %, então o p99 de um shard é só cerca do p82 da consulta — 18 % das consultas são mais lentas. Para o p99 da consulta ficar abaixo de uma meta, cada shard precisa cumpri-la no seu p99,95 (0,99^(1/20) ≈ 0,9995) (veja Percentis).
Rebalanceamento e resharding
Nós são adicionados, removidos e falham; os dados crescem de forma desigual. O rebalanceamento move partições para manter a carga equilibrada, de preferência enquanto o banco continua atendendo tráfego e movendo apenas os dados que precisam se mover.
- Prefira esquemas em que o mapeamento chave → partição é estável (partições fixas, hashing consistente, divisão de intervalos), para que o rebalanceamento mova partições, não chaves individuais.
- O rebalanceamento disputa rede e disco com o tráfego real; limite sua velocidade e evite rebalanceamento totalmente automático disparado por um nó que está apenas lento (isso pode virar uma cascata).
- Trocar a chave de partição (resharding) é um projeto de migração. A receita comum é online:
flowchart LR
A["1. crie os novos shards<br/>com a nova chave"] --> B["2. escrita dupla:<br/>antigo + novo"]
B --> C["3. backfill do histórico<br/>(cópia + CDC para alcançar)"]
C --> D["4. verifique:<br/>compare as leituras"]
D --> E["5. mude as leituras<br/>para os novos shards"]
E --> F["6. pare as escritas antigas,<br/>apague os shards antigos"] Camadas de sharding automatizam boa parte disso para bancos relacionais existentes: o Vitess para MySQL (criado no YouTube) e o Citus para PostgreSQL cuidam de roteamento, resharding e consultas distribuídas.
Roteamento de requisições
Algum componente precisa saber qual nó guarda qual partição:
| Abordagem | Como | Exemplos |
|---|---|---|
| Qualquer nó encaminha | o cliente fala com qualquer nó; ele encaminha ou redireciona | Cassandra, Redis Cluster (redirecionamentos MOVED) |
| Camada de roteamento | um proxy sem estado conhece o mapa | mongos do MongoDB, vtgate do Vitess, coordenador do Citus |
| Cliente que conhece as partições | a biblioteca cliente mantém o mapa em cache | clientes Kafka, SDK do DynamoDB via endpoints, clientes Redis "inteligentes" |
O próprio mapa é um metadado pequeno e crítico — guardado num armazenamento de consenso como etcd ou ZooKeeper, ou propagado por gossip entre os nós (veja Consenso).
Consultas e transações entre partições
O particionamento funciona lindamente até uma operação precisar de várias partições ao mesmo tempo:
- Joins e agregações entre shards viram scatter/gather, feitos por um coordenador — mais lentos e limitados pelo shard mais lento.
- Transações entre shards precisam de um protocolo de coordenação — commit em duas fases ou commit baseado em consenso — com idas e voltas extras e novos modos de falha (veja Transações).
- Constraints de unicidade em colunas que não são a chave (e-mail único quando o sharding é por
customer_id) precisam de um índice global ou de uma tabela de consulta separada.
Então o objetivo do design é: escolher as chaves para que o caminho quente seja de uma única partição e mandar o resto para sistemas feitos para isso (um data warehouse para analytics, um índice de busca para consultas transversais).
NewSQL: bancos SQL distribuídos
E se você quiser particionamento e transações SQL entre partições, sem escrever uma camada de sharding? Os bancos NewSQL, ou SQL distribuído, fazem isso5:
- O Google Spanner6 divide as tabelas em intervalos, replica cada intervalo com Paxos entre zonas ou regiões e executa transações distribuídas com commit em duas fases sobre esses grupos. Sua API TrueTime expõe a incerteza do relógio (alguns milissegundos, limitada por GPS e relógios atômicos); um commit espera essa incerteza passar, o que dá consistência externa (transações linearizáveis) no mundo todo.
- O CockroachDB7 (protocolo de rede do PostgreSQL) divide os dados em intervalos de algumas centenas de megabytes, replica cada um com Raft e usa relógios lógicos híbridos em vez de hardware especial; o isolamento serializable é o padrão.
- O YugabyteDB (compatível com PostgreSQL) e o TiDB (compatível com MySQL) seguem designs parecidos; os provedores de nuvem também oferecem SQL distribuído gerenciado e serverless.
sequenceDiagram
participant C as Cliente
participant G as Nó gateway
participant R1 as Líder do intervalo 1 (orders)
participant R2 as Líder do intervalo 2 (tickets)
C->>G: BEGIN, INSERT order, INSERT tickets, COMMIT
G->>R1: intenção de escrita (replicada por Raft)
G->>R2: intenção de escrita (replicada por Raft)
R1-->>G: ok (maioria confirmou)
R2-->>G: ok (maioria confirmou)
G->>G: registro de commit (atômico)
G-->>C: COMMIT ok | SQL em um nó + réplicas | SQL com sharding (Citus, Vitess) | SQL distribuído (Spanner, CockroachDB…) | NoSQL (Cassandra, DynamoDB) | |
|---|---|---|---|---|
| Escala de escrita | um primário | por shard | automática, por intervalo | automática, por partição |
| Transações entre partições | n/a (um nó) | limitadas / 2PC | sim, serializable | limitadas ou nenhuma |
| Latência de escrita | a menor | baixa | + ida e volta do consenso (mais se for multirregião) | baixa, ajustável |
| SQL e joins | completos | a maior parte, com ressalvas | a maior parte | nenhum / limitado |
| Operação | simples | camada de sharding para operar | gerenciada ou complexa | gerenciada ou complexa |
SQL distribuído não sai de graça: toda escrita é replicada por consenso, então a latência é maior do que num único nó, e uma transação que abrange regiões paga idas e voltas entre regiões. É a resposta certa quando você precisa ao mesmo tempo de transações SQL e de mais capacidade de escrita ou resiliência geográfica do que um primário consegue dar — não como padrão.
Na entrevista
- Introduza o sharding só depois de estimar: "~1 bilhão de pedidos × 1 KB ≈ 1 TB, ~5 mil escritas/s no pico — além de um primário confortável, então eu particiono
ordersporcustomer_id". - Justifique a chave com os três critérios (cardinalidade, distribuição, localidade do caminho quente) e diga o que se espalha ("relatórios por evento vão para o data warehouse").
- Diga "hashing consistente com nós virtuais" e por quê: adicionar um nó move ~1/N das chaves, não todas.
- Levante sempre a partição quente antes que o entrevistador o faça — a celebridade, a flash sale — e dê uma mitigação concreta (cache, chave mais fina, salting, fila).
- Se as transações precisam abranger partições, mencione SQL distribuído (Spanner, CockroachDB) e seu custo: idas e voltas de consenso.
Boas práticas
- Crie muito mais partições lógicas do que nós — por exemplo 1.024 partições em 4 nós: o rebalanceamento então move partições inteiras, e o número nunca precisa mudar.
- Coloque a chave de partição em toda chave primária e em toda consulta quente — o roteamento vira uma consulta a um mapa, nunca um broadcast a todos os shards.
- Acompanhe a carga por partição, não só o total — as 10 partições com mais requisições/s e maior tamanho revelam uma chave quente antes que ela sature o seu nó.
- Ensaie uma divisão antes de precisar dela — mova uma partição numa cópia de homologação com dados do tamanho da produção e meça o tempo; esse tempo decide quão cedo você precisa começar quando o crescimento chegar.
- Mantenha uma camada de roteamento entre os serviços e os shards — uma biblioteca ou um proxy (Vitess, um coordenador Citus) é dono do mapa de chave para shard, então a topologia pode mudar sem novo deploy de cada serviço.
Armadilhas comuns
hash mod Npara qualquer coisa que um dia vá mudar de tamanho.- Chaves de partição sequenciais (timestamps, autoincremento) com particionamento por intervalo: um shard quente recebe todas as escritas.
- Uma chave que ignora o caminho quente: transações que viram entre shards desde o primeiro dia.
- Poucas partições fixas: depois você não consegue escalar além de um nó por partição.
- Scatter/gather no caminho quente: o p99 da consulta vira o p99 do shard mais lento.
- Sharding cedo demais: você paga a complexidade por anos antes de precisar da capacidade.
Como se conecta
flowchart LR
RANGE["por faixa de chave"] --> KEY["escolher a chave de partição<br/>cardinalidade, distribuição, localidade"]
HASH["por hash"] --> KEY
CH["hashing consistente<br/>nós virtuais"] --> HASH
KEY --> HOT["partições quentes<br/>o evento em venda"]
HOT --> MIT["cache, chave mais fina, salting,<br/>fila → Caching, Mensageria"]
KEY --> SEC["índices secundários<br/>locais vs globais"]
KEY --> RESH["rebalanceamento,<br/>roteamento de requisições"]
KEY --> XP["consultas entre partições<br/>relatórios → warehouse"]
XP --> NEW["NewSQL: Spanner, CockroachDB<br/>consenso por faixa → Distribuídos"]
CH -.-> LB["o mesmo anel nos balanceadores<br/>→ Borda"] Componentes da paleta: nosql (Cassandra, DynamoDB, MongoDB particionam por chave) · sql (PostgreSQL fragmentado, ou SQL distribuído) · cache (hash slots do Redis Cluster; serve a página do evento quente no lugar do shard) · search (shards do Elasticsearch) · kafka (partições por chave) · queue (a sala de espera na frente de uma partição quente) · lb (hashing consistente para afinidade) · dwh (onde ficam os relatórios scatter-gather). Cenários: sharding, flash-sale. Continua em: Armazenamentos Especializados, Distribuídos — consenso, Borda — balanceamento de carga, Caching — Redis Cluster, Estudo de caso: cache distribuído.
-
KLEPPMANN, M.; RICCOMINI, C. Designing Data-Intensive Applications. 2. ed. O'Reilly. Capítulo "Sharding" (chamado "Partitioning" na 1ª edição). ↩
-
KARGER, D. et al. Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web. ACM STOC, 1997. ↩
-
THALER, D. G.; RAVISHANKAR, C. V. Using Name-Based Mappings to Increase Hit Rates. IEEE/ACM Transactions on Networking, 6(1), 1998. ↩
-
LAMPING, J.; VEACH, E. A Fast, Minimal Memory, Consistent Hash Algorithm. arXiv:1406.2294, 2014. ↩
-
PAVLO, A.; ASLETT, M. What's Really New with NewSQL? ACM SIGMOD Record, 45(2), 2016. ↩
-
CORBETT, J. C. et al. Spanner: Google's Globally-Distributed Database. USENIX OSDI, 2012. ↩
-
TAFT, R. et al. CockroachDB: The Resilient Geo-Distributed SQL Database. ACM SIGMOD, 2020. ↩