SQLite Schema 迁移与数据持久化
SQLite Schema 迁移与数据持久化
学习目标
- 理解 CC Switch 的 10 版 Schema 增量迁移机制
- 掌握预迁移备份、迁移失败对话框与用户回退策略
- 了解 SQLite 的增量 vacuum 与空间回收策略
- 识别 Schema 迁移中的兼容性风险
项目实践
Schema 版本管理
CC Switch 使用 SQLite 的 PRAGMA user_version 存储当前 Schema 版本号:
pub(crate) const SCHEMA_VERSION: i32 = 10;每次修改表结构时:
- 递增
SCHEMA_VERSION常量 - 在
src-tauri/src/database/schema.rs中添加相应的迁移逻辑 - 迁移逻辑只执行需要的步骤(版本号比较后跳过已完成的迁移)
迁移执行流程
apply_schema_migrations() { current_version = PRAGMA user_version
if current_version < 1: 执行 v1 迁移 (创建基础表) if current_version < 2: 执行 v2 迁移 (添加 mcp_servers 表) ... if current_version < 10: 执行 v10 迁移
PRAGMA user_version = SCHEMA_VERSION}关键特性:
- 迁移是累积的——从 v1 升级到 v10 会执行所有中间步骤
- 从 v9 升级到 v10 只执行 v10 的步骤
- 每个迁移步骤是独立的 SQL
ALTER TABLE或CREATE TABLE语句
预迁移备份
在执行任何 Schema 迁移之前,如果检测到版本升级:
if current_version > 0 && current_version < SCHEMA_VERSION: backup_database_file() → 复制 cc-switch.db 到 cc-switch.db.backup → 迁移失败时用户可以手动恢复为什么是文件复制而非 SQL 导出:文件复制速度更快,且保留了完整的数据库状态(包括索引、触发器)。SQL 导出可能在大型数据库中很慢。
数据库初始化防护
Database::init() 使用 loop 模式支持用户重试:
let db = loop { match Database::init_inner() { Ok(db) => break Arc::new(db), Err(e) => { log::error!("Failed to init database: {e}"); if !show_database_init_error_dialog(app, &db_path, &e) { std::process::exit(1); } // 用户选择重试,继续循环 } }};对话框提供关键信息:
- 数据库文件路径
- 常见原因(版本过新、文件损坏、权限不足、磁盘空间不足)
- 建议操作(先备份、升级/回退版本)
- 中英双语(根据系统 locale 自动切换)
空间管理策略
CC Switch 实现了三层空间管理:
1. 增量 Auto-Vacuum
PRAGMA auto_vacuum = INCREMENTAL;- 新数据库直接设置
- 旧数据库需要 VACUUM 重建(因为 auto_vacuum 模式不能在已有数据的数据库上直接切换)
- 检测到非 INCREMENTAL 模式时:备份 → 设置 → VACUUM
2. 启动时 Vacuum
startup { incremental_vacuum; // 回收已删除行的空间}在日志清理和 ROLLUP 之后执行,确保被删除的日志行占用的空间被回收。
3. 定期维护定时器
每 24 小时: periodic_backup_if_needed() rollup_and_prune(30) incremental_vacuumMutex 保护的连接安全
rusqlite::Connection 不是 Sync 的,不能在多线程间直接共享。CC Switch 使用 Mutex<Connection> 封装:
pub struct Database { pub(crate) conn: Mutex<Connection>,}
macro_rules! lock_conn { ($mutex:expr) => { $mutex.lock() .map_err(|e| AppError::Database(format!("Mutex lock failed: {}", e)))? };}为什么不用 r2d2 连接池:SQLite 是进程内数据库,连接池的意义在于管理多个并发连接。对于桌面应用的场景,单连接 + Mutex 足够,且更简单(不需要管理连接的生命周期)。
问题与规避
陷阱 1:Schema 迁移中途失败
问题:如果迁移执行到一半(如 v8 完成但 v9 失败),数据库处于中间状态。
CC Switch 的解决方案:
- 预迁移备份(用户可手动恢复)
- 迁移失败时
user_version不会更新,下次启动时重新执行迁移 - 已完成的迁移步骤会因
IF NOT EXISTS/IF NOT COLUMN等幂等 SQL 被跳过
陷阱 2:数据库文件权限不足
问题:用户通过 sudo 运行过一次 CC Switch,数据库文件属主变为 root,普通用户无法写入。
CC Switch 的解决方案:
- 数据库初始化时自动创建父目录(
create_dir_all) - 打开失败时弹出对话框,包含文件路径信息
- 用户可以自行修复权限后重试
陷阱 3:VACUUM 过程中磁盘空间不足
问题:VACUUM 需要创建数据库的临时副本,如果磁盘空间不足会失败。
CC Switch 的解决方案:
- 增量 vacuum 比全量 VACUUM 占用更少的临时空间
- 只在数据库重建时执行全量 VACUUM,且在此之前先创建备份
- 如果 VACUUM 失败,记录警告但不中断应用
设计取舍
单连接 vs 连接池
| 维度 | 单连接 + Mutex | 连接池 (r2d2) |
|---|---|---|
| 并发度 | 串行访问 | 并行访问 |
| 复杂度 | 低 | 高 |
| 适用场景 | 桌面应用(低并发) | 服务端(高并发) |
| 内存占用 | 低 | 高 |
CC Switch 作为桌面应用,用户操作频率低(秒级间隔),单连接完全足够。
SQLite vs 其他嵌入式数据库
| 数据库 | 优势 | 劣势 |
|---|---|---|
| SQLite | 成熟稳定、零配置、SQL 支持完整 | 高并发写入受限 |
| sled | 纯 Rust、高并发 | 生态较小、查询能力弱 |
| rocksdb | 高性能 | 需要编译 C 依赖、复杂 |
SQLite 的 bundled 特性(CC Switch 使用 rusqlite { features: ["bundled"] })意味着 SQLite 源码被编译为静态库,不需要系统安装 SQLite。
参考来源
- rusqlite 文档. https://docs.rs/rusqlite/
- SQLite PRAGMA 文档. https://www.sqlite.org/pragma.html
- SQLite Auto-vacuum. https://www.sqlite.org/autoindex.html