GET 传参超长、所有接口都用 POST:HTTP 方法语义踩过的坑

团队最近在梳理一批中后台接口的规范,起因是新来的同事提了个问题:为什么有的更新接口用 PUT,有的用 POST,有的干脆 POST /api/user/update,三种写法在项目里同时存在。这个问题看着简单,往下拆会牵出一串更值钱的东西——方法语义、幂等性、安全性、重试策略,这些点在字面意义上大家都"知道",但真正落到接口设计和前端请求层实现时,很多团队其实没有一以贯之的判断标准。

安全性和幂等性,是两个不同的维度

规范里对 HTTP 方法有两个正交的分类标准,很多人把它们混成一件事,其实得分开看。

**安全性(safe)**指的是这个方法在语义上不应该改变服务端状态。GETHEADOPTIONS 是安全的:调用它们理论上只是读,不产生副作用。这也是为什么浏览器预加载、爬虫抓取、CDN 缓存都敢对 GET 请求"随便发"——反正它不改数据。POSTPUTDELETEPATCH 都不安全,它们的存在就是为了产生副作用。

**幂等性(idempotent)**指的是同一个请求发一次和发 N 次,服务端最终状态是不是一样。GETPUTDELETEHEAD 是幂等的;POSTPATCH 不是。这里容易踩的第一个坑,是把"安全"和"幂等"当成一回事——GET 又安全又幂等,但 PUTDELETE 不安全(会改状态)却是幂等的。删除同一条记录十次,和删除一次,结果都是"这条记录不存在了",所以 DELETE 幂等;但删除动作本身显然改变了服务端状态,所以它不安全。这两个维度决定了两件完全不同的事:安全性决定这个方法能不能被代理、爬虫、预加载随意调用;幂等性决定这个方法能不能在网络失败时被安全地重试。

这一步想清楚之后,前面那个"更新接口用什么方法"的问题就有了判断依据。如果一个更新操作是幂等的——传的是完整的目标状态,不管调用几次结果都收敛到同一个值——用 PUT 是准确的。如果只是修改某个字段、且这个修改本身依赖当前状态(比如"库存数量减一"),那严格来说它既不是替换也不天然幂等,语义上更接近 PATCH,而且这类接口在网络重试时要格外小心,下一节会展开讲。

PUT 和 PATCH 的边界,比想象中更容易踩空

PUT 的规范语义是"用请求体整体替换目标资源"。这意味着如果你只传了对象的一部分字段,严格意义上没传的字段应该被服务端置空或恢复默认值,而不是保留原值。团队里有个真实的分歧:前端页面只想改用户的昵称,用 PUT /api/user/123 只传了 { nickname: '新名字' },结果头像字段在数据库里被清空了——后端是按 PUT 的规范语义实现的,问题出在前端对 PUT 的理解停留在"更新接口"这个模糊认知上,没意识到它暗含"整体替换"的约定。

PATCH 就是为了解决这个问题引入的,语义是"对资源做局部修改",请求体只携带要改的字段。多数中后台的"编辑表单只提交变更字段"场景,其实更贴近 PATCH 而不是 PUT。但 PATCH 有个规范里容易被忽略的地方:它默认不是幂等的。举个例子,PATCH 请求体是 { stock: stock - 1 } 这种基于当前值做增量运算的语义,同一个请求重放两次,结果就是减了两次库存,这和幂等的定义正相反。如果 PATCH 请求体传的是目标值本身(比如 { status: 'closed' }),那这次调用又是幂等的了——幂不幂等取决于请求体表达的是"增量"还是"目标状态",跟方法名本身没有必然关系,这是规范里最容易被简化误读的一点。

团队现在的约定是:能设计成"传目标状态"就尽量传目标状态,哪怕多传几个字段,也好过传增量。这样不管是 PUT 全量替换还是 PATCH 局部修改,接口都天然获得幂等性,为后面讲的自动重试打好了地基。

所有接口都用 POST,省的是设计成本,丢的是协作红利

另一种常见问题走向了反方向:干脆所有写操作都用 POST,路径上靠 /api/user/delete/api/user/update 这类动词来区分语义。这样做接口设计的心智负担确实小,但代价也是实打实的。

