跳转到内容

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;

每次修改表结构时:

  1. 递增 SCHEMA_VERSION 常量
  2. src-tauri/src/database/schema.rs 中添加相应的迁移逻辑
  3. 迁移逻辑只执行需要的步骤(版本号比较后跳过已完成的迁移)

迁移执行流程

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 TABLECREATE 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_vacuum

Mutex 保护的连接安全

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。


参考来源