第 19 章 数据库持久化:SQLite + sqlx + 迁移
本章目标
- 在 Tauri 项目里集成 SQLite +
sqlx。 - 写迁移脚本,随应用启动自动执行。
- 掌握
sqlx::query!、query_as!、连接池、事务、FTS5。 - 为 CloudTone 设计一张完整的数据库 schema。
一、为什么 SQLite
- 零服务:随 app 发布。
- 文件数据库:方便备份、跨机迁移。
- 支持 FTS5 全文搜索(CloudTone 本地搜索的底层)。
- 支持触发器、视图、JSON1。
客户端库里 sqlx 是首选:async 原生、编译期 SQL 检查。
二、依赖
# Cargo.toml
sqlx = { version = "0.8", features = [
"runtime-tokio",
"sqlite",
"macros",
"migrate",
"chrono"
]}
三、初始化与迁移
src-tauri/migrations/20260101_init.sql:
CREATE TABLE IF NOT EXISTS artists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS albums (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
artist_id INTEGER NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
year INTEGER,
cover_path TEXT,
UNIQUE(title, artist_id)
);
CREATE TABLE IF NOT EXISTS songs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
artist_id INTEGER REFERENCES artists(id),
album_id INTEGER REFERENCES albums(id),
path TEXT NOT NULL UNIQUE,
duration_ms INTEGER NOT NULL DEFAULT 0,
track_no INTEGER,
disc_no INTEGER,
file_size INTEGER NOT NULL DEFAULT 0,
file_hash TEXT NOT NULL,
format TEXT NOT NULL,
bitrate INTEGER NOT NULL DEFAULT 0,
sample_rate INTEGER NOT NULL DEFAULT 0,
added_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
liked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title);
CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist_id);
CREATE INDEX IF NOT EXISTS idx_songs_album ON songs(album_id);
-- 全文搜索
CREATE VIRTUAL TABLE IF NOT EXISTS songs_fts USING fts5(
title, artist, album,
content='',
tokenize = 'unicode61 remove_diacritics 2'
);
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS playlist_songs (
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
song_id INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
PRIMARY KEY (playlist_id, song_id)
);
CREATE TABLE IF NOT EXISTS play_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
song_id INTEGER NOT NULL REFERENCES songs(id),
played_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
duration_played_ms INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
Rust 侧启动
// src-tauri/src/core/db.rs
use sqlx::{sqlite::{SqlitePoolOptions, SqliteConnectOptions}, SqlitePool};
use std::path::Path;
pub async fn open_db(path: &Path) -> anyhow::Result<SqlitePool> {
if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await.ok(); }
let opts = SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.synchronous(sqlx::sqlite::SqliteSynchronous::Normal)
.foreign_keys(true);
let pool = SqlitePoolOptions::new().max_connections(8).connect_with(opts).await?;
// 内置迁移
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
在 setup 里调用:
.setup(|app| {
let db_path = app.path().app_data_dir()?.join("cloudtone.sqlite");
let pool = tauri::async_runtime::block_on(open_db(&db_path))?;
app.manage(AppState::new(pool));
Ok(())
})
WAL 模式 非常重要:并发读不阻塞写,写不阻塞读。
四、查询
query!:编译期校验
let row = sqlx::query!(
"SELECT title, duration_ms FROM songs WHERE id = ?",
id
).fetch_one(&pool).await?;
println!("{} {}", row.title, row.duration_ms);
这需要 DATABASE_URL 在编译期指向一个真实 DB。CI 麻烦。推荐用 query_as + struct:
query_as!:结构体映射
#[derive(sqlx::FromRow)]
pub struct SongRow {
pub id: i64,
pub title: String,
pub artist: Option<String>,
pub duration_ms: i64,
pub path: String,
}
pub async fn list_recent(pool: &SqlitePool, limit: i64) -> sqlx::Result<Vec<SongRow>> {
sqlx::query_as::<_, SongRow>(
"SELECT s.id, s.title, a.name AS artist, s.duration_ms, s.path
FROM songs s LEFT JOIN artists a ON s.artist_id = a.id
ORDER BY s.added_at DESC LIMIT ?"
)
.bind(limit)
.fetch_all(pool)
.await
}
事务
let mut tx = pool.begin().await?;
sqlx::query("INSERT INTO artists (name) VALUES (?)").bind(name).execute(&mut *tx).await?;
let artist_id = tx.last_insert_rowid();
sqlx::query("INSERT INTO albums (title, artist_id) VALUES (?, ?)")
.bind(album).bind(artist_id).execute(&mut *tx).await?;
tx.commit().await?;
五、upsert:「不存在则插入」
INSERT INTO artists (name) VALUES (?)
ON CONFLICT(name) DO UPDATE SET name=excluded.name
RETURNING id
Rust:
let id: i64 = sqlx::query_scalar(
"INSERT INTO artists (name) VALUES (?) ON CONFLICT(name) DO UPDATE SET name=excluded.name RETURNING id"
).bind(name).fetch_one(&mut *tx).await?;
六、FTS5 全文搜索(中英文友好)
配合触发器维护:
CREATE TRIGGER songs_ai AFTER INSERT ON songs BEGIN
INSERT INTO songs_fts(rowid, title, artist, album)
VALUES (new.id, new.title,
(SELECT name FROM artists WHERE id = new.artist_id),
(SELECT title FROM albums WHERE id = new.album_id));
END;
CREATE TRIGGER songs_ad AFTER DELETE ON songs BEGIN
DELETE FROM songs_fts WHERE rowid = old.id;
END;
CREATE TRIGGER songs_au AFTER UPDATE ON songs BEGIN
UPDATE songs_fts SET title = new.title WHERE rowid = new.id;
END;
查询:
let rows = sqlx::query_as::<_, SongRow>(
"SELECT s.id, s.title, a.name AS artist, s.duration_ms, s.path
FROM songs_fts fts JOIN songs s ON s.id = fts.rowid
LEFT JOIN artists a ON s.artist_id = a.id
WHERE songs_fts MATCH ? ORDER BY rank LIMIT 50"
)
.bind(format!("{}*", keyword)) // 前缀匹配
.fetch_all(&pool).await?;
中文分词:FTS5 默认 unicode61 对中文只能按字拆分。如果需要词级分词,可以:
- 集成 jieba(
sqlite3_jieba动态库),打包复杂。 - 简化方案:前端/Rust 做 N-gram 预处理再存进 FTS。CloudTone 用 2-gram。
七、tauri-plugin-sql:在前端直接读 DB?
插件提供了前端直接执行 SQL 的能力:
pnpm tauri add sql
import Database from "@tauri-apps/plugin-sql";
const db = await Database.load("sqlite:cloudtone.sqlite");
const rows = await db.select<SongRow[]>("SELECT * FROM songs LIMIT 50");
不推荐 在大项目里用。原因:
- SQL 在前端泄露表结构;权限控制细粒度难。
- 难写类型安全;绕过业务校验。
CloudTone 一律走 Rust command + sqlx。
八、备份与迁移升级
- 用户升级 app 时,
sqlx::migrate!会自动执行新文件。 - 破坏性 schema 变更走 "拷贝 + 新建 + 迁移数据"。
- 定期导出
VACUUM INTO备份到用户$APPDATA/cloudtone/backup/。
常见陷阱
1.
migrate!宏找不到 migrations 目录
migrate!("./migrations")的路径相对src-tauri/。
2. DB 文件锁
忘了 WAL + 其他进程打开 DB。要关 GUI 工具再调试。
3.
i64/number精度前端拿
duration_ms是 number。上限 9e15 远超够。
4. 并发写冲突
多 writer 要用
BEGIN IMMEDIATE或重试。CloudTone 写入大都串行化到单一 task。
本章小结
- SQLite +
sqlx+ WAL + 迁移 = Tauri 本地持久化黄金组合。 - FTS5 支撑搜索。
- 前端不要直接读 DB。
动手时刻
-
在 hello 项目里接入 sqlite,建一张
notes(id, text)表。 -
写 4 个 command:
add_note/list_notes/delete_note/update_note。 - 前端做一个简单 notes 页面,验证 CRUD。
下一章,日志 & 错误处理 & 崩溃上报。