首先是前面提过的,POST 既不安全也不幂等,浏览器和网关不敢对它做任何假设——不会缓存、断线重连时不会自动重试、也不会被预加载。如果一个查询接口的参数很复杂,团队图省事全用 POST 传 body,其实主动放弃了 GET 天然具备的缓存能力:同样的查询条件短时间内重复请求,GET 可以借助 Cache-Control 或 CDN 直接命中缓存不用打到源站,POST 做不到这点,即便手动加缓存层,成本也比让浏览器和网关顺手处理高得多。

其次是可观测性上的损失。API 网关、日志系统、监控面板很多是按方法维度统计的——多少读请求、多少写请求、哪类操作的错误率高。全用 POST 之后,这些统计全部塌缩成一个维度,出了问题定位方向会模糊很多。这不是说非得较真到每个接口都严格对齐 RESTful 语义,但至少读写要分开,GETGET、写操作用带副作用语义的方法,这个底线守住,后续网关层的限流、缓存、监控策略才有地方挂。

反过来,"GET 传大量参数"是另一个方向的误用。搜索页面把几十个筛选条件、一批勾选的 id 都塞进 URL 的 query string,本地测试没问题,量一大直接命中服务器或浏览器对 URL 长度的隐性上限(多数实现在 2KB 到 8KB 之间),请求还没发出去就先报错。这种场景哪怕语义上是"查询",也应该改用 POST 传 body——RESTful 不是教条,语义准确性要向工程现实低头,前提是这个让步是团队清楚意识到、并达成一致的,而不是图省事把所有接口都糊成一种方法。

fetch 的超时取消:AbortController 怎么接进请求层

fetch 本身不带超时参数,这是它相比 axios(内置 timeout 配置)更原始的一点。过去常见的变通办法是 Promise.race([fetch(url), timeoutPromise]),问题是这种写法只是让前端"假装"超时了——fetch 底层的网络请求并没有真的取消,浏览器还在等这个连接,白白占着一个 TCP 连接槽位,race 只是让前端提前拿到一个失败的 Promise。真正的取消需要 AbortController

1function fetchWithTimeout(url, options = {}, timeout = 8000) {
2  const controller = new AbortController()
3  const timer = setTimeout(() => controller.abort(), timeout)
4
5  return fetch(url, { ...options, signal: controller.signal }).finally(() => {
6    clearTimeout(timer)
7  })
8}

AbortController 生成的 signal 传给 fetch 之后,调用 controller.abort() 会让底层的网络请求真正中断,连接被释放,而不只是前端逻辑上不再等待。捕获这个中断也有讲究:fetch 被 abort 之后,Promise 会 reject,抛出的错误 nameAbortError,得把它和真正的网络错误(断网、DNS 失败)区分开,因为这两者对用户的提示应该不一样,一个是"网络较慢,请重试",一个是"网络异常,请检查连接"。

1try {
2  const res = await fetchWithTimeout('/api/list', {}, 5000)
3  return await res.json()
4} catch (err) {
5  if (err.name === 'AbortError') {
6    throw new Error('请求超时,请重试')
7  }
8  throw new Error('网络异常,请检查连接')
9}

AbortController 还有个经常被漏掉的用法场景:组件卸载时取消尚未完成的请求。列表页切换 tab 很快,如果上一个 tab 的搜索请求还没返回,组件已经切走了,这个请求的响应回来时再去 setState,轻则控制台报警告,重则把已经不该展示的数据渲染出来。做法是在发起请求时创建 controller,把它存起来,在组件卸载或者参数变化需要发起新请求时主动 abort 掉上一个:

1let currentController = null
2
3async function search(keyword) {
4  if (currentController) {
5    currentController.abort() // 取消上一次还没回来的搜索
6  }
7  currentController = new AbortController()
8
9  try {
10    const res = await fetch(`/api/search?q=${keyword}`, {
11      signal: currentController.signal,
12    })
13    return await res.json()
14  } catch (err) {
15    if (err.name === 'AbortError') return null // 主动取消,不当错误处理
16    throw err
17  }
18}

这个模式在搜索框输入联想、快速切换 tab 这类场景特别常用——本质上是用 AbortController 实现了"只关心最后一次请求结果"的语义,比用一个 requestId 自己在业务层做版本号比对要干净得多,取消动作直接下沉到网络层。

AbortController 还能一控多个请求:一个 signal 可以同时传给多个 fetch 调用,abort() 一次全部取消,适合"批量并发请求,只要有一个超时就整体放弃"的场景。

