【AI开发之Rust】第 18 课:SQLite 持久化与本地缓存 —— 给 store 填上真实现

18.1 这节课解决什么问题

第 17 课的 HistoryStore 还是内存假实现------App 一重启历史全丢。本课把它换成 SQLite 本地数据库,回答三件事:

复制代码
① rusqlite:怎么连、怎么建表、怎么读写(参数化查询防注入)
② 表结构 & 迁移:schema 版本升级不丢数据
③ 把 sqlite 实现塞回 core:服务层测试一行不改(17 课接口红利的兑现)

同时处理 SQLite 在真实 App 里最常见的"最后 20%":忙锁(database is locked)、连接共享、并发读写。这些坑恰恰是 UniFFI(19-21 课)把 core 暴露给多个 UI 线程后必然撞上的。

💡 为什么本地存储选 SQLite 而不是"写个 JSON 文件"?历史记录会持续增长,需要随机查询单会话/删除/排序 ------JSON 全量读改写在大数据量下又慢又容易坏。SQLite 单文件、零服务、SQL 能力完整,是移动/桌面端本地存储的事实标准。会话与消息的结构化适合表;而"聊天原样快照"之类大对象适合存成 JSON 列/独立文件------两种都会在本课出现。

18.2 rusqlite 起步:打开连接 + 最小读写

toml 复制代码
# core/Cargo.toml 追加
[dependencies]
rusqlite = { version = "0.32", features = ["bundled"] }   # bundled:免系统 SQLite,跨端更省心
chrono 不再需要------时间用 i64 毫秒(第 17 课 now_ms)
rust 复制代码
// store/sqlite.rs ------ 新的模块
use rusqlite::Connection;
use std::path::Path;

pub struct SqliteStore {
    conn: Connection,
}

impl SqliteStore {
    /// 打开(或创建)数据库文件
    pub fn open(path: impl AsRef<Path>) -> Result<Self, rusqlite::Error> {
        let conn = Connection::open(path)?;
        conn.pragma_update(None, "journal_mode", "WAL")?;      // 见 18.5
        conn.pragma_update(None, "foreign_keys", "ON")?;
        let store = SqliteStore { conn };
        store.migrate()?;                                       // 见 18.3
        Ok(store)
    }
}

最小读写样例(临时库,:memory: 可用于测试):

rust 复制代码
use rusqlite::{params, Connection};

fn main() -> rusqlite::Result<()> {
    let conn = Connection::open_in_memory()?;

    conn.execute("CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT NOT NULL)", [])?;

    // 参数化插入(占位符 ?1,值作为参数传入 → 自动防注入)
    conn.execute("INSERT INTO kv (k, v) VALUES (?1, ?2)", params!["greeting", "你好"])?;

    // 参数化查询
    let v: String = conn.query_row(
        "SELECT v FROM kv WHERE k = ?1",
        ["greeting"],
        |row| row.get(0),
    )?;
    println!("{v}");

    // 多行读取(迭代 Statement)
    let mut stmt = conn.prepare("SELECT k, v FROM kv")?;
    let rows = stmt.query_map([], |row| {
        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
    })?;
    for pair in rows {
        println!("{:?}", pair?);
    }
    Ok(())
}

⚠️ 永远用参数化,绝不要字符串拼接 SQL:

rust 复制代码
// ❌ 注入风险 + 引号转义地狱
let sql = format!("INSERT INTO kv VALUES ('{}')", user_input);
// ✅ 参数绑定:rusqlite 负责转义,SQL 与数据分离
conn.execute("INSERT INTO kv VALUES (?1)", params![user_input])?;

18.3 表结构与迁移:schema 版本化

18.3.1 建表 DDL

sql 复制代码
-- sessions:会话表
CREATE TABLE IF NOT EXISTS sessions (
    id           TEXT PRIMARY KEY,     -- "s-<ms>-<rand>"
    title        TEXT NOT NULL,
    created_at_ms INTEGER NOT NULL
);

