ADRIANOLAUREANO← Artigos

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.

APLICAÇÃOAnaBrunoCarla
MINI DBserializar · paginar · indexar · armazenar
DISCOusuarios.dbheader + páginas
Nosso banco será o programa que transforma structs em páginas persistentes e as encontra novamente.
4 KiBpor página
128 Bpor registro
31slots por página
6operações na API

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.

ETAPA 110usuários
ETAPA 2100usuários
ETAPA 310.000usuários
ETAPA 41.000.000usuários

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?

ARQUIVO JSONalterar 1 usuárioler tudo → mudar um item → gravar tudo novamente
BANCO DE DADOSalterar 1 usuáriolocalizar a página → mudar um slot → gravar uma pequena região
A RESPOSTA CURTA

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.

01Identificarmagic bytes e formatos
02RepresentarPNG, chunks e compressão
03Agruparíndices e offsets
04Armazenarblocos e sistema de arquivos
05Executarloader, memória e CPU
06Consultarpáginas, buscas, índice e cache

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.

PROBLEMA 1a memória desaparece
SOLUÇÃOarquivo persistente
NOVO PROBLEMAo arquivo cresce
PRÓXIMAS PEÇASpáginas · índice · cache

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.

ANAusuário 1primeiro registro do exemplo
BRUNOusuário 2segundo registro do exemplo
CARLAusuário 3terceiro registro do exemplo

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.

MOLDE USERid: ___name: ___email: ___
preencher →
VALOR ANAid: 1name: Anaemail: ana@exemplo.com

Os tipos dizem que espécie de valor cabe em cada campo

Tipo RustLeitura simplesUso no projeto
u8inteiro sem sinal de 8 bits, de 0 a 255bytes, status e comprimentos pequenos
u32inteiro sem sinal de 32 bitsnúmero da página
u64inteiro sem sinal de 64 bitsID do usuário
usizeinteiro apropriado para índices na máquina atualposição do slot em um vetor
Stringtexto UTF-8 que possui seus caracteresnome e e-mail
boolverdadeiro ou falsodecisõ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.

OPTIONSome(User) ou Nonehá valor ou não há
RESULTOk(User) ou Err(DbError)funcionou ou sabemos por que falhou

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.

VEC<USER> NA MEMÓRIAposição 0
Ana
posição 1
Bruno
posição 2
Carla
espaç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:

01ler arquivo inteiro
02alterar Vec
03serializar tudo
04reescrever arquivo

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á.

NA MEMÓRIAUser { id, nome, e-mail }
SERIALIZARseguir a receita do formato
NO ARQUIVO128 bytes organizados

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.

NO ARQUIVO128 bytes organizados
DESSERIALIZARinterpretar a mesma receita
NA MEMÓRIAUser { id, nome, e-mail }

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 bytes
1 Bstatus
8 Bid
2 Btamanhos
48 Bnome
64 Be-mail
5 Breserva
Campos fixos tornam possível calcular onde começa qualquer slot sem ler os anteriores.

Um 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.

NÚMERO INTEIRO0x1234o valor que queremos guardar
BYTE DE MAIOR PESO12a parte que mais influencia o valor
BYTE DE MENOR PESO34a parte que completa o valor

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.

MESMO NÚMERO0x1234o valor não muda
BIG-ENDIAN12 34o byte de maior peso vem primeiro
LITTLE-ENDIAN34 12o byte de menor peso vem primeiro

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.

TEXTOAna3 caracteres
UTF-841 64 613 bytes
TEXTOJoão4 caracteres
UTF-84A 6F C3 A3 6F5 bytes: ã ocupa dois

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)
}
ID12ID27ID41ID58ID73ID91

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])
}
32 itensescolha o meio
16 itensdescarte metade
8 · 4 · 2repita
1 itemencontrado

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.

0…4095HEADERMDB1 · versão · quantidade
4096…8191PÁGINA 031 registros
8192…12287PÁGINA 131 registros
12288…PÁGINA Ncrescimento
O primeiro bloco descreve o arquivo; cada página seguinte pode ser lida e regravada sozinha.

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.