1const controller = new AbortController()
2const timer = setTimeout(() => controller.abort(), 6000)
3
4const [userRes, orderRes] = await Promise.all([
5  fetch('/api/user', { signal: controller.signal }),
6  fetch('/api/orders', { signal: controller.signal }),
7])
8clearTimeout(timer)

重试策略:哪些方法能重试,哪些不能

超时和取消解决了"该不该继续等"的问题,紧接着的问题是失败之后要不要自动重试。这一步如果不结合前面讲的幂等性去判断,很容易埋雷。

安全的重试对象是幂等方法:GET 失败重试没有副作用风险,PUTDELETE 只要请求体表达的是目标状态而不是增量,重试的最终结果也和只发一次一样。真正危险的是 POST 和不幂等的 PATCH——网络在服务端已经处理完请求、但响应还没送达客户端之前抖动,前端判断为超时发起重试,服务端会收到两次“创建订单”的请求,产生两条订单记录。这种问题在弱网环境或者移动端场景格外常见,抓包看往往能看到同一笔业务对应两条几乎同时的日志。

工程上有两种应对思路。一种是把不安全的操作也改造成幂等的,最常见的做法是幂等键(idempotency key):前端在发起创建请求前生成一个唯一 id(比如 UUID),放在请求头或请求体里,服务端收到后先查这个 id 是否已经处理过,处理过就直接返回上次的结果,不重复执行业务逻辑。

1function createOrder(payload) {
2  // 浏览器原生 crypto.randomUUID 这一年才刚起步、还不能指望所有目标浏览器都有,
3  // 团队沿用的是社区常见的 uuid 库来生成
4  const idempotencyKey = uuidv4()
5  return fetchWithTimeout('/api/orders', {
6    method: 'POST',
7    headers: {
8      'Content-Type': 'application/json',
9      'Idempotency-Key': idempotencyKey,
10    },
11    body: JSON.stringify(payload),
12  })
13}

这样一来,就算网络抖动导致前端重发了两次一模一样的创建请求,服务端因为幂等键相同能识别出是重复请求,只会真正创建一次订单。这个方案的关键在于前后端要约定好幂等键的生效范围和过期时间,否则设计不当反而会带来"永远查不到最新状态"的新问题。

幂等键的过期时间怎么定,是这个方案里最容易被简化处理、却真正决定它好不好用的细节。设得太短,比如只保留几秒,遇到用户网络特别差、重试间隔被前面讲的退避策略拉得比较长的情况,幂等键可能在下一次重试发出之前就已经从服务端过期失效,等于白设计;设得太长,比如保留几天,又意味着服务端要为每个幂等键维护一份处理结果的缓存,存储成本会累积,而且如果前端不小心对同一个业务操作复用了旧的幂等键(比如用户复制了一个旧的草稿再次提交),会导致这次本该是新订单的请求被误判成重复请求而直接返回了旧结果。团队目前的约定是幂等键的生命周期跟单次表单会话绑定——每次打开创建订单的表单时生成一个新的 UUID 存在页面状态里,提交、重试期间沿用同一个,表单一旦关闭或提交成功,这个键就作废,不会被下一次操作复用,服务端那边配合设置一个几分钟量级的过期窗口,覆盖住正常重试的时间范围就足够。

另一种思路更保守:干脆不自动重试不确定是否幂等的操作,把决定权交给用户——请求失败后界面上给一个"重试"按钮,让用户自己决定要不要再点一次,这样至少重复提交是用户知情下发生的,而不是前端在背后偷偷做的。中后台系统里"提交""确认""扣款"这类操作,团队现在基本都用这个策略,只有查询类的接口才允许前端做自动的、带退避的静默重试。

1async function requestWithRetry(url, options, retries = 2) {
2  for (let i = 0; i <= retries; i++) {
3    try {
4      const res = await fetchWithTimeout(url, options, 5000)
5      if (res.ok) return await res.json()
6      if (res.status >= 500 && i < retries) {
7        await sleep(300 * 2 ** i) // 指数退避:300ms、600ms……
8        continue
9      }
10      throw new Error(`HTTP ${res.status}`)
11    } catch (err) {
12      if (err.name === 'AbortError' && i < retries) {
13        await sleep(300 * 2 ** i)
14        continue
15      }
16      if (i === retries) throw err
17    }
18  }
19}
20
21function sleep(ms) {
22  return new Promise(resolve => setTimeout(resolve, ms))
23}

