界面已本地化;技能介绍与说明文档按所选语言由 AI 自动翻译,代码、命令、路径和专业术语保留原文,以原作者文档为准。
探索技能
部署与运维 · SKILL.md

sqlite-ops

sqlite-ops

【部署与运维】SQLite across every host and engine - query performance, concurrency, schema, feature modules, operations. Triggers on: sqlite, slow query, EXPLAIN QUERY PLAN, query plan, SCAN vs SEARCH, covering index, index not used, rows read, rows_read, sql_duration_ms, ANALYZE, sqlite_stat1, LIKE performance, database is locked, SQLITE_BUSY, WAL, busy_timeout, …

独立 GitHub 快照 · 自动分类 · 以原作者文档为准

技能说明

GitHub

name: sqlite-ops description: "SQLite across every host and engine - query performance, concurrency, schema, feature modules, operations. Triggers on: sqlite, slow query, EXPLAIN QUERY PLAN, query plan, SCAN vs SEARCH, covering index, index not used, rows read, rows_read, sql_duration_ms, ANALYZE, sqlite_stat1, LIKE performance, database is locked, SQLITE_BUSY, WAL, busy_timeout, STRICT tables, type affinity, foreign_keys, VACUUM, integrity_check, fts5, trigram, json_extract, D1, cloudflare d1, wrangler d1, node:sqlite, better-sqlite3, bun:sqlite, aiosqlite, libsql, turso, migration, d1 batch, read replication, sessions api, d1 bookmark, migration timeout." license: MIT compatibility: "Guidance is engine-agnostic (SQLite 3.x semantics). Examples are labelled by host: sqlite3 CLI, Python sqlite3/aiosqlite, node:sqlite/better-sqlite3/bun:sqlite, Cloudflare D1 via wrangler, libSQL/Turso. scripts/eqp-triage.py needs Python 3.8+ (stdlib only)." allowed-tools: "Read Write Bash" metadata: author: claude-mods related-skills: "sql-ops, perf-ops, cloudflare-ops, postgres-ops"

SQLite Operations

SQLite is one engine with many hosts. The SQL semantics, query planner, and pragmas are the same whether you reach it through the sqlite3 CLI, Python, node:sqlite, better-sqlite3, Bun, Cloudflare D1, or libSQL/Turso — what differs is the driver surface and the operational envelope (who owns the file, what a "connection" costs, whether you can even run PRAGMA). Reason about the engine first; then check the host section for the traps that differ.

Where does the problem live?
│
├─ A statement is slow, or scans too much
│  └─ EXPLAIN QUERY PLAN first, always → references/query-performance.md
│
├─ "database is locked" / SQLITE_BUSY / writers blocking readers
│  └─ WAL + busy_timeout + BEGIN IMMEDIATE → references/concurrency-durability.md
│
├─ Wrong data got in, or a constraint didn't fire
│  └─ Type affinity, STRICT, foreign_keys=OFF → references/schema-design.md
│
├─ Search / JSON / geo / analytics feature question
│  └─ FTS5, JSON, R-tree, window fns → references/feature-modules.md
│
├─ Running on a managed/edge engine (D1, Turso)
│  └─ references/d1-edge.md + references/hosts.md
│
└─ Corruption, size, backup, VACUUM
   └─ references/operations.md

Measurement discipline (read this before optimising anything)

Most SQLite "optimisations" are unmeasured. Four rules, in order of how often they are broken:

  1. Measure the statement, not the tool call. An expensive aggregate that ships inside a batch another query was already sending costs no extra round trip and is therefore invisible to per-call timing — while still scanning the whole table on every request. Decompose multi-part statements and time each part separately.
  2. Report latency AND rows scanned. They move independently. An optimisation can cut latency ~25x while leaving rows-read essentially unchanged (and on a billed engine like D1, rows read is the money metric — see references/d1-edge.md).
  3. Never trust wall-clock time from a CLI. Process startup dominates. Use the engine's own reported duration (.timer on in the CLI, meta.timings.sql_duration_ms on D1).
  4. Take a median of 10+ runs and report the range. First runs are cold. In one measured session a cold run hit 2,495 ms against a 171 ms median on the same statement — a 1.5–1.7x first-run penalty was routine on multi-thousand-row reads.
