SQLite 索引缺失导致全表扫描:EXPLAIN QUERY PLAN 显示 SCAN 而非 SEARCH,管道却报 ok、退出码 0
日期:2026-09-30 · 技术栈:SQLite 3.45 / Python 3.12 / Docker Compose / 数据管道 关键词:SQLite 索引缺失、EXPLAIN QUERY PLAN、SCAN vs SEARCH、COVERING INDEX、 全表扫描、静默性能退化、数据集重建后索引没建、docker compose 服务启动竞态、 depends_on service_completed_successfully、退出码 0 假绿、就绪校验闸门、死字段型契约
一、问题背景
线上有一条联系人匹配管道,每小时跑一轮,其中 match 阶段做「邮箱 + 账号 + 来源」三列匹配:
SELECT COUNT(*) FROM external_contacts
WHERE email = ? AND account_id = ? AND source = 'mail';
external_contacts 是热点表(140,000 行),这句查询本该毫秒级返回。
最近一次数据集重建之后,它变成了全表扫描:
pipeline-report.json里errors: []、matched: 70,每个 stage 都写着self_reported_ok: true;- 调度器打印
[runner] match round OK、退出码 0; - 监控面板全绿、报告是合法 JSON;
- 但
match阶段耗时从 0.3 ms 涨到 280~600 ms,慢了约 900 倍。
外部审计工具直接连库看到的是:
[index_audit] 索引数量 = 0
[index_audit] 匹配语句执行计划 = ['SCAN external_contacts']
[index_audit] 没有以 email 打头、且覆盖 (email,account_id,source) 的索引 —— 匹配只能全表扫描
RESULT: BAD
「四个绿灯」与「一个 BAD」同时存在 —— 这就是典型的静默性能退化。
二、复现步骤
需要 docker + docker compose:
git fetch origin
git checkout 2026-09-30-0800
cd scenario/2026-09-30-0800
docker compose down -v && rm -rf data
docker compose up -d --build
跑运维复现(对比「管道自述 / 数据库事实 / 外部探针」三个来源):
docker compose exec -T db bash /srv/ops/repro.sh
跑外部验收器(黑盒 14 条断言):
docker compose exec -T db bash /srv/ops/verify.sh
# 问题态: PASS=11 FAIL=3 / RESULT: BAD
关键输出(问题态):
=== [2] 管道自述 ===
stage match max_ms=296.58 budget_ms=4000.0 self_reported_ok=True
indexes = {'check_enabled': False, 'match_uses_index': False}
errors = []
=== [3] 外部探针 ===
[probe] 匹配总耗时 294 ms(预算 4000 ms)
=== [4] 索引事实 ===
[index_audit] 索引数量 = 0
[index_audit] 匹配语句执行计划 = ['SCAN external_contacts']
RESULT: BAD
独立的执行计划确认:
docker compose exec -T db python3 -c "
import sqlite3
c=sqlite3.connect('file:/srv/data/contacts.db?mode=ro',uri=True)
print('idx:', c.execute(\"SELECT name FROM sqlite_master WHERE type='index'\").fetchall())
print('plan:', [r[-1] for r in c.execute(
\"EXPLAIN QUERY PLAN SELECT COUNT(*) FROM external_contacts \"
\"WHERE email=? AND account_id=? AND source='mail'\", ('x',1))])
"
# idx: []
# plan: ['SCAN external_contacts']
三、根本原因
不是一个 bug,是三个叠在一起的 bug。
3.1 数据集发布流程漏了「建索引」这一步
app/dataset.py 的 build() 里 load_schema()(建表)和 load_rows()(灌数据)都在,
建索引那一步只剩一句注释:
# 数据集更新流程:先重灌数据,再让 DBA 按变更单补索引。
# 这一批数据是直接从上游导出的,索引要在后面的一次「结构同步」里补。
con.commit()
con.close()
这次「结构同步」从来没有发生。于是数据集天生就没有匹配可用的索引。
3.2 就绪校验闸门「关着」,而且打开也没牙
conf/pipeline.env 里 CHECK_INDEXES=0,注释写着「最初数据集只有几千行、
运维为了让校验别老报错把它关掉了;数据集现在大了几个数量级,这个开关一直没人回头看」。
但真正致命的是:即使把 CHECK_INDEXES 改成 1,原来的代码也拦不住任何东西。
app/contacts.py 原来的第四步:
if rep["indexes"]["check_enabled"]:
quick = con.execute("SELECT COUNT(DISTINCT email) ...").fetchone() # 只是查个数
rep["indexes"]["probe"] = quick
rep["indexes"]["match_uses_index"] = any("USING" in s.upper() for s in scripts)
check_enabled这个分支没有任何判定,也没有往rep["errors"]里写东西;match_uses_index是个谁都不读的死字段(死字段型契约)。
退出码由 rep["exit_code"] = 0 if not rep["errors"] else 4 决定,
而 errors 永远是空的 → 退出码永远是 0。
| 自述来源 | 问题态的值 | 能证明什么 |
|---|---|---|
errors=[] | 空 | 只证明没有被记录的错误 |
self_reported_ok=true | true | 只证明步骤没崩 |
[runner] match round OK | 打印了 | 只证明子进程 rc=0 |
exit 0 | 0 | 只证明进程正常退出 |
indexes.match_uses_index=false | false | ✅ 真正的事实,但没人读 |
「跑了」≠「跑对了」。 能暴露问题的只有外部实测耗时和执行计划。
3.3 最隐蔽:修了也白修 —— ingest 与 seed 的启动竞态
把 3.1 / 3.2 都修好、完整重建之后,结果居然是:
3day-91-seed | indexed idx_ext_email_account_source ← seed 明明建好了索引
3day-91-ingest | [runner] round 1 rc=4
| errors=1 match=293.86
ingest exit=4
索引建好了、闸门也开了,管道却失败了。查运行副本:
docker compose exec -T db python3 -c "
import sqlite3
c=sqlite3.connect('file:/srv/state/contacts-live.db?mode=ro',uri=True)
print('idx:', c.execute(\"SELECT name FROM sqlite_master WHERE type='index'\").fetchall())
print('pages:', c.execute('PRAGMA page_count').fetchone())"
# idx: [] pages: (1885,)
| 文件 | 大小 | page_count | 索引 |
|---|---|---|---|
/srv/data/contacts.db(seed 产物) | 13,008,896 | 3176 | ✅ |
/srv/state/contacts-live.db(运行副本) | 7,720,960 | 1885 | ❌ |
时间戳显示运行副本比发布数据集早 79 ms 诞生:
/srv/state/contacts-live.db 7720960 00:16:53.147
/srv/data/contacts.db 13008896 00:16:53.203
为什么早?因为 docker-compose.yml 里:
ingest:
depends_on: [db] # ← 只等了 db,没等 seed
seed 与 ingest 同时启动。ingest 的 runner.py → db.connect() → _work_copy()
在 seed 还没跑完时就去 sqlite3.backup() 了 contacts.db。
直接抓到那个窗口 —— 只起 seed,毫秒级轮询探测发布中的文件:
docker compose down -v && rm -rf data && mkdir -p data
docker compose up -d --build seed
for i in 1 2 3 4 5 6 7 8; do python3 -c "
import os,sqlite3
p='data/contacts.db'
if not os.path.exists(p): print('$i: 不存在')
else:
s=os.path.getsize(p)
c=sqlite3.connect('file:%s?mode=ro'%p,uri=True)
print('$i: size=%d rows=%d indexes=%d' % (s,
c.execute('SELECT COUNT(*) FROM external_contacts').fetchone()[0],
c.execute(\"SELECT COUNT(*) FROM sqlite_master WHERE type='index'\").fetchone()[0]))
"; sleep 0.25; done
1: 不存在
2: size=651264 rows=140000 indexes=0 ← 命中!完整可读、但没索引的快照
3: size=13008896 rows=140000 indexes=1
...
第 2 拍的 651,264 字节是一个结构完整、能正常打开、rows=140000 全部就位、
只是没有索引的合法 SQLite 库。backup() 从它复制出来的运行副本自然也没索引。
而 _work_copy() 只在副本文件不存在时才复制:
if not os.path.exists(WORK_COPY):
...backup...
所以这份坏副本会被一直沿用下去。
这解释了全部「诡异现象」:
- 为什么手动单独调
db.connect()一切正常 —— 手动跑时seed早已结束; - 为什么
docker compose down -v清干净后依旧复现 —— 不是残留状态, 是每次重建都稳定复现的竞态。
SQLite 的
sqlite3.backup()不会校验数据集的「构建是否完成」。 它忠实地复制调用那一刻的文件内容。数据集发布流程如果不是原子发布 (写完 → 再 rename,或让消费者等完成信号),任何在中间窗口启动的消费者 都会拿到一份语义上不完整、但结构上合法的快照。
四、解决办法
4.1 app/dataset.py:建索引必须跟数据在同一次发布里完成
MATCH_INDEX = "idx_ext_email_account_source"
MATCH_INDEX_SQL = (
"CREATE INDEX IF NOT EXISTS %s ON external_contacts(email, account_id, source)"
% MATCH_INDEX
)
def build_indexes(con):
con.execute(MATCH_INDEX_SQL)
con.execute("ANALYZE")
con.commit()
return [r[0] for r in con.execute(
"SELECT name FROM sqlite_master WHERE type='index'").fetchall()]
build() 末尾把 con.commit() 换成 build_indexes(con) 并打印索引名。
索引列顺序刻意与匹配语句一致 (email, account_id, source):
语句是三列等值 + source 是常量,SQLite 会把它当 COVERING INDEX 用,
连回表都不需要 —— 这是实测能到 0.3 ms 的关键。
4.2 app/contacts.py:让闸门真的有牙
rep["indexes"]["match_uses_index"] = db.uses_index(con, MATCH_SQL)
if rep["indexes"]["check_enabled"] and not rep["indexes"]["match_uses_index"]:
rep["errors"].append(
"index_not_ready: 索引未就绪,匹配语句仍在全表扫描(计划: %s)"
% " / ".join(scripts))
三个要点:
- 复用
db.uses_index(),它的实现是("USING" in txt) and ("SCAN" not in txt), 比原来的any("USING" in s.upper())更严 —— 后者会把SCAN xxx USING COVERING INDEX也误判成「走索引」。 - 判定结果写进
rep["errors"],才能被退出码逻辑接住,变成exit 4。 - 保留
check_enabled语义:开关关着时行为不变(不阻断),只是不再自欺。
4.3 conf/pipeline.env:打开闸门
-CHECK_INDEXES=0
+CHECK_INDEXES=1
只改开关值,不删键。
4.4 docker-compose.yml:让 ingest 等数据集发布完
ingest:
depends_on:
seed:
condition: service_completed_successfully
db:
condition: service_started
这一条是让 4.1 / 4.2 真正生效的前提 —— 否则运行副本永远来自那份半成品快照。
五、验证
docker compose down -v && rm -rf data
docker compose up -d --build
ingest 日志:
3day-91-seed | indexed idx_ext_email_account_source
3day-91-ingest | [runner] round 1 rc=0
| [contacts] matched=70 enriched=20000 imported=5000
stages={'import': 3.98, 'enrich': 219.38, 'match': 0.31, 'verify_accuracy': 1.22}
errors=0
[runner] match round OK
ingest exit=0
外部验收器:
docker compose exec -T db bash /srv/ops/verify.sh
A1 [PASS] 索引就绪(index_audit rc=0)
A2 [PASS] 计划 = SEARCH external_contacts USING COVERING INDEX idx_ext_email_account_source (email=? AND account_id=? AND source=?)
B2 [PASS] 40 条样本匹配实测 = 0 ms(预算 4000 ms)
D1 [PASS] CHECK_INDEXES=1
======================================
PASS=14 FAIL=0
RESULT: OK
关键指标对比:
| 指标 | 问题态 | 修复后 |
|---|---|---|
| 执行计划 | SCAN external_contacts | SEARCH ... USING COVERING INDEX ... |
| 索引数量(运行副本) | 0 | 1 |
| 40 条样本匹配实测 | 294 ~ 297 ms | 0 ~ 1 ms |
stages.match.max_ms | 296.58 | 0.31 |
| runner 退出码 | 0(假绿) | 0(真绿) |
verify.sh | PASS=11 FAIL=3 | PASS=14 FAIL=0 |
稳定性:连续两次全量重建,verify.sh 都稳定 PASS=14 FAIL=0。
闸门「有牙」的独立验证(删掉索引,看是否真的阻断):
docker compose exec -T db bash -lc '
python3 -c "
import sqlite3
c=sqlite3.connect(\"/srv/state/contacts-live.db\")
c.execute(\"DROP INDEX IF EXISTS idx_ext_email_account_source\"); c.commit()"
SCHED_RETRY_MAX=1 python3 /srv/app/runner.py; echo "rc=$?"'
[runner] round 1 rc=4
[runner] all 1 rounds failed (rc=4)
rc=4
报告里:
"errors": ["index_not_ready: 索引未就绪,匹配语句仍在全表扫描(计划: SCAN external_contacts)"],
"indexes": {"check_enabled": true, "match_uses_index": false},
"exit_code": 4
同一场景在修复前是 rc=0 —— 这就是「闸门有没有牙」的判据。
六、经验与避坑
可复用的排查思路
- 「进程没崩」不等于「业务跑对」。当
errors=[]/self_reported_ok=true/OK日志 /exit 0四个一起出现时,它们只证明进程正常退出。 找出外部可观测的判据 —— 这里就是EXPLAIN QUERY PLAN的SCAN/SEARCH与实测耗时。 EXPLAIN QUERY PLAN是索引问题的唯一事实来源:SCAN table= 全表扫描;SEARCH table USING INDEX idx(col)= 走索引;SEARCH table USING COVERING INDEX idx(...)= 走覆盖索引,连回表都省了。 注意SCAN xxx USING COVERING INDEX这种两者同时出现的写法 —— 判「有没有走索引」 不能只 grepUSING,必须同时确认没有SCAN。
- 索引列顺序要对着查询写。三列等值查询用
(email, account_id, source)能吃到覆盖索引; 顺序错了(比如把低基数的source放最前)就吃不到。 - 找「死字段型契约」:报告里/契约里写得明明白白、却没有任何逻辑读它的字段
(这里的
indexes.match_uses_index),往往就是「本该阻断却没人阻断」的缺口。 - 闸门要验证「有牙」:光把开关打开(
CHECK_INDEXES=1)不够, 必须人为制造失败条件(删掉索引)确认它真的能把退出码变成非 0。 「判定在、闸门关着、打开也没牙」= 没有闸门。 - 时序/竞态类问题:时间戳 + 文件大小是最便宜的破案工具。
这里
contacts-live.db比contacts.db早 79ms、且 1885 vs 3176 页 —— 一眼定位。 - 永远用题面给的入口复现,别自己造入口。我在
db容器里手动import db; db.connect()得到的副本是带索引的(此时 seed 早已结束), 一度让我以为「问题已修复」。只有docker compose up并发拉起才会踩中竞态。
避坑清单
- ❌ 别调大
ENRICH_BUDGET_MS。140000 行扫一遍才 ~290 ms,预算 4000 ms 富裕得很, 调大预算只会把线埋得更深,执行计划照样是SCAN。 - ❌ 别删
CHECK_INDEXES键来让校验变绿。要改的是开关值, 并且让代码真的按它判定。 - ❌ 别把抽查步骤的「软删除」去掉 —— 那是既定动作,去掉会让准确性断言失去判别力。
- ⚠️
docker compose down -v清不掉竞态。清干净后依旧复现时, 要意识到「这不是残留状态,是每次都会重演的时序问题」。 - ⚠️ 数据集产物不入库。
data/contacts.db是 13 MB 生成物,提交前rm -rf data。 - ✅ 数据集发布要原子化:写完 →
rename,或让消费者用depends_on: condition: service_completed_successfully等发布完成信号。 依赖「消费者不会恰好在中途启动」是靠不住的设计。
关键词索引(便于检索)
SQLite 索引缺失 / SQLite 全表扫描 / EXPLAIN QUERY PLAN SCAN SEARCH / COVERING INDEX 覆盖索引 / CREATE INDEX 复合索引列顺序 / SQLite backup API / 数据管道静默性能退化 / 退出码 0 假绿 / 自述与事实不一致 / 就绪校验闸门 / 死字段型契约 / docker compose depends_on service_completed_successfully / docker compose 服务启动竞态 / 数据集重建后索引没建 / Python sqlite3
