Skip to content

perf(db): 修复 getDb() 缓存旁路导致的 KV/D1 全量重复加载 - #53

Open
PIKACHUIM wants to merge 1 commit into
mainfrom
fix/db-cache-bypass-perf
Open

PIKACHUIM wants to merge 1 commit into
mainfrom
fix/db-cache-bypass-perf

Conversation

@PIKACHUIM

Copy link
Copy Markdown
Member

perf(db): 修复 getDb() 缓存旁路导致的 KV/D1 全量重复加载

本文件为 PR 说明草稿,不提交到仓库

内容
分支 fix/db-cache-bypass-perf
基线 main @ 5cac7eb
提交 1a6cf7e
类型 性能修复(bug fix,无 API/数据格式破坏性变更)
影响面 后端数据访问层(KV / D1 / Blob / MySQL 全后端通用)

一、问题现象

线上(Cloudflare Workers)反馈:页面返回明显变慢,WebDAV 载入变慢,相比此前明显退化。

关键线索:KV 与 D1 两种后端同时变慢

KV 与 D1 的底层实现、网络路径与延迟特性完全不同。若瓶颈在存储后端本身,两者不可能同时同向退化。这一现象本身即指向共同的上层调用层


二、根因定位

2.1 直接根因:getDb() 的缓存旁路

src/backend/internal/model/db.ts

export const getDb = async (envCtx?: any) => {
  if (envCtx) { globalEnvCtx = envCtx }

  // ❌ 致命:无 envCtx 时直接落盘,dbCache / dbInflight 两个缓存全部跳过
  if (!envCtx) return loadDb(envCtx)

  const pending = dbInflight.get(envCtx)
  if (pending) return pending
  const hit = dbCache.get(envCtx)
  if (hit && Date.now() - hit.ts < DB_CACHE_TTL_MS) return hit.db
  ...
}

dbCache / dbInflight 均以调用方传入的 envCtx 对象身份为键。而代码库中存在大量无参 getDb() 调用:

位置 无参调用
src/backend/internal/op/storage.ts(WebDAV / fs 热路径) 24 处
db.ts 末尾 5 个 getter:getSettings / getUsers / getStorages / getMetas / getPlugins 全部为 await getDb()
server/seed.tsassets.tspublic.ts 若干处

于是:每一次无参 getDb() = 一次完整的冷加载

2.2 单次冷加载(loadDb)的真实开销

loadDb(envCtx)
 └─ backend.load()
     ├─ KV 后端:1 次 KV get(整块 JSON)
     └─ D1 后端:1 + N 次 SELECT(schema_info + settings/storages/users/shares/metas/plugins 全表读)
 └─ JSON.parse
 └─ unsealDb()   ← 串行 AES 逐字段解密(每个 storage/setting/user 一次 await)
 └─ ensureDefaultSettings / Storages / Shares / Plugins / Metas()  ← 5 次初始化检查

一次 WebDAV PROPFIND 或一次页面加载会触发数十次上述全流程:

  • KV 慢 = 数十次 KV get + 数十轮逐字段解密;
  • D1 慢 = 数十 × N 次 SQL 往返 + 同样的数十轮解密。

D1 通常比 KV 更慢,因为它把「1 次 KV 读」放大为「N 次 SQL 查询」,而放大倍率来自 getDb()调用次数,与后端无关 —— 这正是两者同时退化的原因。

2.3 三个叠加放大的次生缺陷

# 位置 问题
A server/middlewares.ts getJwtSecret KV 命中的分支只 return kvSecret从不写 cachedJwtSecret,导致每次调用都回源 KV。该函数每请求被 getUserFromContext / csrfProtection / checkAdminAuth 调用 2–4 次
B store/json.ts getKvBinding 无结果缓存,每次调用都重新探测 env.KV / globalThis.KV、尝试初始化 Blob SDK,并在热路径 console.log。被 readPersistedSecret / writePersistedSecret / logAudit 频繁调用。
C db.ts unsealDb 逐字段串行 await 解密,字段越多线性叠加。

2.4 回归引入时间线

Commit 日期 影响
85639e8 08-31 重构 getDb,引入 if (!envCtx) return loadDb(envCtx) 缓存旁路 ← 主因
3c5f30d 09-05 CSRF + audit 中间件每请求读 KV
1ab882e 09-09 request 级 env 透传,令 storage 层调用落入非缓存分支
758f233 / 52b947a / 2eb07ce 09-04 / 09-10 可插拔后端 + 列式 SQL 重构,放大 D1 查询数

