BANCO DE DADOS · ALGORITMOS · RUST
Construindo um banco de dados do zero em Rust
Descubra como bancos armazenam, organizam e encontram milhões de registros construindo um pequeno banco funcional do zero.
01 / O ARQUIVO QUE COMEÇA A SOFRER
Se já sabemos salvar arquivos, por que bancos de dados existem?
Imagine que um sistema começou com dez usuários. Guardar tudo em usuarios.json parece perfeito: é legível, simples de gerar e fácil de abrir. Para encontrar alguém, carregamos o arquivo e percorremos a lista.
Com um milhão de registros, perguntas inocentes ficam caras. Como encontrar apenas o usuário 734.291? Como alterar seu e-mail sem reescrever todos os outros? Como remover alguém sem mover centenas de megabytes? Como impedir que o arquivo fique corrompido no meio de uma gravação?
Um banco de dados é um programa especializado em organizar dados dentro de arquivos para que leitura e alteração continuem previsíveis quando o volume cresce.
Não começaremos por SQL. SQL é uma linguagem para conversar com um banco. Primeiro precisamos construir o mecanismo que recebe uma estrutura, decide onde guardá-la e consegue encontrá-la depois.
02 / TODAS AS PEÇAS JÁ ESTAVAM CHEGANDO AQUI
Agora sabemos o suficiente sobre arquivos, memória e execução para construir software que administra dados.
Nosso banco será um programa executando sobre o sistema de arquivos. Ele abrirá usuarios.db, interpretará um formato binário próprio e manterá estruturas temporárias na memória. Os artigos anteriores deixam de ser assuntos isolados e passam a formar a arquitetura deste projeto.
Vamos chamá-lo simplesmente de Mini DB. O nome é honesto: funcional o bastante para persistir, consultar, atualizar e remover; pequeno o bastante para entendermos cada linha.
Esse será o ritmo do artigo. Não apresentaremos uma estrutura porque bancos famosos a utilizam. Cada peça aparecerá somente quando a versão anterior produzir um problema que conseguimos enxergar.
ANTES DO CÓDIGO / OS DADOS DO NOSSO EXEMPLO
Nosso banco guardará usuários simples: cada registro terá ID, nome e e-mail.
Para acompanhar o banco crescendo, usaremos três pessoas fictícias: Ana, Bruno e Carla. Esses nomes não representam tecnologias nem conceitos. São apenas dados de exemplo, como os que preencheríamos em um formulário.
Para o Mini DB, cada pessoa é apenas um User com ID, nome e e-mail. O conteúdo muda de um usuário para outro, mas o formato do registro continua igual.
User é o molde; Ana é um valor criado a partir dele
pub struct User {
pub id: u64,
pub name: String,
pub email: String,
}
let ana = User::new(1, "Ana", "ana@exemplo.com");Uma struct reúne campos relacionados e define a forma de um valor. Pense em uma ficha vazia com três espaços. ana é uma ficha preenchida usando esse molde.
Os tipos dizem que espécie de valor cabe em cada campo
| Tipo Rust | Leitura simples | Uso no projeto |
|---|---|---|
u8 | inteiro sem sinal de 8 bits, de 0 a 255 | bytes, status e comprimentos pequenos |
u32 | inteiro sem sinal de 32 bits | número da página |
u64 | inteiro sem sinal de 64 bits | ID do usuário |
usize | inteiro apropriado para índices na máquina atual | posição do slot em um vetor |
String | texto UTF-8 que possui seus caracteres | nome e e-mail |
bool | verdadeiro ou falso | decisões internas |
Option e Result tornam ausência e falha visíveis
Procurar um ID pode encontrar ou não encontrar. Em Rust, Option<User>representa “existe um usuário” ou “não existe”. Já abrir o arquivo pode falhar por permissão, corrupção ou ausência; Result<User, DbError>representa sucesso ou um erro explicado.
Agora que os personagens e as peças básicas têm nome, podemos começar com a menor coleção possível e deixá-la evoluir.
03 / O MENOR BANCO POSSÍVEL
Começaremos com um Vec de usuários — porque uma solução simples merece funcionar antes de ser descartada.
pub struct Database {
users: Vec<User>,
}
impl Database {
pub fn insert(&mut self, user: User) {
self.users.push(user);
}
pub fn get_all(&self) -> &[User] {
&self.users
}
}Vec, abreviação de vector, é uma coleção que cresce.Vec<User> significa “um vetor cujos elementos sãoUser”. Os sinais < > informam qual tipo a coleção aceita.
Anaposição 1
Brunoposição 2
Carlaespaço para crescer →
Os elementos ficam em posições numeradas. insert coloca um usuário no final; get_all empresta uma fatia, uma visão temporária de uma sequência do vetor sem copiar os usuários. Para poucos registros e uma execução curta, isso é rápido e compreensível.
O primeiro problema aparece quando o programa termina: a memória do processo desaparece. Poderíamos serializar o vetor inteiro e gravá-lo em usuarios.db. Ao iniciar, leríamos tudo de volta. Nossa primeira versão persistente seria:
Isso não é errado. Muitos aplicativos pequenos prosperam com estratégias parecidas. Mas o custo cresce junto com o arquivo, mesmo quando alteramos um único registro. A limitação cria nossa próxima necessidade: representar cada usuário de forma independente.
04 / STRUCTS NÃO MORAM NO DISCO
Para persistir um User, precisamos transformar seus campos em uma sequência reproduzível de bytes.
Na memória, String guarda ponteiros, tamanho e capacidade; seus caracteres vivem em outra região. Copiar os bytes crus da struct não copiaria o texto corretamente e ainda dependeria do compilador e da plataforma.
O que significa serializar?
Enquanto o programa está rodando, Ana existe como uma struct User: um valor conveniente para o Rust manipular. O arquivo, porém, não conhece structs, nomes de campos nem String. Ele só consegue guardar bytes. Precisamos, portanto, estabelecer uma receita para transformar cada campo em bytes e decidir exatamente onde cada um ficará.
Esse caminho da memória para uma representação que pode ser armazenada ou transmitida chama-se serialização. Não é simplesmente jogar valores no arquivo: a receita precisa definir a ordem dos campos, quantos bytes cada campo ocupa, como números são separados e como textos são codificados.
E o que significa desserializar?
Quando abrimos o banco, fazemos o caminho inverso. Lemos os 128 bytes, separamos cada trecho segundo a mesma receita e reconstruímos umastruct User que o programa consegue usar. Esse retorno dos bytes para um valor compreensível pelo programa chama-se desserialização.
Agora podemos definir essa receita. Nosso registro terá exatamente 128 bytes:
status: 1 byte
id: 8 bytes
name_len: 1 byte
email_len: 1 byte
name: 48 bytes
email: 64 bytes
reserved: 5 bytes
-------------------
total: 128 bytesUm número pode ocupar vários bytes. Qual deles vai primeiro?
Um byte possui oito bits e consegue representar valores de 0 a255. Isso basta para guardar a idade 42, mas não basta para guardar o número 4.660. Esse número precisa ser dividido entre dois bytes.
Em hexadecimal, 4.660 pode ser escrito como 0x1234. O prefixo 0x apenas avisa que estamos usando hexadecimal. Cada par — 12 e 34 — cabe em um byte. Portanto, temos duas peças do mesmo número.
Agora aparece uma decisão que o arquivo precisa registrar em seu contrato: escrevemos primeiro o byte 12 ou o byte 34? O nome dessa regra é ordem dos bytes, também chamada de endianess.
Portanto, little-endian é uma convenção que grava primeiro o byte de menor peso. A palavra little se refere justamente a essa extremidade de menor peso; ela não significa que o arquivo ou o número ficou menor. Big-endian faz a escolha oposta e grava primeiro o byte de maior peso.
O nosso banco escolhe little-endian como parte do formato. Em Rust,to_le_bytes separa um número em bytes nessa ordem, enquantofrom_le_bytes junta os bytes e reconstrói o número. O sufixole é a abreviação de little-endian.
let bytes = 0x1234_u16.to_le_bytes();
assert_eq!(bytes, [0x34, 0x12]);
let numero = u16::from_le_bytes(bytes);
assert_eq!(numero, 0x1234);u16 é um inteiro sem sinal de 16 bits, portanto ocupa exatamente dois bytes. A primeira linha transforma 0x1234 em[0x34, 0x12]. A terceira faz o caminho inverso. Agora, quando aparecer user.id.to_le_bytes() no encoder, já sabemos exatamente qual problema esse método resolve.
UTF-8 responde: como letras viram bytes?
Computadores armazenam números, não desenhos de letras. UTF-8 é uma codificação: um acordo que associa caracteres a sequências de bytes. Letras ASCII simples usam um byte; muitos caracteres acentuados usam mais.
Por isso limitamos nome e e-mail em bytes, não em quantidade visual de letras. Nome e e-mail são UTF-8, acompanhados por seus comprimentos. Espaço não utilizado permanece zero. O primeiro byte vale zero para slot vazio, um para registro ativo e dois para removido.
Por que impor limites? Porque um tamanho fixo simplifica localização e reutilização. Bancos reais aceitam valores variáveis usando páginas com diretórios de slots, áreas de overflow ou armazenamento separado. Começamos pelo formato mais visível.
Vamos acompanhar como os dados de Ana são organizados nos 128 bytes
Suponha User::new(1, "Ana", "ana@exemplo.com"). O encoder começa com 128 zeros. Coloca 1 no byte zero para indicar registro ativo. Converte o ID para oito bytes e os copia para as posições de 1 a 8.
O nome possui três bytes em UTF-8, então o byte 9 recebe 3. O e-mail possui quinze, então o byte 10 recebe 15. Em seguida copiamos os caracteres para as áreas reservadas. Os bytes que sobraram continuam zerados.
let mut bytes = [0_u8; RECORD_SIZE];
bytes[0] = 1;
bytes[1..9].copy_from_slice(&user.id.to_le_bytes());
bytes[9] = name.len() as u8;
bytes[10] = email.len() as u8;
bytes[11..11 + name.len()].copy_from_slice(name);
bytes[59..59 + email.len()].copy_from_slice(email);Uma fatia, como 1..9, seleciona um intervalo sem criar outro array. copy_from_slice exige que origem e destino tenham o mesmo tamanho; esse requisito nos protege de cópias parciais acidentais.
Desserializar é refazer o caminho com desconfiança
O decoder lê o status, reconstrói o ID com u64::from_le_bytes, valida os comprimentos e somente então interpreta as áreas de texto como UTF-8. Não usamos os comprimentos antes de conferir os limites, porque o arquivo pode estar truncado ou corrompido.
Serialização não é apenas “converter para bytes”. É definir um contrato estável, validar cada fronteira na escrita e repetir as validações na leitura. É assim que dados produzidos hoje continuam compreensíveis por uma versão futura.
05 / A PRIMEIRA BUSCA
Para encontrar um ID sem nenhuma estrutura auxiliar, precisamos olhar os registros um por um.
pub fn linear(users: &[User], id: u64) -> Option<&User> {
users.iter().find(|user| user.id == id)
}Se procuramos 73, comparamos 12, 27, 41, 58 e finalmente 73. No melhor caso, encontramos o primeiro. No pior, percorremos todos ou descobrimos no fim que o ID não existe.
Chamamos esse crescimento de O(n): dobrar a quantidade pode dobrar o trabalho. O símbolo não mede segundos exatos; descreve como o custo cresce. Busca linear continua excelente para listas pequenas, dados sem ordem ou varreduras em que precisamos examinar tudo de qualquer maneira.
06 / DESCARTANDO METADE
Se os IDs estiverem ordenados, cada comparação pode eliminar metade das possibilidades.
pub fn binary(users: &[User], id: u64) -> Option<&User> {
users
.binary_search_by_key(&id, |user| user.id)
.ok()
.map(|index| &users[index])
}Busca binária custa O(log n). Em cerca de vinte comparações podemos reduzir um milhão de posições a uma. Mas ganhamos uma nova obrigação: manter a lista ordenada.
Inserir no meio de um vetor pode deslocar milhares de elementos. E se os dados estiverem espalhados em um arquivo grande, ainda precisamos descobrir quais bytes ler. Melhoramos a comparação, mas ainda não resolvemos a organização física.
07 / PARAR DE REESCREVER TUDO
Dividiremos usuarios.db em páginas fixas, pequenas o bastante para alterar isoladamente.
Cada página possui 4.096 bytes. Dezesseis formam o cabeçalho da página; o restante comporta 31 registros de 128 bytes. Sobra uma pequena reserva. O offset da página N é (N + 1) × 4096, porque a página zero do arquivo pertence ao header geral.
Esse “zoom” é o mapa mental do armazenamento. O arquivo contém páginas; a página contém slots; o slot contém os campos serializados de um usuário. Cada nível reduz a região que precisamos ler.
Agora atualizar o usuário 73 exige regravar uma página de 4 KiB, não um arquivo de centenas de megabytes. A página também vira a unidade natural do cache e dos mecanismos de recuperação.
Por que 4 KiB?
É um tamanho familiar em sistemas de memória e armazenamento e mantém nossos exemplos pequenos. Não existe um número universal: bancos escolhem tamanhos como 4, 8, 16 KiB ou outros valores conforme formato e objetivos. A decisão equilibra metadados, quantidade de registros por leitura e desperdício.
Páginas muito pequenas multiplicam cabeçalhos e acessos. Páginas muito grandes trazem dados desnecessários quando queremos um único registro e ocupam mais cache. O importante é que o formato escolha um tamanho e todas as contas respeitem o mesmo valor.
Como calculamos o slot sem procurar marcadores?
O primeiro registro começa depois dos 16 bytes de cabeçalho. Como cada slot possui 128 bytes, o início do slot N é:
slot_offset = PAGE_HEADER_SIZE + slot * RECORD_SIZE
slot 0 = 16 + 0 × 128 = 16
slot 1 = 16 + 1 × 128 = 144
slot 9 = 16 + 9 × 128 = 1168Essa aritmética substitui delimitadores. Não precisamos percorrer os oito registros anteriores para alcançar o nono. A página transforma uma posição lógica em offset constante.
O header do arquivo e o header da página respondem perguntas diferentes
Header significa cabeçalho: uma pequena região inicial que descreve o que vem depois. O header geral começa com MDB1. Esse nome foi inventado por nós: Mini DB, versão1.
Os quatro bytes funcionam como assinatura ou magic bytes. Ao abrir um arquivo, o Mini DB verifica se eles existem antes de interpretar o restante. O header também registra versão e quantidade de páginas. Já o header interno começa com PAGE e informa qual página estamos interpretando. Uma assinatura correta no arquivo não torna automaticamente todas as páginas válidas.
08 / UM MAPA PARA NÃO VARRER AS PÁGINAS
O índice liga uma chave pequena à localização física do registro.
pub struct RecordLocation {
pub page_id: u32,
pub slot: usize,
}
pub fn find(&self, id: u64) -> Option<RecordLocation> {
self.entries.get(&id).copied()
}BTreeMap é uma agenda ordenada de chaves e valores
Na biblioteca padrão do Rust, BTreeMap<K, V> é um mapa ordenado. Cada chave K aponta para um valor V. No nosso caso, K é u64, o tipo do ID, eV é RecordLocation.
RecordLocation não é uma função especial do Rust. É uma struct que criamos para guardar duas coordenadas: page_id, o número da página, e slot, a posição dentro dela. O nome significa literalmente “localização do registro”.
Portanto, o índice pode ser lido como uma agenda:ID 73 → página 2, slot 9. A árvore mantém chaves ordenadas e oferece busca logarítmica. Ao abrir o banco, percorremos as páginas uma vez para reconstruí-la.
Isso cria uma limitação consciente: a abertura custa uma varredura e o índice desaparece quando o processo termina. Persisti-lo exigiria páginas próprias e regras para mantê-lo consistente junto com os dados — um ótimo próximo passo, mas grande demais para ser escondido em poucas linhas.
O índice não contém uma segunda cópia de Ana
Ele guarda somente 73 → (página 2, slot 9). Nome e e-mail permanecem na página de dados. Isso reduz a estrutura auxiliar e estabelece uma única fonte para os valores completos.
Se quiséssemos pesquisar por e-mail, criaríamos outro índice: email → localização. Cada índice acelera certas perguntas, mas custa memória, armazenamento e trabalho em todo insert, update e delete. É por isso que criar índices indiscriminadamente pode prejudicar escrita.
Da localização ao byte exato
page_offset = (page_id + 1) * 4096
record_offset = page_offset + 16 + slot * 128
ID 73 → página 2, slot 9
page_offset = (2 + 1) × 4096 = 12288
record_offset = 12288 + 16 + 9 × 128 = 13456O índice entrega coordenadas. O layout transforma coordenadas em offset. A serialização transforma os 128 bytes encontrados em User. Uma consulta rápida é uma cadeia de traduções pequenas.
09 / O SSD NÃO DEVERIA RESPONDER À MESMA PERGUNTA DUAS VEZES
Se uma página acabou de ser usada, há uma boa chance de ela ser usada novamente.
Cache hit acontece quando encontramos a página na memória. Cache miss exige leitura do arquivo; depois colocamos a página no cache para a próxima consulta.
Nosso cache comporta oito páginas e mantém uma fila de uso. Ao ultrapassar a capacidade, remove a menos recentemente tocada. É uma aproximação de LRU. Bancos reais administram buffers muito maiores, páginas sujas, escrita atrasada, concorrência e políticas de substituição mais cuidadosas.
Cache não muda a resposta da consulta. Ele muda o caminho físico até a resposta. Essa separação permite otimizar armazenamento sem alterar a API do banco.
O que significa “menos recentemente usada”?
Sempre que uma página é lida ou inserida, movemos seu ID para o fim de uma fila. Quando entra a nona página em um cache de capacidade oito, removemos o ID do início: aquela que passou mais tempo sem ser tocada.
Nosso cache devolve um clone da página. Alteramos a cópia, o storage grava e depois substituímos a versão em cache. Essa ordem evita que uma consulta posterior enxergue uma versão que nunca chegou ao arquivo.
Página suja é a página modificada que ainda não foi persistida
Dirty significa “suja”. A página em memória ficou diferente da versão que ainda está no disco. A sujeira não é corrupção: é um aviso de que existe uma alteração pendente.
Flush é o ato de enviar a versão modificada para a camada de armazenamento. Bancos reais costumam acumular alterações na memória e gravá-las depois para reduzir I/O. Isso exige coordenar o cache com o WAL e com a ordem dos flushes: o log recuperável precisa estar seguro antes de uma página principal depender dele.
Remover uma página dirty do cache sem fazer flush perderia a alteração. Fazer flush da página antes do WAL poderia deixar no arquivo uma mudança que o log ainda não sabe recuperar. Nosso Mini DB evita esse problema didático gravando imediatamente.
10 / A API FINAL
Insert, find e update agora coordenam serialização, página, índice, cache e arquivo.
let mut db = Database::create("usuarios.db")?;
db.insert(User::new(1, "Ana", "ana@exemplo.com"))?;
let ana = db.find(1)?;
db.update(User::new(
1,
"Ana Souza",
"ana@exemplo.com.br",
))?;
let users = db.list()?;INSERT
Primeiro rejeitamos ID duplicado. Depois procuramos uma página com slot vazio ou removido. O registro entra na cópia da página, a página é persistida e o índice recebe a localização. Publicar o índice antes da gravação criaria uma referência para dados que talvez não tenham chegado ao disco.
FIND
A consulta não percorre usuários. O índice fornece página e slot. O cache tenta atender a página; em um miss, o storage lê exatamente 4 KiB. Desserializamos somente o slot indicado.
UPDATE
Como o registro continua com 128 bytes, atualizar é substituir o mesmo slot e persistir a mesma página. Valores variáveis poderiam não caber no espaço original e exigiriam mover o registro ou usar overflow. O formato fixo compra simplicidade com limites explícitos.
Onde entraria o SQL?
Uma instrução como SELECT * FROM users WHERE id = 73 precisaria ser analisada por um parser. Um planejador reconheceria que existe índice para id e escolheria o caminho índice → página → slot. O executor chamaria operações equivalentes às que escrevemos.
UPDATE users SET email = ... WHERE id = 73 encontraria o mesmo slot, validaria o novo valor e persistiria a página. SQL não substitui armazenamento, páginas ou índices; ele oferece uma linguagem declarativa e um componente que escolhe como chegar ao resultado.
Em SQL dizemos qual resultado queremos. O banco escolhe quais índices, páginas e algoritmos usar para produzi-lo.
11 / APAGAR SEM MOVER O MUNDO
Em vez de reorganizar a página imediatamente, marcaremos o registro com um tombstone.
Tombstone significa lápide: a posição ainda existe, mas registra que aquele valor morreu. Removemos o ID do índice e trocamos o status do slot para dois. Uma consulta normal ignora o registro.
No próximo insert, first_free_slot pode reutilizar a posição. Se dados variáveis deixassem muitos buracos, uma compactação posterior reorganizaria páginas. Adiar o trabalho transforma uma remoção cara em uma pequena escrita previsível.
Os bytes antigos permanecem no slot até a reutilização. Exclusão lógica não equivale a destruição segura, assim como vimos no artigo do RaizFS.
12 / SEIS MANEIRAS DE ENCONTRAR A MESMA FICHA
Imagine um arquivo com um milhão de fichas e uma missão: encontrar o usuário 73.
O conteúdo procurado é sempre o mesmo. O que muda é a maneira como organizamos o arquivo antes da busca. Podemos empilhar fichas sem ordem, mantê-las classificadas, construir armários numerados ou espalhar placas que encurtam o caminho.
Você recebe o ID 73. Seu custo será medido por quantas fichas, gavetas ou páginas precisa abrir até encontrar o registro certo.
Antes de brincar com as estratégias: o que significam O(n), O(log n) e O(1)?
A letra O descreve como o trabalho cresce quando a quantidade de dados aumenta. Não é um cronômetro nem promete uma quantidade exata de milissegundos. É como perguntar: “se o arquivo ficar mil vezes maior, minha procura também ficará mil vezes maior?”
O(n) cresce junto com a quantidade de fichas.O(log n) cresce devagar porque cada decisão descarta uma grande parte do galpão. O(1) médio tenta calcular diretamente onde olhar. Agora vamos enxergar essas diferenças.
Uma pilha de fichas caiu no chão e perdeu toda a ordem.
Você pega a primeira ficha, lê o ID e a coloca de lado. Depois repete. Não existe mapa nem preparação, mas também não existe atalho. Na sequência acima, abrimos 12, 27, 41, 58 e finalmente 73.
Para seis fichas isso é perfeitamente aceitável. Para um milhão, encontrar a última poderia exigir abrir todas. A busca linear não é “ruim”: ela é a solução mais simples quando o conjunto é pequeno, está desordenado ou quando realmente precisamos visitar todos os registros.
Agora as fichas estão ordenadas como as páginas de uma lista telefônica.
Em vez de começar pela primeira página, abrimos no meio. Encontramos 58. Como 73 é maior, toda a metade anterior deixa de interessar. Abrimos o meio da metade restante, encontramos 84 e descartamos o lado maior. Cada pergunta corta o espaço de busca.
O truque só funciona porque as fichas estão ordenadas. Inserir o ID 60 entre 58 e 73 em um vetor pode exigir empurrar muitas fichas para abrir espaço. Economizamos durante a procura, mas passamos a pagar para manter a estante organizada.
Como um guarda-volumes: o número do ticket indica em qual armário procurar.
A função hash recebe o ID e calcula um número de armário. Não precisamos visitar os armários anteriores. Mas o prédio possui uma quantidade limitada de portas: IDs diferentes podem apontar para o mesmo armário. Isso se chama colisão, e então verificamos os poucos itens guardados ali.
O ticket é ótimo para “traga exatamente a mala 73”. Ele não ajuda tanto em “traga todas as malas dos tickets 70 a 90”, porque armários calculados por hash não ficam necessariamente na ordem dos IDs.
Antes da B-Tree: o que são árvore e nó?
Em computação, uma árvore é uma forma de organizar informações em caixas conectadas. Ela recebe esse nome porque lembra os galhos de uma árvore, mas normalmente é desenhada de cabeça para baixo: começa no topo e cresce em direção à parte inferior.
Cada caixa é chamada de nó. Um nó pode guardar chaves, dados e ligações para outros nós. Essas ligações permitem escolher qual parte da estrutura visitar em seguida, em vez de examinar todas as caixas.
| Palavra | Significado simples | No desenho |
|---|---|---|
| Árvore | o conjunto inteiro de caixas e ligações | toda a estrutura |
| Nó | uma caixa que guarda valores e pode apontar para outras | 50, 20 e 80 são nós |
| Raiz | o primeiro nó, por onde a busca começa | o nó 50 |
| Filho | um nó alcançado diretamente a partir de outro | 20 e 80 são filhos de 50 |
| Folha | um nó que não possui filhos | as caixas da última fileira |
| Nível | uma altura da árvore | raiz, filhos e folhas ocupam três níveis |
| Caminho | a sequência de nós visitados | 50 → 80 → folha com 73 |
Procurar 73 pode seguir apenas o caminho 50 → 80 → folha. Os nós do lado esquerdo nem são abertos. Essa é a ideia importante: uma árvore troca uma grande varredura por uma sequência curta de decisões.
Uma biblioteca enorme usa placas que apontam para muitas alas de uma vez.
Na entrada existe uma placa: IDs até 20 ficam na ala A; 21 a 40 na B; 41 a 60 na C; 61 a 80 na D. Para buscar 73, caminhamos diretamente para a ala D. Lá, outra placa ou a própria estante reduz novamente o caminho.
Uma B-Tree coloca muitas chaves — valores usados para orientar a busca — em cada nó. Isso acontece porque um nó costuma ocupar uma página do banco. Ler uma página que oferece cinquenta direções é melhor que ler muitas páginas com apenas duas. Quando um nó fica sem espaço, ele é dividido em dois; uma chave sobe para o nó anterior e passa a orientar o caminho entre as duas novas partes.
O mapa aponta para a primeira estante; depois percorremos estantes vizinhas.
Na B-Tree tradicional, chaves e dados podem aparecer em diferentes níveis. Na B+Tree, os níveis internos funcionam principalmente como mapa; os registros ficam nas folhas, as caixas da última fileira.
As folhas são ligadas. Para buscar IDs de 70 a 95, o mapa nos leva até a primeira estante relevante. Depois caminhamos para a estante vizinha: 73, 84, 91 e 95. Não voltamos à entrada da biblioteca para cada livro. Por isso essa estrutura combina procura exata com intervalos e listagens ordenadas.
A biblioteca recebe devoluções numa mesa rápida e organiza as estantes depois.
Guardar cada livro imediatamente na estante definitiva faria o bibliotecário atravessar o prédio a cada devolução. Uma LSM Tree recebe alterações primeiro numa estrutura ordenada em memória. Quando ela enche, grava um novo arquivo sequencial: um lote inteiro de uma vez.
Mais tarde, a compactação funciona como o turno de organização: combina lotes, mantém a versão mais recente e descarta registros marcados por tombstones. A escrita fica amigável ao armazenamento, mas uma leitura talvez precise consultar a mesa e vários níveis até achar a resposta mais nova.
Agora a tabela faz sentido: ela é o placar da nossa missão
| Estratégia | Imagem mental | Brilha quando... | Preço pago |
|---|---|---|---|
Linear · O(n) | virar fichas uma a uma | há poucos itens ou nenhuma preparação | o trabalho cresce com todos os registros |
Binária · O(log n) | abrir a lista no meio | a coleção já está ordenada | inserir pode exigir reorganização |
Hash · O(1) médio | ticket de guarda-volumes | queremos igualdade por uma chave | não preserva ordem nem intervalos |
B-Tree · O(log n) | placas para muitas alas | queremos ordem com poucas páginas lidas | nós precisam dividir e se reorganizar |
B+Tree · O(log n) | mapa + estantes conectadas | queremos intervalos e varreduras ordenadas | a estrutura possui mais regras de manutenção |
| LSM Tree | mesa de devolução + organização em lotes | há um grande volume de escrita | leituras e compactações combinam níveis |
Por que árvores de banco são largas?
Uma árvore binária possui no máximo dois filhos por nó. Uma B-Tree coloca muitas chaves em uma página e aponta para muitos filhos. Como cada nó coincide com uma unidade de armazenamento, uma única leitura elimina uma enorme faixa de possibilidades.
Por que uma B+Tree liga as folhas?
Os nós internos orientam a busca; os registros ou referências ficam nas folhas ordenadas. Depois de encontrar o primeiro valor de um intervalo, o banco percorre folhas vizinhas sem voltar ao topo.
Por que LSM favorece escrita?
Alterações entram primeiro em estruturas de memória e arquivos sequenciais. Depois, processos de compactação mesclam níveis. Isso evita muitas escritas aleatórias, mas consultas podem precisar combinar fontes e tombstones.
Busca por igualdade e busca por intervalo não são a mesma pergunta
Para id = 73, um hash pode chegar rapidamente ao balde certo. Para id BETWEEN 70 AND 90, a ausência de ordem atrapalha. Uma B+Tree alcança 70 e percorre folhas ordenadas até 90.
Também existem Bloom filters, pequenos filtros que respondem “certamente não está aqui” ou “talvez esteja”. O “talvez” pode produzir um falso positivo: o filtro manda procurar, mas a chave não existe. Ele nunca responde “não” para uma chave que realmente está presente. Em uma LSM Tree, isso evita abrir muitos arquivos que certamente não possuem o ID desejado.
Complexidade não substitui o custo do armazenamento
Duas estruturas podem ter O(log n) e realizar quantidades muito diferentes de leituras. Bancos desenham árvores largas para reduzir altura porque acessar outra página costuma custar muito mais que comparar mais chaves já carregadas na memória.
13 / E SE A ENERGIA ACABAR?
Nosso banco sabe onde gravar, mas ainda não sabe desfazer uma operação interrompida.
Imagine um insert que escreve a página e desliga antes de atualizar um índice persistente. O registro existe, mas o índice não sabe encontrá-lo. Também pode acontecer o inverso: o índice aponta para um registro que ainda não foi gravado. Em ambos os casos enxergamos metade de uma operação. Esse é justamente o problema que a atomicidade resolverá neste capítulo.
WAL é um caderno de alterações escrito antes do arquivo principal
WAL significa Write-Ahead Log, log de escrita antecipada. “Log” aqui não é apenas uma mensagem para o desenvolvedor: é uma sequência persistente que descreve alterações suficientes para recuperar o banco.
Antes de modificar as páginas principais, o banco registra a intenção em uma sequência recuperável. Após uma queda, o log permite refazer uma alteração confirmada ou ignorar uma incompleta.
Coordenar WAL e flush significa respeitar uma ordem de segurança
Suponha que a página dirty chegue ao arquivo principal antes da entrada correspondente do WAL estar segura. Se a energia acabar, o banco pode encontrar uma alteração pela metade sem possuir a descrição necessária para recuperá-la.
Sincronizar significa pedir que a camada de armazenamento torne os bytes duráveis, e não apenas guardados em algum buffer temporário. O métodosync_data usado no Mini DB expressa essa intenção para o arquivo, embora garantias completas dependam também do sistema operacional e do hardware.
Não implementamos WAL porque ele merece um projeto próprio com transações e recuperação. Mas agora conseguimos dizer exatamente por que ele existe: múltiplas estruturas precisam mudar como se fossem uma única operação.
Atomicidade: o banco não pode deixar uma operação pela metade
Atomicidade vem da ideia de tratar algo como uma unidade que não pode ser observada em pedaços. Isso não significa que a CPU executará tudo em uma única instrução. Várias gravações podem acontecer internamente; a garantia é que, para quem consulta o banco, o conjunto aparece completo ou não aparece.
No nosso insert existem pelo menos duas mudanças relacionadas: colocar os 128 bytes do usuário em uma página e acrescentar ID → página:slot ao índice. Uma sem a outra deixa o banco incoerente.
Uma transação é o envelope que agrupa essas mudanças. Commit é o momento em que o banco confirma: “o conjunto está completo e agora pode ser considerado válido”. Se algo falhar antes disso, o banco faz rollback, isto é, volta ao estado anterior, ou usa o WAL para concluir a recuperação de maneira segura.
A transferência bancária é outro exemplo: retirar R$ 100 de uma conta e adicionar R$ 100 à outra fazem parte da mesma transação. Se a energia acabar depois da retirada, o banco não pode simplesmente perder o depósito. Ele recupera o conjunto para preservar “as duas mudanças” ou “nenhuma mudança”.
Durabilidade: confirmar só depois de tornar recuperável
Se o banco responde “sucesso” antes de os dados ou o log alcançarem armazenamento estável, uma queda pode apagar uma operação que o cliente acreditava concluída. sync_data no Mini DB aproxima cada escrita dessa intenção, mas não implementa toda a semântica de transações.
Concorrência adicionaria outro problema: duas operações podem tentar alterar a mesma página ao mesmo tempo. Um lock funciona como uma placa de “ocupado”, impedindo que outra operação modifique aquela região durante um momento crítico. MVCC mantém versões dos dados para que leitores enxerguem uma versão consistente enquanto escritores preparam outra. Essas técnicas surgem quando várias execuções compartilham o banco sem poder observar estados pela metade.
14 / MESMAS PERGUNTAS, ESCALAS DIFERENTES
SQLite, PostgreSQL, Redis e RocksDB escolhem arquiteturas diferentes porque resolvem cargas diferentes.
| Sistema | Ideia central | Quando faz sentido |
|---|---|---|
| SQLite | banco embarcado em um arquivo | aplicações locais, simplicidade e distribuição fácil |
| PostgreSQL | servidor relacional completo | concorrência, consultas ricas, integridade e extensões |
| Redis | estruturas principalmente em memória | baixa latência, cache, filas e estado efêmero ou persistido |
| RocksDB | chave-valor baseado em LSM | muitas escritas e armazenamento embutido |
SQLite ser “um arquivo” não significa que seja simples por dentro. Esse arquivo contém páginas, árvores, freelists, schemas e mecanismos transacionais. PostgreSQL distribui dados e metadados por vários arquivos e trabalha com processos, buffers e WAL. Redis ganha velocidade mantendo o conjunto ativo na RAM. RocksDB organiza escrita em níveis.
Um banco não é rápido por usar uma estrutura famosa. Ele é rápido quando suas estruturas combinam com as perguntas e alterações que recebe.
15 / PROJETO COMPLETO
Todas as limitações que encontramos agora formam um pequeno banco coerente.
mini-db/
├── Cargo.toml
├── README.md
├── src/
│ ├── lib.rs
│ ├── main.rs
│ ├── database.rs
│ ├── page.rs
│ ├── record.rs
│ ├── serializer.rs
│ ├── storage.rs
│ ├── cache.rs
│ ├── index.rs
│ ├── search.rs
│ └── errors.rs
└── tests/
└── database.rsBaixar o projeto completo do Mini DB Antes de abrir onze arquivos, precisamos enxergar a arquitetura. Separar módulos não é espalhar código: é colocar cada decisão perto da responsabilidade que ela protege.
| Módulo | Pergunta que ele responde | O que ele não deve decidir |
|---|---|---|
database.rs | em que ordem as peças participam de uma operação? | como cada campo vira byte |
record.rs | o que é um usuário válido? | em qual página ele será gravado |
serializer.rs | como User e os 128 bytes se transformam um no outro? | quando fazer flush |
page.rs | como slots são lidos, substituídos e reutilizados? | qual ID o cliente pesquisou |
storage.rs | qual offset deve ser lido ou gravado no arquivo? | o significado de nome e e-mail |
cache.rs | já temos esta página na memória? | se um registro satisfaz a consulta |
index.rs | onde está o registro de determinado ID? | como os bytes do registro são codificados |
errors.rs | como uma falha atravessa as camadas sem perder significado? | como recuperar uma transação |
Acompanhe um insert atravessando os módulos
Quando main.rs chama db.insert(ana),database.rs assume a coordenação. Ele verifica no índice se o ID já existe, pede uma página ao cache ou ao storage, procura um slot reutilizável e entrega o User ao serializer.
Somente depois da gravação o índice recebe ID → RecordLocation e o cache recebe a nova versão da página. Essa ordem é parte da correção do programa: publicar a localização antes dos dados criaria um caminho para um registro que ainda não existe no arquivo.
Acompanhe um find fazendo o caminho inverso
find(73) consulta o índice e obtém uma localização. O cache tenta fornecer a página; se não conseguir, o storage lê 4 KiB do arquivo. A página recorta apenas o slot indicado e o serializer reconstrói o User. A API devolve Some(user) ou None.
Os painéis abaixo disponibilizam todo o código usado no projeto. Leia-os na ordem apresentada: cada módulo corresponde a um problema que já apareceu na narrativa e as explicações acima mostram como eles cooperam.
Cargo.tomlDefine o projeto sem dependências externas
Ver código completo de Cargo.toml
[package]
name = "mini-db"
version = "0.1.0"
edition = "2024"
[lib]
name = "mini_db"
path = "src/lib.rs"
[[bin]]
name = "mini-db"
path = "src/main.rs"
src/record.rsRepresenta o usuário na memória
Ver código completo de src/record.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
pub id: u64,
pub name: String,
pub email: String,
}
impl User {
pub fn new(id: u64, name: impl Into<String>, email: impl Into<String>) -> Self {
Self { id, name: name.into(), email: email.into() }
}
}
src/serializer.rsTransforma usuário em 128 bytes e volta
Ver código completo de src/serializer.rs
use crate::{DbError, User};
pub const RECORD_SIZE: usize = 128;
const NAME_CAPACITY: usize = 48;
const EMAIL_CAPACITY: usize = 64;
pub fn encode(user: &User, deleted: bool) -> Result<[u8; RECORD_SIZE], DbError> {
let name = user.name.as_bytes();
let email = user.email.as_bytes();
if name.len() > NAME_CAPACITY {
return Err(DbError::InvalidRecord("nome excede 48 bytes"));
}
if email.len() > EMAIL_CAPACITY {
return Err(DbError::InvalidRecord("e-mail excede 64 bytes"));
}
let mut bytes = [0_u8; RECORD_SIZE];
bytes[0] = if deleted { 2 } else { 1 };
bytes[1..9].copy_from_slice(&user.id.to_le_bytes());
bytes[9] = name.len() as u8;
bytes[10] = email.len() as u8;
bytes[11..11 + name.len()].copy_from_slice(name);
bytes[59..59 + email.len()].copy_from_slice(email);
Ok(bytes)
}
pub fn decode(bytes: &[u8; RECORD_SIZE]) -> Result<Option<(User, bool)>, DbError> {
if bytes[0] == 0 {
return Ok(None);
}
if bytes[0] != 1 && bytes[0] != 2 {
return Err(DbError::InvalidRecord("status desconhecido"));
}
let id = u64::from_le_bytes(bytes[1..9].try_into().unwrap());
let name_len = bytes[9] as usize;
let email_len = bytes[10] as usize;
if name_len > NAME_CAPACITY || email_len > EMAIL_CAPACITY {
return Err(DbError::InvalidRecord("comprimento fora do limite"));
}
let name = std::str::from_utf8(&bytes[11..11 + name_len])
.map_err(|_| DbError::InvalidRecord("nome não é UTF-8"))?
.to_owned();
let email = std::str::from_utf8(&bytes[59..59 + email_len])
.map_err(|_| DbError::InvalidRecord("e-mail não é UTF-8"))?
.to_owned();
Ok(Some((User { id, name, email }, bytes[0] == 2)))
}
src/page.rsOrganiza registros em páginas de 4 KiB
Ver código completo de src/page.rs
use crate::serializer::{self, RECORD_SIZE};
use crate::{DbError, User};
pub const PAGE_SIZE: usize = 4096;
pub const PAGE_HEADER_SIZE: usize = 16;
pub const SLOTS_PER_PAGE: usize = (PAGE_SIZE - PAGE_HEADER_SIZE) / RECORD_SIZE;
#[derive(Debug, Clone)]
pub struct Slot {
pub user: User,
pub deleted: bool,
}
#[derive(Debug, Clone)]
pub struct Page {
pub id: u32,
pub slots: Vec<Option<Slot>>,
}
impl Page {
pub fn empty(id: u32) -> Self {
Self { id, slots: vec![None; SLOTS_PER_PAGE] }
}
pub fn first_free_slot(&self) -> Option<usize> {
self.slots.iter().position(|slot| slot.is_none() || slot.as_ref().is_some_and(|value| value.deleted))
}
pub fn encode(&self) -> Result<[u8; PAGE_SIZE], DbError> {
let mut bytes = [0_u8; PAGE_SIZE];
bytes[0..4].copy_from_slice(b"PAGE");
bytes[4..8].copy_from_slice(&self.id.to_le_bytes());
bytes[8..10].copy_from_slice(&(SLOTS_PER_PAGE as u16).to_le_bytes());
for (index, slot) in self.slots.iter().enumerate() {
if let Some(slot) = slot {
let start = PAGE_HEADER_SIZE + index * RECORD_SIZE;
bytes[start..start + RECORD_SIZE]
.copy_from_slice(&serializer::encode(&slot.user, slot.deleted)?);
}
}
Ok(bytes)
}
pub fn decode(bytes: &[u8; PAGE_SIZE]) -> Result<Self, DbError> {
if &bytes[0..4] != b"PAGE" {
return Err(DbError::InvalidFile("magic da página incorreto"));
}
let id = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
let mut slots = Vec::with_capacity(SLOTS_PER_PAGE);
for index in 0..SLOTS_PER_PAGE {
let start = PAGE_HEADER_SIZE + index * RECORD_SIZE;
let record: &[u8; RECORD_SIZE] = bytes[start..start + RECORD_SIZE].try_into().unwrap();
slots.push(serializer::decode(record)?.map(|(user, deleted)| Slot { user, deleted }));
}
Ok(Self { id, slots })
}
}
src/storage.rsLê e grava páginas no usuarios.db
Ver código completo de src/storage.rs
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::Path;
use crate::page::{Page, PAGE_SIZE};
use crate::DbError;
const FILE_MAGIC: &[u8; 4] = b"MDB1";
pub struct Storage {
file: File,
pub page_count: u32,
}
impl Storage {
pub fn create(path: impl AsRef<Path>) -> Result<Self, DbError> {
let mut file = OpenOptions::new().create(true).truncate(true).read(true).write(true).open(path)?;
let mut header = [0_u8; PAGE_SIZE];
header[0..4].copy_from_slice(FILE_MAGIC);
header[4..6].copy_from_slice(&1_u16.to_le_bytes());
header[8..12].copy_from_slice(&0_u32.to_le_bytes());
file.write_all(&header)?;
file.sync_all()?;
Ok(Self { file, page_count: 0 })
}
pub fn open(path: impl AsRef<Path>) -> Result<Self, DbError> {
let mut file = OpenOptions::new().read(true).write(true).open(path)?;
let mut header = [0_u8; PAGE_SIZE];
file.read_exact(&mut header)?;
if &header[0..4] != FILE_MAGIC {
return Err(DbError::InvalidFile("magic MDB1 ausente"));
}
let page_count = u32::from_le_bytes(header[8..12].try_into().unwrap());
Ok(Self { file, page_count })
}
pub fn read_page(&mut self, page_id: u32) -> Result<Page, DbError> {
if page_id >= self.page_count {
return Err(DbError::InvalidFile("página inexistente"));
}
let mut bytes = [0_u8; PAGE_SIZE];
self.file.seek(SeekFrom::Start((page_id as u64 + 1) * PAGE_SIZE as u64))?;
self.file.read_exact(&mut bytes)?;
Page::decode(&bytes)
}
pub fn write_page(&mut self, page: &Page) -> Result<(), DbError> {
self.file.seek(SeekFrom::Start((page.id as u64 + 1) * PAGE_SIZE as u64))?;
self.file.write_all(&page.encode()?)?;
if page.id >= self.page_count {
self.page_count = page.id + 1;
self.write_header()?;
}
self.file.sync_data()?;
Ok(())
}
fn write_header(&mut self) -> Result<(), DbError> {
self.file.seek(SeekFrom::Start(8))?;
self.file.write_all(&self.page_count.to_le_bytes())?;
Ok(())
}
}
src/search.rsCompara busca linear e binária
Ver código completo de src/search.rs
use crate::User;
pub fn linear(users: &[User], id: u64) -> Option<&User> {
users.iter().find(|user| user.id == id)
}
pub fn binary(users: &[User], id: u64) -> Option<&User> {
users.binary_search_by_key(&id, |user| user.id).ok().map(|index| &users[index])
}
src/index.rsLiga ID a página e slot
Ver código completo de src/index.rs
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy)]
pub struct RecordLocation {
pub page_id: u32,
pub slot: usize,
}
#[derive(Default)]
pub struct PrimaryIndex {
entries: BTreeMap<u64, RecordLocation>,
}
impl PrimaryIndex {
pub fn insert(&mut self, id: u64, location: RecordLocation) {
self.entries.insert(id, location);
}
pub fn find(&self, id: u64) -> Option<RecordLocation> {
self.entries.get(&id).copied()
}
pub fn remove(&mut self, id: u64) {
self.entries.remove(&id);
}
}
src/cache.rsMantém páginas quentes na memória
Ver código completo de src/cache.rs
use std::collections::{HashMap, VecDeque};
use crate::page::Page;
pub struct PageCache {
capacity: usize,
pages: HashMap<u32, Page>,
order: VecDeque<u32>,
pub hits: usize,
pub misses: usize,
}
impl PageCache {
pub fn new(capacity: usize) -> Self {
Self { capacity: capacity.max(1), pages: HashMap::new(), order: VecDeque::new(), hits: 0, misses: 0 }
}
pub fn get(&mut self, id: u32) -> Option<Page> {
if let Some(page) = self.pages.get(&id).cloned() {
self.hits += 1;
self.touch(id);
Some(page)
} else {
self.misses += 1;
None
}
}
pub fn put(&mut self, page: Page) {
let id = page.id;
self.pages.insert(id, page);
self.touch(id);
while self.pages.len() > self.capacity {
if let Some(oldest) = self.order.pop_front() {
self.pages.remove(&oldest);
}
}
}
fn touch(&mut self, id: u32) {
self.order.retain(|current| *current != id);
self.order.push_back(id);
}
}
src/database.rsExpõe a API e coordena todas as peças
Ver código completo de src/database.rs
use std::path::Path;
use crate::cache::PageCache;
use crate::index::{PrimaryIndex, RecordLocation};
use crate::page::{Page, Slot};
use crate::storage::Storage;
use crate::{DbError, User};
pub struct Database {
storage: Storage,
cache: PageCache,
index: PrimaryIndex,
}
impl Database {
pub fn create(path: impl AsRef<Path>) -> Result<Self, DbError> {
Ok(Self { storage: Storage::create(path)?, cache: PageCache::new(8), index: PrimaryIndex::default() })
}
pub fn open(path: impl AsRef<Path>) -> Result<Self, DbError> {
let storage = Storage::open(path)?;
let mut database = Self { storage, cache: PageCache::new(8), index: PrimaryIndex::default() };
database.rebuild_index()?;
Ok(database)
}
pub fn insert(&mut self, user: User) -> Result<(), DbError> {
if self.index.find(user.id).is_some() {
return Err(DbError::DuplicateId(user.id));
}
let (mut page, slot) = self.page_with_space()?;
let id = user.id;
page.slots[slot] = Some(Slot { user, deleted: false });
self.persist_page(page.clone())?;
self.index.insert(id, RecordLocation { page_id: page.id, slot });
Ok(())
}
pub fn find(&mut self, id: u64) -> Result<User, DbError> {
let location = self.index.find(id).ok_or(DbError::NotFound(id))?;
let page = self.load_page(location.page_id)?;
page.slots[location.slot].as_ref()
.filter(|slot| !slot.deleted)
.map(|slot| slot.user.clone())
.ok_or(DbError::NotFound(id))
}
pub fn update(&mut self, user: User) -> Result<(), DbError> {
let location = self.index.find(user.id).ok_or(DbError::NotFound(user.id))?;
let mut page = self.load_page(location.page_id)?;
page.slots[location.slot] = Some(Slot { user, deleted: false });
self.persist_page(page)
}
pub fn delete(&mut self, id: u64) -> Result<(), DbError> {
let location = self.index.find(id).ok_or(DbError::NotFound(id))?;
let mut page = self.load_page(location.page_id)?;
let slot = page.slots[location.slot].as_mut().ok_or(DbError::NotFound(id))?;
slot.deleted = true;
self.persist_page(page)?;
self.index.remove(id);
Ok(())
}
pub fn list(&mut self) -> Result<Vec<User>, DbError> {
let mut users = Vec::new();
for page_id in 0..self.storage.page_count {
let page = self.load_page(page_id)?;
users.extend(page.slots.into_iter().flatten().filter(|slot| !slot.deleted).map(|slot| slot.user));
}
users.sort_by_key(|user| user.id);
Ok(users)
}
pub fn cache_stats(&self) -> (usize, usize) {
(self.cache.hits, self.cache.misses)
}
fn page_with_space(&mut self) -> Result<(Page, usize), DbError> {
for page_id in 0..self.storage.page_count {
let page = self.load_page(page_id)?;
if let Some(slot) = page.first_free_slot() {
return Ok((page, slot));
}
}
let page = Page::empty(self.storage.page_count);
Ok((page, 0))
}
fn load_page(&mut self, page_id: u32) -> Result<Page, DbError> {
if let Some(page) = self.cache.get(page_id) {
return Ok(page);
}
let page = self.storage.read_page(page_id)?;
self.cache.put(page.clone());
Ok(page)
}
fn persist_page(&mut self, page: Page) -> Result<(), DbError> {
self.storage.write_page(&page)?;
self.cache.put(page);
Ok(())
}
fn rebuild_index(&mut self) -> Result<(), DbError> {
for page_id in 0..self.storage.page_count {
let page = self.load_page(page_id)?;
for (slot, value) in page.slots.iter().enumerate() {
if let Some(value) = value.as_ref().filter(|value| !value.deleted) {
self.index.insert(value.user.id, RecordLocation { page_id, slot });
}
}
}
Ok(())
}
}
src/errors.rsNomeia falhas sem esconder contexto
Ver código completo de src/errors.rs
use std::fmt;
#[derive(Debug)]
pub enum DbError {
Io(std::io::Error),
InvalidFile(&'static str),
InvalidRecord(&'static str),
DuplicateId(u64),
NotFound(u64),
}
impl fmt::Display for DbError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(f, "erro de I/O: {error}"),
Self::InvalidFile(message) => write!(f, "arquivo inválido: {message}"),
Self::InvalidRecord(message) => write!(f, "registro inválido: {message}"),
Self::DuplicateId(id) => write!(f, "o id {id} já existe"),
Self::NotFound(id) => write!(f, "o id {id} não foi encontrado"),
}
}
}
impl std::error::Error for DbError {}
impl From<std::io::Error> for DbError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
src/main.rsDemonstra o ciclo completo
Ver código completo de src/main.rs
use mini_db::{Database, User};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = "usuarios.db";
let mut db = Database::create(path)?;
db.insert(User::new(1, "Ana", "ana@exemplo.com"))?;
db.insert(User::new(2, "Bruno", "bruno@exemplo.com"))?;
db.insert(User::new(3, "Carla", "carla@exemplo.com"))?;
println!("encontrado: {:?}", db.find(2)?);
db.update(User::new(2, "Bruno Silva", "bruno@exemplo.com.br"))?;
db.delete(1)?;
drop(db);
let mut reopened = Database::open(path)?;
println!("persistidos: {:#?}", reopened.list()?);
println!("cache (hits, misses): {:?}", reopened.cache_stats());
Ok(())
}
tests/database.rsProva persistência, atualização e remoção
Ver código completo de tests/database.rs
use std::time::{SystemTime, UNIX_EPOCH};
use mini_db::{Database, User};
fn temp_file() -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"mini-db-{}.db",
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
))
}
#[test]
fn persists_insert_update_and_delete() {
let path = temp_file();
{
let mut db = Database::create(&path).unwrap();
db.insert(User::new(10, "Ana", "ana@example.com")).unwrap();
db.insert(User::new(20, "Carla", "carla@example.com")).unwrap();
db.update(User::new(20, "Carla Souza", "carla@example.com.br")).unwrap();
db.delete(10).unwrap();
}
let mut reopened = Database::open(&path).unwrap();
assert!(reopened.find(10).is_err());
assert_eq!(reopened.find(20).unwrap().name, "Carla Souza");
std::fs::remove_file(path).unwrap();
}
O índice é reconstruído de propósito
Database::open percorre todas as páginas, ignora slots vazios e tombstones e insere cada ID ativo no índice. Isso prova que o arquivo é a fonte persistente; índice e cache são acelerações reconstruíveis.
O teste fecha e reabre o banco
Persistência não é provada lendo a mesma struct ainda viva. O teste cria um arquivo temporário, insere, atualiza, remove, deixa o Database sair de escopo e abre o arquivo novamente. Só então compara os resultados.
16 / DA STRUCT AO DISCO E DE VOLTA
Execute, feche, reabra: os usuários continuam lá porque agora possuem endereço.
cargo run
encontrado: User { id: 2, name: "Bruno", ... }
persistidos: [
User { id: 2, name: "Bruno Silva", ... },
User { id: 3, name: "Carla", ... },
]
cargo testAbra usuarios.db em um editor hexadecimal. No começo verá MDB1. A partir do offset 4096 começa PAGE. Depois aparecem status, IDs little-endian e textos UTF-8 dentro de slots fixos.
Experimente inserir 32 usuários para criar a segunda página; buscar o mesmo ID repetidamente para aumentar hits; remover e inserir para reutilizar o tombstone; corromper MDB1; trocar o tamanho de um nome. Cada teste liga uma regra abstrata a bytes observáveis.
Agora podemos responder à pergunta inicial. Bancos existem porque persistir é apenas o começo. Eles criam endereços, unidades de trabalho, caminhos de busca e cópias quentes em memória para que o custo de uma operação não cresça junto com todos os dados.
Construímos o mecanismo e chamamos seus métodos diretamente. O próximo problema nasce dessa vitória: se cada aplicação precisar programar manualmente find, filtros e caminhos de acesso, como pessoas farão perguntas novas sem reescrever o programa? Essa necessidade nos leva naturalmente ao SQL.
17 / QUANDO CONSULTAR DEIXOU DE SER NAVEGAR
Como nasceu o SQL — e qual problema ele resolveu?
Durante o artigo, evitamos SQL para enxergar as peças que normalmente ficam escondidas. Chamamos find(73), seguimos o índice, calculamos uma página e desserializamos um slot. Agora podemos entender por que alguém quis colocar uma linguagem acima desse mecanismo.
Antes do modelo relacional, o caminho fazia parte da pergunta
Muitos sistemas antigos organizavam registros como hierarquias ou redes de ligações. Para encontrar um dado, o programa precisava conhecer antecipadamente o caminho: entrar por um registro, seguir um ponteiro, alcançar outro conjunto e continuar navegando.
Essa dependência era frágil. Se a organização física mudasse, programas que conheciam o caminho poderiam precisar mudar também. O desenvolvedor precisava pensar simultaneamente na pergunta de negócio e nos detalhes de armazenamento.
Em 1970, Edgar F. Codd separou a pergunta do caminho
O pesquisador Edgar “Ted” Codd, da IBM, publicou em 1970 o trabalho A Relational Model of Data for Large Shared Data Banks. A proposta representava dados como relações — que podemos visualizar como tabelas formadas por linhas e atributos — conectadas pelos próprios valores, em vez de depender de uma rede rígida de ponteiros.
Essa mudança parece acadêmica, mas resolve um problema muito concreto. O usuário poderia dizer que deseja todos os pedidos de uma pessoa, sem escrever quais páginas abrir nem quais ponteiros seguir. O mecanismo do banco ganhava liberdade para escolher o caminho.
Descrever o resultado sem amarrar a consulta à organização física permite que índices e estratégias mudem sem reescrever toda aplicação.
Do artigo de Codd ao System R
Uma ideia precisava provar que funcionava em escala industrial. Em 1973, a IBM iniciou o projeto System R. Donald Chamberlin e Raymond Boyce trabalharam em uma linguagem declarativa inicialmente chamada SEQUEL. Em vez de ordenar ao banco cada passo, o usuário escreveria uma descrição próxima da pergunta.
SELECT name, email
FROM users
WHERE id = 73;Essa frase não diz “abra a página dois, leia o slot nove”. Ela informa colunas, fonte e condição. O banco analisa a expressão, verifica o schema, procura alternativas e cria um plano. O nome passou de SEQUEL para SQL — Structured Query Language — e a linguagem se espalhou por produtos e padrões.
O otimizador transformou liberdade em desempenho
Se a linguagem não escolhe o caminho, alguém precisa escolhê-lo. O otimizador examina possibilidades: varrer a tabela, usar um índice, começar por uma tabela menor, escolher a ordem de joins. Patricia Selinger liderou no System R um trabalho fundamental de otimização baseada em custos.
Aqui nossa implementação reaparece. Para id = 73, o Mini DB possui uma única opção eficiente: consultar o índice primário. Um banco real compara estatísticas e pode escolher entre vários índices, tipos de join e ordens de leitura.
SQL resolveu mais do que uma sintaxe
A contribuição principal não foi trocar código por palavras em inglês. Foi consolidar uma interface declarativa: usuários dizem o quê; o mecanismo decide como. Isso permitiu consultas novas sobre dados existentes sem criar um programa de navegação específico para cada uma.
| No SQL | No nosso Mini DB |
|---|---|
CREATE TABLE | define formato, campos e regras dos registros |
INSERT | serializa, escolhe slot, grava página e atualiza índice |
SELECT | escolhe busca, encontra página e desserializa |
UPDATE | localiza e substitui o registro na página |
DELETE | marca tombstone e remove a chave do índice |
COMMIT | exigiria transação, WAL e publicação atômica |
Portanto, SQL não concorre com páginas, B-Trees ou cache. Ele se apoia neles. Tudo o que construímos continua existindo abaixo da linguagem.
18 / UM BANCO QUE VIAJA COM O PROGRAMA
Como nasceu o SQLite — e por que “sem servidor” mudou tanta coisa?
No começo do artigo criamos usuarios.db. A aplicação abre esse arquivo usando uma biblioteca e trabalha diretamente com suas páginas. Não iniciamos um serviço, não configuramos uma porta e não administramos outro processo. Essa arquitetura aproxima nosso projeto do problema que o SQLite resolveu.
O banco tradicional vive em outro processo
Em um modelo cliente-servidor, a aplicação envia comandos por uma interface de comunicação. O servidor autentica, coordena conexões, executa consultas e administra os arquivos. Essa separação é poderosa para muitos usuários, controle central e grandes cargas.
Mas nem todo problema precisa de um servidor. Um aplicativo de celular, um navegador, um equipamento isolado ou uma ferramenta de desktop pode precisar de consultas e transações locais sem depender de instalação e administração separadas.
Richard Hipp encontrou esse problema em 2000
D. Richard Hipp criou o SQLite em 2000 enquanto trabalhava em um projeto contratado pela General Dynamics para a Marinha dos Estados Unidos. O contexto envolvia software embarcado em navios e a frustração com a dependência de um banco cliente-servidor tradicional. O objetivo era ter armazenamento confiável que funcionasse localmente, sem exigir um processo de servidor separado.
Os primeiros registros do código datam de 29 de maio de 2000. O que começou como uma solução específica evoluiu para uma biblioteca em C, autocontida, multiplataforma e de domínio público.
SQLite é leve para instalar, mas continua sendo um banco de dados completo
A palavra lite, que significa “leve”, descreve principalmente a forma simples de usar e distribuir o SQLite. A aplicação inclui a biblioteca e abre o arquivo do banco diretamente: não precisamos instalar e manter um programa servidor separado, configurar uma porta ou criar uma conta administrativa. Essa simplicidade de implantação não remove os recursos importantes. O SQLite entende SQL, executa transações, mantém índices, relaciona tabelas e possui mecanismos para recuperar o banco após falhas.
SQLite não elimina o mecanismo de banco. Ele coloca o mecanismo dentro da aplicação.
Por que um único arquivo é tão útil?
Copiar o banco pode ser tão simples quanto copiar um arquivo quando nenhuma transação está em andamento e o modo utilizado é considerado. Backup, transporte e inspeção ficam mais acessíveis. O formato reúne schema, tabelas, índices e metadados em páginas coordenadas.
Nosso usuarios.db faz a mesma escolha fundamental. O header identifica o formato; páginas armazenam registros; o índice conduz consultas; o cache evita leituras repetidas. A diferença é a maturidade: SQLite acrescenta B-Trees persistentes, planejador SQL, transações, journaling ou WAL, controle de concorrência e uma enorme quantidade de validações.
| Mini DB | SQLite | Ideia compartilhada |
|---|---|---|
MDB1 | header do formato SQLite | o arquivo declara sua identidade |
| páginas de 4 KiB | arquivo organizado em páginas | I/O e cache trabalham por unidades |
| slots fixos | células e registros variáveis | página localiza conteúdo interno |
BTreeMap reconstruída | B-Trees persistentes | chaves reduzem o caminho até dados |
| cache de oito páginas | page cache configurável | memória evita I/O repetido |
| tombstone simples | freelists e reorganização | espaço removido precisa ser administrado |
| gravação imediata | journal ou WAL e transações | quedas não podem deixar metade da verdade |
SQLite e PostgreSQL não disputam exatamente o mesmo trabalho
PostgreSQL centraliza dados, atende muitos clientes, oferece controle de acesso, extensões e alta concorrência. SQLite prioriza armazenamento local para uma aplicação ou dispositivo, com simplicidade e independência. A própria documentação do SQLite ressalta que ele resolve um problema diferente dos bancos cliente-servidor.
A escolha não é “qual banco é melhor?”. É “onde vivem os dados, quantos escritores existem, quem administra o sistema e que falhas precisamos tolerar?”. Engenharia começa pela pergunta.
Juntando toda a série em uma única abertura
Quando uma aplicação abre um banco SQLite, quase tudo o que estudamos participa: o sistema identifica o arquivo; o sistema de arquivos encontra seus blocos; o loader colocou a biblioteca e a aplicação na memória; o mecanismo valida o header; páginas são lidas e armazenadas em cache; B-Trees encontram registros; SQL é analisado e transformado em um plano.
Essa é a conclusão mais importante desses capítulos: SQL e SQLite deixam de parecer magia quando reconhecemos as camadas que construímos uma a uma.
Fontes históricas: IBM — The relational database, SQLite turns 20 e Appropriate Uses For SQLite.
19 / PERGUNTAS FREQUENTES
Dúvidas que aparecem quando um arquivo começa a responder consultas.
Um banco de dados é apenas um arquivo?
O arquivo guarda a representação persistente, mas o banco também é o programa que interpreta páginas, mantém índices e cache, valida operações e coordena alterações.
O mini banco usa SQL?
Não. Ele expõe create, insert, find, update, delete e list diretamente em Rust. SQL seria uma camada que transforma texto em operações sobre essas estruturas.
Por que dividir o arquivo em páginas?
Páginas criam unidades fixas de leitura, escrita, cache e alocação. Alterar um registro deixa de exigir regravar o arquivo inteiro.
Qual é a diferença entre índice e registro?
O registro contém os dados. O índice contém uma chave e uma localização, permitindo chegar à página e ao slot sem examinar todos os registros.
O que é um cache hit?
É quando a página solicitada já está na memória e não precisa ser lida novamente do armazenamento. Cache miss é o caso contrário.
Por que a remoção usa tombstone?
Marcar um slot como removido é uma alteração pequena e previsível. O espaço pode ser reutilizado depois, sem mover imediatamente todos os registros seguintes.
B-Tree, B+Tree e Hash Index são iguais?
Não. Árvores mantêm ordem e favorecem intervalos; hash favorece igualdade; LSM Trees favorecem alto volume de escrita. Bancos escolhem estruturas conforme a carga.
O que são árvore, nó, raiz e folha?
Uma árvore organiza informações em caixas conectadas. Cada caixa é um nó; a primeira é a raiz; um nó apontado por outro é seu filho; e um nó sem filhos é uma folha. O caminho entre esses nós reduz a região em que o banco precisa procurar.
O que significa atomicidade em um banco de dados?
Atomicidade garante que um conjunto de alterações seja observado por inteiro ou não seja observado. Se uma operação falhar no meio, o banco recupera o estado anterior ou conclui o conjunto, evitando estados parciais.
O que falta para este banco ser usado em produção?
Transações, WAL, recuperação após falhas, concorrência, checksums, índices persistentes, consultas, tipos mais ricos, compactação e muitas validações.
Por que o SQL foi criado?
O SQL nasceu para permitir que pessoas descrevessem quais dados desejavam sem programar manualmente o caminho físico até cada registro. O banco passou a escolher índices, joins e estratégias de acesso.
Qual é a diferença entre SQL e SQLite?
SQL é uma linguagem declarativa. SQLite é um mecanismo de banco de dados que entende grande parte dessa linguagem e funciona como uma biblioteca embarcada, sem exigir um servidor separado.
Por que o SQLite usa um único arquivo?
A proposta é oferecer armazenamento local, simples de instalar, transportar e incorporar a aplicações. O arquivo único contém páginas, schema, tabelas, índices e metadados interpretados pela biblioteca SQLite.
O que é Vec em Rust?
Vec é uma coleção dinâmica e ordenada na memória. Vec<User> significa um vetor que aceita valores do tipo User e pode crescer conforme novos usuários são inseridos.
O que são little-endian e UTF-8?
Little-endian define a ordem dos bytes de números com vários bytes. UTF-8 define como caracteres de texto são representados por bytes. São dois contratos diferentes usados na serialização.
O que significa uma página dirty?
É uma página modificada no cache cuja nova versão ainda não foi persistida no arquivo principal. Ela precisa de flush antes de ser descartada.
Como WAL e flush trabalham juntos?
O WAL registra a alteração de forma recuperável antes da página principal depender dela. Depois que o log está seguro, páginas dirty podem passar por flush para o arquivo principal.
A CONVERSA CONTINUA
O que este artigo fez você pensar?
Dúvidas, experiências e contrapontos ajudam a próxima pessoa a enxergar o assunto por outro ângulo.