这段重试逻辑本身没有对方法做限制,是不是套用它,取决于调用方——查询接口随便包一层就能用,创建、扣款这类接口调用前得先问一句"这个操作幂等吗",答案是否定的话就不该无脑套这层重试,而应该走前面提到的幂等键方案,或者干脆交给用户手动点重试。指数退避(300ms、600ms、1200ms 依次翻倍)这个细节也值得留意——固定间隔重试在服务端刚好因为过载而失败时,只会让所有客户端在同一时刻再次涌入,加重雪崩;退避间隔拉开之后,重试请求会自然错峰。

HEAD 和 OPTIONS:两个被严重低估的方法

日常业务代码里几乎看不到有人手写 HEADOPTIONS,但这两个方法其实一直在背后发挥作用,理解它们对排查一些"看起来很怪"的问题很有帮助。

HEAD 的语义是"只要响应头,不要响应体",服务端处理逻辑和 GET 完全一样,只是最后不把 body 写回去。它的价值在于用很小的代价探测资源状态:文件下载前用 HEAD 探一下 Content-Length,可以提前知道文件大小、决定要不要提示用户"文件较大,确认下载吗";探 Last-ModifiedETag,可以判断本地缓存的版本是不是最新的,而不必真的把整个资源下载下来再比较。

1async function checkFileSize(url) {
2  const res = await fetch(url, { method: 'HEAD' })
3  const size = Number(res.headers.get('content-length') || 0)
4  return size
5}

OPTIONS 的语义是"询问这个资源支持哪些方法、允许哪些请求头",响应里通常带一个 Allow 头列出支持的方法列表。手动调它的场景不多,但浏览器自己在大量场景下悄悄发着 OPTIONS——这就是 CORS 预检请求(preflight)。当一次跨域请求满足"非简单请求"的条件(用了 PUT/DELETE/PATCH 这类方法,或者带了自定义请求头,或者 Content-Type 不是那三种表单/文本/纯文本格式之一),浏览器会先自动发一个 OPTIONS 去问服务器"我接下来这个请求你允不允许",服务器要在响应头里明确回答 Access-Control-Allow-MethodsAccess-Control-Allow-Headers,浏览器确认允许之后,真正的请求才会发出去。

这里有个跟本篇主题直接相关的联动:如果一个团队图省事把所有写操作都改成 POST 加自定义头(比如用 X-Action: delete 表达删除语义,而不是老老实实用 DELETE 方法),会发现预检请求依然会触发——因为自定义头本身就会打破"简单请求"的判定条件,方法语义上的取巧并不能绕开预检成本。反过来,如果接口设计老实遵循 RESTful,该用 DELETE 就用 DELETE,预检请求同样会发生,这是浏览器基于安全考虑的固定行为,跟方法选择的"优雅程度"无关,只是提前了解这一点,能避免把预检请求的开销错怪到接口设计头上。

预检请求本身也有优化空间:响应头里的 Access-Control-Max-Age 可以告诉浏览器这次预检结果能缓存多久,在有效期内同源同方法同头部的请求不用再发一次 OPTIONS。团队里有个批量操作页面,一开始每次交互都要经历一次预检加一次真实请求,体感上慢了一拍,后来后端把 Access-Control-Max-Age 设到 600 秒,同一个页面会话内的重复操作省掉了大量重复预检,网络面板里那一片 OPTIONS 请求肉眼可见地少了。

用条件请求做乐观并发控制:PUT 和 ETag 配合的场景

PUT 的幂等语义解决了"重复提交安不安全"的问题,但没解决另一个常见问题:两个人同时编辑同一条记录,后保存的一方会不会不知不觉覆盖掉前一个人的修改。这是并发写入场景里绕不开的问题,HTTP 规范里给了一套现成的方案,叫条件请求(conditional request),配合 ETag 就能实现乐观并发控制。

流程是这样的:GET 一条记录时,响应头带上这条记录当前版本对应的 ETag(通常是内容的哈希或者版本号);页面拿着这个 ETag 编辑,保存时用 PUT 提交,同时在请求头里带上 If-Match: "上次拿到的 ETag";服务端收到请求后,先比较当前记录的最新 ETag 和请求头里的是否一致——一致说明这段时间没人改过,正常执行更新并生成新的 ETag;不一致说明数据已经被别人改过,服务端应该拒绝这次更新,返回 412 Precondition Failed,而不是直接覆盖。

