朕喜技术(ZhenxiJS)
2026.09.30
1 分钟阅读

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=truetrue只证明步骤没崩
[runner] match round OK打印了只证明子进程 rc=0
exit 00只证明进程正常退出
indexes.match_uses_index=falsefalse✅ 真正的事实,但没人读

「跑了」≠「跑对了」。 能暴露问题的只有外部实测耗时和执行计划。

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,8963176✅
/srv/state/contacts-live.db(运行副本)7,720,9601885❌

时间戳显示运行副本比发布数据集早 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))

三个要点:

  1. 复用 db.uses_index(),它的实现是 ("USING" in txt) and ("SCAN" not in txt), 比原来的 any("USING" in s.upper()) 更严 —— 后者会把 SCAN xxx USING COVERING INDEX 也误判成「走索引」。
  2. 判定结果写进 rep["errors"],才能被退出码逻辑接住,变成 exit 4。
  3. 保留 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_contactsSEARCH ... USING COVERING INDEX ...
索引数量(运行副本)01
40 条样本匹配实测294 ~ 297 ms0 ~ 1 ms
stages.match.max_ms296.580.31
runner 退出码0(假绿)0(真绿)
verify.shPASS=11 FAIL=3PASS=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 —— 这就是「闸门有没有牙」的判据。

六、经验与避坑

可复用的排查思路

  1. 「进程没崩」不等于「业务跑对」。当 errors=[] / self_reported_ok=true / OK 日志 / exit 0 四个一起出现时,它们只证明进程正常退出。 找出外部可观测的判据 —— 这里就是 EXPLAIN QUERY PLAN 的 SCAN/SEARCH 与实测耗时。
  2. EXPLAIN QUERY PLAN 是索引问题的唯一事实来源:
    • SCAN table = 全表扫描;
    • SEARCH table USING INDEX idx(col) = 走索引;
    • SEARCH table USING COVERING INDEX idx(...) = 走覆盖索引,连回表都省了。 注意 SCAN xxx USING COVERING INDEX 这种两者同时出现的写法 —— 判「有没有走索引」 不能只 grep USING,必须同时确认没有 SCAN。
  3. 索引列顺序要对着查询写。三列等值查询用 (email, account_id, source) 能吃到覆盖索引; 顺序错了(比如把低基数的 source 放最前)就吃不到。
  4. 找「死字段型契约」:报告里/契约里写得明明白白、却没有任何逻辑读它的字段 (这里的 indexes.match_uses_index),往往就是「本该阻断却没人阻断」的缺口。
  5. 闸门要验证「有牙」:光把开关打开(CHECK_INDEXES=1)不够, 必须人为制造失败条件(删掉索引)确认它真的能把退出码变成非 0。 「判定在、闸门关着、打开也没牙」= 没有闸门。
  6. 时序/竞态类问题:时间戳 + 文件大小是最便宜的破案工具。 这里 contacts-live.db 比 contacts.db 早 79ms、且 1885 vs 3176 页 —— 一眼定位。
  7. 永远用题面给的入口复现,别自己造入口。我在 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

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-30-0800-sqlite

分享这篇文章