发现于 #5184(降级结果的缓存策略)实现期,不在该单范围内,故单独立卡。未认领 —— 只是记录。
现象
packages/metadata/src/metadata-manager.ts 的 list(type) 是「查缓存 → 没有就走 loader → 写缓存」的裸结构,中间没有任何 in-flight promise 共享:
const cached = this.readCachedList(type);
if (cached) return cached.items;
// …走完所有 loader…
this.cacheListResult(type, result, degraded);
缓存只在读完成之后才被写入。所以在第一次读返回之前发出的所有并发 list(type),全都 miss、全都各自把每个 loader 完整走一遍。缓存吸收的是后续调用,不是并发调用。
为什么这不只是「多花一点点」
listCache 字段注释写明了这个缓存存在的理由,以及它承诺的效果:
The cache absorbs the repeated lookups so the loader is only hit once per TTL window.
这句话对串行调用者成立,对并发调用者不成立。而注释描述的那个场景恰恰是并发最容易发生的地方 —— security/permission 中间件在请求路径上调 list('permission'),DatabaseLoader 的读在事务持锁时要等满 knex 的 acquireConnectionTimeout(60s)。在那 60s 里到达的每一个并发请求都会自己再烧一次 60s,因为还没有任何东西被写进缓存。
也就是说:这个缓存是为「一次 60s 的挂起」设计的止血,但它止不住同时发生的那几次。
日常影响则温和得多,但确实每天在跑:冷启动、每次 register() / unregister() 失效之后、#5109 的集群对端失效之后、#5218 的 FS 失效之后 —— 每一个失效点后面紧跟的那一小簇并发 list() 都会重复完整的 loader 遍历。健康路径下单次读很便宜,所以这部分是浪费而不是故障。
不是重复。 #5184 裁的是「降级结果该不该进缓存、进多久、带不带标记」—— 写入路径的判据;本卡是「写入发生得太晚,并发者根本等不到它」—— 缓存的时序。#5184 完整落地(PR #5251)也不会碰到这一点。
一处交互值得记一笔:#5251 把降级条目的 TTL 从 30s 收到 2s。这不会让并发问题变严重(并发窗口取决于一次读的耗时,不是 TTL),但它让「窗口开合更频繁」,所以每个窗口边界上的并发簇也更频繁。两者共用 list() 同一段代码,建议排在 #5251 之后做,别并行。
#5251 已经在 listCache 注释里就这句承诺挂了指向本卡的说明,免得它读起来像一个无条件保证。
候选方向(裁决留给维护者)
- single-flight:一张
type → Promise 的 in-flight 表,并发者共享同一个 promise,settle 后清掉。最小、最常规,但要想清楚 reject 语义 —— list() 现在是 best-effort 不抛的,共享 promise 后一个失败读会被所有并发者拿到同一份(残缺)结果,这大概率正是想要的,但要写成显式契约而不是巧合;
- 只对已知昂贵的 loader 做 single-flight(例如只有
DatabaseLoader),把普通 loader 的并发遍历留着 —— 复杂度换针对性,不见得划算;
- 什么都不做,但把注释那句承诺改成真话(「只对串行调用者成立」)。这至少消掉 declared ≠ enforced,代价是那个 60s 场景的并发放大原样留着。
复现
const manager = new MetadataManager({ formats: ['json'], loaders: [] });
const slow = new SlowLoader(); // loadMany 挂 100ms 再 resolve
manager.registerLoader(slow);
await Promise.all([
manager.list('permission'),
manager.list('permission'),
manager.list('permission'),
]);
// 期望:1(注释承诺的「每个 TTL 窗口只打一次 loader」)
// 实际:3
expect(slow.loadManyCalls).toBe(1);
关联
#5184 / PR #5251(发现处,同一段代码,建议串行)、#5108(DatabaseLoader 读故障不再被吞)、packages/metadata/src/metadata-manager.ts 的 list() / readCachedList() / cacheListResult() 与 listCache 字段注释。
发现于 #5184(降级结果的缓存策略)实现期,不在该单范围内,故单独立卡。未认领 —— 只是记录。
现象
packages/metadata/src/metadata-manager.ts的list(type)是「查缓存 → 没有就走 loader → 写缓存」的裸结构,中间没有任何 in-flight promise 共享:缓存只在读完成之后才被写入。所以在第一次读返回之前发出的所有并发
list(type),全都 miss、全都各自把每个 loader 完整走一遍。缓存吸收的是后续调用,不是并发调用。为什么这不只是「多花一点点」
listCache字段注释写明了这个缓存存在的理由,以及它承诺的效果:这句话对串行调用者成立,对并发调用者不成立。而注释描述的那个场景恰恰是并发最容易发生的地方 —— security/permission 中间件在请求路径上调
list('permission'),DatabaseLoader的读在事务持锁时要等满 knex 的acquireConnectionTimeout(60s)。在那 60s 里到达的每一个并发请求都会自己再烧一次 60s,因为还没有任何东西被写进缓存。也就是说:这个缓存是为「一次 60s 的挂起」设计的止血,但它止不住同时发生的那几次。
日常影响则温和得多,但确实每天在跑:冷启动、每次
register()/unregister()失效之后、#5109 的集群对端失效之后、#5218 的 FS 失效之后 —— 每一个失效点后面紧跟的那一小簇并发list()都会重复完整的 loader 遍历。健康路径下单次读很便宜,所以这部分是浪费而不是故障。与 #5184 的关系
不是重复。 #5184 裁的是「降级结果该不该进缓存、进多久、带不带标记」—— 写入路径的判据;本卡是「写入发生得太晚,并发者根本等不到它」—— 缓存的时序。#5184 完整落地(PR #5251)也不会碰到这一点。
一处交互值得记一笔:#5251 把降级条目的 TTL 从 30s 收到 2s。这不会让并发问题变严重(并发窗口取决于一次读的耗时,不是 TTL),但它让「窗口开合更频繁」,所以每个窗口边界上的并发簇也更频繁。两者共用
list()同一段代码,建议排在 #5251 之后做,别并行。#5251已经在listCache注释里就这句承诺挂了指向本卡的说明,免得它读起来像一个无条件保证。候选方向(裁决留给维护者)
type→Promise的 in-flight 表,并发者共享同一个 promise,settle 后清掉。最小、最常规,但要想清楚 reject 语义 ——list()现在是 best-effort 不抛的,共享 promise 后一个失败读会被所有并发者拿到同一份(残缺)结果,这大概率正是想要的,但要写成显式契约而不是巧合;DatabaseLoader),把普通 loader 的并发遍历留着 —— 复杂度换针对性,不见得划算;复现
关联
#5184 / PR #5251(发现处,同一段代码,建议串行)、#5108(
DatabaseLoader读故障不再被吞)、packages/metadata/src/metadata-manager.ts的list()/readCachedList()/cacheListResult()与listCache字段注释。