2019年,某电商平台在大促前夕发现一个诡异的现象:虽然后端API响应时间已经优化到10毫秒以内,但前端用户感知的延迟却高达300毫秒。排查后发现,罪魁祸首不是数据库、不是CDN,而是一个被大多数开发者忽视的HTTP方法——OPTIONS。

这不是个例。在现代Web架构中,前后端分离部署几乎成为标配:前端托管在CDN或静态站点,API则运行在独立的域名或子域名上。这种架构带来了灵活性,却也引入了CORS(Cross-Origin Resource Sharing)这一复杂的浏览器安全机制。而其中最容易被忽视的性能杀手,就是预检请求(Preflight Request)。

两次往返的真相

当浏览器发起一个跨域请求时,它并不总是直接发送实际请求。对于不符合"简单请求"条件的请求,浏览器会先发送一个OPTIONS请求,询问服务器是否允许实际的跨域请求。这个过程叫做预检。

预检请求的流程如下:

CORS预检请求流程图

图片来源: 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带来了性能负担。理解其设计初衷,才能在优化时做出正确的选择——毕竟,最好的优化不是让预检更快,而是让预检变得不必要。


参考资料

  1. MDN Web Docs. “Cross-Origin Resource Sharing (CORS)”. https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
  2. MDN Web Docs. “Access-Control-Max-Age”. https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age
  3. HTTP Toolkit. “Cache your CORS, for performance & profit”. https://httptoolkit.com/blog/cache-your-cors/
  4. WHATWG. “Fetch Standard - CORS Protocol”. https://fetch.spec.whatwg.org/#http-cors-protocol
  5. 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
  6. GitHub whatwg/fetch. “CORS: why is Authorization request header forcing preflight? #770”. https://github.com/whatwg/fetch/issues/770
  7. Shyam Verma. “The Hidden 100-300ms Tax: How CORS Preflight Requests Are Slowing Your App”. https://shyamverma.com/cors-preflight-hidden-latency-tax
  8. 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/