1async function saveDocument(id, content, lastKnownEtag) {
2  const res = await fetch(`/api/documents/${id}`, {
3    method: 'PUT',
4    headers: {
5      'Content-Type': 'application/json',
6      'If-Match': lastKnownEtag,
7    },
8    body: JSON.stringify({ content }),
9  })
10
11  if (res.status === 412) {
12    // 数据已被他人修改,不能直接覆盖
13    throw new ConflictError('内容已被他人修改,请刷新后重新编辑')
14  }
15  if (!res.ok) {
16    throw new Error(`保存失败:HTTP ${res.status}`)
17  }
18  return res.json()
19}

团队后台有个多人协作编辑商品详情页的场景,早期没做这层校验,两个运营几乎同时保存,后保存的一方会静默覆盖前一个人刚改完的价格,谁都不知道自己的修改丢了,事后排查靠翻操作日志才发现。接入 If-Match 之后,后保存的一方会立刻收到 412,页面弹出"内容已被修改,请刷新后重试",虽然体验上多了一次交互成本,但避免了数据静默丢失,这类场景下这个代价是值得付的。

需要说明的是,If-Match 属于强条件校验,要求 ETag 完全一致才放行,这对应的是"绝不允许覆盖别人的修改"这种强诉求场景。还有一种更宽松的条件请求 If-Unmodified-Since,语义类似但比较的是时间戳而不是内容哈希,适合对并发精度要求没那么高、允许一定时间窗口内的修改被覆盖的场景。两者都属于条件请求家族,核心思路是一致的——把"这次写操作的前提条件"显式带在请求头里,让服务端来判断这个前提是否还成立,而不是无条件地信任客户端手里的数据是最新的。这个思路本质上是把并发控制的一部分决策权从后端数据库层的锁机制,挪到了协议层用一个请求头来表达,对于读多写少、冲突概率不高的场景,比整套悲观锁的实现成本低很多。

这个机制也回答了本文开头提出的问题的另一面:PUT 本身幂等,但幂等说的是"同一个请求发多次结果一样",并不保证"两个不同的请求之间不会互相覆盖"。乐观并发控制解决的正是后一个问题,是幂等性之外一个常被忽略的维度,值得在设计涉及多人协作的编辑类接口时提前考虑进去。

批量接口的方法选择:一个真实的取舍案例

批量删除、批量导入这类操作在中后台系统里非常常见,也是前面提到的"路径设计里容易卡壳"的具体延伸,值得多花篇幅拆开讲讲实际怎么权衡。

严格贴着 RESTful 语义,批量删除 20 条记录应该发 20 次 DELETE /api/orders/:id。这样做的好处是每个请求语义清晰、幂等、可以并发发送,失败了只需要重试失败的那几条,不用把整批重新做一遍。团队一开始也是这么设计的,用 Promise.allSettled 并发发送:

1async function batchDelete(ids) {
2  const results = await Promise.allSettled(
3    ids.map(id => fetch(`/api/orders/${id}`, { method: 'DELETE' }))
4  )
5  const failed = results
6    .map((r, i) => ({ r, id: ids[i] }))
7    .filter(({ r }) => r.status === 'rejected' || !r.value.ok)
8    .map(({ id }) => id)
9
10  return { successCount: ids.length - failed.length, failed }
11}

Promise.allSettled 在这里比 Promise.all 更合适——批量操作里一部分失败是常态,Promise.all 一个 reject 就会让整体 reject,拿不到"哪几条成功了、哪几条失败了"这个更有用的信息,allSettled 会等所有请求都有结果之后再统一返回,成功和失败分得清清楚楚,页面上可以准确提示"18 条删除成功,2 条失败"。

但这个方案在批量规模上到几百条时会撞上现实问题:Promise.allSettled 一次性把几百个请求全部发出去,容易把浏览器对同一域名的并发连接数(通常 6 条左右)挤爆,请求在排队阶段就先卡住一片,服务端那边也可能被瞬时并发压出限流。这时候团队权衡之下改成了批量接口 POST /api/orders/batch-delete,body 里带 id 数组,一次网络往返、服务端内部循环处理、返回一个逐条的结果列表。