16 BPAGE · id · slots
128 Bregistro 0
128 Bregistro 1
128 Bregistro 2
128 Bregistro 3
128 Bregistro 4
128 Bregistro 5
restanteslots e espaço reservado
ARQUIVOusuarios.db
ampliar →
PÁGINA 24.096 bytes
ampliar →
SLOT 9128 bytes
ampliar →
CAMPOSstatus · ID · nome · e-mail

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 = 1168

Essa 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.

MMiniDBDatabase1versão do formatoMDB1

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.

CHAVEID 73
ÍNDICEpágina 2 · slot 9
ARQUIVOoffset calculado
REGISTROAna · ana@...
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.

CHAVES ORDENADAS12417391
cada chave aponta ↓
LOCALIZAÇÕESp0:s0p0:s1p2:s9p3:s4

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 = 13456

O í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.

REQUISIÇÃOpágina 2
CACHEjá está aqui?hit: devolver · miss: buscar
ARMAZENAMENTOusuarios.db

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.

DISCOpágina 2 · versão Ae-mail antigo
update →
CACHE · DIRTYpágina 2 · versão Be-mail novo ainda só na memória
flush →
DISCOpágina 2 · versão Balteração persistida

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

Aplicaçãovalidar IDslot livreserializargravar páginaatualizar índice

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

IDíndicecachepáginaslotUser

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.

ANTES · PÁGINA 2, SLOT 9Ana · ana@exemplo.como endereço do registro
DEPOIS · MESMO ENDEREÇOAna Souza · ana@exemplo.com.br128 bytes substituídos; índice continua apontando para o mesmo lugar

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.

DECLARAR EM VEZ DE ORDENAR

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.

ANTES1 · Ana1 · Bruno1 · Carla
delete(Bruno) →
DEPOIS1 · Ana2 · Bruno1 · Carla

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.

A MISSÃO

Você recebe o ID 73. Seu custo será medido por quantas fichas, gavetas ou páginas precisa abrir até encontrar o registro certo.

PERGUNTA DE IGUALDADEonde está exatamente o ID 73?hash pode ser excelente
PERGUNTA DE INTERVALOquais IDs existem entre 70 e 90?ordem e folhas ligadas passam a importar

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?”

UMA CAIXA · 10 FICHASO(n) até 10 fichas abertasO(log n) cerca de 4 decisõesO(1) médio vá ao armário calculado
UM GALPÃO · 1.000.000 DE FICHASO(n) até 1.000.000 abertasO(log n) cerca de 20 decisõesO(1) médio o caminho esperado continua curto

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.

01
BUSCA LINEAR · O(n)

Uma pilha de fichas caiu no chão e perdeu toda a ordem.

passo 112passo 227passo 341passo 458passo 573passo 691

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.

02
BUSCA BINÁRIA · O(log n)

Agora as fichas estão ordenadas como as páginas de uma lista telefônica.

5873 é maior → descarte a esquerda
8473 é menor → descarte a direita
73encontrado em três comparações

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.

03
HASH · O(1) MÉDIO

Como um guarda-volumes: o número do ticket indica em qual armário procurar.

CHAVEID 73
hash(73) % 4 →
012173 · 41258327 · 91

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 . 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.

RAIZ · TAMBÉM É UM NÓ50primeiro ponto da busca
escolher um caminho ↓
NÓ FILHO20valores menores que 50
NÓ FILHO80valores maiores que 50
continuar descendo ↓
FOLHA12 · 18 · 20não aponta para nenhum filho
FOLHA73 · 80 · 91fim deste caminho
PalavraSignificado simplesNo desenho
Árvoreo conjunto inteiro de caixas e ligaçõestoda a estrutura
uma caixa que guarda valores e pode apontar para outras50, 20 e 80 são nós
Raizo primeiro nó, por onde a busca começao nó 50
Filhoum nó alcançado diretamente a partir de outro20 e 80 são filhos de 50
Folhaum nó que não possui filhosas caixas da última fileira
Níveluma altura da árvoreraiz, filhos e folhas ocupam três níveis
Caminhoa sequência de nós visitados50 → 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.

