前端并发控制:100个请求同时发,系统扛不住怎么办

摘要:面试的时候经常遇到这道题:100个请求一起打过来,怎么处理?很多人第一反应是用Promise.all。代码写出来也没错,问题是100个请求会同时发出去。如果请求的是自己的服务,可能把连接池打满。

面试的时候经常遇到这道题:100个请求一起打过来,怎么处理?

很多人第一反应是用Promise.all。代码写出来也没错,问题是100个请求会同时发出去。如果请求的是自己的服务,可能把连接池打满。如果调的是第三方接口,对方可能直接返回429限流。浏览器端还有并发连接数限制,Node端还有socket、超时、重试这些问题。最后结果不是慢,就是抖。

这道题真正想问的是:你有没有并发控制的意识。


错误写法长什么样

先看一个典型的错误写法:

const tasks = ids.map(id => () => requestUser(id));

Promise.all(tasks.map(fn => fn()))
  .then(res => console.log(res))
  .catch(err => console.error(err));

代码本身没毛病。但100个请求同时发出去,下游系统大概率扛不住。


正确做法:做一个并发池

真正该做的是:同时只跑5个或10个请求,跑完一个再补下一个。

核心就两步:

  1. 把100个请求先存起来,不要一起执行

  2. 启动固定数量的worker,让它们从任务队列里一个一个取


一个能用的版本

先写一个基础版本,守住三个关键点:

  • 最大并发数可控

  • 所有结果最终能拿到

  • 结果顺序和原始任务顺序一致

async function promisePool(taskFns, limit = 5) {
  const results = new Array(taskFns.length);
  let nextIndex = 0;

  async function worker() {
    while (true) {
      const current = nextIndex++;
      if (current >= taskFns.length) {
        break;
      }

      try {
        results[current] = await taskFns[current]();
      } catch (err) {
        results[current] = {
          error: true,
          message: err.message || 'unknown error'
        };
      }
    }
  }

  const workers = Array.from(
    { length: Math.min(limit, taskFns.length) },
    () => worker()
  );

  await Promise.all(workers);
  return results;
}


怎么用

function mockRequest(id) {
  const delay = Math.floor(Math.random() * 2000);

  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id === 17 || id === 42) {
        reject(new Error(`request failed: ${id}`));
        return;
      }

      resolve({
        id,
        delay,
        data: `user-${id}`
      });
    }, delay);
  });
}

const taskFns = Array.from({ length: 100 }, (_, i) => {
  const id = i + 1;
  return () => mockRequest(id);
});

promisePool(taskFns, 10).then(res => {
  console.log(res);
});


为什么要传函数而不是Promise

有一个细节要主动说出来:为什么taskFns里放的是函数,不是Promise实例?

因为Promise一旦创建,基本就开始执行了。要控制并发,就不能先把100个Promise都new出来,否则控制器还没开始控,请求已经在路上了。

看下面这种写法,看着像控制,实际上已经晚了:

const promises = ids.map(id => requestUser(id)); // 这里请求已经发出去了
await promisePool(promises, 10); // 控制不了

真正该控制的是"启动时机",所以传函数最稳。


带重试的版本

线上场景通常还需要知道:哪几个成功了,哪几个失败了,失败要不要重试。可以稍微包装一下:

async function promisePoolWithRetry(taskFns, limit = 5, retryTimes = 2) {
  const results = new Array(taskFns.length);
  let nextIndex = 0;

  async function runWithRetry(taskFn, taskIndex) {
    let count = 0;

    while (count <= retryTimes) {
      try {
        const data = await taskFn();
        return {
          success: true,
          data,
          taskIndex,
          retry: count
        };
      } catch (err) {
        if (count === retryTimes) {
          return {
            success: false,
            taskIndex,
            retry: count,
            error: err.message || 'unknown error'
          };
        }
        count++;
      }
    }
  }

  async function worker(workerId) {
    while (true) {
      const current = nextIndex++;
      if (current >= taskFns.length) {
        break;
      }

      results[current] = await runWithRetry(taskFns[current], current);
      console.log(`worker-${workerId} finished task-${current}`);
    }
  }

  const workers = Array.from(
    { length: Math.min(limit, taskFns.length) },
    (_, i) => worker(i + 1)
  );

  await Promise.all(workers);
  return results;
}


实际业务场景

比如批量同步用户资料:

async function fetchProfile(userId) {
  const res = await fetch(`https://api.example.com/users/${userId}`);

  if (!res.ok) {
    throw new Error(`http status ${res.status}`);
  }

  return res.json();
}

const userIds = Array.from({ length: 100 }, (_, i) => i + 1);

const taskFns = userIds.map(userId => {
  return () => fetchProfile(userId);
});

const result = await promisePoolWithRetry(taskFns, 8, 1);

const successList = result.filter(item => item.success);
const failList = result.filter(item => !item.success);

console.log('success:', successList.length);
console.log('fail:', failList.length);


并发数设多少合适

并发数不是越大越好。这个值不能随便写50或100,要先看下游是谁。

  • 如果是数据库或者内部服务,要先看连接池、线程池、接口响应时间

  • 如果是第三方HTTP接口,要关注它有没有限流,429之后怎么退避

  • 如果是浏览器端,还要看同域连接数限制

并发控制不是为了优雅,是为了别把系统打毛了。


分批执行的问题

有人会把这题答成"分批执行",写成每10个一组:

async function batchRun(taskFns, batchSize = 10) {
  const results = [];

  for (let i = 0; i < taskFns.length; i += batchSize) {
    const currentBatch = taskFns.slice(i, i + batchSize);
    const batchRes = await Promise.all(currentBatch.map(fn => fn()));
    results.push(...batchRes);
  }

  return results;
}

这也能做,但不够好。因为一批里只要有一个慢请求,后面的任务就全得等。前面9个早跑完了,也不能补位。这个吞吐其实不高,空转时间比较多。

所以用worker池这种写法更好:谁跑完谁领下一个,不会傻等。

本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!

链接: https://shenqiku.cn/article/FLY_13669