Banco de Dados para Entrevistas
SQL, índices, transações, ACID, isolation levels e otimização de consultas para desenvolvedores Java seniores.
A Biblioteca e o Almoxarifado
Modelar e indexar um banco de dados é exatamente como organizar uma grande biblioteca física. A normalização garante que cada livro esteja na categoria certa e que o nome do autor não seja duplicado em mil fichas diferentes (o que exigiria retrabalho se ele mudasse de pseudônimo). Já indexar é criar um catálogo por ordem alfabética de autor e data de publicação. Se você quiser encontrar os livros de um autor específico, vai direto ao catálogo (Index Scan) em vez de caminhar por todos os corredores da biblioteca olhando livro por livro (Seq Scan).
Normalização e Chaves no Mundo Real
A normalização (1NF, 2NF, 3NF, BCNF) visa eliminar anomalias de escrita e redundância de dados. No entanto, em sistemas distribuídos ou de alto volume, a denormalização controlada é uma estratégia essencial para otimizar leituras. Outra decisão de design crítica é a escolha de chaves: chaves naturais (como CPF ou e-mail) acoplam o modelo físico a regras de negócios mutáveis. Por isso, chaves substitutas (surrogate keys, como UUID ou sequenciais internos) são preferidas para relacionamentos estáveis, embora exijam JOINs adicionais nas consultas.
Chaves Naturais vs Surrogate com Hibernate
Em Java, usar chaves naturais mutáveis como @Id no JPA/Hibernate é uma má prática porque a identidade da entidade não deve mudar ao longo do seu ciclo de vida. Veja a abordagem recomendada usando uma surrogate key (UUID) e declarando um índice exclusivo para a chave de negócios (natural).
@Entity
@Table(name = "clientes", indexes = {
@Index(name = "idx_cliente_cpf", columnList = "cpf", unique = true)
})
public class Cliente {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id; // Surrogate key estável para relacionamentos
@Column(nullable = false, length = 11)
private String cpf; // Chave de negócios (natural), validada e indexada
@Column(nullable = false)
private String nome;
// Getters, Setters e equals/hashCode baseados no ID surrogate estável
}B-Tree vs Hash Index
Imagine procurar um contato na agenda do celular. Um índice Hash é como a busca rápida por nome exato: você digita o nome inteiro e ele retorna o número instantaneamente. No entanto, se você quiser encontrar todos os contatos cujos nomes começam com 'An' ou que foram criados em uma faixa de datas (queries de intervalo/range), o índice Hash falha por completo. Para buscas de intervalo, ordenação e prefixos, você precisa de um índice B-Tree, que mantém as páginas ordenadas como em um dicionário físico, permitindo buscas hierárquicas rápidas.
ACID e Isolation Levels no Banco de Dados
Transações garantem a consistência dos dados através do ACID. O 'I' (Isolamento) é o mais complexo devido ao tradeoff direto entre concorrência e consistência. Bancos relacionais oferecem 4 níveis de isolamento padrão: READ UNCOMMITTED (permite leitura suja), READ COMMITTED (evita leitura suja), REPEATABLE READ (evita leituras não-repetíveis, mas pode ter fantasma) e SERIALIZABLE (bloqueio total de concorrência). Sob o capô, bancos de dados modernos usam o MVCC (Multi-Version Concurrency Control) para permitir que transações de leitura não bloqueiem transações de escrita e vice-versa na maioria dos níveis.
Controlando Níveis de Isolamento no Spring Boot
No ecossistema Spring, a anotação @Transactional permite definir o nível de isolamento ideal para cada caso de uso. O padrão do banco de dados (geralmente READ COMMITTED no PostgreSQL ou REPEATABLE READ no MySQL) pode ser sobrescrito para transações financeiras críticas que necessitam de consistência SERIALIZABLE.
@Service
public class TransferenciaService {
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transferirFundos(UUID contaOrigemId, UUID contaDestinoId, BigDecimal valor) {
// O isolamento SERIALIZABLE previne race conditions complexas
// e garante que o saldo lido não seja alterado concorrentemente por outra thread.
Conta origem = contaRepository.findById(contaOrigemId)
.orElseThrow(() -> new IllegalArgumentException("Conta de origem não encontrada"));
Conta destino = contaRepository.findById(contaDestinoId)
.orElseThrow(() -> new IllegalArgumentException("Conta de destino não encontrada"));
origem.debitar(valor);
destino.creditar(valor);
}
}O GPS do Banco: Explain Plan
Consultar um banco de dados sem verificar o Explain Plan é como viajar para uma nova cidade sem GPS. O otimizador do banco de dados cria um plano de execução, que é o mapa do caminho. Ele decide se vai percorrer a tabela inteira (Seq Scan), usar uma via rápida indexada (Index Scan) ou se vai fazer um atalho usando um índice coberto (Index Only Scan). Ao cruzar duas tabelas (JOIN), o otimizador escolhe o melhor algoritmo de junção: Nested Loop (para poucas linhas), Hash Join (para tabelas grandes) ou Merge Join (se os dados já estiverem ordenados nas colunas de junção).
O Fantasma do N+1 e Pool de Conexões
Em aplicações Java que utilizam Hibernate, o problema do N+1 é um dos principais gargalos de performance. Ele ocorre quando carregamos uma entidade principal (1 query) e, para cada um dos N registros retornados, o Hibernate executa uma nova query separada para buscar sua associação Lazy. Outro ponto crítico de otimização é o Pool de Conexões (como o HikariCP): criar novas conexões físicas com o banco é extremamente lento. O pool mantém conexões pré-estabelecidas e prontas para uso, limitando também o número máximo de acessos para proteger o servidor de banco contra sobrecarga de CPU.
Resolvendo o N+1 com JOIN FETCH
A forma mais eficiente de resolver o problema N+1 no Spring Data JPA é instruir o Hibernate a carregar a associação em uma única query SQL unificada usando a instrução JPQL 'JOIN FETCH'.
public interface PedidoRepository extends JpaRepository<Pedido, UUID> {
// Resolvendo N+1: Carrega os pedidos e seus respectivos clientes associados
// em uma única query SELECT com JOIN no banco de dados.
@Query("SELECT p FROM Pedido p JOIN FETCH p.cliente WHERE p.status = :status")
List<Pedido> findPedidosComClientePorStatus(@Param("status") String status);
}