第4章 4.5 综合实战:并发请求 + 限流

🎯 开场:为什么你的爬虫跑10分钟就崩了?

上一章我们学了事件循环,搞懂了 JavaScript 是怎么「一心多用」的——主线程像餐厅服务员一样,在等待厨房出菜的间隙还能招呼其他客人。

但光懂原理没用,实战才是真考验

你有没有遇到过这些情况:

  • 想一次性抓取 100 个网页,结果跑了 5 分钟就开始报错 429 Too Many Requests
  • 写了个脚本同时发 50 个请求,结果电脑卡成 PPT
  • 学了 Promise.all 兴奋不已,一用才发现——服务器直接把你 IP 封了

这就是没做「限流」的后果。

学完这一章,你能:

  • async/await + Promise.all 同时抓取 20 个网页,但服务器觉得你只是个正常用户
  • 写一个「假并发真排队」的请求器,不会把你的电脑跑死
  • 做出一个能实际用的数据抓取工具,而不是跑一下就崩溃的半成品

🧱 基础:搞懂并发请求和限流是什么

什么是并发请求\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\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.allSettledPromise.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 的 ImageDownloaderlimiter 换成项目 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 句话总结

  1. Promise.all 让多个请求「同时跑」,省时间是王道
  2. 限流器是保护伞,防止你和服务器「互相伤害」
  3. Promise.allSettledPromise.all 温柔,一个失败不全挂

延伸学习


你在爬虫或数据采集时踩过什么坑?限流是怎么设置的?评论区聊聊,老粉优先回复!

下一章我们要解决一个新问题:代码写多了文件就乱了,怎么把 JavaScript 代码「模块化」——第5章 5.1 模块化:ESM vs CommonJS,敬请期待!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。