手写并发调度器与LRU缓存深度解析:从请求队列到缓存淘汰的系统设计
一句话概括
并发调度器与 LRU 缓存是前端工程中两个”貌合神离”的核心基础设施——调度器通过流量整形解决资源竞争问题,LRU 缓存通过淘汰策略解决内存有限与热数据加速的永恒矛盾,两者在「有限资源下的最优分配」这一本质上殊途同归。
背景与意义
两个看似独立的题目,为何放在一起?
在面试中,手写「带并发限制的调度器」和「LRU 缓存」经常被放在一起考察,背后有深层逻辑:
- 有限资源管理:调度器管理”并发请求数”这一资源,LRU 管理”缓存容量”这一资源
- 状态与边界:两者都需要处理空状态、满状态、并发访问等边界条件
- 数据结构选择:调度器需要队列,LRU 需要双向链表 + 哈希表,两者都考察候选人的数据结构功底
- 时间维度:调度器是时域上的控制(请求到达和完成的时间),LRU 是空域上的控制(存储容量)
调度的困境
想象一个文件批量上传的场景:
1
2
3
4
5
6
7
8
9
用户选择了 50 张高清照片
↓
如果同时发送 50 个上传请求 → 浏览器连接池打满
↓
每个请求的速率都变慢(TCP 拥塞窗口争抢)
↓
所有请求几乎同时超时重试 → 网络风暴
↓
最终用户:「这破网站上传这么慢」
并发调度器解决的就是这个”个体合理,整体灾难”的问题。
缓存的困境
1
2
3
4
5
6
7
8
9
一个电商首页,首页推荐接口返回 500KB 数据
每个用户每 5 秒刷新一次页面
DAU 100 万 → 每天请求 1728 万次 → 1728 万 * 500KB ≈ 8TB 数据流动
↓
不做缓存 → CDN 带宽费用爆炸、后端数据库被打满
↓
但内存有限(浏览器 localStorage 5MB,Redis 几 GB)
↓
有限的缓存空间该保存哪些数据?→ 需要淘汰策略
LRU(Least Recently Used,最近最少使用)是最经典的淘汰策略:如果一个数据最近被访问过,将来被访问的概率也更高。
概念与定义
并发调度器(Concurrency Scheduler)
| 术语 | 定义 |
|---|---|
| 并发数(Concurrency) | 同时执行中的异步任务数量 |
| 任务队列(Task Queue) | 等待执行的任务集合 |
| 生产者(Producer) | 向调度器添加任务的一方 |
| 消费者(Consumer) | 实际执行任务的内部工作线程(概念上的) |
| 背压(Backpressure) | 当队列满时,上游应该放慢生产速率 |
LRU 缓存
| 术语 | 定义 |
|---|---|
| 缓存命中(Cache Hit) | 请求的数据在缓存中找到 |
| 缓存未命中(Cache Miss) | 请求的数据不在缓存中 |
| 淘汰(Eviction) | 当缓存满时,移出最不常用的数据 |
| 最近使用(Recently Used) | 按访问时间排序,最近被访问的排在最前面 |
| 容量(Capacity) | 缓存能存储的最大元素数量 |
LRU 的核心操作
1
2
3
4
5
6
7
8
get(key):
- 如果 key 存在 → 将其移到最前面 → 返回值
- 如果 key 不存在 → 返回 -1
put(key, value):
- 如果 key 已存在 → 更新值,将其移到最前面
- 如果 key 不存在 → 插入到最前面
- 如果容量已满 → 移除最后面的元素
最小示例
并发调度器(基础版)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class Scheduler {
constructor(max = 2) {
this.max = max;
this.queue = []; // 任务队列(存储待执行的任务)
this.pending = 0; // 当前执行中的任务数
}
add(task) {
// task 是一个返回 Promise 的函数
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this.schedule();
});
}
schedule() {
while (this.pending < this.max && this.queue.length > 0) {
const { task, resolve, reject } = this.queue.shift();
this.pending++;
Promise.resolve()
.then(() => task())
.then(resolve, reject)
.finally(() => {
this.pending--;
this.schedule();
});
}
}
}
// 使用
const scheduler = new Scheduler(2);
const request = (url, delay) => () =>
new Promise(resolve => {
console.log(`[开始] ${url}`);
setTimeout(() => {
console.log(`[完成] ${url}`);
resolve(url);
}, delay);
});
scheduler.add(request('请求1', 1000));
scheduler.add(request('请求2', 500));
scheduler.add(request('请求3', 800));
scheduler.add(request('请求4', 300));
// 输出时序:
// [开始] 请求1 (同时启动1和2)
// [开始] 请求2
// [完成] 请求2 (500ms后,激活请求3)
// [开始] 请求3
// [完成] 请求1 (1000ms后,激活请求4)
// [开始] 请求4
// [完成] 请求3 (800ms后)
// [完成] 请求4 (300ms后)
LRU 缓存(基础版)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class LRUCache {
constructor(capacity = 10) {
this.capacity = capacity;
this.cache = new Map(); // Map 按插入顺序迭代
}
get(key) {
if (!this.cache.has(key)) return -1;
// 更新访问顺序:删除再重新插入
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
put(key, value) {
// 如果已存在,先删除
if (this.cache.has(key)) {
this.cache.delete(key);
}
// 如果达到容量上限,删除最久未使用的(Map 的第一个)
if (this.cache.size >= this.capacity) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(key, value);
}
}
// 使用
const cache = new LRUCache(3);
cache.put('a', 1);
cache.put('b', 2);
cache.put('c', 3);
cache.get('a'); // 访问 a → a 被移到最前
cache.put('d', 4); // 容量已满,淘汰最久未使用的 b
console.log(cache.get('b')); // -1 (已被淘汰)
console.log(cache.get('a')); // 1 (还存在)
console.log(cache.get('d')); // 4
核心知识点拆解
1. 并发调度器的进阶:优先级、超时、取消
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class AdvancedScheduler {
constructor(max = 3) {
this.max = max;
this.queue = []; // { task, resolve, reject, priority, timeout }
this.pending = 0;
this.taskId = 0;
}
add(task, { priority = 0, timeout = 0 } = {}) {
return new Promise((resolve, reject) => {
const id = ++this.taskId;
this.queue.push({ id, task, resolve, reject, priority, timeout });
this.queue.sort((a, b) => b.priority - a.priority);
this.schedule();
});
}
cancel(id) {
// 从队列中移除未执行的任务
this.queue = this.queue.filter(t => t.id !== id);
}
schedule() {
while (this.pending < this.max && this.queue.length > 0) {
const { id, task, resolve, reject, timeout } = this.queue.shift();
this.pending++;
const runTask = () => {
let timer = null;
let wrappedPromise = task();
// 超时控制
if (timeout > 0) {
wrappedPromise = Promise.race([
wrappedPromise,
new Promise((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`[超时] 任务 ${id} 执行超过 ${timeout}ms`));
}, timeout);
}),
]);
}
wrappedPromise
.then(resolve, reject)
.finally(() => {
if (timer) clearTimeout(timer);
this.pending--;
this.schedule();
});
};
runTask();
}
}
}
2. 调度器与背压(Backpressure)
当任务生产速度远大于消费速度时,队列会无限增长,导致内存泄漏。需要引入背压机制:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class BackpressureScheduler extends Scheduler {
constructor(max, maxQueueSize = 100) {
super(max);
this.maxQueueSize = maxQueueSize;
}
add(task) {
if (this.queue.length >= this.maxQueueSize) {
return Promise.reject(
new Error('Backpressure: 队列已满,拒绝新任务')
);
}
return super.add(task);
}
get queueSize() {
return this.queue.length;
}
get utilization() {
return this.queue.length / this.maxQueueSize;
}
}
3. 用 Map 实现 LRU 的优势与局限
JavaScript 的 Map 对象按插入顺序迭代,天然适合实现 LRU 核心逻辑:
| 操作 | 时间复杂度 |
|---|---|
Map.has(key) | O(1) |
Map.get(key) | O(1) |
Map.delete(key) | O(1) |
Map.set(key, value) | O(1) |
| 取出最旧元素 | O(1)(keys().next()) |
局限:
- 当容量极大(上百万)时,
Map的内存占用比定制数据结构高 Map.keys().next()取最近最少使用的元素在微基准测试中较快,但不是严格意义上的 O(1)- 无法控制每个元素的 TTL(过期时间)
4. 手写双向链表 + 哈希表的 LRU(最标准的实现)
不使用 Map,使用双向链表 + 哈希表手动实现,这是真正考察数据结构掌握的版本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// 双向链表节点
class ListNode {
constructor(key, value) {
this.key = key;
this.value = value;
this.prev = null;
this.next = null;
}
}
class LRUCache_LinkedList {
constructor(capacity) {
this.capacity = capacity;
this.size = 0;
this.map = new Map(); // key → ListNode
// 伪头尾节点(简化边界处理)
this.head = new ListNode(null, null);
this.tail = new ListNode(null, null);
this.head.next = this.tail;
this.tail.prev = this.head;
}
get(key) {
if (!this.map.has(key)) return -1;
const node = this.map.get(key);
this.moveToHead(node);
return node.value;
}
put(key, value) {
if (this.map.has(key)) {
// 更新已存在的节点
const node = this.map.get(key);
node.value = value;
this.moveToHead(node);
return;
}
// 创建新节点
const node = new ListNode(key, value);
this.map.set(key, node);
this.addToHead(node);
this.size++;
// 检查容量
if (this.size > this.capacity) {
const removed = this.removeTail();
this.map.delete(removed.key);
this.size--;
}
}
// 添加节点到头部
addToHead(node) {
node.next = this.head.next;
node.prev = this.head;
this.head.next.prev = node;
this.head.next = node;
}
// 移除节点
removeNode(node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
// 将节点移到头部
moveToHead(node) {
this.removeNode(node);
this.addToHead(node);
}
// 移除尾部节点(最久未使用)
removeTail() {
const node = this.tail.prev;
this.removeNode(node);
return node;
}
// 获取缓存内容
entries() {
const result = [];
let current = this.head.next;
while (current !== this.tail) {
result.push({ key: current.key, value: current.value });
current = current.next;
}
return result;
}
}
5. LRU 的变体:LRU-K、LFU、FIFO
| 策略 | 淘汰规则 | 适用场景 |
|---|---|---|
| LRU | 淘汰最久未使用的 | 通用场景,走马观花浏览 |
| LRU-2 | 淘汰第 2 次访问之后最久未使用的 | 避免一次性访问污染缓存 |
| LFU | 淘汰访问次数最少的 | 热点数据稳定不变时 |
| FIFO | 淘汰最先进入的 | 简单、不需要历史记录 |
| TTL | 淘汰超过有效期的 | 所有数据有固定生存时间 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// LRU-2:数据被访问 2 次后才会进入缓存
class LRU2Cache {
constructor(capacity) {
this.capacity = capacity;
this.cache = new LRUCache_LinkedList(parseInt(capacity * 0.8));
this.hotCache = new LRUCache_LinkedList(capacity);
// 用于记录访问次数
this.accessCount = new Map();
this.accessThreshold = 2; // 2次后进入缓存
}
get(key) {
// 先查缓存
if (this.hotCache['map'].has(key)) {
return this.hotCache.get(key);
}
return -1;
}
recordAccess(key) {
const count = (this.accessCount.get(key) || 0) + 1;
this.accessCount.set(key, count);
// 达到阈值,Promote 到缓存
if (count >= this.accessThreshold && !this.hotCache['map'].has(key)) {
// 注意:这里需要从原始数据源获取值
// 实际使用时需配合数据查询
}
}
}
实战案例:高并发场景的接口缓存系统
将并发调度器和 LRU 缓存结合,构建一个”缓存优先、请求合并、并发控制”的完整请求层:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number;
}
class CachedRequestScheduler {
private cache: LRUCache_LinkedList;
private scheduler: Scheduler;
private pendingRequests: Map<string, Promise<any>>;
private defaultTTL: number;
constructor(options: {
cacheSize: number;
maxConcurrency: number;
defaultTTL: number;
}) {
this.cache = new LRUCache_LinkedList(options.cacheSize);
this.scheduler = new Scheduler(options.maxConcurrency);
this.pendingRequests = new Map();
this.defaultTTL = options.defaultTTL;
}
// 核心:请求去重 + 缓存 + 并发控制
async fetch<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = this.defaultTTL
): Promise<T> {
// 1. 先查缓存
const cached = this.cacheGet(key);
if (cached !== null) {
return cached as T;
}
// 2. 检查是否有相同的请求正在进行中(请求合并)
if (this.pendingRequests.has(key)) {
return this.pendingRequests.get(key) as Promise<T>;
}
// 3. 创建新请求,通过调度器控制并发
const requestPromise = new Promise<T>((resolve, reject) => {
this.scheduler.add(async () => {
try {
const data = await fetcher();
// 写入缓存
this.cachePut(key, data, ttl);
resolve(data);
} catch (err) {
reject(err);
}
});
});
this.pendingRequests.set(key, requestPromise);
// 4. 请求完成后清理 pending 记录
return requestPromise.finally(() => {
this.pendingRequests.delete(key);
}) as Promise<T>;
}
// 缓存读取(带过期检查)
private cacheGet(key: string): any | null {
const entry = (this.cache as any).map?.get(key);
if (!entry) return null;
if (entry.value && typeof entry.value === 'object' && 'ttl' in entry.value) {
const cacheEntry: CacheEntry<any> = entry.value;
if (Date.now() - cacheEntry.timestamp > cacheEntry.ttl) {
// 过期了,删除
this.cache.get(key); // 触发 get 然后...
this.cache['map'].delete(key);
return null;
}
}
// 再次获取(会更新 LRU 顺序)
const result = this.cache.get(key);
return result === -1 ? null : result;
}
// 缓存写入
private cachePut(key: string, data: any, ttl: number): void {
const entry: CacheEntry<any> = {
data,
timestamp: Date.now(),
ttl,
};
this.cache.put(key, entry);
}
// 更新默认 TTL
setDefaultTTL(ttl: number) {
this.defaultTTL = ttl;
}
// 清除所有缓存
clearCache(): void {
this.cache = new LRUCache_LinkedList(this.cache['capacity']);
}
// 获取缓存统计信息
getStats() {
return {
cacheSize: this.cache['size'],
cacheCapacity: this.cache['capacity'],
pendingRequests: this.pendingRequests.size,
schedulerQueue: this.scheduler.queue.length,
};
}
}
// 使用示例
const apiScheduler = new CachedRequestScheduler({
cacheSize: 50,
maxConcurrency: 5,
defaultTTL: 30000, // 30秒
});
// 页面中有 20 个组件都请求同一个接口
const results = await Promise.all(
Array.from({ length: 20 }, () =>
apiScheduler.fetch('/api/user/profile', async () => {
const resp = await fetch('/api/user/profile');
return resp.json();
}, 60000)
)
);
// 实际只发了一个请求!
// 其余 19 个等待者直接从缓存或 pending Promise 获取结果
底层原理
1. 并发调度的 V8 微任务队列分析
调度器 schedule() 中 then().finally() 链实际上是在 V8 的微任务队列中串联起来:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 微任务链可视化
scheduler.add(task1);
scheduler.add(task2);
// 微任务队列中发生的事情:
// 当前宏任务:
// schedule() → task1() → 返回 Promise
// schedule() → task2() → 返回 Promise
// 两个 Promise 都 pending
//
// 当前宏任务结束 → 进入微任务队列:
// 微任务 1: task1 的 .then() 回调 → 输出 "task1 done"
// 微任务 2: task2 的 .then() 回调 → 输出 "task2 done"
// 微任务 3: task1 的 .finally() → schedule() → 从队列取下一个任务
// 微任务 4: task2 的 .finally() → schedule() → 从队列取下一个任务
关键点:所有回调都在同一个宏任务周期内的不同微任务中执行。这意味着:
- 如果 task1 是同步的(如
() => 42),它在微任务中同步执行,不会让出主线程 - 如果 task1 是真正的异步(如
fetch),网络请求在浏览器网络线程中进行,微任务只是注册了回调
2. Map 的迭代顺序保证
ECMAScript 规范中明确规定 Map 的迭代顺序遵循插入顺序:
规范 23.1.3.1:Map.prototype.entries() — 返回一个新的 Iterator 对象,每次调用 next 方法都按插入顺序返回 Map 的键值对。
这意味着:
for (const [key, value] of map)按插入顺序迭代map.keys().next()返回最早插入的 key(即 LRU 中需要淘汰的 last recently used)
但在 LRU 中,当我们 get(key) 后 delete 再 set,key 被移到末尾,这模拟了”最近使用”的语义。
3. LRU 的缓存友好性(Cache Locality)
从硬件层面分析,双向链表 + 哈希表的 LRU 实现存在缓存局部性问题:
1
2
3
4
5
6
7
// 双向链表的每个节点在内存中随机分布
class ListNode {
constructor(key, value) {
// key/value/prev/next 可能分散在不同的内存页
// CPU 缓存命中率较低
}
}
优化方案:使用数组实现的 LRU(对 CPU 缓存更友好):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class LRUCache_Array {
constructor(capacity) {
this.capacity = capacity;
this.cache = new Map(); // key → index
this.keys = new Array(capacity);
this.values = new Array(capacity);
this.size = 0;
this.order = 0; // 单调递增的时间戳
}
get(key) {
if (!this.cache.has(key)) return -1;
const idx = this.cache.get(key);
// 更新时间戳
this.keys[idx] = { key, order: ++this.order };
return this.values[idx];
}
put(key, value) {
if (this.cache.has(key)) {
const idx = this.cache.get(key);
this.values[idx] = value;
this.keys[idx] = { key, order: ++this.order };
return;
}
// 找到淘汰位置
if (this.size >= this.capacity) {
let minIdx = 0;
let minOrder = this.keys[0]?.order || Infinity;
for (let i = 1; i < this.size; i++) {
if (this.keys[i].order < minOrder) {
minOrder = this.keys[i].order;
minIdx = i;
}
}
// 淘汰
this.cache.delete(this.keys[minIdx].key);
this.keys[minIdx] = { key, order: ++this.order };
this.values[minIdx] = value;
this.cache.set(key, minIdx);
} else {
// 直接插入
const idx = this.size;
this.keys[idx] = { key, order: ++this.order };
this.values[idx] = value;
this.cache.set(key, idx);
this.size++;
}
}
}
数组版本虽然访问速度略快(CPU 缓存友好),但淘汰时需要遍历查找最小 order,时间复杂度为 O(n)。实际工程中,双向链表版仍是标配。
高频面试题解析
面试题 1:实现一个带并发限制的调度器,要求同时传入的 Promise 工厂函数最多只有 limit 个在执行。还有一个额外的要求:任务可以被取消。
解答:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
class CancelableScheduler {
constructor(limit = 2) {
this.limit = limit;
this.queue = [];
this.active = new Map(); // taskId → { abortController, promise }
this.idCounter = 0;
}
add(factory) {
const taskId = ++this.idCounter;
const controller = new AbortController();
const promise = new Promise((resolve, reject) => {
this.queue.push({
taskId,
factory,
resolve,
reject,
controller,
});
// 注册取消监听
controller.signal.addEventListener('abort', () => {
const idx = this.queue.findIndex(t => t.taskId === taskId);
if (idx !== -1) {
this.queue.splice(idx, 1);
reject(new DOMException('Task cancelled', 'AbortError'));
}
});
this.schedule();
});
return { taskId, promise, cancel: () => controller.abort() };
}
schedule() {
while (this.queue.length > 0 && this.active.size < this.limit) {
const { taskId, factory, resolve, reject, controller } = this.queue.shift();
if (controller.signal.aborted) continue;
const taskPromise = Promise.resolve().then(() => factory());
const wrappedPromise = taskPromise.then(resolve, reject);
this.active.set(taskId, wrappedPromise);
wrappedPromise.finally(() => {
this.active.delete(taskId);
this.schedule();
});
}
}
}
// 使用
const scheduler = new CancelableScheduler(3);
const { taskId, cancel } = scheduler.add(async () => {
await new Promise(r => setTimeout(r, 5000));
return '完成';
});
// 3 秒后取消
setTimeout(() => cancel(), 3000);
面试题 2:手写一个 LRU 缓存,要求 get 和 put 的时间复杂度均为 O(1),且支持 expire 过期时间。
解答:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
class LRUCacheWithExpiry {
constructor(capacity) {
this.capacity = capacity;
this.size = 0;
this.map = new Map();
this.head = { prev: null, next: null };
this.tail = { prev: null, next: null };
this.head.next = this.tail;
this.tail.prev = this.head;
}
get(key) {
if (!this.map.has(key)) return -1;
const node = this.map.get(key);
// 检查是否过期
if (node.expireAt && Date.now() > node.expireAt) {
this.removeNode(node);
this.map.delete(key);
this.size--;
return -1;
}
this.moveToHead(node);
return node.value;
}
put(key, value, ttl = 0) {
if (this.map.has(key)) {
const node = this.map.get(key);
node.value = value;
node.expireAt = ttl > 0 ? Date.now() + ttl : 0;
this.moveToHead(node);
return;
}
const node = {
key,
value,
expireAt: ttl > 0 ? Date.now() + ttl : 0,
prev: null,
next: null,
};
this.map.set(key, node);
this.addToHead(node);
this.size++;
if (this.size > this.capacity) {
const removed = this.removeTail();
this.map.delete(removed.key);
this.size--;
}
}
// 清除所有过期数据
purgeExpired() {
let current = this.head.next;
const now = Date.now();
const expiredKeys = [];
while (current !== this.tail) {
if (current.expireAt && now > current.expireAt) {
expiredKeys.push(current.key);
}
current = current.next;
}
for (const key of expiredKeys) {
const node = this.map.get(key);
if (node) {
this.removeNode(node);
this.map.delete(key);
this.size--;
}
}
}
addToHead(node) {
node.next = this.head.next;
node.prev = this.head;
this.head.next.prev = node;
this.head.next = node;
}
removeNode(node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
moveToHead(node) {
this.removeNode(node);
this.addToHead(node);
}
removeTail() {
const node = this.tail.prev;
this.removeNode(node);
return node;
}
}
面试题 3:设计一个高并发场景下的前端缓存策略,要求:
- 避免缓存雪崩(大量缓存同时过期)
- 避免缓存穿透(请求不存在的数据)
- 避免缓存击穿(热点 key 过期瞬间被大量请求打到后端)
解答:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
class RobustCache {
constructor(options) {
this.cache = new LRUCacheWithExpiry(options.cacheSize || 100);
this.pending = new Map(); // 进行中的请求去重
this.defaultTTL = options.defaultTTL || 30000;
this.staleTTL = options.staleTTL || 60000; // 过期但允许用旧数据的窗口
}
async get(key, fetcher, ttl = this.defaultTTL) {
// 1. 正常缓存命中
const cached = this.cache.get(key);
if (cached !== -1) {
return cached;
}
// 2. 缓存过期,但允许使用"过期的数据"(击穿保护)
// 在 staleTTL 窗口内,返回旧数据并异步刷新
const staleData = this.getStale(key);
if (staleData !== null) {
// 异步刷新缓存
this.refreshAsync(key, fetcher, ttl);
return staleData;
}
// 3. 请求合并(缓存穿透保护)
if (this.pending.has(key)) {
return this.pending.get(key);
}
// 4. 真正发起请求
const promise = fetcher()
.then(data => {
// 缓存雪崩保护:随机的 TTL 抖动
const jitter = ttl * (0.8 + Math.random() * 0.4);
this.cache.put(key, data, jitter);
this.setStale(key, data, this.staleTTL);
return data;
})
.finally(() => {
this.pending.delete(key);
});
this.pending.set(key, promise);
return promise;
}
// 用 localStorage 存储过期数据(击穿保护)
getStale(key) {
try {
const entry = localStorage.getItem(`cache:stale:${key}`);
if (!entry) return null;
const { data, expireAt } = JSON.parse(entry);
return Date.now() < expireAt ? data : null;
} catch {
return null;
}
}
setStale(key, data, ttl) {
try {
localStorage.setItem(`cache:stale:${key}`, JSON.stringify({
data,
expireAt: Date.now() + ttl,
}));
} catch {
// localStorage 满,忽略
}
}
refreshAsync(key, fetcher, ttl) {
// 使用微任务异步刷新,不阻塞主请求
Promise.resolve().then(() => {
this.get(key, fetcher, ttl).catch(() => {
// 静默失败,后台刷新不抛错
});
});
}
}
总结与扩展
并发调度器和 LRU 缓存虽然看起来是两道独立的手写题,但它们共享同一个核心设计思想:在有限的资源(连接数/内存)下,通过调度/淘汰策略最大化系统吞吐量。
值得进一步探索的方向:
- RxJS 的 Observable 背压:在响应式编程中,
Observable通过buffer、throttle、debounce、sample等运算符实现不同的背压策略 - Redis 的 LRU 近似实现:Redis 不维护精确的 LRU 顺序,而是使用抽样淘汰(近似 LRU),在性能与精度间做 trade-off
- 缓存策略的 ACID 保证:缓存与数据库的双写一致性(Cache Aside / Read Through / Write Through / Write Behind)
- Web Workers 中的并发:使用 Worker 池实现 CPU 密集任务的并发调度
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 终端加乘:调度器 + 缓存合并
class UltimateRequestLayer {
constructor(maxConcurrency = 5, cacheSize = 50) {
this.scheduler = new Scheduler(maxConcurrency);
this.cache = new LRUCache_LinkedList(cacheSize);
this.inFlight = new Map();
}
async request(key, fetcher, ttl = 30000) {
// 缓存查询
const cached = this.cache.get(key);
if (cached !== -1 && Date.now() - cached.timestamp < ttl) {
return cached.data;
}
// 请求合并
if (this.inFlight.has(key)) {
return this.inFlight.get(key);
}
const promise = new Promise((resolve, reject) => {
this.scheduler.add(async () => {
try {
const data = await fetcher();
this.cache.put(key, { data, timestamp: Date.now() });
resolve(data);
} catch (err) {
reject(err);
}
});
});
this.inFlight.set(key, promise);
promise.finally(() => this.inFlight.delete(key));
return promise;
}
}
并发控制与缓存淘汰——这两个主题共同构成了前端”高可用”基础设施的核心。面试时写出”活”的代码比写出”对”的代码更重要,因为考官在寻找的,是你面对复杂系统时的抽象能力和边界敏感度。