libSQL 数据库在线体检与修复:sqlite3_checker 与 repair 扩展深度解析
【免费下载链接】libsqllibSQL is a fork of SQLite that is both Open Source, and Open Contributions.项目地址: https://gitcode.com/GitHub_Trending/li/libsql
本指南围绕 libSQL 仓库中libsql-sqlite3/ext/repair/目录(SQLite 上游继承的 repair 工具集),讲解如何在不将应用下线的前提下,对大型 SQLite/libSQL 数据库进行在线完整性检查、损坏定位与修复准备。读完本文,你将掌握sqlite3_checker的构建方法、全部命令行选项、checkfreelist与incremental_index_check两个核心扩展的实现原理,以及如何用官方测试套件验证工具行为。
为什么需要"在线"数据库体检
SQLite 的典型应用场景中数据库规模普遍不大,PRAGMA integrity_check之类的全量扫描可以在毫秒到秒级完成。但随着 SQLite 被用于越来越大的数据库,单库文件尺寸正进入 TB 量级。在如此规模下,硬件故障乃至宇宙射线偶发翻转比特(cosmic rays)都会以不可忽视的概率损坏数据库文件;而对一个 TB 级数据库执行完整的问题检测与修复,耗时可能长达数小时甚至数天。要求依赖这些数据库的应用为此长时间下线,是绝大多数生产环境无法接受的。
libsql-sqlite3/ext/repair/README.md明确指出了这一背景:本目录中的工具与扩展,目标正是在数据库处于活跃使用状态时,提供检测与修复大型数据库问题的机制。同时 README 也如实声明(截至 2017-10-12 撰写时),这些工具属于实验性质、处于积极开发中,待其稳定后 README 会更新说明。
repair 目录全景:工具与扩展的组成
整个目录包含两个层面的产物:独立的分析工具,以及可嵌入 SQL 的扩展模块。
| 文件 | 角色 |
|---|---|
| README.md | 目录定位说明(本文主题文档) |
| sqlite3_checker.c.in | sqlite3_checker程序的 C 骨架,经tool/mkccode.tcl生成最终源码 |
| sqlite3_checker.tcl | sqlite3_checker的 Tcl 主驱动脚本:参数解析、检查调度、进度与结果输出 |
| checkfreelist.c | 空闲页链表(freelist)检查扩展,提供 C API 与 SQL 函数 |
| checkindex.c | 增量式索引完整性检查虚表incremental_index_check |
| test/ | 测试套件:test.tcl驱动脚本与checkfreelist01.test、checkindex01.test两个测试模块 |
从 sqlite3_checker.c.in 的编译指令可以看到程序的血统:它把sqlite3.c、tclsqlite.c以及三个扩展(ext/misc/btreeinfo.c、checkindex.c、checkfreelist.c)全部静态编入一个可执行文件,并通过sqlite3_auto_extension()在启动时自动注册sqlite3_btreeinfo_init、sqlite3_checkindex_init、sqlite3_checkfreelist_init三个扩展入口。此外它还内置了一个名为sqlite3_imposter的 Tcl 命令——这是测试用钩子,允许以自定义表结构"覆盖"某个真实表的 rootpage,从而在不破坏原数据的前提下模拟出索引与表内容不一致的损坏场景(见 checkindex.c 的 imposter 机制)。
构建 sqlite3_checker
根据 test/README.md,构建只需一条命令:
make sqlite3_checker该目标定义在 main.mk 中:先用tclsh tool/mkccode.tcl ext/repair/sqlite3_checker.c.in > sqlite3_checker.c展开模板(把INCLUDE $ROOT/...指令替换为各源码文件的实际内容,BEGIN_STRING/END_STRING之间的 Tcl 脚本被内嵌为字符串常量),再以$(TCCX) $(TCL_FLAGS)连同$(LIBTCL)链接出可执行文件。sqlite3_checker同时也是 main.mk 中TESTPROGS列表的一员,这意味着它不仅是运维工具,也是官方测试基础设施的一部分。
编译产物sqlite3_checker本质上是一个内嵌 SQLite 与 Tcl 解释器的单文件程序:它能直接打开数据库执行检查,也能在--test模式下驱动 Tcl 测试脚本。若你的构建环境缺少 Tcl 开发库,可先参考仓库根目录的构建说明(如 libsql-sqlite3/README-SQLite.md)准备好依赖。
命令行选项详解
sqlite3_checker的用法与选项定义在 sqlite3_checker.tcl 的usage过程中:
Usage: sqlite3_checker OPTIONS database-filename对指定的 SQLite 数据库文件执行完整性检查。核心选项如下:
| 选项 | 含义 |
|---|---|
--batchsize N | 每个事务中检查的行数(默认 1000) |
--freelist | 仅执行空闲页链表(freelist)检查 |
--index NAME | 仅检查名为 NAME 的索引 |
--summary | 输出数据库空间利用概况 |
--table NAME | 检查表 NAME 上的所有索引 |
--tclsh | 进入内置 Tcl 解释器(调试用) |
--trace | (仅调试)输出扫描过程追踪信息 |
--version | 打印 SQLite 版本号与源码标识 |
--test FILENAME ARGS | 用 FILENAME 替换默认主脚本执行(测试模式) |
需要注意几个参数间的交互语义,这直接来自 sqlite3_checker.tcl 的解析逻辑:
- 默认行为是全量索引检查:变量
bAll初始为 1。只有显式给出--freelist、--summary、--index、--table之一时bAll才被置 0。因此不带任何检查选项运行时,工具会先做 freelist 检查,再按行数升序(ORDER BY nEntry,即从小到大)遍历sqlite_btreeinfo('main')中所有rootpage>0的索引逐一检查。 --batchsize的语义:check_index过程以LIMIT $batchsize的方式分批调用incremental_index_check虚表,每批结束后用返回的current_key作为下一次扫描的after_key续查。因为每一批查询都发生在独立的语句中,数据库可以在批次之间继续接受其他连接的事务,这正是"在线检查"的具体体现。默认 1000 行/批,可通过--batchsize调大或调小以权衡吞吐与并发影响。- 进度输出:每次扫描前先用
sqlite_btreeinfo('main')查出索引总行数nEntry,然后以\r回车符原地刷新 "索引名: 已查 i 行 / 共 max 行 (百分比%)" 的进度条,结束时打印 "索引名: N errors out of M entries"。 - 索引选择:
--index NAME指定单个索引;--table NAME则从sqlite_master查出该表所有type='index' AND rootpage>0的索引逐个检查。 - 数据库文件可通过
file:URI 形式传入,脚本会剥离前缀校验实际文件是否存在、是否可读,并以sqlite3 db $file_to_analyze打开;若参数是.tcl结尾的脚本文件,则直接当作 Tcl 脚本执行。
一个典型的组合用法:
# 只查 freelist,速度快,适合日常巡检 ./sqlite3_checker --freelist app.db # 输出空间占用概况(按索引大小排序) ./sqlite3_checker --summary app.db # 全量在线检查所有索引,每批 500 行以降低对业务的影响 ./sqlite3_checker --batchsize 500 app.db # 只检查某个大表上的全部索引 ./sqlite3_checker --table orders app.db # 查看内置 SQLite 版本 ./sqlite3_checker --version app.db空闲页链表检查:checkfreelist 的实现与原理
freelist 是 SQLite 记录"已删除、可复用"页面的链表,其头部指针记录在数据库第 1 页偏移 32 字节处。若 freelist 元数据被损坏(例如删页操作中途崩溃、比特翻转),会导致页面复用错误甚至数据覆盖,因此它是完整性检查的第一道防线。
checkfreelist.c 提供三种使用形态:
- C 函数:
int sqlite3_check_freelist(sqlite3 *db, const char *zDb),检查zDb("main"、"temp" 等)的 freelist,通过sqlite3_log()报告错误,返回SQLITE_OK或错误码;注意——即使 freelist 已损坏但未发生 IO/OOM 错误,返回值也可能是SQLITE_OK,错误细节在日志中。 - SQL 函数:编译为可加载扩展后注册
checkfreelist(<database-name>),把所有错误消息合并为单个文本值(换行分隔)返回;无损坏时返回空字符串。 - 在
sqlite3_checker中由--freelist触发:SELECT checkfreelist('main')并在外层包了BEGIN/END事务。
其核心算法(checkFreelist 函数)值得拆解:
WITH freelist_trunk(i, d, n) AS ( SELECT 1, NULL, sqlite_readint32(data, 32) FROM sqlite_dbpage(:1) WHERE pgno=1 UNION ALL SELECT n, data, sqlite_readint32(data) FROM freelist_trunk, sqlite_dbpage(:1) WHERE pgno=n ) SELECT i, d FROM freelist_trunk WHERE i!=1;这条递归 CTE 借助sqlite_dbpage虚表(由SQLITE_ENABLE_DBPAGE_VTAB编译选项开启)逐页读取原始数据,从第 1 页的偏移 32 处取得第一个 trunk 页号,再沿 trunk 页首 4 字节的"下一 trunk 页号"指针走完整个链表。对每个 trunk 页,代码校验:
- leaf 数量越界:trunk 页偏移 4 处的 4 字节记录该页挂载的 leaf 页数量
nLeaf,若超过(nData/4)-2-6则报leaf count out of range; - trunk 页号越界:若下一 trunk 页号大于
PRAGMA page_count,报trunk page N is out of range; - leaf 页号越界:逐一检查每个 leaf 页号,为 0 或超过总页数时报
leaf page N is out of range (child i of trunk page T); - 总数不一致:把链表实际统计的 free 页数与
PRAGMA freelist_count头部记录对比,不一致时报free-list count mismatch: actual=X header=Y。
配套的sqlite_readint32(BLOB[, OFFSET])SQL 函数用于从页数据 blob 中解码大端序 32 位整数,注册于 cflRegister。
该扩展也可脱离sqlite3_checker单独编译为可加载扩展使用:
gcc -Os -fPIC -shared checkfreelist.c -o checkfreelist.so增量式索引检查:incremental_index_check 虚表
索引与表数据不一致是数据库损坏的常见形式,而全量PRAGMA integrity_check在 TB 级数据库上会扫描全部内容树,耗时过长。checkindex.c 通过注册名为incremental_index_check的只读虚表,把索引检查改造成可分批推进的增量扫描——这正是本目录"在线体检"思想的核心实现。
虚表结构定义于 cidxConnect:
CREATE TABLE xyz( errmsg TEXT, -- 错误消息;无错误时为 NULL current_key TEXT, -- 当前扫描到的键值(quote() 后的文本) index_name HIDDEN, -- IN:要扫描的索引名 after_key HIDDEN, -- IN:从该键之后继续扫描 scanner_sql HIDDEN -- 调试:本次扫描实际执行的 SQL )errmsg列只产生两类错误文案(见 cidxColumn):
row missing:索引中存在某条目,但按主键/rowid 回表查不到对应数据行(子查询返回非整数,即无行);row data mismatch:索引条目与表中实际行的数据不一致(子查询返回 0)。
查询方式即把约束写在 WHERE 中,例如检查索引i1全部条目:
SELECT errmsg, current_key FROM incremental_index_check('i1');分批续查则利用after_key(值来自上一批最后一条current_key),LIMIT控制每批行数——sqlite3_checker.tcl 的 check_index 过程 正是按此模式循环直到返回空集。cidxBestIndex通过给三种计划赋不同代价(无参数 10^9、单参数 10^6、双参数 10^3)引导查询规划器优先选择带after_key的续查计划,见 checkindex.c。
每次扫描的 SQL 由 cidxGenerateScanSql 动态生成,过程为:
- 用
sqlite_schema查出索引所属表与其CREATE INDEX语句; - 用
PRAGMA index_xinfo获取索引列、排序方向(DESC)、是否为 PK 键列及排序规则(collation); - 解析
CREATE INDEX原文(cidxParseSQL 手写了一个能识别 SQL 字符串、--与/* */注释的微型解析器),提取表达式列、ASC/DESC 与可选的WHERE子句; - 生成
SELECT (子查询), quote(i0)||','||quote(i1)... FROM (SELECT 列 FROM 表 INDEXED BY 索引 ORDER BY ...) AS i形式的扫描语句:内层强制按索引键序读取条目,外层对每个条目回表比对。INDEXED BY保证读取顺序与索引完全一致。
这套设计使虚表能正确处理各种复杂索引形态——测试 checkindex01.test 覆盖了:普通索引、DESC索引、多列复合索引、WITHOUT ROWID表、非默认排序规则(COLLATE nocase)、表达式索引(json_extract(y,'$.x'))、自动索引sqlite_autoindex_*、列定义中夹杂注释、以及带WHERE的部分索引。例如其中用t4cc ON t4(c1 COLLATE nocase, c2 COLLATE nocase)验证大小写不敏感排序规则下仍能正确比对(测试 4.1)。
如何制造损坏:sqlite3_imposter 测试钩子
为了在不破坏测试库的前提下验证检测能力,sqlite3_checker.c.in暴露了sqlite3_imposterTcl 命令,用法如下:
# 安装 imposter:用给定表结构覆盖真实表 t1 的 rootpage sqlite3_imposter db main $tblroot {CREATE TABLE xt1(a,b)} # 在 imposter 上制造不一致(直接改页内容) db eval { UPDATE xt1 SET a='six' WHERE rowid=3; DELETE FROM xt1 WHERE rowid = 5; } # 卸载 imposter sqlite3_imposter db main其实现位于 sqlite3_checker.c.in,调用 SQLite 内部测试接口SQLITE_TESTCTRL_IMPOSTER:先以 imposter 模式覆盖 rootpage,sqlite3_exec执行构造表语句后恢复。由于 imposter 表与真实表共享同一 B-tree 页,修改它等价于直接篡改真实数据——checkindex01.test 的 1.5 节正是这样把rowid=3的行改为'six'、删掉rowid=5的行,随后 1.6 节即看到{row missing} 'five',5与{row data mismatch} 'three',3的精确报错。
而 freelist 检查的损坏注入更直接——checkfreelist01.test 通过UPDATE sqlite_dbpage SET data = set_int(...)直接改写页内字节,验证了四类典型错误的报错文案:
| 注入方式 | 期望输出 |
|---|---|
| 把 trunk 页头部 free 计数减 1 | free-list count mismatch: actual=6725 header=6726 |
| 把 leaf 页号改为超出总页数的值 | leaf page 10092 is out of range (child 3 of trunk page 4860) |
| 把 leaf 页号清零 | leaf page 0 is out of range (child 3 of trunk page 4860) |
| 把 trunk 页的 leaf 数量字段改为 249 | leaf count out of range (249) on trunk page 5 |
空间占用概况:--summary 与 sqlite_btreeinfo
--summary选项输出按索引占空间降序排列的清单,数据源是sqlite_btreeinfo虚表(来自 ext/misc/btreeinfo.c):
SELECT nPage*$pgsz AS sz, name, tbl_name FROM sqlite_btreeinfo WHERE type='index' ORDER BY 1 DESC, name其中nPage是该索引 B-tree 的页数,乘以PRAGMA page_size即为其字节占用。脚本会自动选择单位:总大小超过 10 MB 用 MB,否则用 KB,输出形如:
1234.5 MB index idx_orders_created of table orders 45.0 KB index sqlite_autoindex_users_1 of table users这为定位"哪些索引值得优先检查"(占用越大、扫描代价越高)提供了量化依据,也能辅助排查冗余索引。
运行官方测试套件
测试驱动脚本与说明位于 test/README.md 与 test/test.tcl。构建后运行:
# 先构建工具 make sqlite3_checker # 运行 test 目录下全部 *.test 模块 ./sqlite3_checker --test $path/test.tcl # 可选:只运行指定模块 ./sqlite3_checker --test $path/test.tcl $path/checkfreelist01.test $path/checkindex01.testtest.tcl 实现了与 SQLite 官方测试同风格的do_test/do_execsql_test框架:不传模块时自动glob同目录下所有*.test;每个模块运行前重建test.db,结束后汇总输出N errors out of M tests。它依赖sqlite3_checker的--test特殊模式——sqlite3_checker.tcl 检测到首参数为--test时,直接用第二个参数替换自身脚本源码执行,因此sqlite3_checker同时充当了测试运行器。
局限性与使用建议
结合源码与 README 的声明,使用前应明确以下边界:
- 实验状态:README 明确标注这些工具当前为实验性、处于积极开发中(2017-10-12 时点)。升级 SQLite/libSQL 后建议重新验证行为,关注 README.md 是否更新了稳定声明。
- 检测为主、修复能力有限:目录定位是"检测问题并(可能)修复"(detect problems, and possibly fix them)。从实现看,
checkfreelist与incremental_index_check目前只负责报告损坏位置(错误文案、键值、页号),并不自动改写数据。定位到row missing/row data mismatch的具体键后,通常仍需配合REINDEX、VACUUM或从备份恢复等手段完成修复。 - 在线是相对概念:虽然检查按批推进、允许其他连接并发读写,但每批内部仍是一个受限于事务边界的读扫描;
--batchsize越小对在线业务扰动越小,但总耗时越长。TB 级场景下应结合--summary优先处理大索引。 - 依赖编译特性:
sqlite3_checker需要 Tcl 开发库,并启用了SQLITE_ENABLE_DBPAGE_VTAB等编译选项(见 sqlite3_checker.c.in);若要用checkfreelist的 SQL 函数形态,需按上文命令单独编译.so并LOAD_EXTENSION。
总而言之,ext/repair是 libSQL(SQLite 分支)仓库中面向大规模部署的"健康巡检工具箱":sqlite3_checker以单文件工具形式集成了分批索引扫描、freelist 校验与空间统计;checkfreelist和incremental_index_check既可作为内嵌能力,也可作为独立可加载扩展复用。理解其按 key 续扫的分批模型与虚拟表实现,是将其正确接入自身巡检体系的前提。
【免费下载链接】libsqllibSQL is a fork of SQLite that is both Open Source, and Open Contributions.项目地址: https://gitcode.com/GitHub_Trending/li/libsql
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考