
18.1 这节课解决什么问题第 17 课的HistoryStore还是内存假实现——App 一重启历史全丢。本课把它换成SQLite本地数据库回答三件事① rusqlite怎么连、怎么建表、怎么读写参数化查询防注入 ② 表结构 迁移schema 版本升级不丢数据 ③ 把 sqlite 实现塞回 core服务层测试一行不改17 课接口红利的兑现同时处理 SQLite 在真实 App 里最常见的最后 20%忙锁database is locked、连接共享、并发读写。这些坑恰恰是 UniFFI19-21 课把 core 暴露给多个 UI 线程后必然撞上的。 为什么本地存储选 SQLite 而不是写个 JSON 文件历史记录会持续增长需要随机查询单会话/删除/排序——JSON 全量读改写在大数据量下又慢又容易坏。SQLite 单文件、零服务、SQL 能力完整是移动/桌面端本地存储的事实标准。会话与消息的结构化适合表而聊天原样快照之类大对象适合存成 JSON 列/独立文件——两种都会在本课出现。18.2 rusqlite 起步打开连接 最小读写# core/Cargo.toml 追加 [dependencies] rusqlite { version 0.32, features [bundled] } # bundled免系统 SQLite跨端更省心 chrono 不再需要——时间用 i64 毫秒第 17 课 now_ms// store/sqlite.rs —— 新的模块userusqlite::Connection;usestd::path::Path;pubstructSqliteStore{conn:Connection,}implSqliteStore{/// 打开或创建数据库文件pubfnopen(path:implAsRefPath)-ResultSelf,rusqlite::Error{letconnConnection::open(path)?;conn.pragma_update(None,journal_mode,WAL)?;// 见 18.5conn.pragma_update(None,foreign_keys,ON)?;letstoreSqliteStore{conn};store.migrate()?;// 见 18.3Ok(store)}}最小读写样例临时库:memory:可用于测试userusqlite::{params,Connection};fnmain()-rusqlite::Result(){letconnConnection::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,你好])?;// 参数化查询letv:Stringconn.query_row(SELECT v FROM kv WHERE k ?1,[greeting],|row|row.get(0),)?;println!({v});// 多行读取迭代 Statementletmutstmtconn.prepare(SELECT k, v FROM kv)?;letrowsstmt.query_map([],|row|{Ok((row.get::_,String(0)?,row.get::_,String(1)?))})?;forpairinrows{println!({:?},pair?);}Ok(())}⚠️永远用参数化绝不要字符串拼接 SQL// ❌ 注入风险 引号转义地狱letsqlformat!(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-- sessions会话表CREATETABLEIFNOTEXISTSsessions(idTEXTPRIMARYKEY,-- s-ms-randtitleTEXTNOTNULL,created_at_msINTEGERNOTNULL);-- messages消息表CREATETABLEIFNOTEXISTSmessages(idTEXTPRIMARYKEY,session_idTEXTNOTNULLREFERENCESsessions(id)ONDELETECASCADE,roleTEXTNOTNULL,-- user / assistant / systemcontentTEXTNOTNULL,created_at_msINTEGERNOTNULL);CREATEINDEXIFNOTEXISTSidx_messages_sessionONmessages(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做轻量迁移implSqliteStore{/// 简单迁移记录 schema 版本号逐版升级fnmigrate(self)-rusqlite::Result(){letcur:i64self.conn.query_row(PRAGMA user_version,[],|r|r.get(0))?;ifcur1{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 SqliteStoreusecrate::models::{Message,Role,Session};usecrate::store::{HistoryStore,StoreError};userusqlite::params;implHistoryStoreforSqliteStore{fncreate_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(())}fnlist_sessions(self,limit:u32)-ResultVecSession,StoreError{letmutstmtself.conn.prepare(SELECT id, title, created_at_ms FROM sessions ORDER BY created_at_ms DESC LIMIT ?1).map_err(map_err)?;letrowsstmt.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::ResultVec_,_().map_err(map_err)}fndelete_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(())}fnappend_message(self,msg:Message)-Result(),StoreError{letrolerole_to_db(msg.role);// Role → strself.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(())}fnlist_messages(self,session_id:str)-ResultVecMessage,StoreError{letmutstmtself.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)?;letrowsstmt.query_map([session_id],|row|{letrole:Stringrow.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::ResultVec_,_().map_err(map_err)}fnclear_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 课的契约类型fnmap_err(e:rusqlite::Error)-StoreError{matche{rusqlite::Error::QueryReturnedNoRowsStoreError::NotFound(记录不存在.into()),otherStoreError::Io(other.to_string()),}}fnrole_to_db(role:Role)-staticstr{matchrole{Role::Useruser,Role::Assistantassistant,Role::Systemsystem}}fnrole_from_db(s:str)-Role{matchs{assistantRole::Assistant,systemRole::System,_Role::User}} 读 DB 行 → Rust struct 的样板循环query_maprow.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含内部缓存直接放进Boxdyn HistoryStore并跨线程调用会编译不过。三种处理姿势姿势做法适用A. 每操作新开连接线程/调用各自Connection::open演示、低频B. 进程级单连接 外部 MutexMutexConnection串行化所有访问简单可靠本课采用C. 连接池r2d2_sqlite之类高并发、多读多写本课采用 B把Connection包在Mutex里天然满足Sync且把写冲突变成排队加上 18.2 已开的 WAL 模式读不阻塞写usestd::sync::Mutex;pubstructSqliteStore{conn:MutexConnection,// 内部串行化对外仍是 Sync}implSqliteStore{pubfnopen(path:implAsRefstd::path::Path)-ResultSelf,rusqlite::Error{letconnConnection::open(path)?;conn.pragma_update(None,journal_mode,WAL)?;conn.pragma_update(None,foreign_keys,ON)?;letstoreSqliteStore{conn:Mutex::new(conn)};store.migrate()?;Ok(store)}fnlock(self)-Resultstd::sync::MutexGuard_,Connection,StoreError{self.conn.lock().map_err(|_|StoreError::Io(存储锁中毒.into()))}}然后每个方法开头let conn self.lock()?;后续全部走connimplHistoryStoreforSqliteStore{fncreate_session(self,session:Session)-Result(),StoreError{letconnself.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 模式是什么journal_mode WALWrite-Ahead Logging - 写操作先追加到 .wal 文件再择机合入主库 - 效果读写不互相阻塞一个写者 多个读者可同时进行 - App 常见标配配合 busy_timeout 可进一步缓解极端冲突// 极端冲突兜底等锁最长 5 秒而不是立刻报错conn.busy_timeout(std::time::Duration::from_secs(5))?;18.6 把真 store 接回 service17 课测试一行不改// core 集成测试sqlite_smoke.rsusemy_ai_core::llm::chat::ChatLlm;usemy_ai_core::models::Message;usemy_ai_core::service::AssistantService;usemy_ai_core::store::{HistoryStore,SqliteStore};structFakeLlm;// 同 17.6#[tokio::test]asyncfnsqlite_roundtrip_via_service(){// 临时文件库测完自动删letdirstd::env::temp_dir();letpathdir.join(format!(ai_test_{}.db,std::process::id()));letstoreSqliteStore::open(path).unwrap();letserviceAssistantService::new(Box::new(FakeLlm),Box::new(store),系统提示.into());letsessionservice.new_session(持久化测试).await.unwrap();letmutgotString::new();service.ask_stream(session.id,存下来了吗,Box::new(|d|got.push_str(d))).await.unwrap();assert!(got.contains(你好世界));// 重新打开同一个文件模拟 App 重启数据还在letstore2SqliteStore::open(path).unwrap();letsessionsstore2.list_sessions(10).unwrap();assert_eq!(sessions.len(),1);letmsgsstore2.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/写作时同步给出。rusqlite 最小读写:memory:建表/插入/查询/迭代练参数化与query_map收行。防注入验证向 18.2 的插入函数传x); DROP TABLE kv; --确认它只是被当作普通字符串存进去参数化生效。完整实现 HistoryStore for SqliteStore按 18.4 补全全部 6 个方法并跑通 18.6 的冒烟测试。迁移实验先建 version1 的库再用version2给 messages 加一列 complete INTEGER DEFAULT 1的迁移脚本升级验证老数据不丢、新字段可写。忙锁观察开两个SqliteStore指向同一文件线程 A 在事务里sleep 200ms后提交线程 B 同时写——先不设 busy_timeout 记录报错再设 5s 观察排队成功。并发冒烟8 个 tokio 任务各 append 50 条消息到同一会话结束后断言总数 400体会 Mutex 串行化 WAL 的效果。综合重点给messages补complete列并在模型加字段让ask_stream断流时FakeLlm 抛StreamInterrupted能落一条completefalse的尾巴消息——为 19 课流中断可恢复预演。验收门禁能写出带?1占位 params!的最小读写能说出 WAL 解决了什么、Mutex 解决了什么能解释为什么 schema 变更必须走版本迁移而不是改旧 SQL。✅ 本节小结rusqlitebundled特性免系统依赖:memory:便于测试永远参数化 SQLschemasessions/messages 两张表 复合索引 ON DELETE CASCADEPRAGMA user_version做增量迁移枚举映射DB 存 TEXT行读取时再转回 Rust enum并发MutexConnection串行化天然 Sync、WAL 读写分离、busy_timeout兜底接口红利换 store 实现service 测试与调用方零改动纪律持锁不做 IO、schema 只加不改、迁移逐版本前进。下一课预告第 19 课《UniFFI 导出核心能力》——把 17-18 课的 core 交给各端UniFFI 生成 Swift/Kotlin/Python 绑定、async 方法导出、复杂类型Session/Message/enum 错误映射、以及如何在壳与 core 之间传回调。这是实战篇的技术主峰。