Skip to content

Commit 3728204

Browse files
committed
fix:P1 Bug 修复:MultiLevelCache L2→L1 回填 TTL 缺失(null 永久驻留 L1)+ 新增 backfillLocalTTL 配置 + Redis getWithTTL 方法 + 14 个回归测试
1 parent 0029630 commit 3728204

8 files changed

Lines changed: 532 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
# 变更日志 (CHANGELOG)
22

33
> **说明**: 版本概览摘要,详细变更见 [changelogs/](./changelogs/) 目录
4-
> **最后更新**: 2026-03-16
4+
> **最后更新**: 2026-04-02
55
66
---
77

88
## 版本概览
99

1010
| 版本 | 日期 | 变更摘要 | 详细 |
1111
|------|------|---------|------|
12+
| [v1.1.9](./changelogs/v1.1.9.md) | 2026-04-02 | 🚨 **P1 Bug 修复**:MultiLevelCache L2→L1 回填 TTL 缺失(null 永久驻留 L1)+ 新增 `backfillLocalTTL` 配置 + Redis `getWithTTL` 方法 + 14 个回归测试 | [查看](./changelogs/v1.1.9.md) |
1213
| [v1.1.8](./changelogs/v1.1.8.md) | 2026-03-16 | 🆕 **新功能**:Model 热重载支持(`undefine()` + `redefine()` + `_loadModels` reload 模式)+ 22个测试 (100%通过) | [查看](./changelogs/v1.1.8.md) |
1314
| [v1.1.6](./changelogs/v1.1.6.md) | 2026-02-11 | 🎉 **重大功能**:精准缓存失效机制 + 🚨 upsert 缓存失效 Bug 修复 + 36个测试 (100%通过) | [查看](./changelogs/v1.1.6.md) |
1415
| [v1.1.4](./changelogs/v1.1.4.md) | 2026-02-09 | 🎉 重大功能:通用函数缓存 - 52个测试 (100%通过) + 多层缓存 delPattern 修复 | [查看](./changelogs/v1.1.4.md) |

