咱们今天不聊虚的,直接切入那个让前端开发者和后端架构师都头疼的问题:为什么我的页面明明没写什么复杂的逻辑,但数据加载就是慢得像蜗牛爬?很多时候,罪魁祸首不是网络带宽不够,而是浏览器那套看似“保护隐私”实则“限制性能”的同源策略并发限制。
想象一下,你正在开发一个大型电商后台或者一个实时数据大屏。你需要同时拉取用户信息、订单列表、库存状态、物流轨迹……几十个接口呼啦啦地发出去。如果你用的是传统的 XMLHttpRequest (XHR),你会发现事情开始变得诡异起来:前几个请求嗖嗖地回来了,后面的请求却像被冻住了一样,卡在 pending 状态,直到前面的请求完成或者超时。
这就是我们要聊的核心——浏览器的同源并发限制。这不仅仅是个技术细节,更是决定用户体验生死的关键。
同源策略下的并发限制真相
首先,得澄清一个误区:同源策略本身并不直接限制并发数量。同源策略(Same-Origin Policy)主要是为了安全,防止一个域名的脚本去读取另一个域名 cookie 或数据。它限制的是“跨域访问”,而不是“请求数量”。
但是,浏览器为了实现这个安全模型以及优化资源调度,对单个域名(Origin)的 TCP 连接数做了严格限制。这就是所谓的“并发限制”。
不同浏览器的默认限制
虽然 HTML5 规范没有明确规定具体数字,但各大浏览器厂商基于各自的引擎实现,形成了行业事实标准:
| 浏览器/引擎 | 最大并发连接数 (每域名) | 备注 |
|---|---|---|
| Chrome / Edge (Blink) | 6 | 现代版本通常稳定在 6 |
| Firefox (Gecko) | 6 | 较新版本可能略有调整,但通常也是 6 |
| Safari (WebKit) | 6 | iOS 和 macOS 上均为此限制 |
| Internet Explorer | 2 | 远古时代的遗留问题,现在基本不用管了 |
关键点在于: 这个限制是针对 HTTP/1.1 协议的。如果你使用的是 HTTP/2 或 HTTP/3,情况就完全不同了,后面我们会细说。但在很多老旧系统、内部网管或者特定配置下,HTTP/1.1 依然是主力,这时候这 6 个槽位就是稀缺资源。
为什么是 6 个?
这背后有一个历史原因。早期的 TCP 连接建立需要三次握手,关闭需要四次挥手,开销很大。浏览器厂商经过大量实测发现,对于大多数网页应用,同时打开超过 6 个到服务器的连接并不会显著提升吞吐量,反而会增加 CPU 开销和内存占用,甚至导致网络拥塞控制算法失效。所以,6 成了一个平衡点。
XMLHttpRequest:老朋友的困境与突围
XMLHttpRequest 是 AJAX 的鼻祖。虽然现代前端框架很少直接调用它,但理解它的行为对于排查深层问题至关重要,而且很多底层库(如 Axios 的某些旧版本配置)依然基于它。
XHR 的并发表现
当你使用 XHR 发起多个请求时,浏览器会在后台维护一个连接池。一旦某个域名下的活跃连接数达到 6,后续发出的请求就会被放入队列,等待前面的请求完成(成功、失败或超时)后,再复用或新建连接。
典型场景演示:
假设你有 12 个 XHR 请求要同时发出,全部指向 api.example.com。
// 模拟 12 个并行请求
const urls = Array.from({ length: 12 }, (_, i) => `https://api.example.com/data/${i}`);
urls.forEach(url => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
// 监听状态变化,观察排队现象
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
console.log(`Request to ${url} completed.`);
}
};
xhr.send();
});
执行结果分析:
- 前 6 个请求立即发送,进入
sending->done状态。 - 后 6 个请求处于
pending状态,它们在等待队列中。 - 当第 1 个请求完成(假设耗时 1 秒),队列中的第 7 个请求才会开始发送。
- 整个过程的时间大约是
ceil(12 / 6) * avg_request_time。如果平均请求时间是 1 秒,总耗时约 2 秒。但如果平均请求时间是 5 秒,总耗时就是 10 秒!
优化 XHR 的策略
既然 XHR 受限于连接数,我们有什么办法突破吗?
1. 域名分片(Domain Sharding)—— 经典但已过时
这是老一代的前端优化手段。原理很简单:既然 api.example.com 只能开 6 个连接,那我就再搞几个子域名:api1.example.com, api2.example.com…
// 伪代码示例
const domains = ['api1.example.com', 'api2.example.com', 'api3.example.com'];
const requests = [...]; // 12 个请求
requests.forEach((req, index) => {
const domain = domains[index % domains.length];
const xhr = new XMLHttpRequest();
xhr.open('GET', `https://${domain}/data/${index}`, true);
xhr.send();
});
优点: 理论上可以将并发上限提升到 6 * 域名数量。
缺点:
- DNS 查询开销: 每个新域名都需要 DNS 解析,虽然浏览器会缓存,但首次加载会变慢。
- Cookie 隔离: 如果子域名不同,可能需要设置不同的 Cookie 或处理跨域 Cookie 问题,增加了复杂性。
- SSL 握手开销: 每个新域名都需要新的 TLS 握手,消耗 CPU 和延迟。
- 现代浏览器优化: Chrome 89+ 引入了
Connection: keep-alive的改进,对同一域名下的并发限制有所放宽(实际上是通过多路复用逻辑优化,而非真正增加 TCP 连接数,这点在 HTTP/2 部分详解)。
结论: 除非你被迫使用 HTTP/1.1 且无法升级,否则不建议使用域名分片。它治标不治本,还带来新的问题。
2. 请求合并与批量接口
与其发 12 个请求,不如让后端提供一个 /batch 接口,一次性返回所有数据。
const batchData = [
{ id: 1, type: 'user' },
{ id: 2, type: 'order' },
// ...
];
fetch('/api/batch', {
method: 'POST',
body: JSON.stringify(batchData)
})
.then(res => res.json())
.then(data => {
console.log('All data received in one go!', data);
});
优点: 只占用 1 个连接槽位,极大减少网络往返次数(RTT)。 缺点: 需要后端配合开发,且单次响应体可能较大,解析耗时增加。
3. 优先级调度与懒加载
不要一股脑发出所有请求。根据用户可见性,优先加载首屏数据,非首屏数据可以延迟加载或按需加载。
// 使用 Intersection Observer 实现懒加载
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadMoreData(); // 触发更多请求
observer.unobserve(entry.target);
}
});
});
document.querySelectorAll('.lazy-load-section').forEach(section => {
observer.observe(section);
});
Fetch API:现代浏览器的新宠
Fetch API 是基于 Promise 的新一代 AJAX 方案。它在语法上更简洁,功能上更强大,而且在处理并发方面有一些微妙但重要的差异。
Fetch 的并发表现
在 HTTP/1.1 环境下,Fetch 和 XHR 的行为几乎一致:同样受限于 6 个并发连接。浏览器不会因为你用了 fetch() 就多给你开两个通道。
但是,Fetch 在处理取消请求和流式响应方面做得更好,这对于高并发场景下的资源管理非常有意义。
1. AbortController:优雅地取消冗余请求
在高并发场景中,经常会出现这种情况:用户快速点击了多个按钮,触发了多个请求。如果第一个请求已经拿到了最新数据,后续的请求可能就是浪费资源。AbortController 允许你在请求发出后随时取消它们。
// 创建控制器
const controller = new AbortController();
const signal = controller.signal;
// 发起多个请求
const promises = [
fetch('/api/data/1', { signal }),
fetch('/api/data/2', { signal }),
fetch('/api/data/3', { signal })
];
// 模拟业务逻辑:500ms 后只保留第一个请求的结果
setTimeout(() => {
controller.abort(); // 取消其他未完成的请求
}, 500);
Promise.race(promises)
.then(response => response.json())
.then(data => console.log('Winner:', data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('Requests cancelled.');
} else {
console.error('Other error:', err);
}
});
优势: 释放被占用的连接槽位,让其他关键请求得以执行。这在移动端网络不稳定时尤为重要。
2. 流式响应(Streaming Response)
对于大文件下载或大数据量接口,Fetch 支持通过 ReadableStream 逐步读取数据,而不必等待整个响应体加载完毕。这可以减少内存峰值,并允许 UI 提前渲染部分内容。
async function streamDownload(url) {
const response = await fetch(url);
const reader = response.body.getReader();
let receivedLength = 0;
const chunks = [];
while(true) {
const {done, value} = await reader.read();
if (done) break;
chunks.push(value);
receivedLength += value.length;
// 实时更新进度条
updateProgressBar(receivedLength);
}
return new Blob(chunks);
}
Fetch vs XHR 在高并发中的选择
- 代码简洁性: Fetch 完胜。
- 错误处理: Fetch 只有在网络故障时才 reject,HTTP 错误码(如 404, 500)被视为成功响应,需要手动检查
response.ok。这点需要特别注意,否则在高并发错误处理时容易漏判。 - 兼容性: XHR 兼容 IE11,Fetch 不兼容。如果项目需要支持 IE,还是得用 XHR 或 polyfill。
- 并发控制: 两者在 HTTP/1.1 下无本质区别,但 Fetch 的
AbortController更易用。
HTTP/2 与 HTTP/3:终结者到来
如果你还在为 6 个连接的瓶颈焦虑,那么好消息是:在现代 Web 环境中,HTTP/1.1 的并发限制正在成为历史。
HTTP/2 的多路复用(Multiplexing)
HTTP/2 引入了多路复用技术。这意味着所有请求都可以复用到同一个 TCP 连接上。
- 不再区分请求顺序: 你可以同时发出 100 个请求,它们都在同一个连接上交错传输,互不阻塞。
- 头部压缩: HPACK 算法减少了 HTTP 头部的开销。
- 服务器推送: 服务器可以在客户端请求之前主动推送资源。
实践建议: 如果你的服务器已经支持 HTTP/2,并且客户端浏览器也支持(现代浏览器都支持),那么完全不需要担心并发限制问题。你可以放心地发起大量请求,浏览器会自动管理连接。
如何检测?
if ('connection' in navigator && navigator.connection.effectiveType === '4g') {
console.log('High bandwidth connection, likely HTTP/2 supported.');
}
// 更准确的方式是查看 Network 面板中的 Protocol 列
HTTP/3 (QUIC)
HTTP/3 基于 UDP,彻底解决了 TCP 的队头阻塞(Head-of-Line Blocking)问题。即使在丢包率较高的移动网络下,HTTP/3 也能保持极高的并发效率和低延迟。Cloudflare、Google 等大厂已经在大规模部署 HTTP/3。
高并发场景的最佳实践总结
回到最初的问题:如何在高并发场景下优化 AJAX 请求?以下是经过实战检验的最佳实践清单:
1. 协议升级优先
- 确保服务器启用 HTTP/2 或 HTTP/3。 这是最根本的解决方案。一旦启用,6 连接限制自动失效。
- 检查 CDN 是否支持 HTTP/2。
2. 智能请求调度
- 不要盲目并行: 分析请求之间的依赖关系。如果 B 请求需要 A 请求的数据,那就串行。
- 优先级队列: 将请求分为高、中、低优先级。先处理高优先级(如首屏数据),再处理低优先级(如日志上报、非核心数据)。
class RequestScheduler {
constructor(maxConcurrent = 6) {
this.maxConcurrent = maxConcurrent;
this.running = 0;
this.queue = [];
}
addRequest(requestFn) {
return new Promise((resolve, reject) => {
this.queue.push({ requestFn, resolve, reject });
this.processQueue();
});
}
async processQueue() {
if (this.running >= this.maxConcurrent || this.queue.length === 0) {
return;
}
this.running++;
const { requestFn, resolve, reject } = this.queue.shift();
try {
const result = await requestFn();
resolve(result);
} catch (error) {
reject(error);
} finally {
this.running--;
this.processQueue(); // 处理下一个
}
}
}
// 使用示例
const scheduler = new RequestScheduler(6); // 显式限制并发
const requests = urls.map(url => () => fetch(url).then(r => r.json()));
Promise.all(requests.map(req => scheduler.addRequest(req)))
.then(results => console.log('All done', results));
3. 缓存策略
- Service Worker 缓存: 对于静态资源或不常变化的数据,使用 Service Worker 拦截请求,直接从缓存读取,避免发起网络请求。
- HTTP Cache Headers: 合理设置
Cache-Control、ETag、Last-Modified,利用浏览器原生缓存机制。
4. 数据聚合
- GraphQL: 如果后端支持,使用 GraphQL 一次性获取所需的所有字段,避免多次 RESTful 调用。
- Batch API: 如前所述,后端提供批量接口。
5. 监控与调试
- Chrome DevTools Network Tab: 仔细查看
Waterfall视图,识别哪些请求在等待。 - Performance API: 使用
performance.getEntriesByType('resource')分析请求耗时分布。
写给小朋友的比喻
最后,我想用一个简单的比喻来总结一下,方便你向团队里的新人或小朋友解释这个概念。
想象你家(浏览器)要去图书馆(服务器)借书。
- 同源策略:图书馆规定,只有持有你家钥匙的人才能进,别人不行。这是为了安全。
- 并发限制(6 个连接):图书馆只有 6 个窗口可以同时办理借书手续。如果你一次派 12 个人去借 12 本书,前 6 个人能马上办,后 6 个人得在旁边等着,前面有人办完了,后面的人才能补上去。这就是为什么你会感觉“卡住了”。
- XHR:就像传统的纸质借书单,每个人拿一张单子去排队,比较笨重。
- Fetch:就像电子自助借书机,更灵活,还能随时喊停(AbortController)。
- HTTP/2:图书馆翻新了,变成了一个巨大的自助服务区,不管多少人,都在同一个大通道里流转,互不干扰。这才是终极解决方案!
所以,下次遇到请求卡顿,别急着怪网络不好,先看看是不是“窗口”不够用了,或者该升级到“自助服务区”(HTTP/2)了。
希望这篇详细的解析能帮你彻底搞定高并发 AJAX 请求的问题。如果有具体的代码场景需要优化,欢迎随时交流!