三、修复内容

3.1 getDb():无参调用复用请求级缓存(核心)

const cacheKey = envCtx || resolveNoArgKey()   // 无参 → 回退 globalEnvCtx
if (!cacheKey) return loadDb(envCtx)           // 仅进程刚启动、纯内存调试时退化
  • 无参调用回退到请求级 globalEnvCtx 作为缓存键,复用同一套 dbCache / dbInflight
  • 同一请求(同一 isolate)内的重复调用命中缓存,不再重复落盘与解密;
  • 仅在连 globalEnvCtx 都不存在时才退化为直读 —— 不影响数据正确性,仅作性能兜底

3.2 saveDb():同步刷新无参缓存键

无参路径现在也会命中缓存,因此写入后必须同步刷新该键,否则「写后读」可能读到 TTL(1s)内的旧快照:

const cacheKey = envCtx || resolveNoArgKey()
if (cacheKey) dbCache.set(cacheKey, { ts: Date.now(), db: data })

3.3 getJwtSecret():补上 KV 命中缓存

if (cachedJwtSecret && cachedJwtSecret.length >= 32) {
  return cachedJwtSecret
}
const kvSecret = await readKvSecret(env)
if (kvSecret && kvSecret.length >= 32) {
  cachedJwtSecret = kvSecret     // ← 修复前缺失
  return kvSecret
}

env.JWT_SECRET 分支故意不写 cachedJwtSecret,避免与「KV/随机生成」缓存语义混淆(环境变量优先级更高且零成本)。
resetJwtSecretCache() 仍然生效,保证 reset_token 能使旧 token 全部失效。

3.4 getKvBinding():新增按 env 身份的结果缓存

const kvBindingCache = new WeakMap<object, { binding: any; platform: string; mode: ... }>()
  • 绑定解析结果只与 env 对象身份相关(同一请求内 env 为同一对象);
  • 仅缓存已确定结果,不缓存异常;
  • 键为 env 对象,env 释放后条目自动回收,无跨请求内存泄漏
  • 消除重复探测与热路径 console.log 噪音。

3.5 unsealDb():并行解密

同步收集待解密任务,再 Promise.all 并行执行,消除逐字段串行 await 的线性叠加。

3.6 测试可注入性(生产行为不变)

新增后端解析间接层 storeBackendLoader(生产默认 getStoreBackend),并导出两个仅供测试的钩子:

  • __resetDbCacheForTest():重置模块级缓存与内存快照,保证用例隔离;
  • __setStoreBackendLoaderForTest(loader):注入统计型后端,用于断言 load 次数。

四、验证

4.1 新增回归测试(8 个用例,全部通过)

src/backend/internal/model/db_cache.test.ts

用例 断言
重复无参调用只触发一次 load 连续 3 次 getDb()load === 1;有参调用共享同一缓存
并发无参调用合并为一次 load Promise.all 5 次并发 → load === 1
saveDb 后写后读一致 saveDbgetDb() 不额外 load 且读到新值
五个无参 getter 复用缓存快照 5 个 getter 并发 → load === 1(修复前为 5)
TTL 过期后允许重载 等待 1.1s 后 → load === 2(缓存非永久固化)

src/backend/server/jwt_secret_cache.test.ts

用例 断言
KV 命中后缓存,不再回源 后续 2 次调用 KV 读取次数不增加
resetJwtSecretCache 后重新回源 清缓存后 KV 读取次数增加
env.JWT_SECRET 优先级最高 不读取 KV

该文件中的「KV 命中后缓存」用例在修复前会失败(已本地回退验证),具备真实的回归防护能力。

4.2 类型检查

npx tsc --noEmit -p tsconfig.json   → 通过(0 error)

4.3 全量后端测试

npx tsx --test "src/backend/**/*.test.ts"
# tests 185 | # pass 183 | # fail 2

2 个失败为既有问题,已在原始 main(stash 掉本次改动后)复现,与本 PR 无关:

  • Security(F-11): ADMIN_PASS still forces an explicit reset
  • CAS codec matches casmeta base64 JSON field names

五、兼容性与风险