04
B-TREE · O(log n)

Uma biblioteca enorme usa placas que apontam para muitas alas de uma vez.

20406080
≤2021…4041…6061…80>80
61687379

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.

05
B+TREE · O(log n)

O mapa aponta para a primeira estante; depois percorremos estantes vizinhas.

GUIA306090
01 · 12 · 2731 · 41 · 5861 · 73 · 8491 · 95

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.

06
LSM TREE · ESCRITA EM NÍVEIS

A biblioteca recebe devoluções numa mesa rápida e organiza as estantes depois.

MEMÓRIAnovas escritas ordenadas73 · 91 · tombstone 41
flush ↓
NÍVEL 0arquivos recentespodem sobrepor chaves
compactação ↓
NÍVEL 1arquivos maiores e ordenadosversões mescladas
NÍVEIS SEGUINTESdados antigoscada vez maiores

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égiaImagem mentalBrilha quando...Preço pago
Linear · O(n)virar fichas uma a umahá poucos itens ou nenhuma preparaçãoo trabalho cresce com todos os registros
Binária · O(log n)abrir a lista no meioa coleção já está ordenadainserir pode exigir reorganização
Hash · O(1) médioticket de guarda-volumesqueremos igualdade por uma chavenão preserva ordem nem intervalos
B-Tree · O(log n)placas para muitas alasqueremos ordem com poucas páginas lidasnós precisam dividir e se reorganizar
B+Tree · O(log n)mapa + estantes conectadasqueremos intervalos e varreduras ordenadasa estrutura possui mais regras de manutenção
LSM Treemesa de devolução + organização em loteshá um grande volume de escritaleituras 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.

PÁGINA ATUALID 73 · e-mail antigo
anotar antes →
WALtransação 8: página 2 receberá e-mail novo
depois →
PÁGINA NOVAID 73 · e-mail novo

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.

01registrar intenção no WAL
02confirmar log
03alterar páginas
04marcar conclusão

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.

ORDEM PERIGOSA1. flush da página2. queda de energia3. WAL ainda ausentenão sabemos recuperar
ORDEM SEGURA1. gravar e sincronizar WAL2. flush da página3. registrar conclusãopodemos refazer

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.

ESTADO ANTIGO · VÁLIDOsem o usuário 73página e índice concordam
transação
ESTADO PARCIAL · PROIBIDOpágina mudou, índice nãoo registro existe, mas não pode ser encontrado
commit
ESTADO NOVO · VÁLIDOusuário 73 completopágina e índice concordam novamente

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.

FALHOU ANTES DO COMMITvoltar ao estado antigopara o leitor, o insert não aconteceu
COMMIT CONCLUÍDOpublicar o estado novopara o leitor, todas as partes aconteceram

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.

SistemaIdeia centralQuando faz sentido
SQLitebanco embarcado em um arquivoaplicações locais, simplicidade e distribuição fácil
PostgreSQLservidor relacional completoconcorrência, consultas ricas, integridade e extensões
Redisestruturas principalmente em memóriabaixa latência, cache, filas e estado efêmero ou persistido
RocksDBchave-valor baseado em LSMmuitas 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.

SQLITEperto do aplicativobiblioteca embarcada + arquivo local
POSTGRESQLservidor compartilhadoconcorrência + consultas relacionais ricas
REDISperto da CPUdados ativos prioritariamente na memória
ROCKSDBperto da escritachave-valor organizado em níveis
A LIÇÃO

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.rs
Baixar 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.

CAMADA 1 · APIdatabase.rscoordena create, insert, find, update, delete e list
CAMADA 2 · LOCALIZAÇÃOindex.rs · search.rstransforma um ID em página e slot
CAMADA 3 · REPRESENTAÇÃOrecord.rs · serializer.rs · page.rstransforma User em bytes organizados
CAMADA 4 · I/Ocache.rs · storage.rsmantém páginas quentes e conversa com usuarios.db
MóduloPergunta que ele respondeO que ele não deve decidir
database.rsem que ordem as peças participam de uma operação?como cada campo vira byte
record.rso que é um usuário válido?em qual página ele será gravado
serializer.rscomo User e os 128 bytes se transformam um no outro?quando fazer flush
page.rscomo slots são lidos, substituídos e reutilizados?qual ID o cliente pesquisou
storage.rsqual offset deve ser lido ou gravado no arquivo?o significado de nome e e-mail
cache.rsjá temos esta página na memória?se um registro satisfaz a consulta
index.rsonde está o registro de determinado ID?como os bytes do registro são codificados
errors.rscomo 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.

DATABASEvalidar e coordenar
PAGEescolher slot
SERIALIZERUser → 128 bytes
STORAGEpágina → arquivo

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.

INDEXID → página:slot
CACHE / STORAGEobter 4 KiB
PAGErecortar 128 bytes
SERIALIZERbytes → User

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.

01
Cargo.toml

Define 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"

02
src/record.rs

Representa 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() }
    }
}

03
src/serializer.rs

Transforma 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)))
}

04
src/page.rs

Organiza 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 })
    }
}

05
src/storage.rs

Lê 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(())
    }
}

06
src/search.rs

Compara 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])
}

07
src/index.rs

Liga 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);
    }
}

08
src/cache.rs

Manté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);
    }
}

09
src/database.rs

Expõ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(())
    }
}
10
src/errors.rs

Nomeia 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)
    }
}

11
src/main.rs

Demonstra 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(())
}
12
tests/database.rs

Prova 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 test

Abra 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.

OFFSET 0MDB1“este é um Mini DB”
OFFSET 4096PAGE“a primeira página começa aqui”
OFFSET 4112primeiro slotstatus + ID + textos

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.

O PROJETO FUNCIONA. A HISTÓRIA CONTINUA.

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.

ANTESComo chegar?abra clientes → siga pedidos → percorra itens → compare produtos
MODELO RELACIONALQual resultado?relacione valores de clientes, pedidos, itens e produtos

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.

INDEPENDÊNCIA DOS DADOS

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.

ANTES DE 1970navegar por caminhoso programa conhece a organização física
1970 · CODDmodelo relacionaldescrever dados como relações
1973 · SYSTEM Rprovar a ideialinguagem + armazenamento + otimizador
SQLdeclarar o resultadoo banco escolhe o caminho

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.

SQLo que queremos
PARSERestrutura da pergunta
OTIMIZADORqual caminho custa menos?
EXECUTORíndice → página → registro

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 SQLNo nosso Mini DB
CREATE TABLEdefine formato, campos e regras dos registros
INSERTserializa, escolhe slot, grava página e atualiza índice
SELECTescolhe busca, encontra página e desserializa
UPDATElocaliza e substitui o registro na página
DELETEmarca tombstone e remove a chave do índice
COMMITexigiria 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.

CLIENTE-SERVIDORaplicaçãorede ou socketservidor do bancoarquivos
EMBARCADOaplicação + biblioteca SQLitebanco.db

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.

A DIFERENÇA ESSENCIAL

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 DBSQLiteIdeia compartilhada
MDB1header do formato SQLiteo arquivo declara sua identidade
páginas de 4 KiBarquivo organizado em páginasI/O e cache trabalham por unidades
slots fixoscélulas e registros variáveispágina localiza conteúdo interno
BTreeMap reconstruídaB-Trees persistenteschaves reduzem o caminho até dados
cache de oito páginaspage cache configurávelmemória evita I/O repetido
tombstone simplesfreelists e reorganizaçãoespaço removido precisa ser administrado
gravação imediatajournal ou WAL e transaçõesquedas 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.

ARQUIVOassinatura e formatoo que estes bytes significam?
SISTEMA DE ARQUIVOSblocos e caminhosonde o banco está?
PROCESSOloader e memóriacomo o mecanismo executa?
BANCOpáginas, cache e índicesonde está o registro?
SQLconsulta declarativaqual resultado queremos?

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.

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.

Todos passam por moderação. Ao enviar, você concorda com a política de privacidade.

Receba os próximos artigos.

Uma mensagem quando uma nova investigação estiver pronta. Só isso.