# sqlite3 CLI: engine-reported timing, not shell time
sqlite3 app.db '.timer on' "SELECT count(*) FROM q_product WHERE org LIKE '%acme%';"

# What the planner thinks the data looks like (empty = ANALYZE never ran)
sqlite3 app.db 'SELECT * FROM sqlite_stat1;'

Prove an index will help before you create it

The highest-leverage trick in this skill, and the one that keeps schema work inside a deploy gate: run the identical statement shape against a column an existing index already covers. Same table, same row count, same predicate shape — only the column changes. The difference is your projected payoff, measured on live production data with zero schema writes.

-- Hypothesis: a covering index on (org, product_id) makes this fast.
-- Unindexed control (what you have today):
SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%';

-- Proof shot: same shape, over a column an existing index already covers.
-- If this is fast, the index is worth writing. If it isn't, the index is not your problem.
SELECT DISTINCT org FROM q_product WHERE org LIKE '%acme%';

In the worked example the proof shot returned 6.75 ms against a 171.83 ms control — enough to justify the index without touching production schema.

EXPLAIN QUERY PLAN — the 60-second read

EXPLAIN QUERY PLAN (EQP) is the first command for any slow statement. It is cheap, safe, read-only, and available on every host that lets you run arbitrary SQL.

EXPLAIN QUERY PLAN
SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%';
Plan lineMeansVerdict
SEARCH t USING INDEX ix (col=?)B-tree seek, touches matching rows onlyBest case
SEARCH t USING COVERING INDEX ixSeek, and every needed column is in the index — table never readBest case
SCAN t USING COVERING INDEX ixFull pass, but over narrow index entries, not wide rowsOften fine — see below
SCAN t USING INDEX ixFull pass over the index and a row fetch per hitSuspicious: the index is buying little
SCAN tFull table scanFix it, unless the table is tiny
USE TEMP B-TREE FOR ORDER BYSorting because no index supplies the orderCost signal
USE TEMP B-TREE FOR GROUP BYSame, for groupingCost signal
CORRELATED SCALAR SUBQUERYSubquery re-executed per outer rowUsually the whole problem

The distinction that matters most: SCAN … USING COVERING INDEX is not a failure. A covering scan reads narrow index entries instead of paging in wide rows, which is exactly how you make an unseekable predicate fast.

Deep dive: ./references/query-performance.md — index design, column order, partial and expression indexes, ANALYZE/sqlite_stat1, and the full catalogue of planner defeats.

The unseekable-predicate trap (worked example)

The measured D1 case behind this advice (why a plain index on LIKE '%x%' changes nothing, the fix, before/after numbers) and the with/without-sqlite_stat1 planner check: references/covering-index-worked-example.md.

Index design in one table

Predicate shapeIndexable?What to build
col = ?, col IN (…), col > ?, BETWEENYesB-tree on col
a = ? AND b = ?YesComposite (a, b) — equality columns first
a = ? ORDER BY bYesComposite (a, b) — kills the temp B-tree
col LIKE 'x%' (anchored)Yes, if col is TEXT with BINARY collationB-tree on col
col LIKE '%x%' (leading wildcard)No seek possibleMake the scan covering, or use FTS5 trigram
lower(col) = ?Not on a plain indexExpression index ON t(lower(col))
status = 'open' where 2% of rows qualifyYesPartial index WHERE status = 'open'
json_extract(doc,'$.k') = ?Not on a plain indexExpression index, or generated column + index

Rules that repay themselves: put the filtered column first and the projected column second in a covering index; index the column, never a function of it (unless it is an expression index); and every index you add taxes every write — audit before adding.

Concurrency and durability — the 80/20

