Detectando full table scans do SQLite no Node.js
🇬🇧 Read it in English
Em julho o Aaron Patterson escreveu sobre detectar full table scans com SQLite. O pulo do gato é que você não precisa de EXPLAIN QUERY PLAN pra isso. O SQLite já mantém um contador por statement de quantas linhas ele percorreu durante um scan, e dá pra ler esse número depois que a query roda. Se for maior que zero, aquele statement escaneou.
Um dia depois, o Kevin Gibbons abriu uma issue no nodejs/node pedindo a mesma coisa no node:sqlite, citando o post. Não tinha como chegar no sqlite3_stmt_status() a partir do JavaScript. Eu peguei a issue, e entrou hoje. Dois métodos no StatementSync:
statement.stat(counter) // lê um contador
statement.resetStats() // zera todos
O teste de scan
Mesmo formato do exemplo em Ruby do Aaron. Mil usuários, uma query numa coluna sem índice:
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)');
const insert = db.prepare('INSERT INTO users (name, age) VALUES (?, ?)');
for (let i = 0; i < 1000; i++) {
insert.run(`user-${i}`, i % 80);
}
const stmt = db.prepare('SELECT * FROM users WHERE age = ?');
function query(age) {
const rows = stmt.all(age);
console.log('fullscanStep:', stmt.stat('fullscanStep'));
stmt.resetStats();
return rows;
}
query(30);
db.exec('CREATE INDEX users_age_idx ON users (age)');
query(30);
fullscanStep: 999
fullscanStep: 0
999 antes do índice, 0 depois. O vmStep também cai, de 3059 pra 101, com exatamente o mesmo result set.
Repare na chamada de resetStats(). Os contadores são cumulativos pelo tempo de vida do prepared statement. Ou seja, se você reusa o statement num loop de requisições - que é justamente o motivo de preparar ele - precisa zerar entre as medições, senão está lendo um total acumulado.
Os contadores
O stat() recebe um nome e devolve um número:
| Nome | O que conta |
|---|---|
fullscanStep |
Linhas percorridas durante um full table scan |
sort |
Operações de ordenação executadas |
autoindex |
Linhas inseridas em índices transientes que o SQLite criou pra acelerar um join |
vmStep |
Operações da máquina virtual executadas |
reprepare |
Re-prepares automáticos depois de uma mudança de schema |
run |
Ciclos de execução iniciados |
filterMiss |
Resultados de Bloom filter que ainda exigiram o passo do join |
filterHit |
Passos de join pulados porque um Bloom filter retornou não-encontrado |
memused |
Bytes de heap aproximados que o statement segura |
filterMiss e filterHit precisam do SQLite 3.38.0 ou mais novo. O Node embarca uma versão recente, então isso só te pega se você compilou com --shared-sqlite contra algo antigo. Nesse caso os nomes lançam ERR_INVALID_ARG_VALUE.
O memused é o esquisito da lista. Ele reporta uso atual em vez de uma contagem acumulada, então o SQLite ignora a flag de reset pra ele e o resetStats() não mexe nesse valor.
Um bom uso pra isso
O Aaron cogitou plugar isso no Rails pra avisar ou estourar erro em test e development. Mesma ideia aqui, e é barato - o stat() lê um inteiro que o SQLite já mantém. O guardrail inteiro cabe em seis linhas:
import assert from 'node:assert';
function assertNoScan(stmt, ...params) {
stmt.resetStats();
const rows = stmt.all(...params);
assert.strictEqual(
stmt.stat('fullscanStep'), 0,
`full table scan em: ${stmt.sourceSQL}`,
);
return rows;
}
Passe o statement SELECT * FROM users WHERE age = ? lá de cima por essa função antes do índice existir e ela quebra com 999 !== 0, imprimindo o SQL culpado.
Isso pesa mais agora que muito SQL sai da mão de um agente. Um modelo que emite WHERE age = ? não sabe se age tem índice, e nada na saída dele marca o chute.
O code review também deixa passar, porque a query está correta. O índice só passa a sustentar peso em produção, meses depois. O fullscanStep transforma isso numa asserção que a CI consegue quebrar.
Uma tabela indexada de cinco linhas ainda reporta zero, então fixture pequena não dá alarme falso. E os contadores são por statement, então você habilita query a query - o que é justamente o que você quer, já que um monte de query deve escanear mesmo.
Está na main, então sai na próxima release. Se você plugar a asserção num helper de teste, quero saber como foi.
Por hoje é só.