Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 102 additions & 2 deletions src/dao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,10 +275,20 @@ pub fn list_all_models(conn: &Connection) -> Result<Vec<serde_json::Value>> {
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(())
Expand Down Expand Up @@ -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<String> {
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);
}
}
46 changes: 41 additions & 5 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,24 @@ pub fn open(path: &str) -> Result<Connection> {
}
}
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)
Expand Down Expand Up @@ -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);
}
}
Loading