SymptomCauseFix
SQLITE_BUSYAnother connection holds a lock; yours gave up waitingPRAGMA busy_timeout = 5000; and keep write transactions short
SQLITE_LOCKEDConflict within the same connection (or a shared cache)Fix the code — a retry loop will spin forever
"database is locked" mid-transactionBEGIN (DEFERRED) read that later writes → upgrade deadlock, not retryableBEGIN IMMEDIATE for any transaction that will write
Readers blocked by a writerRollback journal modePRAGMA journal_mode = WAL; (persistent, set once)
-wal file grows without boundLong-lived reader pins the checkpointClose/refresh readers; PRAGMA wal_checkpoint(TRUNCATE);
PRAGMA journal_mode = WAL;      -- persistent; survives reconnect
PRAGMA busy_timeout = 5000;     -- per-connection; set on EVERY connection
PRAGMA foreign_keys = ON;       -- per-connection, OFF by default — see below
PRAGMA synchronous = NORMAL;    -- safe with WAL; FULL only if you fear power loss

Deep dive: ./references/concurrency-durability.md — WAL internals, the DEFERRED-upgrade deadlock, synchronous levels, checkpoint starvation, multi-process access.

Schema — the three silent bugs

  1. PRAGMA foreign_keys is OFF by default. Per connection, every connection. Your REFERENCES clauses parse, are stored, and do nothing. This is the classic silent data-integrity bug in SQLite applications.
  2. Type affinity is not a type. A TEXT column will happily store an integer; a declared type is a suggestion about conversion. Use STRICT tables (SQLite 3.37+) when you want a declared type enforced.
  3. ALTER TABLE is limited. Adding a column and renaming are supported; dropping, retyping, and changing constraints need the 12-step recreate dance.
CREATE TABLE product (
    id       INTEGER PRIMARY KEY,
    org      TEXT NOT NULL,
    price    REAL NOT NULL,
    doc      TEXT,
    -- indexable projection of a JSON field
    sku      TEXT GENERATED ALWAYS AS (json_extract(doc, '$.sku')) VIRTUAL
) STRICT;

Deep dive: ./references/schema-design.md (affinity, STRICT, generated columns, WITHOUT ROWID, constraints) and ./references/migration-patterns.md (the 12-step ALTER dance, versioned migration runners).

Feature modules at a glance

NeedReach forNote
Substring / fuzzy text searchFTS5 with the trigram tokenizerThe real answer to LIKE '%x%' at scale
Word/phrase search with rankingFTS5 + bm25()External-content table avoids duplicating the corpus
Semi-structured documentsjson_extract / -> / ->>, JSONB (3.45+)Index via generated column or expression index
Bounding-box / interval overlapR-tree virtual tableCompile-time module; check availability
Running totals, ranking, gapsWindow functions (3.25+)Same syntax as PostgreSQL
Insert-or-updateON CONFLICT … DO UPDATE (3.24+)excluded.col refers to the proposed row
Read back what you wroteRETURNING (3.35+)Makes atomic claim-a-job patterns single-statement

Deep dive: ./references/feature-modules.md.

Hosts

The engine is the same; the envelope is not.

Per-host connection model and traps: Hosts at a glance. On D1, cost is rows read, bound parameters cap at 100, and there is no PRAGMA surface.

Deep dive: ./references/hosts.md for per-host connection recipes and traps.

On D1 specifically, three platform features have no stock-SQLite equivalent and are the most commonly missed:

wrangler d1 insights <db> --sort-type=sum --sort-by=reads --limit=10   # rank REAL queries by cost
wrangler d1 time-travel info <db>                                      # 30-day point-in-time restore point
# Sessions API (env.DB.withSession(bookmark)) - read replicas, sequential consistency

./references/d1-edge.md covers those plus the rows-read economics, the verified limits table, the error catalogue, and import/export. For the production incident patterns — a timed-out migrations apply --remote that landed anyway, batch() treating a 0-row scoped UPDATE as success, and the opt-in-to-replica rollout shape for read replication — see ./references/d1-production-patterns.md.

Operations

sqlite3 app.db 'PRAGMA quick_check;'        # fast structural check
sqlite3 app.db 'PRAGMA integrity_check;'    # full check — slow on big DBs
sqlite3 app.db "VACUUM INTO 'backup.db';"   # consistent backup, no downtime, defragmented
sqlite3 app.db '.dump' > backup.sql         # portable text backup
sqlite3 app.db 'PRAGMA optimize;'           # run before closing a long-lived connection

