Colmap 进军嵌入式:SQLite 数据库从崩溃退出到自动治愈
场景 :将 Colmap 离线 SfM 工具链移植到嵌入式平台的实时SLAM系统中,数据库因断电、SD 老化等异常导致损坏,程序直接崩溃退出。在线系统优先保证可运行 ,因此必须识别到数据库损坏的情况,进行提前处理,避免崩溃。本文记录了从问题发现到完整修复方案的工程实践。
有懂数据库的大佬,可以指导更加优雅的做法。
一、问题背景:离线与在线的本质差异
1.1 Colmap 的原始设计假设
Colmap 作为离线 SfM 工具链,其数据库操作层有一个"简单粗暴"的错误处理策略:
cpp
// colmap/src/util/sqlite3_utils.h
inline int SQLite3CallHelper(const int result_code, ...) {
switch (result_code) {
case SQLITE_OK:
case SQLITE_ROW:
case SQLITE_DONE:
return result_code;
default:
fprintf(stderr, "SQLite error ...");
exit(EXIT_FAILURE); // ← 直接终止进程!
}
}
#define SQLITE3_CALL(func) SQLite3CallHelper(func, __FILE__, __LINE__)
这个设计在离线场景下完全合理:
- 用户在桌面端运行重建任务
- 崩溃了无非重新跑一次
- 数据可以随时备份恢复
- 开发效率优先于容错
但在 嵌入式实时系统中,这个设计是致命的:
| 维度 | 离线 SfM (Colmap 原生) | 实时SLAM |
|---|---|---|
| 运行环境 | PC / 服务器 | 嵌入式平台 |
| 可靠性要求 | 崩溃可接受 | 全程不中断 |
| 数据来源 | 用户主动导入 | 自动采集,无人值守 |
| 存储介质 | 稳定硬盘 | SD 卡 / eMMC(易老化) |
| 异常场景 | 少见 | 频繁断电、存储满、坏块 |
| 错误处理 | exit() 终止 | 必须自愈恢复 |
1.2 生产环境中的真实故障
在一次实际运行中,设备异常断电后重启,日志显示:
SQLite error [database.cc, line 276]: disk I/O error (code: 5386)
SQLite error [database.cc, line 300]: database disk image is malformed
SQLite error [database.cc, line 303]: database disk image is malformed
...后续所有 SQL 操作全部失败...
根因分析:
sqlite3_open_v2()返回了非SQLITE_OK的错误码 →SQLITE3_CALL宏触发exit(EXIT_FAILURE)- 即使 open "成功"返回
SQLITE_OK(SQLite 对某些损坏文件会宽容处理),后续 PRAGMA 和 SQL 操作也会因为读取到损坏页面而报malformed
核心矛盾:离线场景下退出后,用户介入再次执行,而嵌入式平台在线运行时,导致程序崩溃,无法介入,后果很严重。
二、技术深潜:SQLite
2.1 为什么"不关闭就修改文件"检测不到损坏?
这是调试过程中最容易踩的坑:
cpp
// ❌ 错误做法:db 还活着就去破坏文件
{
Database db(db_path); // sqlite3_open_v2() 打开数据库
db.WriteCamera(camera); // 数据写入 WAL 文件
// 此时 db 对象还活着!
CorruptDatabase(db_path); // 用 fstream 覆盖前 64 字节
Database db2(db_path); // 第二次打开 ------ 居然成功了?!
}
原因:三层缓存机制
┌─────────────────────────────────────────────────────────────────┐
│ SQLite 连接内部状态 │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Page Cache │ ←── │ OS Page Cache│ ←── │ 磁盘 .db 文件 │ │
│ │ (SQLite层) │ │ (内核层) │ │ │ │
│ │ │ │ │ │ [0xAB 0xAB ...]│ │
│ │ page 0: 合法│ │ fd_A 的缓存 │ │ (被 fstream │ │
│ │ header 页 │ │ │ │ 覆盖了) │ │
│ └─────────────┘ └──────────────┘ └────────────────┘ │
│ ↑ ↑ ↑ │
│ │ db 的 fd_A │ │ fstream 的 │
│ │ 从这里读 │ │ fd_B 写到这里 │
└─────────────────────────────────────────────────────────────────┘
关键点:
db的连接持有独立的 file descriptor (fd_A),其 page cache 中已有合法的 header 页CorruptDatabase()用全新的fstream打开 (fd_B),直接写磁盘- 但
db的后续操作优先从自己的 page cache 读取,根本不碰磁盘 - 当
db2.Open()时,通过 WAL 文件的协调机制,仍能读到一致的状态
这点还未经验证,暂且相信
2.2 sqlite3_open_v2() 对不同损坏的反应
| 损坏类型 | 返回值 | 能否被捕获? |
|---|---|---|
| Header 被 0xAB 覆盖 | SQLITE_OK(只创建句柄,不验证内容) |
✅ 需要 PRAGMA quick_check |
| 文件截断到 100 字节 | SQLITE_OK(同上) |
✅ quick_check 报 "not a database" |
| 磁盘坏块 / I/O 错误 | SQLITE_IOERR |
❌ SQLITE3_CALL 直接 exit! |
| 权限丢失 | SQLITE_CANTOPEN |
❌ 同上 |
| WAL 文件损坏但主 DB 正常 | SQLITE_OK |
⚠️ 取决于后续操作 |
结论 :对于 I/O 类错误,检查逻辑必须在 sqlite3_open_v2() 之前执行。
三、解决方案设计
3.1 核心思路:预检查模式
DatabaseFileHealthyCheck(path) ← 新增!独立预检查
│
├─ 健康 → 继续
└─ 损坏 → 备份 + 清理 → 删除旧文件
↓
Open() → sqlite3_open_v2() → 创建全新空库 → 成功 ✓
3.2 接口设计
cpp
// database.h --- 对外暴露的唯一新接口
class Database {
public:
// 原有接口保持不变
void Open(const std::string& path);
/**
* 静态方法:在 Open() 之前预检查数据库文件健康状态
*
* 设计要点:
* 1. 用临时独立连接(SQLITE_OPEN_READONLY)探测,不影响主流程
* 2. 不使用 SQLITE3_CALL 宏,避免 IOERR 时直接 exit
* 3. 检测到损坏时自动备份 + 清理 WAL/SHM + 删除原文件
* 4. 让后续 Open() 自然创建空库
*
* @return true 文件健康或首次创建
* @return false 文件曾损坏但已清理(调用方可据此决定是否报警)
*/
static bool DatabaseFileHealthyCheck(const std::string& path);
};
3.4 核心实现
cpp
// database.cc --- DatabaseFileHealthyCheck 实现
bool Database::DatabaseFileHealthyCheck(const std::string& path) {
// 1. 快速路径:文件不存在说明是首次创建
if (!boost::filesystem::exists(path)) {
return true;
}
// 2. 用临时独立连接打开(只读模式,避免影响主流程)
sqlite3* temp_db = nullptr;
const int rc = sqlite3_open_v2(
path.c_str(), &temp_db,
SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX,
nullptr);
bool is_healthy = false;
// 3. 即使 open 失败也继续检查(IOERR/CANTOPEN 都算损坏)
if (rc != SQLITE_OK) {
std::cerr << "[Ensure] Cannot open for health check (rc=" << rc << ")" << std::endl;
}
if (temp_db != nullptr) {
// 4. 执行完整性校验
sqlite3_stmt* stmt = nullptr;
int check_rc = sqlite3_prepare_v2(temp_db, "PRAGMA quick_check;", -1, &stmt, nullptr);
if (check_rc == SQLITE_OK) {
std::string result;
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char* text = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
if (text != nullptr) result = text;
}
is_healthy = (result == "ok");
sqlite3_finalize(stmt);
}
sqlite3_close_v2(temp_db);
}
// 5. 损坏则备份并清理
if (!is_healthy || rc != SQLITE_OK) {
//BackupAndRebuild
return false; // 表示曾损坏
}
return true; // 健康
}
四、价值总结
4.1 改造带来的收益
| 收益维度 | 具体表现 |
|---|---|
| 系统可用性 | 断电/SD 老化后自动恢复,无需人工干预 |
| 运维成本 | 减少现场排查和手动修复数据库的工作量 |
| 数据安全 | 损坏的数据库自动带时间戳备份(.corrupted_xxx.db),保留事后分析的可能性 |
| 代码质量 | Open() 接口保持原始状态不变,降低回归风险 |
| 架构清晰 | 预检查逻辑独立为静态方法,职责单一,易于测试 |
4.2 改造的代价
| 代价 | 应对措施 |
|---|---|
额外一次 sqlite3_open_v2 调用 |
只读模式,开销 < 5ms;且只在文件已存在时才执行 |
| 重建后原有数据丢失 | 这是设计决策:在线系统优先保证可运行,而非数据恢复(数据可通过地图服务重新下发) |
| 需要维护额外的测试用例 | 5 个测试用例覆盖主要场景,维护成本低 |
五、知识点索引
| 知识点 | 应用场景 | 参考资料 |
|---|---|---|
| SQLite WAL 模式 | 理解 page cache、checkpoint 机制 | SQLite WAL 文档 |
| PRAGMA quick_check vs integrity_check | 快速校验 vs 完全校验的性能权衡 | SQLite PRAGMA 文档 |
| sqlite3_open_v2 行为 | 不同错误码对应的损坏类型判断 | SQLite C API |
| OS Page Cache 与 fd 缓存隔离 | 解释"不 close 就修改检测不到损坏"现象 | Linux man 2 read |
| SQLITE_OPEN_READONLY vs READWRITE | 预检查用只读模式避免副作用 | SQLite flags 定义 |
| boost::filesystem 跨平台文件操作 | 文件大小获取、rename、remove | Boost.Filesystem |