Índices e transações
Escolha de índices, planos de consulta, propriedades ACID, commit, rollback e savepoints.
A loja procura as encomendas de Ana num intervalo de datas. Um índice pode localizar essas linhas sem percorrer toda a tabela.
Ao transferir dinheiro entre contas, a preocupação é outra: debitar e creditar em conjunto. Uma transação define essa unidade de alteração, para a confirmar ou desfazer.
O que um índice guarda
Um índice é uma estrutura persistente que relaciona valores de uma chave de pesquisa com a localização das linhas. Não muda a resposta lógica de uma consulta.
O motor pode usá-lo para encontrar correspondências, percorrer intervalos ou obter uma ordem útil sem ordenar tudo.
Numa árvore equilibrada, a procura da primeira correspondência costuma crescer com o logaritmo do número de entradas; depois há o custo de percorrer e recuperar as correspondências. Uma tabela hash é adequada a igualdade, mas não fornece diretamente a mesma ordenação para intervalos. SQLite usa índices baseados em árvores, não uma opção USING HASH no comando CREATE INDEX.
Não confundas chave de pesquisa do índice com chave candidata. Podemos indexar stock apesar de vários produtos terem o mesmo stock.
CREATE UNIQUE INDEX acrescenta também uma regra de unicidade. Um índice comum só acrescenta um método de acesso.
Índices compostos
Para consultar encomendas de um cliente num intervalo de datas, um índice composto pode ser útil:
CREATE INDEX idx_encomenda_cliente_data
ON Encomenda(idCliente, data);
As entradas ficam ordenadas primeiro por cliente e, dentro dele, por data. A igualdade no primeiro atributo permite procurar o intervalo relevante no segundo. O índice também pode ajudar consultas apenas por cliente. Uma procura apenas por data não beneficia da mesma forma desse prefixo, embora certos planos e distribuições permitam outras estratégias do otimizador.
Com os dados da loja, as entradas de (idCliente, data) ficam assim ordenadas:
| idCliente | data | encomenda |
|---|---|---|
| 1 | 2026-01-05 | 100 |
| 1 | 2026-01-06 | 101 |
| 2 | 2026-01-06 | 102 |
Fixar o cliente 1 isola as duas primeiras entradas; o intervalo de datas percorre só esse bloco.
A ordem (data, idCliente) tem comportamento diferente. Se procurarmos um intervalo amplo de datas, a igualdade do cliente já não restringe da mesma maneira um único intervalo ordenado. Escolhe a ordem pelo conjunto de consultas mais frequentes e pelas distribuições reais.
Índices também ajudam junções, por exemplo encontrar as encomendas que referenciam um cliente. SQLite não cria automaticamente um índice para cada estrangeira. As primárias e unicidades já têm mecanismos de acesso próprios, com a particularidade de INTEGER PRIMARY KEY corresponder ao rowid.
Ler um plano
EXPLAIN QUERY PLAN
SELECT id, data FROM Encomenda
WHERE idCliente = 1 AND data >= '2026-01-01';
CREATE INDEX idx_encomenda_cliente_data ON Encomenda(idCliente, data);
EXPLAIN QUERY PLAN
SELECT id, data FROM Encomenda
WHERE idCliente = 1 AND data >= '2026-01-01';PRAGMA foreign_keys = ON;
CREATE TABLE Cliente (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
email TEXT UNIQUE
);
CREATE TABLE Produto (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
precoCentimos INTEGER NOT NULL CHECK (precoCentimos >= 0),
stock INTEGER NOT NULL CHECK (stock >= 0)
);
CREATE TABLE Encomenda (
id INTEGER PRIMARY KEY,
data TEXT NOT NULL,
idCliente INTEGER NOT NULL REFERENCES Cliente(id)
);
CREATE TABLE Item (
idEncomenda INTEGER NOT NULL REFERENCES Encomenda(id) ON DELETE CASCADE,
idProduto INTEGER NOT NULL REFERENCES Produto(id),
qtd INTEGER NOT NULL CHECK (qtd > 0),
precoUnitario INTEGER NOT NULL CHECK (precoUnitario >= 0),
PRIMARY KEY (idEncomenda, idProduto)
);
INSERT INTO Cliente VALUES
(1, 'Ana', 'ana@example.test'),
(2, 'Rui', NULL),
(3, 'Mia', NULL);
INSERT INTO Produto VALUES
(10, 'Teclado', 4500, 20),
(11, 'Rato', 2500, 50),
(12, 'Monitor', 18000, 5);
INSERT INTO Encomenda VALUES
(100, '2026-01-05', 1),
(101, '2026-01-06', 1),
(102, '2026-01-06', 2);
INSERT INTO Item VALUES
(100, 10, 2, 4500),
(100, 11, 1, 2500),
(101, 12, 1, 18000),
(102, 11, 3, 2500);Executa a consulta para ver as tabelas.
O primeiro plano percorre Encomenda e o segundo pode procurar usando o índice, por isso confere sobretudo SCAN, SEARCH e o nome do índice.
Um índice cobridor contém os valores de que a consulta precisa, permitindo evitar acessos adicionais à tabela em certos planos. Nos índices comuns de uma tabela SQLite com rowid, esse identificador também está nas entradas; isso ajuda a devolver o id de uma INTEGER PRIMARY KEY.
O custo dos índices
Cada inserção, eliminação e alteração dos atributos indexados precisa de manter os índices. Eles ocupam espaço e têm custo de criação. Se uma condição devolve quase toda a tabela, um percurso completo pode ser mais barato que muitas visitas por índice.
A seletividade indica quanto a condição restringe os dados. Uma coluna com poucos valores pode ser útil ou inútil conforme a frequência dos valores procurados e o plano. “Há dois valores distintos” não chega para decidir: um deles pode corresponder a uma fração muito pequena das linhas.
O otimizador usa estatísticas para estimar custos. ANALYZE recolhe estatísticas em SQLite. A escolha final depende do tamanho, da distribuição, dos índices e da carga de leitura e escrita. Criar um índice em todas as colunas não é uma estratégia de desenho.
Uma transação e as propriedades ACID
Uma transação agrupa operações que têm de formar uma unidade. Transferir 25 euros exige debitar uma conta e creditar outra. Uma falha entre as duas operações não pode deixar uma transferência a meio.
- Atomicidade: as alterações da transação são confirmadas em conjunto ou desfeitas.
- Consistência: a transação leva um estado válido a outro válido segundo as regras do domínio.
- Isolamento: define o que transações concorrentes podem observar e como interferem.
- Durabilidade: as alterações confirmadas sobrevivem às falhas abrangidas pelas garantias e configuração do sistema.
O SGBD verifica as restrições declaradas, mas não adivinha todas as regras. Uma transação que debita duas contas sem creditar nenhuma pode ser atómica e ainda estar errada. Consistência exige código correto e restrições adequadas.
BEGIN, COMMIT e ROLLBACK
Em SQLite, fora de uma transação explícita, as instruções decorrem em transações implícitas. BEGIN inicia uma transação explícita, COMMIT confirma e ROLLBACK desfaz as alterações dessa transação.
CREATE TABLE Conta(id INTEGER PRIMARY KEY,
saldo INTEGER NOT NULL CHECK (saldo >= 0));
INSERT INTO Conta VALUES (1, 10000), (2, 5000);
BEGIN;
UPDATE Conta SET saldo = saldo - 2500 WHERE id = 1;
UPDATE Conta SET saldo = saldo + 2500 WHERE id = 2;
ROLLBACK;
SELECT id, saldo FROM Conta ORDER BY id;
BEGIN;
UPDATE Conta SET saldo = saldo - 2500 WHERE id = 1;
UPDATE Conta SET saldo = saldo + 2500 WHERE id = 2;
COMMIT;
SELECT id, saldo FROM Conta ORDER BY id;Executa a consulta para ver as tabelas.
Os estados das contas na transferência de 2500 cêntimos:
| Momento | Conta 1 | Conta 2 | Confirmado |
|---|---|---|---|
| início | 10000 | 5000 | nenhuma |
| dentro da transação | 7500 | 7500 | ainda não |
após ROLLBACK | 10000 | 5000 | não |
após COMMIT (nova tentativa) | 7500 | 7500 | sim |
Depois do rollback, os saldos voltam ao início e depois do commit refletem a transferência, porque a primeira transferência não deixou efeitos guardados.
O código pressupõe que as duas contas existem e que há saldo suficiente. Um UPDATE de uma conta inexistente altera zero linhas e não dá automaticamente erro. Uma aplicação deve conferir o número de linhas afetadas e só confirmar quando todas as condições da transferência estiverem cumpridas.
Um erro não faz sempre rollback de tudo
Em SQLite, uma violação de CHECK com a política por omissão costuma abortar a instrução, mantendo anteriores instruções da transação. Se o primeiro débito ocorreu e a segunda operação falhou, não assumes que o débito já foi desfeito. Trata o erro e decide fazer rollback da transação inteira.
PostgreSQL tem outro comportamento: um erro deixa normalmente a transação em estado abortado até rollback, ou recuperação para um savepoint. A distinção importa ao ler código de aplicação e ao prever resultados.
Savepoints
Um savepoint marca um ponto de recuperação dentro de uma transação:
CREATE TABLE Valor(id INTEGER PRIMARY KEY, n INTEGER NOT NULL);
INSERT INTO Valor VALUES (1, 10);
BEGIN;
UPDATE Valor SET n = n + 5 WHERE id = 1;
SAVEPOINT tentativa;
UPDATE Valor SET n = n * 2 WHERE id = 1;
ROLLBACK TO tentativa;
RELEASE tentativa;
COMMIT;
SELECT n FROM Valor;Executa a consulta para ver as tabelas.
A primeira atualização dá 15. A segunda dá 30, mas ROLLBACK TO regressa a 15. RELEASE retira o marcador; o COMMIT exterior confirma 15. Libertar um savepoint interior não equivale a tornar as suas alterações definitivamente duráveis antes do commit exterior.
Falhas e duração
Um SGBD pode usar registos de escrita, journaling ou WAL para recuperar estados após falhas. Atomicidade desfaz trabalho não confirmado; durabilidade conserva trabalho confirmado dentro das condições assumidas. A recuperação não substitui cópias de segurança contra eliminação deliberada, corrupção fora dessas garantias ou perda de todos os suportes.
Mantém transações curtas. Pedir confirmação humana enquanto a transação está aberta pode conservar bloqueios por muito tempo. A página de concorrência explica por que atomicidade, por si só, não impede leituras inconsistentes nem decisões simultâneas erradas.
Exercícios
Escolher a ordem de um índice
A consulta frequente é WHERE idCliente=7 AND data BETWEEN
duas datas. Compara os índices (idCliente,data) e (data,idCliente), sem
assumir distribuições adicionais. Justifica o efeito da ordem das colunas.
Pista
Um índice composto ordena primeiro pela primeira coluna.
Solução
(idCliente,data) é a escolha natural para este padrão: a igualdade fixa o prefixo e o intervalo atua nas datas desse cliente. (data,idCliente) organiza primeiro todas as encomendas por data, podendo exigir percorrer clientes diferentes no intervalo. Não há uma regra universal de que o primeiro será sempre mais rápido: tamanho, distribuição, cobertura e outras consultas influenciam o plano. Confere EXPLAIN QUERY PLAN e a carga real.
Confere a tua resposta:
- Justifiquei igualdade no prefixo e intervalo seguinte.
- Indiquei as condições que ainda influenciam a escolha.
Erros frequentes
Não escolhas por ordem das condições no WHERE; o otimizador
não depende dessa ordem textual. Um índice também tem custo de manutenção.
Rollback parcial
Um valor começa em 10. Dentro de BEGIN, somamos 5, criamos
SAVEPOINT s, multiplicamos por 2, fazemos ROLLBACK TO s,
RELEASE s e COMMIT. Qual o valor final?
Pista
Solução
O valor final é 15. A soma alterou 10 para 15 antes do marcador. A
multiplicação deu 30, mas foi desfeita ao regressar ao savepoint. RELEASE
retirou o marcador e o COMMIT exterior confirmou 15. Um
ROLLBACK da transação inteira teria reposto 10.
Erros frequentes
Não confundas RELEASE de um savepoint interior com confirmação durável da transação exterior.
Um erro desfez quanto trabalho?
Em SQLite, Conta(id,saldo) tem saldo NOT NULL
CHECK(saldo >= 0). A conta 1 começa com 100. Executamos
BEGIN, um UPDATE que põe o saldo a 80 e
outro UPDATE que o põe a -1. A aplicação apanha a violação
de CHECK, usando a política ABORT por
omissão. Qual o saldo dentro da transação? E depois de
ROLLBACK? A transação terminou automaticamente com o erro?
Pista
ABORT desfaz a instrução, não as anteriores da mesma
transação.
Solução
Após o erro, o saldo continua 80 dentro da transação, que permanece aberta.
A escrita de -1 foi desfeita. ROLLBACK repõe 100 e termina
a transação. Se a aplicação fizer COMMIT após apanhar o
erro, pode confirmar 80. Numa operação que exige sucesso de todos os passos,
deve fazer rollback da unidade completa em vez de assumir que o motor já o
fez.
Confere a tua resposta:
- Distingui o estado após a instrução falhada do estado após ROLLBACK.
- Expliquei o tratamento necessário antes de confirmar.
Erros frequentes
Não generalizes a política ABORT a
ROLLBACK, FAIL ou a todos os tipos de erro. PostgreSQL
normalmente deixa a transação em estado abortado após erro.
Interpretar um plano observado
Para SELECT id,data FROM Encomenda WHERE idCliente=1 AND data>='2026-01-01', foram observados estes planos SQLite:
Antes: SCAN Encomenda
Depois: SEARCH Encomenda USING COVERING INDEX idx_encomenda_cliente_data
(idCliente=? AND data>?)O índice foi criado em (idCliente,data); id é INTEGER PRIMARY KEY. Explica a procura e a palavra COVERING. Estes planos provam que a segunda consulta é mais rápida para qualquer volume de dados?
Pista
Mais uma pista
Solução
Antes, o motor percorre Encomenda e testa o filtro. Depois, fixa idCliente no prefixo do índice e percorre o intervalo de data. COVERING indica que os valores pedidos podem ser obtidos no índice: data é parte da chave de pesquisa e id é o rowid desta tabela.
Os planos não medem tempo. A escolha depende de dados, estatísticas, versão e consultas concorrentes. É necessário medir com uma carga representativa para comparar desempenho; um índice também custa espaço e manutenção nas escritas.
Confere a tua resposta:
- Identifiquei o percurso completo e a procura pelo índice.
- Expliquei o prefixo e a cobertura.
- Distingui plano escolhido de medição de tempo.
Erros frequentes