Never copy a live database file with cp while a writer is active — use VACUUM INTO, the backup API, or .dump.

Deep dive: ./references/operations.md — corruption causes and recovery, VACUUM vs VACUUM INTO, page/cache sizing, size analysis.

Triage script

scripts/eqp-triage.py reads an EXPLAIN QUERY PLAN result — either by running the statement against a database, or from piped plan text — and classifies each line by severity with a fix hint. Exits 10 when it finds something (the domain signal), 0 when the plan is clean.

# Run against a database file (uses Python's bundled sqlite3 — no external binary needed)
python3 scripts/eqp-triage.py --db app.db \
  --sql "SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%'"

# More: triage a plan piped from D1 or a log, and --json output -> references/eqp-triage.md

Gotchas

MistakeWhy it bitesFix
Adding an index for LIKE '%x%'Leading wildcard can never seekCovering index, or FTS5 trigram
Timing with a shell stopwatchCLI/driver startup dominatesEngine-reported duration; median of 10+
Timing the tool call, not the statementPiggy-backed statements are invisibleDecompose and time each part
Assuming REFERENCES is enforcedforeign_keys is OFF per connectionPRAGMA foreign_keys = ON on every connection
Assuming a declared type is enforcedAffinity, not typingSTRICT tables
Retrying SQLITE_LOCKEDSame-connection conflict never clearsFix the code path
BEGIN then writeDEFERRED→write upgrade deadlocks and is not retryableBEGIN IMMEDIATE
cp on a live databaseTorn copyVACUUM INTO / backup API
SELECT *Defeats covering indexes; widens every row readProject only what you need
VACUUM to "speed things up"Rewrites the whole file, needs 2x space, holds a lockPRAGMA optimize / targeted index work
Trusting one cold run1.5–1.7x first-run penalty is routineMedian of 10+, report the range
Inlining literals to dodge a parameter capThat is how injection happensChunk the work; keep bound parameters
Re-running a timed-out remote migrationThe apply may have landed; the error was about the responseVerify schema state read-only first — ./references/d1-production-patterns.md
Reading a committed batch() as per-statement successA conditional UPDATE matching 0 rows is not an errorCheck meta.changes; 0 on a scoped write = 403/conflict

Reference files

ReferenceLoad when
./references/query-performance.mdAny slow statement: EQP, index design, ANALYZE, planner defeats, measurement method
./references/d1-edge.mdCloudflare D1: rows-read economics, d1 insights, Sessions API/replication, Time Travel, limits, errors
./references/d1-production-patterns.mdRunning D1 in production: verifying a timed-out migration, batch() 0-row write verification, the opt-in-to-replica replication rollout
./references/concurrency-durability.mdLocking, WAL, busy_timeout, transaction modes, checkpointing, durability
./references/schema-design.mdAffinity, STRICT, foreign keys, generated columns, WITHOUT ROWID, constraints
./references/schema-patterns.mdReady-made table designs: state, cache, event log, queue, session, dedup
./references/migration-patterns.mdVersioned migrations, the 12-step ALTER dance, host-specific runners
./references/feature-modules.mdFTS5, JSON/JSONB, R-tree, window functions, upsert, RETURNING
./references/hosts.mdPer-host connection recipes and driver traps (Python, Node, Bun, D1, libSQL)
./references/async-patterns.mdPython aiosqlite depth: async CRUD, batching, pooling
./references/operations.mdIntegrity checks, corruption recovery, VACUUM, backups, size and page tuning
./references/testing.mdIn-memory vs file databases, fixtures, deterministic seeding, migration tests
./references/covering-index-worked-example.mdThe measured D1 covering-index case behind the advice above; the with/without-statistics planner check
./references/eqp-triage.mdMore eqp-triage.py invocations: a plan piped from D1 or a log, --json output

See also

SkillWhen to combine
sql-opsVendor-neutral SQL: CTEs, window functions, JOIN strategy
perf-opsThe wider performance workflow — profiling, load testing, before/after protocol
cloudflare-opsWorkers, bindings, and deployment around a D1 database
postgres-opsWhen the workload has outgrown SQLite's single-writer model
python-database-opsSQLAlchemy / ORM layers over SQLite
我的收藏