1async function batchDeleteViaEndpoint(ids) {
2  const res = await fetch('/api/orders/batch-delete', {
3    method: 'POST',
4    headers: { 'Content-Type': 'application/json' },
5    body: JSON.stringify({ ids }),
6  })
7  const body = await res.json()
8  // body.results 形如 [{ id, success, message }, ...]
9  return body.results
10}

这个取舍本质上是"语义纯粹度"和"网络请求成本"之间的权衡:几十条以内、对失败重试粒度要求高的场景,多次独立的 DELETE 更合适;量大、且能接受"批量整体作为一个事务单元"的场景,单一批量接口效率更高。团队现在的判断标准大致是条数阈值定在 50 左右,超过就切批量接口,这个数字没有什么理论依据,是压测了几轮网关和浏览器连接数表现之后拍板的,不同团队的基础设施不同,具体阈值需要自己测。

可选链在错误处理里的实际收益

前面这些请求层代码里穿插用到的 ?.,值得单独说一句它解决的实际问题。请求层最终要把各种形态的响应体统一喂给页面组件,而后端返回的数据结构往往不总是符合预期——字段被服务端漏返、嵌套对象在某些分支下是 null,这些情况过去得写成一长串 && 判断:

1// 过去的写法
2const cityName = res && res.data && res.data.address && res.data.address.city
3
4// 现在
5const cityName = res?.data?.address?.city

这不只是少打几个字符的问题。链式 && 判断很容易在中间某一层漏掉判断,一旦真实数据在某层是 undefined,代码会直接抛出 Cannot read property of undefined,这类报错在生产环境的监控里出现频率不低,往往就是因为一处深层属性访问没做完整的空值判断。?. 把这一整条链路的判断收敛成一种写法,配合 ?? 给最终值兜底默认值,请求层和页面层里那些"接口返回的数据结构和文档对不上"的问题,处理起来干净了很多:

1function normalizeUser(raw) {
2  return {
3    name: raw?.name ?? '未知用户',
4    avatar: raw?.profile?.avatar ?? '/default-avatar.png',
5    tags: raw?.profile?.tags ?? [],
6  }
7}

这个写法在请求层做统一的数据归一化时尤其有用——不管后端某次返回缺了哪一层字段,前端这一层总能兜出一个结构完整、字段类型稳定的对象交给页面,页面组件不用反过来关心接口数据"可能缺胳膊少腿"这件事。

退避策略里的抖动:为什么固定的指数退避还不够

前面重试那节写的指数退避(300ms、600ms、1200ms)已经比固定间隔重试合理,但放到真实的高并发场景下还有一个漏洞:如果大量客户端在同一时刻请求失败(比如服务端一次短暂过载导致一批请求同时超时),它们的退避时间序列是完全相同的,300ms 之后这批客户端又会在同一毫秒级窗口里再次涌入,只是把"雪崩"往后拖延了固定的几百毫秒,没有真正打散。

工程上常见的补充手段是在退避时间上叠加随机抖动(jitter),让原本整齐划一的重试时间点被打散开:

1function backoffWithJitter(attempt, base = 300) {
2  const exp = base * 2 ** attempt
3  // 全抖动:在 [0, exp] 区间内随机取值,而不是固定用 exp
4  return Math.random() * exp
5}
6
7async function requestWithJitterRetry(url, options, retries = 3) {
8  for (let i = 0; i <= retries; i++) {
9    try {
10      const res = await fetchWithTimeout(url, options, 5000)
11      if (res.ok) return await res.json()
12      if (res.status >= 500 && i < retries) {
13        await sleep(backoffWithJitter(i))
14        continue
15      }
16      throw new Error(`HTTP ${res.status}`)
17    } catch (err) {
18      if (i === retries) throw err
19      await sleep(backoffWithJitter(i))
20    }
21  }
22}

这种"全抖动”(full jitter)策略在退避区间里整体随机取值,而不是用一个固定的指数值,能有效避免大量客户端的重试请求在时间上扎堆。这个问题在单个用户独立调试时几乎不可能被观察到——只有在压测或者真实高并发场景下,才能看出"没有抖动的指数退避"和"带抖动的指数退避"在服务端恢复速度上的差异。团队在一次大促前压测网关限流恢复能力时才真正验证了这一点:没有抖动的版本里,限流刚放开的那个时间窗口会立刻被下一波集中重试再次打满,形成一种脉冲式的过载与恢复循环;加上抖动之后,这个脉冲被显著削平。

Retry-After 头:服务端主动告诉你该等多久