-- messages:消息表
CREATE TABLE IF NOT EXISTS messages (
    id           TEXT PRIMARY KEY,
    session_id   TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
    role         TEXT NOT NULL,        -- 'user' / 'assistant' / 'system'
    content      TEXT NOT NULL,
    created_at_ms INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_messages_session
    ON messages (session_id, created_at_ms);
  • ON DELETE CASCADE:删会话自动连带删消息(17 课 delete_session 就两个动作,其实库层一步到位);
  • 索引建在 (session_id, created_at_ms) 上:查单会话历史只走索引,随数据增长不退化;
  • role 用 TEXT 存,读出来再转 Role 枚举(写入反过来)------不存 SQLite 不认识的 Rust enum。

18.3.2 用 PRAGMA user_version 做轻量迁移

rust 复制代码
impl SqliteStore {
    /// 简单迁移:记录 schema 版本号,逐版升级
    fn migrate(&self) -> rusqlite::Result<()> {
        let cur: i64 = self.conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;

        if cur < 1 {
            self.conn.execute_batch(
                "BEGIN;
                 CREATE TABLE IF NOT EXISTS sessions (...同 18.3.1...);
                 CREATE TABLE IF NOT EXISTS messages (...);
                 CREATE INDEX ...;
                 PRAGMA user_version = 1;
                 COMMIT;",
            )?;
        }
        // 将来加字段/加表:if cur < 2 { ... PRAGMA user_version = 2; }
        Ok(())
    }
}

💡 真实项目可用 rusqlite_migration 这类 crate 管理有序迁移脚本;但"用户版本号 + 逐段 execute_batch"的原生写法已经足够支撑本课程规模,且零额外依赖。关键是养成习惯:任何 schema 变更都必须是"新增一段 if 版本号"的迁移,而不是改旧 SQL------否则老用户升级必炸。

18.4 实现 HistoryStore for SqliteStore

rust 复制代码
use crate::models::{Message, Role, Session};
use crate::store::{HistoryStore, StoreError};
use rusqlite::params;

impl HistoryStore for SqliteStore {
    fn create_session(&self, session: &Session) -> Result<(), StoreError> {
        self.conn
            .execute(
                "INSERT INTO sessions (id, title, created_at_ms) VALUES (?1, ?2, ?3)",
                params![session.id, session.title, session.created_at_ms],
            )
            .map_err(map_err)?;
        Ok(())
    }

    fn list_sessions(&self, limit: u32) -> Result<Vec<Session>, StoreError> {
        let mut stmt = self
            .conn
            .prepare("SELECT id, title, created_at_ms FROM sessions ORDER BY created_at_ms DESC LIMIT ?1")
            .map_err(map_err)?;
        let rows = stmt
            .query_map([limit], |row| {
                Ok(Session {
                    id: row.get(0)?,
                    title: row.get(1)?,
                    created_at_ms: row.get(2)?,
                })
            })
            .map_err(map_err)?;
        rows.collect::<Result<Vec<_>, _>>().map_err(map_err)
    }

    fn delete_session(&self, session_id: &str) -> Result<(), StoreError> {
        self.conn
            .execute("DELETE FROM sessions WHERE id = ?1", params![session_id])
            .map_err(map_err)?;          // messages 靠 CASCADE 一起删
        Ok(())
    }

    fn append_message(&self, msg: &Message) -> Result<(), StoreError> {
        let role = role_to_db(&msg.role);   // Role → &str
        self.conn
            .execute(
                "INSERT INTO messages (id, session_id, role, content, created_at_ms)
                 VALUES (?1, ?2, ?3, ?4, ?5)",
                params![msg.id, msg.session_id, role, msg.content, msg.created_at_ms],
            )
            .map_err(map_err)?;
        Ok(())
    }

    fn list_messages(&self, session_id: &str) -> Result<Vec<Message>, StoreError> {
        let mut stmt = self
            .conn
            .prepare(
                "SELECT id, session_id, role, content, created_at_ms
                 FROM messages WHERE session_id = ?1 ORDER BY created_at_ms ASC",
            )
            .map_err(map_err)?;
        let rows = stmt
            .query_map([session_id], |row| {
                let role: String = row.get(2)?;
                Ok(Message {
                    id: row.get(0)?,
                    session_id: row.get(1)?,
                    role: role_from_db(&role),
                    content: row.get(3)?,
                    created_at_ms: row.get(4)?,
                })
            })
            .map_err(map_err)?;
        rows.collect::<Result<Vec<_>, _>>().map_err(map_err)
    }

    fn clear_messages(&self, session_id: &str) -> Result<(), StoreError> {
        self.conn
            .execute("DELETE FROM messages WHERE session_id = ?1", params![session_id])
            .map_err(map_err)?;
        Ok(())
    }
}

/// 把 rusqlite 错误统一转成 StoreError(第 17 课的契约类型)
fn map_err(e: rusqlite::Error) -> StoreError {
    match e {
        rusqlite::Error::QueryReturnedNoRows => StoreError::NotFound("记录不存在".into()),
        other => StoreError::Io(other.to_string()),
    }
}

fn role_to_db(role: &Role) -> &'static str {
    match role { Role::User => "user", Role::Assistant => "assistant", Role::System => "system" }
}

fn role_from_db(s: &str) -> Role {
    match s { "assistant" => Role::Assistant, "system" => Role::System, _ => Role::User }
}

💡 读 DB 行 → Rust struct 的样板循环(query_map + row.get)在代码里很常见。量大后可考虑 serde 的 rusqlite 适配(derive 直接映射行),但先学会手写版,错误类型和列顺序都由你掌控。

18.5 并发与"database is locked":真实 App 必修课

18.5.1 问题从哪来

第 19 课起,UI 的多个线程/任务可能同时调 append_message(用户连发多条)、list_sessions(刷新列表)、delete_session。SQLite 默认一次只允许一个写者 ,写锁没释放时另一写会报 database is locked。

rusqlite 的 Connection 默认不是 Sync (含内部缓存),直接放进 Box<dyn HistoryStore> 并跨线程调用会编译不过。三种处理姿势:

姿势 做法 适用
A. 每操作新开连接 线程/调用各自 Connection::open 演示、低频
B. 进程级单连接 + 外部 Mutex Mutex<Connection> 串行化所有访问 简单可靠(本课采用)
C. 连接池 r2d2_sqlite 之类 高并发、多读多写

本课采用 B :把 Connection 包在 Mutex 里,天然满足 Sync 且把写冲突变成"排队"(加上 18.2 已开的 WAL 模式,读不阻塞写):

rust 复制代码
use std::sync::Mutex;

pub struct SqliteStore {
    conn: Mutex<Connection>,          // 内部串行化;对外仍是 Sync
}

impl SqliteStore {
    pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, rusqlite::Error> {
        let conn = Connection::open(path)?;
        conn.pragma_update(None, "journal_mode", "WAL")?;
        conn.pragma_update(None, "foreign_keys", "ON")?;
        let store = SqliteStore { conn: Mutex::new(conn) };
        store.migrate()?;
        Ok(store)
    }

    fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>, StoreError> {
        self.conn.lock().map_err(|_| StoreError::Io("存储锁中毒".into()))
    }
}

然后每个方法开头 let conn = self.lock()?;,后续全部走 conn:

rust 复制代码
impl HistoryStore for SqliteStore {
    fn create_session(&self, session: &Session) -> Result<(), StoreError> {
        let conn = self.lock()?;
        conn.execute(
            "INSERT INTO sessions (id, title, created_at_ms) VALUES (?1, ?2, ?3)",
            params![session.id, session.title, session.created_at_ms],
        )
        .map_err(map_err)?;
        Ok(())
    }
    // ......其余方法同样先 lock()
}

⚠️ 同步方法里用 std::sync::Mutex 没问题(不跨 .await 持有)。永远不要在持锁期间调用 UI/网络------锁粒度 = 一条 SQL。SQLite 单次操作毫秒级,串行化完全可接受。

18.5.2 WAL 模式是什么

ini 复制代码
journal_mode = WAL(Write-Ahead Logging):
  - 写操作先追加到 .wal 文件,再择机合入主库
  - 效果:读写不互相阻塞(一个写者 + 多个读者可同时进行)
  - App 常见标配;配合 busy_timeout 可进一步缓解极端冲突
rust 复制代码
// 极端冲突兜底:等锁最长 5 秒而不是立刻报错
conn.busy_timeout(std::time::Duration::from_secs(5))?;

18.6 把真 store 接回 service:17 课测试一行不改

rust 复制代码
// core 集成测试:sqlite_smoke.rs
use my_ai_core::llm::chat::ChatLlm;
use my_ai_core::models::Message;
use my_ai_core::service::AssistantService;
use my_ai_core::store::{HistoryStore, SqliteStore};

struct FakeLlm;   // 同 17.6

