第4章 4.5 综合实战:并发请求 + 限流
🎯 开场:为什么你的爬虫跑10分钟就崩了?
上一章我们学了事件循环,搞懂了 JavaScript 是怎么「一心多用」的——主线程像餐厅服务员一样,在等待厨房出菜的间隙还能招呼其他客人。
但光懂原理没用,实战才是真考验。
你有没有遇到过这些情况:
- 想一次性抓取 100 个网页,结果跑了 5 分钟就开始报错
429 Too Many Requests - 写了个脚本同时发 50 个请求,结果电脑卡成 PPT
- 学了
Promise.all兴奋不已,一用才发现——服务器直接把你 IP 封了
这就是没做「限流」的后果。
学完这一章,你能:
- 用
async/await+Promise.all同时抓取 20 个网页,但服务器觉得你只是个正常用户 - 写一个「假并发真排队」的请求器,不会把你的电脑跑死
- 做出一个能实际用的数据抓取工具,而不是跑一下就崩溃的半成品
🧱 基础:搞懂并发请求和限流是什么
什么是并发请求\n\n
\n\n
\n\n?
类比:快递站取件
想象你有 10 个快递同时到了,你去驿站取件:
- 串行取件(一个一个来):你进去,拿第一个,出门;再进去,拿第二个,出门……效率很低
- 并发取件(一起拿):你进去,一次性把 10 个都拿了,同时处理多个任务
在代码里,并发就是:不等一个请求完全返回,就发起下一个请求。
// 串行:等一个完成再发下一个(慢)
const r1 = await fetch('https://api.example.com/user/1')
const r2 = await fetch('https://api.example.com/user/2')
const r3 = await fetch('https://api.example.com/user/3')
// 并发:同时发三个请求(快)
const [r1, r2, r3] = await Promise.all([
fetch('https://api.example.com/user/1'),
fetch('https://api.example.com/user/2'),
fetch('https://api.example.com/user/3')
])
Promise.all 就像一个「集合点」,等所有请求都完成了,再一起返回结果。
什么是限流?
类比:餐厅取号
生意好的餐厅会限流——一次只放 10 个人进去拿号,避免挤爆。
限流(Rate Limiting)就是:控制同时进行的任务数量,保护服务器不被压垮,也保护你自己的电脑不死机。
// 没有限流:100个请求同时轰炸
const promises = urls.map(url => fetch(url)) // 危险!
await Promise.all(promises)
// 有限流:一次最多5个,像排队进游乐场
const limitedRequests = batchRequest(urls, { limit: 5 })
await Promise.all(limitedRequests)
核心概念速查表
| 概念 | 做什么 | 一句话解释 |
|---|---|---|
Promise.all() |
等待所有请求完成 | 集齐所有龙珠才能召唤神龙 |
| 并发 | 同时进行多个任务 | 餐厅同时接10桌单 |
| 限流 | 控制同时进行的数量 | 一次只放5人进场 |
async/await |
写起来像同步代码的异步操作 | 用同步的嘴脸写异步的活儿 |
最简单的并发请求长什么样?
// 定义一个异步获取用户信息的函数
async function getUser(userId) {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
const data = await response.json()
return data
}
// 并发获取3个用户(注意:没有限流!)
async function getThreeUsers() {
console.log('开始获取...', Date.now())
const users = await Promise.all([
getUser(1),
getUser(2),
getUser(3)
])
console.log('获取完成!', Date.now())
return users
}
getThreeUsers().then(users => {
users.forEach(u => console.log(u.name))
})
运行结果类似:
开始获取... 1699999999999
获取完成! 1700000000123
Leanne Graham
Ervin Howell
Clementine Bauch
3 个请求几乎同时完成,总耗时 ≈ 最慢那个请求的耗时,而不是三个相加。
🔥 实战:3个递进项目
项目 1:5分钟 - 最基础的并发请求(能跑就是赢)
场景:老板让你抓取 10 个用户的名字,手动写 10 个 await 太蠢了。
// 项目1:基础并发请求
async function getUser(userId) {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
return res.json()
}
async function main() {
console.log('=== 项目1:抓取10个用户 ===')
// 用 map 创建10个请求(还没执行!)
const promises = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(id => getUser(id))
// Promise.all 一次性发起所有请求
const users = await Promise.all(promises)
users.forEach(user => console.log(`ID:${user.id} - ${user.name}`))
}
main()
预期输出:
=== 项目1:抓取10个用户 ===
ID:1 - Leanne Graham
ID:2 - Ervin Howell
ID:3 - Clementine Bauch
ID:4 - Patricia Lebsack
ID:5 - Chelsey Dietrich
ID:6 - Mrs. Dennis Schulist
ID:7 - Kurtis Weissnat
ID:8 - Nicholas Runolfsdottir V
ID:9 - Glenna Reichert
ID:10 - Clementina DuBuque
一句话解释:用 .map() 把 10 个 ID 变成 10 个「承诺」,再用 Promise.all 一起兑现。
项目 2:15分钟 - 加了「限流」的文明请求器
场景:老板让你抓 100 个用户,但服务器有反爬限制——每秒最多 5 个请求。
// 项目2:带限流的并发请求
class RateLimiter {
constructor({ limit = 5, interval = 1000 }) {
this.limit = limit // 同时最多几个
this.interval = interval // 间隔多少毫秒
this.queue = [] // 排队的任务
this.running = 0 // 当前运行的任务数
}
async execute(fn) {
// 如果正在运行的任务达到上限,就等着
if (this.running >= this.limit) {
await new Promise(resolve => this.queue.push(resolve))
}
this.running++
try {
return await fn()
} finally {
this.running--
// 放行下一个等待的任务
if (this.queue.length > 0) {
const next = this.queue.shift()
next()
}
}
}
}
async function getUser(userId, limiter) {
return limiter.execute(async () => {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
return res.json()
})
}
async function main() {
console.log('=== 项目2:限流抓取20个用户 ===')
const limiter = new RateLimiter({ limit: 3, interval: 500 })
const start = Date.now()
const promises = Array.from({ length: 20 }, (_, i) => getUser(i + 1, limiter))
const users = await Promise.all(promises)
console.log(`耗时: ${Date.now() - start}ms`)
users.forEach(u => console.log(`${u.id}: ${u.name}`))
}
main()
预期输出(注意耗时明显变长,但不会触发 429):
=== 项目2:限流抓取20个用户 ===
耗时: 3500ms
1: Leanne Graham
2: Ervin Howell
...
20: Clementina DuBuque
一句话解释:RateLimiter 类像个「门卫」,只有当前面的人出来了,后面的人才能进去。
项目 3:15分钟 - 做出一个真能用的「图片下载器」
场景:你想下载一组图片 URL,但要控制速度和并发量,还要处理失败重试。
// 项目3:带重试的限流图片下载器(模拟版)
class RateLimiter {
constructor({ limit = 2, interval = 1000 }) {
this.limit = limit
this.interval = interval
this.queue = []
this.running = 0
}
async execute(fn) {
if (this.running >= this.limit) {
await new Promise(resolve => this.queue.push(resolve))
}
this.running++
try {
return await fn()
} finally {
this.running--
if (this.queue.length > 0) {
this.queue.shift()()
}
}
}
}
class ImageDownloader {
constructor({ maxConcurrency = 2, maxRetries = 3 }) {
this.limiter = new RateLimiter({ limit: maxConcurrency })
this.maxRetries = maxRetries
}
async download(url) {
return this.limiter.execute(async () => {
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
// 模拟下载(实际用 fetch)
console.log(`[下载中] ${url} (第${attempt}次尝试)`)
await new Promise(r => setTimeout(r, 500)) // 模拟网络延迟
// 模拟随机失败(10%概率)
if (Math.random() < 0.1) throw new Error('网络波动')
return { url, success: true }
} catch (e) {
if (attempt === this.maxRetries) throw e
console.log(` 重试中... (${attempt + 1}/${this.maxRetries})`)
await new Promise(r => setTimeout(r, 300))
}
}
})
}
}
async function main() {
console.log('=== 项目3:带重试的图片下载器 ===\n')
const downloader = new ImageDownloader({ maxConcurrency: 2, maxRetries: 3 })
const imageUrls = [
'https://example.com/img1.jpg',
'https://example.com/img2.jpg',
'https://example.com/img3.jpg',
'https://example.com/img4.jpg',
'https://example.com/img5.jpg'
]
const results = await Promise.allSettled(
imageUrls.map(url => downloader.download(url))
)
console.log('\n=== 下载结果 ===')
results.forEach((r, i) => {
if (r.status === 'fulfilled') {
console.log(`✅ ${imageUrls[i]} - 成功`)
} else {
console.log(`❌ ${imageUrls[i]} - 失败: ${r.reason.message}`)
}
})
}
main()
预期输出:
=== 项目3:带重试的图片下载器 ===
[下载中] https://example.com/img1.jpg (第1次尝试)
[下载中] https://example.com/img2.jpg (第1次尝试)
重试中... (2/3)
[下载中] https://example.com/img3.jpg (第1次尝试)
[下载中] https://example.com/img4.jpg (第1次尝试)
重试中... (2/3)
[下载中] https://example.com/img5.jpg (第1次尝试)
=== 下载结果 ===
✅ https://example.com/img1.jpg - 成功
✅ https://example.com/img2.jpg - 成功
✅ https://example.com/img3.jpg - 成功
✅ https://example.com/img4.jpg - 成功
✅ https://example.com/img5.jpg - 成功
一句话解释:Promise.allSettled 比 Promise.all 温柔——一个失败了,其他继续跑,不会全挂。
💪 进阶:5个新手必踩的坑 + 调试技巧
坑1:以为 forEach 能用 await
// ❌ 错误:forEach 不会等 await
async function badExample() {
[1, 2, 3].forEach(async (id) => {
const user = await getUser(id)
console.log(user.name)
})
console.log('完了!') // 这行会先执行!
}
// ✅ 正确:用 for...of 或 Promise.all
async function goodExample() {
await Promise.all(
[1, 2, 3].map(id => getUser(id).then(u => console.log(u.name)))
)
console.log('这次是真的完了')
}
坑2:Promise.all 一个失败全盘皆输
// ❌ 错误:一个请求挂了,全部白费
const users = await Promise.all(urls.map(url => fetch(url)))
// ✅ 正确:用 Promise.allSettled,一个挂了下次再来
const results = await Promise.allSettled(urls.map(url => fetch(url)))
const successes = results.filter(r => r.status === 'fulfilled')
坑3:限流值设太低,以为代码坏了
// ❌ 错误:limit = 1 就是串行了,还以为是异步
const limiter = new RateLimiter({ limit: 1 })
// ✅ 正确:一般设 5-10 比较稳妥
const limiter = new RateLimiter({ limit: 5 })
坑4:忘记 return 导致请求没发出去
// ❌ 错误:map 里的 async 函数没 return
const promises = [1, 2, 3].map(async (id) => {
getUser(id) // 少了 return!
})
// ✅ 正确:一定要 return
const promises = [1, 2, 3].map(async (id) => {
return getUser(id)
})
坑5:混淆 async/await 和同步代码的执行顺序
// ❌ 错误:以为 console.log 会等 fetch 完成
async function badOrder() {
console.log('开始请求')
fetch('https://example.com')
console.log('请求完成') // 这行会先执行!
}
// ✅ 正确:必须 await
async function goodOrder() {
console.log('开始请求')
await fetch('https://example.com')
console.log('请求完成') // 现在才会等 fetch 完成
}
调试技巧:用 console.time 测量真实耗时
// 在怀疑慢的地方加 timer
async function debugExample() {
console.time('总耗时')
console.time('并发请求')
const users = await Promise.all([1, 2, 3].map(getUser))
console.timeEnd('并发请求')
console.timeEnd('总耗时')
}
// 输出:
// 并发请求: 523ms
// 总耗时: 524ms
✏️ 练习题
练习 1(2分钟):改个并发数
- 输入:把项目 2 的
limit从 3 改成 10 - 预期输出:耗时从约 3500ms 变成约 1000ms(因为同时能跑更多)
- 提示:只改一个数字
练习 2(3分钟):加个条件判断
- 输入:在项目 1 中,只打印
id大于 5 的用户 - 预期输出:只显示 ID 6-10 的用户
- 提示:在
forEach里加个if (user.id > 5)
练习 3(5分钟):处理新数据格式
- 输入:把项目 2 改成从
https://jsonplaceholder.typicode.com/posts获取文章,只打印前 10 篇的title - 预期输出:10 个文章标题
- 提示:把
users换成posts,.name换成.title
练习 4(8分钟):串两个项目
- 输入:用项目 2 的限流器 + 项目 3 的重试机制,合并成一个类
- 预期输出:既能限流又能自动重试
- 提示:把项目 3 的
ImageDownloader的limiter换成项目 2 的RateLimiter
练习 5(10分钟):分析报错
- 输入:下面的代码为什么
console.log('完成')先打印? - 预期输出:解释原因 + 给出正确写法
function bad() {
[1, 2, 3].forEach(x => fetch(`/api/${x}`))
console.log('完成') // 先打印
}
- 提示:
forEach不会等待 async 回调
作业:做一个「并发请求 + 限流」数据采集工具
需求:做一个能从 JSONPlaceholder API 采集数据的小工具
功能点:
1. 同时获取用户数据(/users)和帖子数据(/posts)
2. 实现限流器,控制每秒请求数
3. 用 Promise.allSettled 保证一个失败不影响其他
4. 统计成功/失败数量并打印报告
加分项:
1. 加个重试机制(失败自动重试 2 次)
2. 支持从 CSV 文件读取 URL 列表
验收标准:
- 能跑起来,不报错
- 输出包含成功/失败统计
- 代码有适当注释
提交方式:评论区贴代码或 GitHub 链接
📚 总结 + 资源
本章 3 句话总结
Promise.all让多个请求「同时跑」,省时间是王道- 限流器是保护伞,防止你和服务器「互相伤害」
Promise.allSettled比Promise.all温柔,一个失败不全挂
延伸学习
- MDN Promise.all 文档 - 官方权威解释
- 《JavaScript 高级程序设计》第 10 章 - 异步编程深度好文
- 视频:JavaScript 事件循环可视化 - 10 分钟搞懂 Event Loop
你在爬虫或数据采集时踩过什么坑?限流是怎么设置的?评论区聊聊,老粉优先回复!
下一章我们要解决一个新问题:代码写多了文件就乱了,怎么把 JavaScript 代码「模块化」——第5章 5.1 模块化:ESM vs CommonJS,敬请期待!

评论(0)