前面的重试逻辑都是前端自己估算退避时间,但有一种情况服务端会直接给出明确答案——429 Too Many Requests(触发限流)和部分 503 Service Unavailable(服务临时不可用,比如计划内维护)的响应里,规范允许服务端带一个 Retry-After 头,告诉客户端具体该等多久再重试。这个头的取值可以是秒数,也可以是一个具体的 HTTP 日期:

1HTTP/1.1 429 Too Many Requests
2Retry-After: 30

前端如果拿到这个头,就不该再自己瞎猜退避时间,而应该优先尊重服务端给出的建议值:

1async function requestRespectingRetryAfter(url, options) {
2  const res = await fetchWithTimeout(url, options, 5000)
3  if (res.status === 429 || res.status === 503) {
4    const retryAfter = res.headers.get('retry-after')
5    if (retryAfter) {
6      const delayMs = /^\d+$/.test(retryAfter)
7        ? Number(retryAfter) * 1000
8        : new Date(retryAfter).getTime() - Date.now()
9      await sleep(Math.max(delayMs, 0))
10      return fetchWithTimeout(url, options, 5000)
11    }
12  }
13  return res
14}

这个头的价值在于它把"该等多久"这个决策权交还给了最了解自己负载状态的一方——服务端。前端自己估算的指数退避只是在信息不足时的兜底策略,一旦服务端明确给出了 Retry-After,继续按前端自己的节奏重试反而可能和服务端的限流恢复窗口对不上,造成不必要的持续失败。团队接第三方开放平台的接口时经常撞到这类限流响应,第一次没处理 Retry-After,重试间隔设的是固定 1 秒,而对方限流窗口是 60 秒,结果是那 1 分钟里请求全部无效地失败了 59 次,纯粹浪费配额;后来老老实实解析这个头,请求成功率立刻回正常了。

RESTful 路径设计:名词化和资源层级

方法语义理清楚之后,路径设计是紧跟着要处理的另一半问题。团队里常见的偏差是路径里带动词——/api/getUserList/api/updateOrder,动词放在方法里表达(GETPUT),路径应该只描述资源本身,用名词、且用复数形式:/api/users/api/orders/123。这不是纯粹的风格洁癖,而是关系到路径能不能表达清楚资源的层级关系——查询某个用户下的所有订单,路径应该是 /api/users/123/orders,这个嵌套结构本身就说明了归属关系,不需要额外文档解释;如果写成 /api/getOrdersByUserId?userId=123,语义全靠参数名硬撑,路径本身传达不出结构。

批量操作是路径设计里容易卡壳的一类。批量删除严格来说应该对每个资源发一次 DELETE,但真实场景里为了减少请求数,团队常见的做法是开一个专门的批量接口,比如 POST /api/orders/batch-delete,请求体带 id 数组。这里出现了一个不那么优雅但很实际的妥协:批量操作本身不再是对单一资源的操作,严格贴着 RESTful 语义已经很难描述清楚,用 POST 加一个明确的动作后缀,是可以接受的折中,只要团队内部认知一致、这类"例外路径"数量可控,不必强行削足适履。

写操作之后要不要顺手让缓存失效

方法语义还有一个经常被忽略的联动效应:一次成功的写操作,会不会让同一资源之前的 GET 缓存变成脏数据。规范里对这件事其实有隐含约定——一次成功的 PUTDELETEPATCH 之后,符合规范的缓存实现应该主动让该资源相关的缓存失效,避免下次 GET 读到写操作之前的旧内容。但这条约定停留在"应该"层面,实际由哪一层落地(浏览器、CDN、网关,还是业务代码自己控制),团队之间差异很大,不能完全指望别人替你处理干净。

团队踩过一次典型的坑:商品详情页用 GET 请求且服务端配了较长的 max-age 强缓存,编辑页保存用 PUT 更新成功后跳回详情页,页面读到的还是浏览器本地缓存的旧数据——因为强缓存在有效期内根本不会向服务端确认,PUT 那次请求和这次 GET 之间没有任何机制自动关联,浏览器不知道该资源已经变了。

1async function updateProduct(id, payload) {
2  const res = await fetch(`/api/products/${id}`, {
3    method: 'PUT',
4    headers: { 'Content-Type': 'application/json' },
5    body: JSON.stringify(payload),
6  })
7  if (!res.ok) throw new Error(`更新失败:HTTP ${res.status}`)
8
9  // 写操作成功后,主动带一个缓存击穿参数,避免读到本地强缓存的旧数据
10  const detail = await fetch(`/api/products/${id}?_t=${Date.now()}`).then(r => r.json())
11  return detail
12}