changelogs/v1.1.9.md

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Changelog v1.1.9
2+
3+
> **发布日期**: 2026-04-02
4+
> **版本类型**: 🚨 Bug 修复(P1 高危)
5+
> **重要性**: ⭐⭐⭐⭐⭐(强烈建议升级,生产环境存在数据不可见风险)
6+
7+
---
8+
9+
## 🚨 Bug 修复
10+
11+
### ✅ MultiLevelCache L2→L1 回填 TTL 缺失修复
12+
13+
**问题描述**
14+
15+
`MultiLevelCache.get()` 在 L2(远端)命中后回填 L1(本地)时,调用 `this.local.set(key, r)` 未传 TTL 参数,导致 TTL 默认为 0,进而 `expireAt = null`(永不过期)。当 L2 缓存的值为 `null`(空结果,如软删除记录)时,该 `null` 会被永久写入 L1 内存,直到进程重启或手动清除。
16+
17+
**修复策略(双层防护)**
18+
19+
1. **主修复**:新增 `policy.backfillLocalTTL` 配置项,回填时传入 TTL;当 remote 支持 `getWithTTL` 时优先使用 L2 剩余 TTL
20+
2. **兜底防护**(无需配置):当 `backfillLocalTTL=0`(默认)且回填值为 `null` 时,**直接跳过回填**——消除"未配置用户仍触发 Bug"的设计缺陷
21+
22+
```javascript
23+
// 修复后的回填逻辑(backfillTTL=0 + null → 跳过)
24+
if (backfillTTL > 0 || r !== null) {
25+
try { await this.local.set(key, r, backfillTTL); } catch(_) {}
26+
}
27+
```
28+
29+
**触发条件**(进程重启/横向扩容后必然触发):L2 有 null 缓存 → L1 miss → 回填 → 修复前永久驻留,修复后安全跳过或按 TTL 过期
30+
31+
**影响范围**:所有启用 `multiLevel: true` 且查询结果可能为 `null` 的场景
32+
33+
**严重程度**:🔴 P1 高危 — 生产数据不可见,无法自愈,需重启进程或手动清除
34+
35+
---
36+
37+
## ✨ 新增功能
38+
39+
### `policy.backfillLocalTTL` — 回填 L1 的兜底 TTL 配置项
40+
41+
新增 `MultiLevelCache` 构造函数 `policy.backfillLocalTTL` 配置(默认 `0`,向后兼容):
42+
43+
```javascript
44+
cache: {
45+
multiLevel: true,
46+
remote: MonSQLize.createRedisCacheAdapter('redis://localhost:6379/0'),
47+
policy: {
48+
backfillLocalOnRemoteHit: true,
49+
// 🆕 v1.1.9: 回填 L1 时的兜底 TTL(毫秒)
50+
// - 0(默认):不设 TTL,与修复前行为一致(向后兼容)
51+
// - 建议生产环境配置合理值,防止 null 值永久驻留
52+
backfillLocalTTL: 60000, // L1 回填最多缓存 60 秒
53+
}
54+
}
55+
```
56+
57+
**优先级策略**(方案 A+B 组合):
58+
1. **方案 A(优先)**:当 `remote` 为内置 Redis 适配器时,使用新增的 `getWithTTL(key)` 方法获取 L2 剩余 TTL,回填时使用真实剩余 TTL(单次 pipeline RTT)
59+
2. **方案 B(兜底)**`remote` 不支持 `getWithTTL`,或 L2 剩余 TTL = 0 时,使用 `backfillLocalTTL` 兜底
60+
61+
### `RedisCacheAdapter.getWithTTL(key)` — 获取值及剩余 TTL
62+
63+
```javascript
64+
// 返回 { value: any, remainingTTL: number } 或 undefined(key 不存在)
65+
// 使用 GET + PTTL pipeline,单次 RTT
66+
const meta = await adapter.getWithTTL('my-key');
67+
// meta.value → 缓存的值(可为 null)
68+
// meta.remainingTTL → 剩余毫秒数(0 = 永不过期)
69+
```
70+
71+
---
72+
73+
## 📊 影响文件
74+
75+
| 文件 | 变更类型 | 说明 |
76+
|------|---------|------|
77+
| `lib/multi-level-cache.js` | 修复 + 新增 | 构造函数加 `backfillLocalTTL``get()` 修复回填 TTL;`getMany()` 修复回填 TTL |
78+
| `lib/redis-cache-adapter.js` | 新增 | 新增 `getWithTTL(key)` 方法 |
79+
| `test/unit/infrastructure/multi-level-cache.test.js` | 新增 | 14 个测试用例(含 null 永久驻留核心回归测试) |
80+
| `test/run-tests.js` | 修改 | 注册 `multi-level-cache``infrastructure` 套件 |
81+
| `docs/cache.md` | 更新 | 补充 `backfillLocalTTL` 配置说明 |
82+
83+
---
84+
85+
## ✅ 验证结果
86+
87+
```
88+
📦 MultiLevelCache — 基础功能 (6 tests ✓)
89+
📦 MultiLevelCache — backfillLocalTTL (5 tests ✓) ← 含补充修复用例
90+
📦 MultiLevelCache — getWithTTL 方案A (3 tests ✓)
91+
📦 MultiLevelCache — getMany 回填 (2 tests ✓) ← 含补充修复用例
92+
═══════════════════════
93+
✓ 通过: 16 个测试 / 耗时: 0.74 秒
94+
```
95+
96+
---
97+
98+
## 📋 升级指南
99+
100+
### 向后兼容
101+
102+
**完全向后兼容**`backfillLocalTTL` 默认值为 `0`,等同修复前行为,无需修改任何现有配置。
103+
104+
### 建议生产配置
105+
106+
对于启用 `multiLevel` 的场景,**强烈建议**配置合理的 `backfillLocalTTL`,防止 `null` 值永久驻留 L1:
107+
108+
```javascript
109+
policy: {
110+
backfillLocalOnRemoteHit: true,
111+
backfillLocalTTL: 60000, // 建议值:与 L1 正常 TTL 相近或更短
112+
}
113+
```
114+
115+
### 临时绕过(升级前)
116+
117+
若暂时无法升级,可通过禁用回填来阻断问题(性能略有损失):
118+
119+
```javascript
120+
policy: { backfillLocalOnRemoteHit: false }
121+
```
122+
123+
---
124+
125+
**文档生成时间**: 2026-04-02
126+