#[tokio::test]
async fn sqlite_roundtrip_via_service() {
    // 临时文件库,测完自动删
    let dir = std::env::temp_dir();
    let path = dir.join(format!("ai_test_{}.db", std::process::id()));

    let store = SqliteStore::open(&path).unwrap();
    let service = AssistantService::new(Box::new(FakeLlm), Box::new(store), "系统提示".into());

    let session = service.new_session("持久化测试").await.unwrap();
    let mut got = String::new();
    service
        .ask_stream(&session.id, "存下来了吗", Box::new(|d| got.push_str(d)))
        .await
        .unwrap();
    assert!(got.contains("你好,世界"));

    // 重新打开同一个文件(模拟 App 重启):数据还在
    let store2 = SqliteStore::open(&path).unwrap();
    let sessions = store2.list_sessions(10).unwrap();
    assert_eq!(sessions.len(), 1);
    let msgs = store2.list_messages(&session.id).unwrap();
    assert_eq!(msgs.len(), 2);

    std::fs::remove_file(&path).ok();
}

验收瞬间 :同一段 service 测试,把 MemStore::default() 换成 SqliteStore::open(...) 就跑通------17 课设计的接口抽象兑现了。

18.7 📝 动手练习

参考实现放 code/18-sqlite/(写作时同步给出)。

  1. rusqlite 最小读写 ::memory: 建表/插入/查询/迭代,练参数化与 query_map 收行。
  2. 防注入验证 :向 18.2 的插入函数传 x'); DROP TABLE kv; --,确认它只是被当作普通字符串存进去(参数化生效)。
  3. 完整实现 HistoryStore for SqliteStore:按 18.4 补全全部 6 个方法,并跑通 18.6 的冒烟测试。
  4. 迁移实验:先建 version=1 的库,再用"version=2:给 messages 加一列 complete INTEGER DEFAULT 1"的迁移脚本升级,验证老数据不丢、新字段可写。
  5. 忙锁观察 :开两个 SqliteStore 指向同一文件,线程 A 在事务里 sleep 200ms 后提交,线程 B 同时写------先不设 busy_timeout 记录报错,再设 5s 观察排队成功。
  6. 并发冒烟:8 个 tokio 任务各 append 50 条消息到同一会话,结束后断言总数 = 400(体会 Mutex 串行化 + WAL 的效果)。
  7. 综合(重点) :给 messages 补 complete 列并在模型加字段,让 ask_stream 断流时(FakeLlm 抛 StreamInterrupted)能落一条 complete=false 的尾巴消息------为 19 课"流中断可恢复"预演。

验收门禁 :能写出带 ?1 占位 + params! 的最小读写;能说出 WAL 解决了什么、Mutex 解决了什么;能解释为什么 schema 变更必须走版本迁移而不是改旧 SQL。

✅ 本节小结

  • rusqlite :bundled 特性免系统依赖;:memory: 便于测试;永远参数化 SQL;
  • schema :sessions/messages 两张表 + 复合索引 + ON DELETE CASCADE;PRAGMA user_version 做增量迁移;
  • 枚举映射:DB 存 TEXT,行读取时再转回 Rust enum;
  • 并发 :Mutex<Connection> 串行化(天然 Sync)、WAL 读写分离、busy_timeout 兜底;
  • 接口红利:换 store 实现,service 测试与调用方零改动;
  • 纪律:持锁不做 IO、schema 只加不改、迁移逐版本前进。

下一课预告:第 19 课《UniFFI 导出核心能力》------把 17-18 课的 core 交给各端:UniFFI 生成 Swift/Kotlin/Python 绑定、async 方法导出、复杂类型(Session/Message/enum 错误)映射、以及"如何在壳与 core 之间传回调"。这是实战篇的技术主峰。

相关推荐
柯南46681 小时前
【AI开发之Rust】第 17 课:项目总览与核心架构 —— AI 助手 Rust 核心从 0 到 1
rust·编程语言
挖掘狂人1 小时前
ObjectSense:一门千行内核、把可靠性写进骨子里的面向对象脚本语言
程序员·编程语言·汇编语言
codigger1 小时前
ObjectSense:一门千行内核、把可靠性写进骨子里的面向对象脚本语言
开发语言·编程·编程语言
柯南46681 小时前
【AI开发之Rust】第 16 课:FFI 手写绑定与内存布局 —— 把 Rust 交给别的语言
rust·编程语言
达子6661 小时前
RUST 图解 第 2 章:写小游戏
rust
健康活着就好1 小时前
Go 并发编程入门,goroutine 和 channel 就这么简单
编程语言
Amos_Web1 小时前
Rspack 源码解析(十四):Resolver 与 NormalModuleFactory
前端·rust·源码
Kapaseker1 小时前
秒懂 Rust 的 7 个核心概念
rust
RobinDevNotes1 小时前
用 godot-rust 给 Godot 写 Rust 扩展
rust·游戏开发