这种手动加时间戳参数击穿缓存的办法比较糙,但简单直接;更规范的做法是后端在设计这类资源的缓存策略时,直接换成协商缓存(ETag + If-None-Match)而不是强缓存,这样每次 GET 好歹会发一次请求去确认版本,写操作之后只要资源的 ETag 变了,下一次读取自然能拿到新内容,不需要前端每次手动加时间戳这种绕开缓存的土办法。这个问题的本质仍然落在方法语义上——写方法造成的状态变化,和读方法的缓存策略,如果不是同一层设计出来的,很容易在边界处对不上。

列表页比详情页更麻烦一些。详情页缓存失效只涉及单个资源的 key,好定位;列表页的缓存 key 往往是一整套筛选条件加分页参数的组合,某一条记录被删除或修改之后,理论上所有可能包含这条记录的列表缓存都应该失效,但列表的筛选组合是发散的,没办法穷举失效哪些 key。团队目前对列表类接口干脆不设强缓存,只用较短的协商缓存兜底,把"数据是否最新"这件事的判断成本转嫁给一次轻量的 304 往返,而不是试图精确地管理列表缓存的失效范围——这也是方法语义思考到最后,会自然引出的一个工程取舍:不是所有场景都值得为了极致的缓存收益去维护复杂的失效逻辑,容忍一次协商请求的开销,换来实现上的简单和数据的可靠,往往更划算。

把方法语义沉到请求层,而不是每个页面各自判断

这些方法语义、幂等性、重试策略的判断,如果让每个页面的开发者临时决定,团队规模一大必然出现前面提到的各种不一致。比较现实的做法是把这些判断收敛到请求层的封装里:GET/HEAD 默认允许被上层做防抖节流意义上的取消(用 AbortController),POST 默认不做自动重试、只暴露手动重试的入口,PUT/PATCH 类接口在封装层强制要求调用方传入完整的目标状态而不是增量、否则类型检查就该报错。

1const httpClient = {
2  get: (url, options) => fetchWithTimeout(url, { method: 'GET', ...options }, 5000),
3  post: (url, body, options) =>
4    fetchWithTimeout(url, { method: 'POST', body: JSON.stringify(body), ...options }, 8000),
5  // put/patch 要求调用方显式传入 idempotent: true 才会启用内部重试
6  put: (url, body, options = {}) => {
7    const { idempotent, ...rest } = options
8    const request = () =>
9      fetchWithTimeout(url, { method: 'PUT', body: JSON.stringify(body), ...rest }, 8000)
10    return idempotent ? requestWithRetry(url, { method: 'PUT', body: JSON.stringify(body) }) : request()
11  },
12}

这一层封装存在的意义,是把"这个方法能不能重试""这个操作要不要取消""超时时间设多少"这类判断从每个业务组件里搬出来,统一固化成约定,业务代码只管调用,不用每次都重新推理一遍语义。团队接口规范文档里现在专门加了一节"方法选择指南":只读用 GET,创建用 POST,整体替换用 PUT,局部修改优先设计成传目标状态、走 PATCH,删除用 DELETE;凡是不确定是否幂等的操作,一律不接入自动重试,只给用户暴露手动重试。这份约定写下来只有几行,但背后是把幂等性、安全性、超时取消、重试代价这几件事一次性想透了之后的结果,比每次评审时临时讨论要稳定得多。

这份指南落地之后,评审新接口时多了一个具体的检查项:提交 PR 描述里要求写清楚这个接口用的方法、是否幂等、能不能被前端自动重试。一开始有人觉得这是多此一举的表格式官僚,但后端团队接手了几次因为方法语义不清导致的线上重复扣款排查之后,态度转变得很快——与其等出了问题回头翻日志判断"这次重试到底安不安全",不如在设计阶段就把这个判断写清楚,存进接口文档,后面无论是前端接入自动重试,还是网关配置限流和缓存策略,都能直接查文档确认,不用每次都临时讨论一遍。方法语义本身是免费的信息,只是很多团队没把它当回事,一旦真正用起来,无论是缓存、重试还是并发控制,都能少踩很多本可以提前避开的坑。