请求防抖:多场景解决方案
AI摘要: 本文介绍了请求防抖:多场景解决方案。在维护博客系统时,作者遇到了并发导致的多次请求问题。通过记录各种解决方案,包括内存锁方案、Promise缓存方案、数据库锁方案和Redis分布式锁方案,展示了如何根据不同场景选择合适的技术方案来避免重复请求和提高性能。
今天在维护博客系统时,遇到了一个并发导致的多次请求问题,由于这个问题是非常常见的,所以在这里记录下各种解决方案。
博客的AI摘要是lazy load的,当请求某篇文章但是发现并没有摘要和关键词,才会向星火大模型发送异步请求,填写数据库中这两个字段。大模型的请求一般耗时10s左右,如果在这10s不断刷新文章页面,就会导致不断去发送异步请求。其实这就是典型的接口重复请求问题,在不同场景中有不同的表现形式,比如电商中的重复下单。
解决防抖/重复请求的方案的本质就是用一个标志来记录已经发送过请求了,后续遇到这个标志就不请求。不同场景中,无非将这个标志放到内存、数据库、redis、消息队列中。
1. 内存锁方案
const processingMap = new Map<string, boolean>();
async function process(id: string) {
if (processingMap.get(id)) {
return;
}
processingMap.set(id, true);
try {
// 处理逻辑
} finally {
processingMap.delete(id);
}
}
| 适用场景 | 优点 | 缺点 |
| --------------------------- | ------------ | ------------------ |
| 单体服务 | 实现简单 | 服务重启后状态丢失 |
| 短时间并发处理 | 性能最好 | 不支持分布式 |
| 内存占用敏感, 对性能要求高 | 内存占用最小 | 无法获取处理结果 |
2. Promise 缓存方案
const processingCache = new Map<string, Promise<any>>();
async function process(id: string) {
if (processingCache.has(id)) {
return processingCache.get(id);
}
const promise = (async () => {
try {
// 处理逻辑
return result;
} finally {
processingCache.delete(id);
}
})();
processingCache.set(id, promise);
return promise;
}
| 适用场景 | 优点 | 缺点 |
| -------------------- | ---------------- | ------------ |
| 单体服务 | 可以返回处理结果 | 实现相对复杂 |
| 需要返回处理结果 | 错误处理完善 | 内存占用较大 |
| 需要错误处理 | 代码可维护性好 | 不支持分布式 |
| 并发请求需要等待结果 | | |
3. 数据库锁方案
async function process(id: string) {
const record = await prisma.task.findUnique({
where: { id }
});
if (record?.isProcessing) {
return;
}
await prisma.task.update({
where: { id },
data: { isProcessing: true }
});
try {
// 处理逻辑
} finally {
await prisma.task.update({
where: { id },
data: { isProcessing: false }
});
}
}
| 适用场景 | 优点 | 缺点 |
| ------------------ | ------------ | -------------------- |
| 分布式服务 | 支持分布式 | 性能较差 |
| 需要持久化处理状态 | 状态持久化 | 实现复杂 |
| 对数据一致性要求高 | 数据一致性好 | 需要额外的数据库操作 |
| 可以接受性能损耗 | | |
4. Redis 分布式锁
async function process(id: string) {
const lockKey = `lock:${id}`;
const acquired = await redis.set(lockKey, '1', 'EX', 60, 'NX');
if (!acquired) {
return;
}
try {
// 处理逻辑
} finally {
await redis.del(lockKey);
}
}
| 适用场景 | 优点 | 缺点 |
| -------------- | ------------ | -------------- |
| 分布式服务 | 性能好 | 需要维护 Redis |
| 高性能要求 | 支持分布式 | 实现复杂 |
| 需要超时机制 | 自带超时机制 | 成本较高 |
| 对可靠性要求高 | 可靠性高 | |
5. 消息队列方案
import PQueue from 'p-queue';
const queue = new PQueue({ concurrency: 1 });
async function process(id: string) {
queue.add(async () => {
// 处理逻辑
});
}
| 适用场景 | 优点 | 缺点 |
| ---------------- | ---------- | ---------------- |
| 需要控制并发数 | 可控制并发 | 服务重启丢失队列 |
| 任务可以排队处理 | 支持优先级 | 内存占用大 |
| 需要任务优先级 | 实现简单 | 不支持分布式 |
| 内存占用不敏感 | | |
选择建议
| 场景 | 选择方案 | 原因 |
| ------------------------ | ---------------- | ------------------------------ |
| 单体服务,简单场景 | 内存锁方案 | 简单高效,满足基本需求 |
| 单体服务,需要结果返回 | Promise 缓存方案 | 可以处理异步结果,错误处理完善 |
| 分布式服务,高性能要求 | Redis 分布式锁 | 性能好,支持分布式,可靠性高 |
| 分布式服务,强一致性要求 | 数据库锁方案 | 数据一致性好,状态可持久化 |
| 需要控制并发数量 | 消息队列方案 | 可以精确控制并发,支持任务排队 |
最后,在实际应用中,这些方案也可以组合使用,比如:
-
Redis 锁 + 消息队列
-
数据库锁 + Promise 缓存
-
内存锁 + 防抖处理