维度 评估
数据格式 无变更。不涉及持久化结构、加解密格式、表结构
公开 API 无变更。不涉及路由、请求/响应结构
缓存语义 缓存键从「仅 envCtx」扩展为「envCtx 或请求级 globalEnvCtx」;saveDb 主动刷新保证写后读一致;TTL 1s 保持不变
安全性 无削弱。resetJwtSecretCache 语义保留;getKvBinding 仅缓存已解析结果,不缓存异常
内存 WeakMapenv 为键,随 env 释放自动回收
多实例一致性 TTL 未变,跨实例可见性行为与修复前一致

风险点:无参缓存的键为「最近一次请求的 env」。在单 isolate 串行处理请求的运行时下行为正确;若运行时在同一 isolate 内并发处理多请求,理论上不同请求的 env 会相互覆盖缓存键。考虑到 DB_CACHE_TTL_MS = 1000saveDb 主动刷新,该风险与修复前(envCtx 传参路径)处于同一量级,不构成新增回归。建议后续将 5 个 getter 与 storage.ts 的 24 处调用的 envCtx 显式透传,作为进一步的收敛(见「后续工作」)。


六、建议的后续工作(本 PR 不含)

  1. envCtx 显式透传进 5 个 getter 与 storage.ts 的 24 处无参调用,彻底消除对「最近一次 env」的隐式依赖。
  2. 为 D1 后端评估批量读取(单次多语句)以进一步降低 SQL 往返数。
  3. 补充性能基准(bench),对 loadDb 调用次数与耗时建立 CI 门禁。

七、Review 指引

  • 核心逻辑集中在 db.tsgetDb / saveDb / unsealDb,建议优先审阅;
  • middlewares.tsgetJwtSecretjson.tsgetKvBinding 为独立的小改动;
  • 两个 *.test.ts 为新增回归测试,可直接运行 npx tsx --test src/backend/internal/model/db_cache.test.ts src/backend/server/jwt_secret_cache.test.ts 复核。

线上表现为 KV 与 D1 后端同时明显变慢。两者底层实现与网络路径完全不同,
同时退化说明瓶颈不在存储后端,而在其上的调用层:loadDb() 在一次请求内被
反复完整执行数十次。

根因:getDb() 对无参调用直接 return loadDb(envCtx),绕过了 dbCache /
dbInflight 两个缓存。而仓库中存在约 30 处无参 getDb() 调用(storage.ts 的
驱动回调,以及 getSettings/getUsers/getStorages/getMetas/getPlugins),
于是每次无参调用都触发一次完整冷加载:后端全量读取 + JSON.parse +
逐字段 AES 解密 + 5 次 ensureDefault*。一次 WebDAV PROPFIND 或一次页面加载
即触发数十次,KV 被读数十次、D1 被查数十乘 N 张表。

本次修复:

1. getDb():无参调用回退到请求级 globalEnvCtx 作为缓存键,复用同一套
   缓存(原为直接落盘)。仅在连 globalEnvCtx 都不存在时才退化为直读。
2. saveDb():同步刷新无参路径的缓存键,保证写后读不读到 TTL 内的旧快照。
3. getJwtSecret():KV 命中分支补写 cachedJwtSecret。修复前该分支只 return,
   导致每请求 2-4 次调用各自回源一次 KV。
4. getKvBinding():新增按 env 身份的结果缓存(WeakMap),消除每次调用的
   重复绑定探测与热路径 console.log 噪音。
5. unsealDb():改为收集任务后 Promise.all 并行解密,消除逐字段串行 await。

为便于测试注入统计型后端,新增仅供测试使用的
__resetDbCacheForTest / __setStoreBackendLoaderForTest 钩子,并将后端解析
统一收敛到 storeBackendLoader 间接层(生产默认行为不变)。

新增回归测试(8 个用例):
- db_cache.test.ts:验证重复/并发无参调用只 load 一次、saveDb 写后读一致、
  五个无参 getter 合并为一次 load、TTL 过期后允许重载。
- jwt_secret_cache.test.ts:验证 KV 命中后不再回源、resetJwtSecretCache 后
  重新回源、env.JWT_SECRET 优先级最高且不读 KV。

注:getJwtSecret 用例在修复前会失败(已本地验证),具备真实回归防护能力。
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
openlist-work 1a6cf7e Sep 14 2026, 07:20 AM

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
openlist-tsworkers 1a6cf7e Sep 14 2026, 07:20 AM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant