2019年,某电商平台在大促前夕发现一个诡异的现象:虽然后端API响应时间已经优化到10毫秒以内,但前端用户感知的延迟却高达300毫秒。排查后发现,罪魁祸首不是数据库、不是CDN,而是一个被大多数开发者忽视的HTTP方法——OPTIONS。
这不是个例。在现代Web架构中,前后端分离部署几乎成为标配:前端托管在CDN或静态站点,API则运行在独立的域名或子域名上。这种架构带来了灵活性,却也引入了CORS(Cross-Origin Resource Sharing)这一复杂的浏览器安全机制。而其中最容易被忽视的性能杀手,就是预检请求(Preflight Request)。
两次往返的真相
当浏览器发起一个跨域请求时,它并不总是直接发送实际请求。对于不符合"简单请求"条件的请求,浏览器会先发送一个OPTIONS请求,询问服务器是否允许实际的跨域请求。这个过程叫做预检。
预检请求的流程如下:
图片来源: mdn.github.io
这意味着一个实际的API调用变成了两次网络往返:首先是OPTIONS预检,然后才是真正的请求。根据HTTP Toolkit的测试数据,预检请求通常会带来50-300毫秒的额外延迟,在网络条件较差的环境下甚至可能达到500毫秒。对于响应时间只有几十毫秒的API来说,预检请求可能让整体延迟翻倍甚至更多。
更隐蔽的成本在于无服务器架构。AWS Lambda、Cloudflare Workers等平台按调用次数计费,预检请求会产生额外的费用。如果你的前端每秒发起100个跨域API请求,那么实际上会产生200次函数调用——成本直接翻倍。
什么触发预检
并非所有跨域请求都需要预检。根据WHATWG Fetch标准和MDN文档,只有不符合"简单请求"条件的请求才会触发预检。
一个请求要成为"简单请求",必须同时满足以下所有条件:
方法限制:只能是GET、HEAD或POST。
头部限制:只能包含以下CORS安全列表中的头部:
- Accept
- Accept-Language
- Content-Language
- Content-Type(有额外限制)
- Range(仅限单个范围请求)
Content-Type限制:如果存在Content-Type头部,其值只能是:
- application/x-www-form-urlencoded
- multipart/form-data
- text/plain
这意味着,绝大多数现代API请求都会触发预检:
// 这些都会触发预检请求
fetch('https://api.example.com/data', {
method: 'PUT' // 不是GET/HEAD/POST
});
fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' } // JSON不是简单类型
});
fetch('https://api.example.com/data', {
headers: { 'Authorization': 'Bearer token' } // 自定义头部
});
fetch('https://api.example.com/data', {
headers: { 'X-Custom-Header': 'value' } // 任何自定义头部
});
Authorization头部是一个典型的陷阱。OAuth 2.0广泛使用Bearer token进行认证,但Authorization并不在CORS安全列表中,因此任何带有Authorization头部的请求都会触发预检。这个问题曾经在GitHub上引发过讨论(whatwg/fetch#770),有人提议将Bearer前缀加入白名单,但出于安全考虑,这个提议最终没有被采纳。
为什么需要预检
理解预检的设计初衷,有助于我们在优化时做出正确的权衡。
预检请求的核心目的是保护那些"不知道CORS存在"的服务器。在CORS出现之前,浏览器的同源策略(Same-Origin Policy)阻止了跨域的JavaScript请求。这意味着服务器可能假设:只要收到某些类型的请求,它一定来自同源页面。
假设一个用户登录了银行网站bank.com,然后访问了恶意网站evil.com。如果没有预检机制,evil.com的JavaScript可以直接向bank.com发送DELETE /account请求,而这个请求会带上用户的银行cookie。如果bank.com是一个不了解CORS的老旧服务器,它可能会直接执行这个危险操作。
预检请求通过强制浏览器先发送OPTIONS请求来避免这个问题。OPTIONS是一个"安全"的HTTP方法——它不会修改服务器状态。只有当服务器明确响应正确的CORS头部时,浏览器才会发送实际的请求。这样,不了解CORS的服务器会拒绝预检请求(因为没有返回正确的CORS头部),从而保护自己免受跨域攻击。
用Stack Overflow上一个回答的总结:预检请求的引入是为了不给非CORS感知的服务器增加额外的CSRF攻击面。HTML表单从来就可以跨域提交POST请求,所以表单能做的事情不需要预检;但对于PUT、DELETE、自定义头部等"新"能力,浏览器必须先确认服务器是否愿意接受。
浏览器缓存机制
好消息是,浏览器会缓存预检请求的结果,避免每次都发送OPTIONS请求。缓存通过Access-Control-Max-Age响应头部控制:
Access-Control-Max-Age: 86400
这告诉浏览器将预检结果缓存86400秒(24小时)。但这里有一个重要的陷阱:浏览器对缓存时间有自己的上限。
根据MDN文档和HTTP Toolkit的测试,各浏览器的上限如下:
| 浏览器 | 最大缓存时间 |
|---|---|
| Chrome/Chromium (v76+) | 2小时(7200秒) |
| Chrome/Chromium (v76前) | 10分钟(600秒) |
| Firefox | 24小时(86400秒) |
| Safari | 约5分钟 |
这意味着即使服务器返回Access-Control-Max-Age: 86400,Chrome也只会缓存2小时。Chrome团队在源码注释中解释了这个限制的原因:“最小化在切换到安全网络后使用被污染缓存的风险”。
更复杂的是,预检缓存是基于URL的,而不是基于域名的。每个URL路径都有独立的缓存条目。如果你的API有100个不同的端点,即使设置了很长的缓存时间,用户访问每个新端点时仍然会触发预检请求。
预检缓存的内部结构在W3C CORS规范中有明确描述。每个缓存条目包含以下字段:
- origin(请求来源)
- url(请求URL)
- max-age(缓存时间)
- credentials(是否包含凭证)
- method(允许的方法)或header(允许的头部)
主缓存键由除max-age外的所有字段组成。这意味着同一个URL、同一个来源、同一个方法的请求会共享缓存,但如果请求的方法或头部不同,则需要单独的预检。
CDN层的挑战
OPTIONS请求在HTTP规范中被定义为不可缓存的,这意味着大多数CDN默认不会缓存预检响应。预检请求会穿透CDN直接到达源服务器,进一步放大性能问题。
要解决这个问题,需要CDN支持显式缓存OPTIONS响应。可以通过添加Cache-Control头部来实现:
Cache-Control: public, max-age=86400
Vary: origin
关键点是Vary头部。预检响应通常包含与请求Origin匹配的Access-Control-Allow-Origin头部。如果不设置Vary: origin,CDN可能会用同一个缓存的响应来回复不同来源的请求,导致CORS验证失败。
但并非所有CDN都支持这个配置。Cloudflare社区就有用户报告OPTIONS请求总是显示DYNAMIC状态,无法被缓存。对于这种情况,可能需要使用Cloudflare Workers或其他边缘计算方案来拦截并响应预检请求。
AWS CloudFront则提供了更细粒度的控制。可以在缓存行为设置中明确启用OPTIONS方法的缓存,并配置需要转发的头部列表(包括Origin)。
优化策略
理解了预检请求的工作原理,我们来看看具体的优化方案。
方案一:同源代理(推荐)
最彻底的解决方案是消除跨域请求。通过反向代理,让前端和API看起来来自同一个源:
# nginx配置示例
server {
listen 443;
server_name app.example.com;
# 前端静态资源
location / {
root /var/www/frontend;
try_files $uri $uri/ /index.html;
}
# API代理
location /api/ {
proxy_pass https://api.example.com/;
proxy_set_header Host api.example.com;
}
}
前端现在请求/api/users而不是https://api.example.com/users,完全绕过了CORS检查。这种方案的优点是完全消除预检请求;缺点是需要配置反向代理,增加了架构复杂度。
现代前端框架通常内置了这个能力。Next.js的rewrites配置、Vite的开发服务器代理、Create React App的proxy设置,都可以轻松实现同源代理。
方案二:延长预检缓存
如果无法消除跨域,至少要最大化预检缓存时间:
// Express.js
const cors = require('cors');
app.use(cors({
origin: 'https://app.example.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400 // 尽可能长,浏览器会自动截断到上限
}));
# FastAPI
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_methods=["*"],
allow_headers=["*"],
max_age=86400,
)
同时,确保CDN层也启用了缓存:
location /api/ {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://app.example.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type';
add_header 'Access-Control-Max-Age' 86400;
add_header 'Cache-Control' 'public, max-age=86400';
add_header 'Vary' 'origin';
return 204;
}
proxy_pass https://api-backend;
}
方案三:避免预检触发
在某些场景下,可以通过调整请求格式来避免触发预检。
将JSON请求改为表单格式:
// 会触发预检
fetch('/api/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'value' })
});
// 不会触发预检(如果其他条件也满足)
fetch('/api/data', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ key: 'value' })
});
但这个方案有明显的局限性:后端需要支持表单格式解析,嵌套对象的传递会变得复杂,且只适用于POST请求。
另一个角度是认证方式。将Authorization头部改为cookie认证可以避免预检,但这引入了CSRF风险,需要额外的保护措施。
方案四:边缘计算
对于使用Cloudflare、AWS等云平台的场景,可以利用边缘计算来处理预检请求。
Cloudflare Workers示例:
export default {
async fetch(request) {
// 直接响应OPTIONS请求
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
}
});
}
// 转发实际请求到源服务器
return fetch(request);
}
};
这个方案的优势是预检请求在边缘节点就被响应,完全不需要到达源服务器。
权衡与选择
每种方案都有其适用场景和代价:
| 方案 | 延迟改善 | 实施复杂度 | 适用场景 |
|---|---|---|---|
| 同源代理 | 100%消除 | 低-中 | 有控制权的架构 |
| 预检缓存 | 约80%改善 | 低 | 所有场景(兜底方案) |
| 避免预检 | 100%消除 | 中 | 特定API场景 |
| 边缘计算 | 100%消除 | 中 | 云原生架构 |
对于新项目,推荐在设计阶段就考虑同源部署,从根源上避免问题。对于已有项目,至少应该配置Access-Control-Max-Age并确保CDN正确缓存OPTIONS响应。如果使用无服务器架构,边缘计算方案可以同时解决性能和成本问题。
预检请求是Web安全与性能之间权衡的产物。它保护了遗留服务器,却给现代API带来了性能负担。理解其设计初衷,才能在优化时做出正确的选择——毕竟,最好的优化不是让预检更快,而是让预检变得不必要。
参考资料
- MDN Web Docs. “Cross-Origin Resource Sharing (CORS)”. https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- MDN Web Docs. “Access-Control-Max-Age”. https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age
- HTTP Toolkit. “Cache your CORS, for performance & profit”. https://httptoolkit.com/blog/cache-your-cors/
- WHATWG. “Fetch Standard - CORS Protocol”. https://fetch.spec.whatwg.org/#http-cors-protocol
- Stack Overflow. “What is the motivation behind the introduction of preflight CORS requests?”. https://stackoverflow.com/questions/15381105/what-is-the-motivation-behind-the-introduction-of-preflight-cors-requests
- GitHub whatwg/fetch. “CORS: why is Authorization request header forcing preflight? #770”. https://github.com/whatwg/fetch/issues/770
- Shyam Verma. “The Hidden 100-300ms Tax: How CORS Preflight Requests Are Slowing Your App”. https://shyamverma.com/cors-preflight-hidden-latency-tax
- AWS. “Improve Single-Page Application (SPA) Performance with a Same-Domain Policy”. https://www.amazonaws.cn/en/blog-selection/improve-single-page-application-spa-performance-with-a-same-domain-policy-using-amazon-cloudfront/