docs/cache.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,12 @@ const msq = new MonSQLize({
377377
// 缓存策略
378378
policy: {
379379
writePolicy: 'both', // 'both' | 'local-first-async-remote'
380-
backfillLocalOnRemoteHit: true // 远端命中时回填本地(默认 true)
380+
backfillLocalOnRemoteHit: true, // 远端命中时回填本地(默认 true)
381+
// 🔧 v1.1.9: 回填 L1 时的兜底 TTL(毫秒)
382+
// - 0(默认): 不设 TTL,与修复前行为一致(向后兼容)
383+
// - 建议生产环境配置合理值(如 60000),防止 null 值永久驻留 L1
384+
// - 当 remote 支持 getWithTTL(如内置 Redis 适配器)时,优先使用 L2 剩余 TTL
385+
backfillLocalTTL: 60000, // 示例:L1 回填最多缓存 60 秒
381386
}
382387
}
383388
});
@@ -559,7 +564,10 @@ const msq = new MonSQLize({
559564
// 缓存策略配置
560565
policy: {
561566
writePolicy: 'both', // 'both' | 'local-first-async-remote'
562-
backfillLocalOnRemoteHit: true // 远端命中时回填本地(默认 true)
567+
backfillLocalOnRemoteHit: true, // 远端命中时回填本地(默认 true)
568+
// 🔧 v1.1.9 新增:回填 L1 时的兜底 TTL(毫秒)
569+
// 防止 null 值因无 TTL 永久驻留 L1,建议生产环境配置合理值
570+
backfillLocalTTL: 60000, // 回填 L1 最多缓存 60 秒
563571
}
564572
}
565573
});
@@ -591,7 +599,10 @@ const msq = new MonSQLize({
591599
// 写策略配置
592600
policy: {
593601
writePolicy: 'both', // 'both' | 'local-first-async-remote'
594-
backfillLocalOnRemoteHit: true // 远端命中时回填本地(默认 true)
602+
backfillLocalOnRemoteHit: true, // 远端命中时回填本地(默认 true)
603+
// 🔧 v1.1.9: 回填 L1 时的兜底 TTL(毫秒),默认 0(不设 TTL,向后兼容)
604+
// 当 remote 为内置 Redis 适配器时,优先使用 L2 剩余 TTL;否则使用此值兜底
605+
backfillLocalTTL: 60000, // 建议生产环境配置此值,防止 null 永久驻留
595606
},
596607

597608
remoteTimeoutMs: 50 // 远端操作超时(默认 50ms)

lib/multi-level-cache.js

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@ class MultiLevelCache {
1616
* @param {Object} [options.policy]
1717
* @param {'both'|'local-first-async-remote'} [options.policy.writePolicy='both']
1818
* @param {boolean} [options.policy.backfillLocalOnRemoteHit=true]
19+
* @param {number} [options.policy.backfillLocalTTL=0] - 回填 L1 时使用的兜底 TTL(毫秒);
20+
* 当 remote 支持 getWithTTL 时优先使用 L2 剩余 TTL,否则降级到此值;
21+
* 0 = 不设 TTL(永不过期,向后兼容)。建议生产环境配置合理值(如 60000)
1922
* @param {number} [options.remoteTimeoutMs=50] - 远端单次操作超时
2023
* @param {(msg:object)=>void} [options.publish] - 可选:失效广播发布器
24+
* @since 1.1.9 backfillLocalTTL 支持
2125
*/
2226
constructor(options = {}) {
2327
const { local, remote, policy = {}, remoteTimeoutMs = 50, publish } = options;
@@ -29,6 +33,8 @@ class MultiLevelCache {
2933
this.policy = {
3034
writePolicy: policy.writePolicy || 'both',
3135
backfillLocalOnRemoteHit: policy.backfillLocalOnRemoteHit !== false,
36+
// 🔧 v1.1.9 修复:新增回填 TTL 兜底配置,0 = 不设 TTL(向后兼容)
37+
backfillLocalTTL: typeof policy.backfillLocalTTL === 'number' ? policy.backfillLocalTTL : 0,
3238
};
3339
this.remoteTimeoutMs = Number(remoteTimeoutMs) || 50;
3440
this.publish = typeof publish === 'function' ? publish : null;
@@ -74,13 +80,30 @@ class MultiLevelCache {
7480
const v = await this.local.get(key);
7581
if (v !== undefined) return v;
7682
try {
77-
const r = await this._withTimeout(this.remote.get(key));
83+
let r;
84+
// 🔧 v1.1.9 修复:backfillTTL 优先取 L2 剩余 TTL(方案A),降级到 backfillLocalTTL(方案B)
85+
let backfillTTL = this.policy.backfillLocalTTL;
86+
87+
if (this.remote && typeof this.remote.getWithTTL === 'function') {
88+
// 方案A:remote 支持 getWithTTL,单次 RTT 同时获取值与剩余 TTL
89+
const meta = await this._withTimeout(this.remote.getWithTTL(key));
90+
if (meta !== undefined) {
91+
r = meta.value;
92+
if (meta.remainingTTL > 0) backfillTTL = meta.remainingTTL;
93+
}
94+
} else {
95+
// 方案B:remote 不支持 getWithTTL,使用普通 get + backfillLocalTTL 兜底
96+
r = await this._withTimeout(this.remote.get(key));
97+
}
98+
7899
if (r !== undefined && this.policy.backfillLocalOnRemoteHit) {
79-
// 无剩余 TTL 信息,采用一个保守的回填策略:原 TTL 由上层 set 决定;这里回填一个短 TTL(例如 50ms)不合适
80-
// 因为我们无法得知原始 TTL,这里直接不设置 TTL(等同于本地不持久)。但为了可用性,设置 50% 的常用短 TTL可配置。
81-
// 为保持简单,我们不传 TTL(由本地实现解释,等价于无过期)。
82-
// 若调用方强依赖 TTL 严格一致性,应在远端适配器中扩展 getWithTTL。
83-
try { await this.local.set(key, r); } catch(_) {}
100+
// 🔧 v1.1.9 补充:backfillTTL=0 时跳过 null 回填,防止无配置用户触发永久驻留 Bug
101+
// - backfillTTL>0:回填所有值(含 null),TTL 保护下能正常过期
102+
// - backfillTTL=0 + null:跳过——无 TTL 保护的 null 永久驻留 = Bug 本身
103+
// - backfillTTL=0 + 非 null:回填(保留原有永久缓存行为,向后兼容)
104+
if (backfillTTL > 0 || r !== null) {
105+
try { await this.local.set(key, r, backfillTTL); } catch(_) {}
106+
}
84107
}
85108
return r;
86109
} catch(_) {
@@ -128,8 +151,16 @@ class MultiLevelCache {
128151
try {
129152
const remoteRes = await this._withTimeout(this.remote.getMany(misses));
130153
if (remoteRes && typeof remoteRes === 'object') {
131-
// 回填本地(异步)
132-
if (this.policy.backfillLocalOnRemoteHit) this.local.setMany(remoteRes).catch(() => {});
154+
// 🔧 v1.1.9 修复:传入 backfillLocalTTL,防止无 TTL 永久回填(含 null 值)
155+
// 🔧 v1.1.9 补充:backfillLocalTTL=0 时过滤 null,防止无配置用户触发永久驻留 Bug
156+
if (this.policy.backfillLocalOnRemoteHit) {
157+
const backfillData = this.policy.backfillLocalTTL > 0
158+
? remoteRes
159+
: Object.fromEntries(Object.entries(remoteRes).filter(([, v]) => v !== null));
160+
if (Object.keys(backfillData).length > 0) {
161+
this.local.setMany(backfillData, this.policy.backfillLocalTTL).catch(() => {});
162+
}
163+
}
133164
for (const k of misses) { if (remoteRes[k] !== undefined) out[k] = remoteRes[k]; }
134165
}
135166
} catch(_) { /* 降级 */ }

lib/redis-cache-adapter.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,35 @@ function createRedisCacheAdapter(redisUrlOrInstance) {
6060
}
6161
},
6262

63+
/**
64+
* 获取单个缓存值及剩余 TTL(毫秒),供 MultiLevelCache L2→L1 回填时携带正确 TTL
65+
* 使用 pipeline(GET + PTTL)单次 RTT,避免额外网络开销
66+
* @param {string} key
67+
* @returns {Promise<{value: any, remainingTTL: number}|undefined>}
68+
* key 不存在返回 undefined;value 为 null 时表示缓存了空结果
69+
* @since 1.1.9
70+
*/
71+
async getWithTTL(key) {
72+
try {
73+
const [[, rawVal], [, pttl]] = await redis.pipeline().get(key).pttl(key).exec();
74+
// PTTL = -2 表示 key 不存在
75+
if (pttl === -2) return undefined;
76+
let value;
77+
try {
78+
value = JSON.parse(rawVal);
79+
} catch (_) {
80+
return undefined;
81+
}
82+
return {
83+
value,
84+
// PTTL = -1 表示永不过期,映射为 0(与 backfillLocalTTL=0 语义一致)
85+
remainingTTL: pttl > 0 ? pttl : 0,
86+
};
87+
} catch (_) {
88+
return undefined;
89+
}
90+
},
91+
6392
/**
6493
* 设置单个缓存值
6594
* @param {string} key

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "monsqlize",
3-
"version": "1.1.8",
3+
"version": "1.1.9",
44
"description": "A lightweight MongoDB ORM with multi-level caching, transaction support, distributed features, Saga distributed transactions, unified expression system with 122 operators, and universal function caching (100% MongoDB support)",
55
"main": "lib/index.js",
66
"module": "index.mjs",

test/run-tests.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ async function runTests() {
139139
'./unit/utils/validation.test.js'
140140
];
141141
title = '工具函数测试套件';
142+
} else if (testSuite === 'multi-level-cache') {
143+
testFiles = ['./unit/infrastructure/multi-level-cache.test.js'];
144+
title = 'MultiLevelCache 单元测试套件 (v1.1.9)';
142145
} else if (testSuite === 'infrastructure') {
143146
testFiles = [
144147
'./unit/infrastructure/connection.test.js',
@@ -151,7 +154,9 @@ async function runTests() {
151154
'./unit/infrastructure/admin.test.js',
152155
'./unit/infrastructure/database.test.js',
153156
'./unit/infrastructure/validation.test.js',
154-
'./unit/infrastructure/collection-mgmt.test.js'
157+
'./unit/infrastructure/collection-mgmt.test.js',
158+
// 🔧 v1.1.9: MultiLevelCache 回填 TTL 修复
159+
'./unit/infrastructure/multi-level-cache.test.js'
155160
];
156161
title = '基础设施测试套件';
157162
} else if (testSuite === 'logger') {

0 commit comments

Comments
 (0)