diff --git a/src/dao.rs b/src/dao.rs index 5413adc..571fc10 100644 --- a/src/dao.rs +++ b/src/dao.rs @@ -275,10 +275,20 @@ pub fn list_all_models(conn: &Connection) -> Result> { Ok(out) } -/// 更新最近使用时间 +/// 更新最近使用时间。 +/// +/// rant 2026-09-15T10:33:34:在这台 NFS 上,**每一次真写都会打掉客户端缓存**,紧随其后的查询 +/// 重新冷读(同一库同一条 COUNT:真写后 1.1s/次 vs 热 13ms)。本函数跑在每个已认证请求的 +/// 鉴权路径上(`routes::mod::AuthUser::from_request_parts`),此前每请求都写一次。 +/// +/// `last_used` 只用于 key 列表展示「最近使用」(分钟级精度足够)⇒ 60 秒内不重复写,把 +/// 「每请求一次真写」降为「每分钟至多一次」。守卫命中 0 行时 SQLite 不落页、不打掉 NFS 缓存 +/// (实测提交 5.8ms vs 真写 39ms)。 pub fn touch_api_key(conn: &Connection, key: &str) -> Result<()> { conn.execute( - "UPDATE api_keys SET last_used = datetime('now') WHERE key_value = ?1", + "UPDATE api_keys SET last_used = datetime('now') \ + WHERE key_value = ?1 \ + AND (last_used IS NULL OR last_used < datetime('now', '-60 seconds'))", [key], )?; Ok(()) @@ -453,4 +463,94 @@ mod tests { // 非标准原样 assert_eq!(utc_iso("just now"), "just now"); } + + fn tmp_conn(tag: &str) -> (Connection, std::path::PathBuf) { + let p = std::env::temp_dir().join(format!("atp_dao_{}_{}.db", std::process::id(), tag)); + let _ = std::fs::remove_file(&p); + let conn = crate::db::open(p.to_str().unwrap()).expect("open tmp db"); + conn.execute( + "INSERT INTO users (id, email, password_hash, name, role) \ + VALUES (1, 'dao@t.local', 'x', 'u', 'user')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO api_keys (id, user_id, key_value, name, status) \ + VALUES (1, 1, 'atk_live_x', 'k', 'active')", + [], + ) + .unwrap(); + (conn, p) + } + + fn last_used(conn: &Connection) -> Option { + conn.query_row( + "SELECT last_used FROM api_keys WHERE key_value = 'atk_live_x'", + [], + |r| r.get(0), + ) + .unwrap() + } + + fn set_last_used(conn: &Connection, modifier: &str) { + conn.execute( + &format!("UPDATE api_keys SET last_used = datetime('now', '{modifier}') WHERE id = 1"), + [], + ) + .unwrap(); + } + + /// 调一次 `touch_api_key` 并返回该语句**匹配的行数**(0 = SQLite 无页可落、不会真写文件)。 + fn touch_rows(conn: &Connection, key: &str) -> usize { + touch_api_key(conn, key).unwrap(); + conn.changes() as usize + } + + #[test] + fn touch_api_key_writes_at_most_once_per_guard_window() { + // rant 2026-09-15T10:33:34:本函数在鉴权路径上(每个已认证请求都会经过),此前每请求 + // 写一次 `last_used = datetime('now')`(秒级精度 ⇒ 同一秒内同值);而在这台 NFS 上 + // **一次真写就打掉客户端缓存**,紧随其后的查询重新冷读(同一条 COUNT 1.1s vs 热 13ms)。 + // 守卫把「每请求一次真写」降为「每分钟至多一次」——`last_used` 只用于列表展示。 + let (conn, p) = tmp_conn("touch_guard"); + + // ① 从未使用过 → 第一次必须写(否则「最近使用」永远是空) + assert_eq!( + last_used(&conn), + None, + "前置:新 key 的 last_used 应为 NULL" + ); + assert_eq!(touch_rows(&conn, "atk_live_x"), 1, "首次调用必须写入"); + let first = last_used(&conn).expect("首次调用必须写入 last_used"); + + // ② 窗口内(30 秒前)重复调用 → **不写**:该 UPDATE 匹配 0 行 ⇒ SQLite 不落页 + set_last_used(&conn, "-30 seconds"); + let stale = last_used(&conn).unwrap(); + assert_eq!( + touch_rows(&conn, "atk_live_x"), + 0, + "守卫窗口内仍改动了行(= 每个已认证请求一次真写,会打掉 NFS 客户端缓存)" + ); + assert_eq!(touch_rows(&conn, "atk_live_x"), 0, "连续调用同样不该写"); + assert_eq!(last_used(&conn).unwrap(), stale, "窗口内 last_used 不该变"); + + // ③ 窗口外(90 秒前)→ 必须写,否则展示会永远停在旧值 + set_last_used(&conn, "-90 seconds"); + assert_eq!( + touch_rows(&conn, "atk_live_x"), + 1, + "超过守护窗口后不再更新 last_used(展示会停在旧值)" + ); + assert!( + last_used(&conn).unwrap() > stale, + "窗口外刷新后 last_used 应前进" + ); + assert!(first <= last_used(&conn).unwrap(), "时间只能前进"); + + // ④ 不存在的 key → 本来就不该产生写入 + assert_eq!(touch_rows(&conn, "atk_live_missing"), 0); + + drop(conn); + let _ = std::fs::remove_file(p); + } } diff --git a/src/db.rs b/src/db.rs index e165f5c..ebc9e0a 100644 --- a/src/db.rs +++ b/src/db.rs @@ -21,13 +21,24 @@ pub fn open(path: &str) -> Result { } } let conn = Connection::open(path).with_context(|| format!("打开数据库失败: {}", path))?; - // v12(rant 2026-08-25T12:02:13):NFS 库性能——64MB 页缓存 + 64MB mmap 预读, - // 整库常驻进程内存,远端存储只首读一次(默认 cache_size 2MB < 库体积 → 每次查询准冷读)。 - // 注意:不要启用 WAL 模式(SQLite 官方明确不支持网络文件系统,有损坏风险); - // 数据库不能移本地盘(部署硬约束,库必须留在 NAS)。 + // v12(rant 2026-08-25T12:02:13):NFS 库性能——64MB 页缓存(默认 cache_size 2MB < 库体积 + // → 每次查询准冷读)。注意:不要启用 WAL 模式(SQLite 官方明确不支持网络文件系统,有损坏 + // 风险);数据库不能移本地盘(部署硬约束,库必须留在 NAS)。 + // + // 更正 v12 的 mmap 那一半(rant 2026-09-15T10:33:34):**mmap_size 必须为 0,不要「预读」**。 + // v12 曾同时设了 64MB mmap(注释写作「整库常驻进程内存,远端存储只首读一次」),该说法在 + // 149MB 库 + 该 NFS 上已被实测证伪:映射页读不进客户端缓存,于是每次查询都真读文件。 + // 同一条覆盖索引 COUNT(user_id=1,963 页): + // mmap=64MB → 1.5–1.8s/次(进程实读 216MB / 5 次);mmap=0 → 13ms 热(实读 2MB); + // 同一副本放本地盘 → 两者都 7ms、零实读 ⇒ 不是存储慢,是 mmap 路径在这台 NFS 上是慢路径 + // (pread 路径能吃到 NFS 客户端读缓存 + readahead)。 + // 尺寸扫描(同库同语句):0 → 13ms;16MB → 334–407ms;64MB → 1.7–2.2s;128MB → 2.2–2.8s + // ⇒ **任何非零值都更差**,所以取 0 而不是「调大一点」。 + // 为什么只有部分查询中招:文件前 64MB 内的对象走 mmap、窗口外走 pread,而索引落在 77–149MB + // ⇒ 两条同为覆盖索引扫描,差 100×,只取决于页在文件里的物理位置。 conn.pragma_update(None, "cache_size", -65536) .with_context(|| "设置 PRAGMA cache_size 失败".to_string())?; - conn.pragma_update(None, "mmap_size", 67108864) + conn.pragma_update(None, "mmap_size", 0) .with_context(|| "设置 PRAGMA mmap_size 失败".to_string())?; migrate(&conn)?; Ok(conn) @@ -1370,4 +1381,29 @@ mod tests { drop(conn); let _ = std::fs::remove_file(p); } + + #[test] + fn open_does_not_enable_memory_mapping() { + // rant 2026-09-15T10:33:34:在这台 NFS 上 mmap 是**慢**路径 —— 映射页读不进客户端缓存, + // 每次查询都真读文件。同一条覆盖索引 COUNT:mmap=64MB 1.5–1.8s/次(进程实读 216MB/5 次) + // vs mmap=0 13ms(实读 2MB);同副本放本地盘两者都 7ms、零实读(⇒ 不是存储慢)。 + // 尺寸扫描 0→13ms / 16MB→334–407ms / 64MB→1.7–2.2s / 128MB→2.2–2.8s ⇒ 任何非零值都更差。 + // 这条断言就是防回归:谁把 mmap_size 调回非零(v12 曾设 64MB),这里立刻红。 + let (conn, p) = tmp_db("mmap_off"); + let mmap: i64 = conn + .query_row("PRAGMA mmap_size", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + mmap, 0, + "open() 不该启用 mmap(PRAGMA mmap_size = {mmap}):该 NFS 上非零 mmap 让每次查询\ + 真读文件,同一条 COUNT 从 13ms 退化到 1.5–1.8s" + ); + // 阳性对照:v12 的页缓存这一半要保留(默认 2MB < 库体积 ⇒ 每次查询准冷读) + let cache: i64 = conn + .query_row("PRAGMA cache_size", [], |r| r.get(0)) + .unwrap(); + assert_eq!(cache, -65536, "64MB 页缓存不该被一起撤掉"); + drop(conn); + let _ = std::fs::remove_file(p); + } }