第 3 章 方法与幂等:为什么刷新 POST 会弹窗,GET 不会

你每天写几十个接口,但你可能从没认真想过:同一个动作,选不同的动词,等于对全世界的中间设备签了一份不同的合同。


3.0 引子:一次只点了一下,却扣了两次款

小美:老哥,财务那边炸了。有个用户说他就下了一单,银行卡被扣了两次。

你:不可能,下单接口有防重的,同一订单号不会重复入账……

老张:查了。用户在支付页只点了一次,但我们的支付回调接口收到了两次,间隔 30 秒。

你:回调重试了?支付渠道的超时重试?

老张:对,支付渠道第一次回调我们没在 5 秒内返回,它超时了,30 秒后又推了一次。我们两次都处理了,入账两次。

你:……(冒汗)

墨叔:先别急着改代码。我问你三个问题,你想清楚,答案就自己浮出来了:

  1. 回调接口,你用的什么方法?POST 还是 PUT?
  2. 你写的处理逻辑,是”幂等”的吗?
  3. “重试”这件事,你控制得了吗?

你:……第三个问题,控制不了。回调是支付渠道推的,它要重试我拦不住。

墨叔:对。这就是今天这一章的全部意义——你永远无法阻止别人重试,所以你必须让你的接口”经得起重试”。

而”经得起重试”,在协议世界里有一个精确的词:幂等。


3.1 先把问题问对:三个被用烂的词,到底什么意思

墨叔:面试题里有一道烂大街的题:“GET 和 POST 有什么区别?”

标准答案是:GET 参数在 URL 里、POST 在 body 里;GET 有长度限制;GET 安全、POST 不安全……

这个答案,几乎全错,或者全是表象。

你:全错?

墨叔:参数在哪,是语法层面的,跟协议语义没关系——你完全可以让 POST 的参数也放 URL 里,GET 也可以带 body(虽然少见)。长度限制,是浏览器实现加的,不是协议规定的。

真正的区别,藏在三个协议语义属性里。这三个词,才是你判断一切方法该怎么用的根基:

三个正交属性

属性含义通俗说法
Safe(安全)不改变服务器状态,没有副作用”只看不动”
Idempotent(幂等)执行一次和执行 N 次,效果相同”重复无害”
Cacheable(可缓存)响应可以被缓存复用”结果能存”

你:这三个词,我区分一下——“安全”是”不会改”,“幂等”是”改多少次都一样”?

墨叔:对,而且注意一个关键关系:

安全的方法,一定是幂等的(因为压根不改,自然改多少次都一样)。 但幂等的方法,不一定是安全的。

DELETE 就是典型:删一个资源,删第二次时它已经没了,结果还是”没了”——所以 DELETE 幂等,但它不安全(它有副作用,它真的删了东西)。

一张表看懂所有方法

方法SafeIdempotent一句话语义
GET✅✅取资源
HEAD✅✅只取头,不取体
OPTIONS✅✅问”这个资源支持什么方法”
TRACE✅✅回显请求(调试用,现在基本禁用)
PUT❌✅整体覆盖替换某个资源
DELETE❌✅删除某个资源
PATCH❌❌局部修改资源
POST❌❌“处理”请求体,语义由业务定
CONNECT❌❌建立隧道(给 HTTPS 代理用)

墨叔:记住这张表,它是你后面所有判断的锚。现在我要逐个拆,尤其是那几个你”以为懂但其实没懂”的。


3.2 GET:安全是它的命,也是它的坑

墨叔:GET 的核心契约只有一条——它必须安全,必须没有副作用。

你:等等,我看很多接口都是 GET /api/delete?id=123 啊,不也没事?

墨叔:没事,是因为你运气好,还没撞上那台”会自发发 GET”的设备。

我给你数数,这个世界上有多少东西,会在你完全不知道、没经过你同意的情况下,自动发出 GET 请求:

  • 浏览器的预取(prefetch):Chrome 会预取你可能会点的链接,提前发 GET。
  • 浏览器的预加载(preload):页面里的 <link rel="preload">、<img> 会自发拉取。
  • 爬虫:Google、百度、各种爬虫,全网乱爬 GET。
  • CDN 预热:为了加速,CDN 会主动回源拉取。
  • 杀毒软件、安全扫描器:拿到一个 URL,先 GET 一遍探测。
  • 聊天软件、邮件客户端的链接预览:微信、QQ、Slack、Outlook 为了显示”链接卡片”,会自动访问你贴的 URL。
  • 链接缩短器、反向代理的健康检查。

你:……这么多。

墨叔:所以你能理解,为什么”GET 有副作用”是致命的了吗?你以为你的”删除”接口只会在用户点按钮时被调一次,但实际上——

世界上任何一个中间设备,都被协议授权可以随时、随意地对一个 GET 请求做”重放”——因为它们看到的契约是”这是个安全方法,随便发不会有事”。

两个真实的事故

事故一:Google Web Accelerator。 2005 年,Google 推出一个加速器插件,会预取页面上所有链接(发 GET),让点开更快。结果那些用 GET 做删除、做登出的网站全遭殃了——用户的文章被”自动删除”,登出状态被”自动触发”。这个插件最后被 Google 自己下架了。

事故二:爬虫删数据。 某公司的管理后台,删除操作用 GET /admin/delete?id=xxx 实现。后来公司做了个 SEO 优化,把后台链接泄露到了前台页面,爬虫顺着爬了一遍,把一批数据”删”了。

墨叔:现在你知道第 1 章案例 A 说的”用 GET 扣款”有多荒唐了。不是 GET 有什么魔法,而是你对着全世界的中间设备撒了一个谎:“这个操作是安全的”。


3.3 POST 为什么不是”新增”,而是”万金油”

墨叔:下一个误区。很多人背”RESTful 规范”:POST = 新增,PUT = 修改,DELETE = 删除。

这是把”REST 风格”当成了”HTTP 协议”。

HTTP 协议里,POST 的官方语义只有一句话:

POST 请求服务器”处理”请求体中的数据,处理的结果由目标资源决定。

它什么都没规定。它可以是新增、可以是登录、可以是搜索、可以是触发一个任务、可以是上传文件。POST 是 HTTP 里的”垃圾桶方法”——所有没法归到其它方法里的操作,都丢给它。

你:那 PUT 和 PATCH 呢?为什么大家都不爱用,非要 POST 一把梭?

墨叔:问得好。PUT 和 PATCH 的语义更精确,但精确是有代价的。

  • PUT:语义是”整体替换”。“把我给你的这个表示,存到目标 URI 这个位置上,如果不存在就创建,存在就整个覆盖。” 所以 PUT 要求你发送完整的资源表示,不能只发一个字段。
  • PATCH:语义是”局部修改”。只发”我要改的那部分”。但 PATCH 的”改法”需要额外约定——RFC 里 PATCH 的 body 到底用什么格式(JSON Patch?JSON Merge Patch?),标准一直没定死,导致大家各搞各的。

为什么最终大家都用 POST? 三个原因:

  1. PUT 的”整体替换”在真实业务里几乎用不上。 真实业务都是”改一个字段""改一下状态”,哪有”把整个对象重发一遍”的?
  2. PATCH 的格式没统一,用了反而增加协作成本。
  3. PUT/PATCH 的幂等性要求更高,实现起来要更小心。

所以这不是”大家水平差”,而是**“语义精确”和”使用成本”之间的真实权衡**。你理解了这一点,再看那些”必须 RESTful”的教条,就能一笑了之了。

但注意:POST 不幂等,这是一个契约声明,不是你偷懒的理由。下一节就讲,当你的 POST 操作业务上其实希望幂等时,你该怎么办。


3.4 幂等到底是谁的责任?——这是本章的核心

墨叔:回到开头那个扣款两次的事故。核心问题来了:

HTTP 协议说”GET 幂等、POST 不幂等”,这是在规定什么?

你:规定……服务器的行为?

墨叔:错。协议规定不了你的行为。

协议说的”幂等”,不是”服务器必须实现幂等”——服务器实现成什么样,协议管不着。协议说的是:

“这个方法,调用方和中间设备被允许假设它是幂等的,因此可以安全地自动重试。”

看到了吗?幂等性是协议给”重试”发的通行证。

  • GET/PUT/DELETE 是幂等的 → 中间设备可以在超时后自动重试,不用问你。
  • POST 不是幂等的 → 中间设备默认不该重试,要重试得你显式授权。

所以”幂等是谁的责任”的答案是:

  1. 协议负责声明(哪些方法允许被重试)。
  2. 服务端负责实现(真正保证”重试 N 次效果一样”)。
  3. 客户端负责遵守(不该重试 POST,除非有幂等保护)。

三层各管一段,任何一层失守都会出事。

你:那开头的回调事故,错在哪一层?

墨叔:错在服务端。支付渠道重试回调,是它作为客户端的行为,它有它的理由(超时重试是回调的行业惯例)。问题在于你的服务端没有把”处理一次”这件事做成幂等的——同一个订单号,第二次回调时你没有识别出”这个我已经处理过了”,而是又入账了一次。

所以解决方案不是”别让它重试”(你做不到),而是”让你的处理逻辑幂等”——用订单号做幂等键,第二次看到同一个订单号,直接返回”已处理”,不再重复入账。


3.5 Idempotency-Key:让 POST 也幂等的官方套路

墨叔:但有些 POST 操作,业务上天生就是”应该只执行一次”的——比如下单、扣款、转账。你怎么保护它不被重试搞出重复执行?

你:加个唯一订单号?重复了就不处理。

墨叔:对,这就是 Idempotency-Key 的思想。它是一个标准化的做法:客户端在请求头里带一个客户端生成的唯一 ID,服务端用它做幂等键。

完整时序

客户端                             服务端
  |--- POST /orders ---------------->|
  |    Idempotency-Key: 8f14e...     |
  |                                  |  1. 查这个 key 处理过没?
  |                                  |  2. 没处理过 → 执行业务 → 把结果存下来(key → 结果)
  |<-- 200 Created ------------------|
  |
  |(网络抖动,客户端没收到响应,超时重试)
  |
  |--- POST /orders ---------------->|
  |    Idempotency-Key: 8f14e...     |  1. 查这个 key → 处理过!
  |                                  |  2. 直接返回上次存的结果,不再执行
  |<-- 200 Created(和上次一模一样)---|

你:核心就是”服务端记住每个 key 的处理结果”。

墨叔:对,但这套方案有三个坑,坑死了无数人:

坑一:并发竞态。 两个相同 key 的请求同时到达(比如客户端超时重试,其实第一次还没处理完)。服务端同时查”没处理过”,于是执行了两次。

解法:用数据库唯一索引(key 做唯一键,第二个插入会失败,然后去查第一个的结果)或分布式锁。注意——唯一索引是幂等的基础设施,不是可选项。

坑二:结果的存活时间。 服务端不可能永远存着所有 key 的结果。那存多久?存太短,客户端很久之后的重试就失效;存太长,存储爆炸。

这个”存活时间”就是幂等窗口(idempotency window)。通常 24 小时或 7 天。超过窗口后的重试,服务端有权当新请求处理——所以你要在文档里写清楚你的窗口是多久。

坑三:失败的请求也要存。 很多人只存”成功的 key”,失败的不存。但你想:客户端超时了,它不知道是成功还是失败,它重试。如果你第一次失败了但没存 key,第二次会再执行一次——如果第二次成功了,客户端以为”重试成功了”,但实际产生了两次副作用(第一次失败前可能已经扣了款)。

正确做法:不管是成功还是失败,都要存 key → 结果,这样重试拿到的是”和第一次一样的结果”,而不是”再执行一次”。


3.6 超时 ≠ 失败:重试决策树

墨叔:现在讲一个反直觉到极点的概念,它是分布式系统的核心直觉之一:

请求超时,不代表请求失败了。它只代表”我不知道结果”。

你:……这我知道,但为什么会这样?

墨叔:因为”超时”是一个客户端本地的判断,而请求的真实结果在服务端。中间隔着网络,你永远无法确定:

  • 请求在网络上丢了?→ 服务端没收到,没执行。
  • 请求到了,服务端执行了,但响应丢了?→ 服务端执行了,你不知道。
  • 请求到了,服务端还没执行完(慢)?→ 正在执行。

三种情况,对你来说长得一模一样:都是”超时”。 但你敢不敢重试,取决于你赌的是哪种情况——而”重试”这个动作本身,会把你推向一个更糟的境地:如果你赌错了(服务端其实执行了),重试就变成了重复执行。

重试决策树

超时了,要不要重试?
│
├─ 这个操作幂等吗?
│   ├─ 幂等(GET/PUT/DELETE/带幂等键的POST)
│   │     → 可以重试,但要做指数退避
│   └─ 不幂等
│         → 先查"结果到底有没有发生",查不到就【不要盲目重试】
│
├─ 上次是超时,还是明确的失败(4xx)?
│   ├─ 明确 4xx → 不要重试,重试一百次结果一样
│   ├─ 5xx / 网络错 / 超时 → 可以重试(配合幂等)
│   └─ 429 → 看 Retry-After,等它说的时间再重试
│
└─ 已经重试几次了?
    ├─ 用指数退避 + 抖动:1s, 2s, 4s, 8s...(还要加随机抖动)
    └─ 设一个"重试预算"上限,用完就熔断

你:指数退避我懂,为什么还要”抖动”?

墨叔:想象 10000 个客户端同时超时,同时开始重试,重试间隔都是 1s、2s、4s……那它们会永远同步地打过来,把服务端一波一波地打死。这叫重试风暴。

加上随机抖动(比如在退避时间上加减 0~30% 的随机量),让它们错开,就把”海啸”变成了”涓流”。

更深一层:抖动还有一个你不容易想到的作用——防止”退避时间”本身成为攻击面。如果退避是固定的,攻击者可以精确预测你的重试时刻,选在你重试的瞬间再打你。


3.7 设计者视角:为什么要把”方法”设计成九个而不是两个

墨叔:换个角度。如果让你设计 HTTP,你会定几个方法?

你:……两个?读和写?

墨叔:HTTP 定九个,是不是过度设计了?

你:现在看,每个都有用——HEAD 省流量,OPTIONS 做探测,CONNECT 做隧道……

墨叔:但你想过没有,这九个方法不是一次定完的,是几十年里长出来的:

  • HTTP/0.9 只有 GET。
  • HTTP/1.0 加了 POST 和 HEAD。
  • HTTP/1.1 加了 PUT、DELETE、OPTIONS、TRACE、CONNECT。
  • PATCH 是后来(2010 年)单独补的。

这说明了什么?

你:方法是可扩展的?

墨叔:对,而且扩展的机制很克制——方法这个”扩展点”,HTTP 用了一个巧妙的降级规则来保护:服务器收到不认识的方法,返回 405 Method Not Allowed 或 501 Not Implemented,而不是崩溃。

这和第 4 章要讲的状态码、第 5 章的首部、第 15 章的扩展点,是同一个设计哲学:给未来留口子,同时保证老实现遇到新东西时优雅降级,而不是爆炸。

你:所以”方法”的设计精髓,不是”九个够不够用”,而是”未来加第十个时,老服务器会怎样”。

墨叔:你已经摸到协议设计的门道了。


3.8 Go 落地:一个幂等中间件的骨架

墨叔:用 Go 写一个 Idempotency-Key 中间件的骨架,体会一下”幂等键”落地成代码长什么样:

// 一个基于内存 + 唯一索引思路的幂等中间件(示意,生产请用 Redis/DB)
var store sync.Map // key -> result
 
func Idempotency(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            next.ServeHTTP(w, r) // 非 POST 不处理幂等键
            return
        }
        key := r.Header.Get("Idempotency-Key")
        if key == "" {
            http.Error(w, "missing Idempotency-Key", http.StatusBadRequest)
            return
        }
 
        // 关键:用 LoadOrStore 原子地"占位",防止并发下重复执行
        if v, loaded := store.LoadOrStore(key, nil); loaded && v != nil {
            // 之前处理过,直接返回缓存的结果
            w.Header().Set("Content-Type", "application/json")
            w.Write(v.([]byte))
            return
        }
 
        // 用一个能捕获响应的 recorder 执行真正的业务,并缓存结果
        rec := &responseRecorder{w: w}
        next.ServeHTTP(rec, r)
 
        // 把结果存起来(无论成功失败都存)
        store.Store(key, rec.body.Bytes())
    })
}

你:这里有几个点很关键:LoadOrStore 原子占位(解决坑一),结果无论成败都存(解决坑三)。

墨叔:对。注意生产环境这个 sync.Map 要换成 Redis/数据库(带 TTL 实现”幂等窗口”,解决坑二)。但骨架的逻辑是不变的:占位 → 执行 → 存结果 → 重复时返回结果。


3.9 排查清单:重复提交 / 重复执行类问题

第 1 步:确认是哪一层的重试
        → 用户双击?前端防抖失效?
        → 浏览器自动重试?(刷新、前进后退)
        → 网关/LB 超时重试?
        → 上游(支付/回调)的重试策略?

第 2 步:确认这个操作在协议上是否幂等
        → 方法是什么?GET 被误用了吗?

第 3 步:确认业务层有没有幂等键
        → 有没有唯一订单号/请求 ID?
        → 数据库有没有唯一索引兜底?

第 4 步:检查并发竞态
        → 两个相同 key 同时到达,会不会都执行?

第 5 步:检查幂等窗口
        → 结果的 TTL 设了多久?过期后的重试怎么办?

思考题

第 1 题(概念)

请填这张表,并解释每一个”为什么”:

操作用什么方法?幂等吗?为什么
查询用户列表
用户注册(手机号唯一)
扣款 100 元
修改用户的昵称
给文章点赞(第二次点赞是取消)

第 2 题(概念)

有人说:“PUT 是幂等的,所以我可以放心地让客户端超时后自动重试 PUT。” 这句话对吗?从”幂等”和”幂等的结果是否等于用户想要的”两个角度分析。

第 3 题(设计)

设计一个”积分扣减”接口:用户用积分兑换一个商品,要求:

  • 积分只能扣一次(重试不能重复扣)
  • 并发下也不能超扣(积分不能扣成负数)
  • 扣减失败要有明确的失败原因(积分不足 / 商品已兑完)

请给出:方法、幂等键方案、并发控制方案,以及失败时返回的状态码。

第 4 题(生活类比)

墨叔:电梯里的”关门”按钮,和 HTTP 里的方法,有什么惊人的相似之处?

提示:想一想——你疯狂按电梯的关门键,电梯门会关得更快吗?这个按钮是”真的在做事”,还是”让你觉得你在做事”?再想想,这跟”协议规定 GET 是安全的,但服务器到底安不安全,协议管不着”,有什么共通之处?

第 5 题(更深一层的发问)

幂等的本质是”重复执行效果相同”。但”效果相同”这个说法,在严格意义上是有问题的:

  1. 一个接口,第一次执行返回”创建成功”,第二次(重复)返回”已存在”。从”返回结果”看,两次不一样。那它还叫幂等吗?
  2. 一个接口执行了会写日志、上报监控、更新”最后访问时间”。第二次执行时,日志多了一条、访问时间变了。从”系统状态”看,两次不一样。那它还叫幂等吗?
  3. 如果严格意义下几乎没有真正幂等的东西,那”幂等”这个词在工程上到底是在承诺什么?

参考答案

第 1 题答案

操作方法幂等吗为什么
查询用户列表GET✅只读,天然安全且幂等
用户注册(手机号唯一)POST❌第一次创建用户;第二次应该报”已注册”,而不是再建一个。虽然业务上手机号唯一会拦住第二次,但那是业务兜底,不是协议的幂等保证
扣款 100 元POST❌扣款是有副作用的,扣两次 = 扣 200。必须靠幂等键或唯一交易号兜底
修改昵称PUT(整体覆盖)或 PATCH(局部)✅把昵称改成”张三”,改两次还是”张三”,幂等
点赞(第二次取消)POST❌点赞/取消是”切换”操作,执行两次状态反而不一样——这是个反例,说明”看起来像幂等的操作”其实不幂等

重点讲”点赞”这个反例:很多人以为点赞是幂等的(“点了就是赞了”),但如果是”再点一次取消赞”,那它就是非幂等的——执行两次 = 赞了又取消 = 跟没点一样。所以正确的做法是用显式的状态,比如 PUT /articles/123/like {liked: true} 或 DELETE /articles/123/like,让”点赞”和”取消赞”是两个不同的、各自幂等的操作。

这引出一个重要原则:

如果业务里存在”切换”(toggle),就永远不要用 POST 去做这个切换,把它拆成两个幂等的显式操作(PUT/DELETE),否则重试就会把状态切来切去。

第 2 题答案

这句话是错的,而且错得很有代表性。

PUT 的幂等性,承诺的是”执行 N 次,资源的最终状态一样”。它没有承诺”第 N 次的结果等于第 1 次的结果”,更没有承诺”第 N 次不会覆盖掉别人的修改”。

看两个反例:

反例一:结果不同。 客户端 A:PUT /user/1 {"name": "张三"} —— 成功,返回 200。 中间有个客户端 B:PUT /user/1 {"name": "李四"} —— 也成功。 现在客户端 A 超时了(其实它的请求已经执行了),它自动重试 PUT /user/1 {"name": "张三"}。

结果:A 把 B 的修改覆盖掉了。从”A 的重试成功执行了 PUT”这个角度看,幂等没问题(重复执行张三还是张三),但从系统整体看,A 的重试造成了丢失更新(lost update)。

反例二:并发覆盖。 两个请求 PUT /user/1 {...} 并发到达,后写覆盖先写,谁的都没了。幂等管不了这个。

所以正确的结论是:

幂等 ≠ 可以放心重试,也 ≠ 并发安全。 幂等只保证”重复执行的副作用可收敛”,不保证”重复执行符合业务预期”,更不保证”并发下的正确性”。

真正要”放心重试 + 并发安全”,你需要的是乐观锁(版本号/CAS):PUT /user/1 带 If-Match: <etag>,只有当资源还是你拿到时的版本时才允许覆盖,否则返回 409/412。

这正好呼应了第 8 章要讲的 ETag:ETag 不只是缓存用的,它是 HTTP 原生的”乐观并发控制”工具。

第 3 题答案

方法:POST(有副作用,且业务希望只执行一次)。

幂等键方案:客户端生成 Idempotency-Key(或用一个全局唯一的”兑换单号”)。服务端用这个 key 做幂等键。

并发控制方案(这是本题的难点):

“不能重复扣” 和 “不能超扣” 是两个不同的并发问题,要分开解决:

  1. 防重复扣:幂等键 + 数据库唯一索引。给”兑换记录表”的 idempotency_key 加唯一索引,两个并发请求插同一条,只有一个能插入成功,另一个拿到”唯一键冲突”,转而去查已有结果返回。

  2. 防超扣:积分的扣减必须用原子的条件更新,不能用”先查再改”:

-- 错误:先查再改,并发下两个请求都查到余额=100,都扣 50,最后余额=50(实际应=0)
SELECT balance FROM accounts WHERE user_id = 1;  -- 得到 100
UPDATE accounts SET balance = 100 - 50 WHERE user_id = 1;
 
-- 正确:原子条件更新,扣减时检查余额,扣不成则返回 0 行受影响
UPDATE accounts SET balance = balance - 50
WHERE user_id = 1 AND balance >= 50;
-- 如果 affected rows == 0,说明余额不足,报"积分不足"

关键点:WHERE balance >= 50 这个条件把”检查”和”扣减”合并成了一个原子操作。数据库的行锁保证并发下不会超扣。这正是”幂等/并发”问题在数据层的标准解法:把判断和变更放进同一个原子语句。

失败的状态码:

  • 积分不足 → 422 Unprocessable Entity(语义正确但业务不合法)或 409 Conflict(资源状态冲突)。不要用 500(那是服务端的锅,而积分不足是客户端的锅)。
  • 商品已兑完 → 409 Conflict 或 410 Gone。

一个补充:如果有”扣积分”和”减库存”两步,它们之间要保证原子性(本地事务),否则会出现”积分扣了、库存没减”的不一致。这属于分布式事务范畴,超出了 HTTP 但你必须知道——幂等键只能保证”这一步不重复执行”,不能保证”多步之间的一致性”。

第 4 题答案

电梯关门按钮的真相:在美国,大多数电梯的”关门”按钮(Door Close)实际上不接线——你按它,门不会关得更快。它的作用是心理安慰:给乘客一种”我在控制”的感觉,减少等待的焦虑(这是 ADA 无障碍法案的遗留设计)。讽刺的是,很多电梯的”开门”按钮是真的接线的。

这个和 HTTP 方法的相似之处,在于”契约”和”实现”的分离:

  1. **按钮的”契约”**是”按了门就关”。实现却是”按了也没用”。按钮承诺了它做不到的事。
  2. GET 的”契约”是”安全、无副作用”。但服务器的实现可以是有副作用的(你写 GET /delete 协议也拦不住)。

共通之处:

协议(契约)只能约束”应该怎样”,无法保证”实际怎样”。 协议声明”GET 是安全的”,就好比电梯按钮印着”关门”——它给使用者一个预期,但兑现预期是另一方的责任,协议本身无能为力。

更深一层:电梯按钮这个”虚假的控制感”,恰恰是它成功的设计——它用一个无害的假动作,化解了乘客的焦虑。而 HTTP 里 GET 的”安全”声明,也是类似的精妙:它给了中间设备一个安全的假设,让整个互联网的缓存、预取、爬虫能够放心地大规模自动化运行。

区别在于:电梯按钮骗你,代价是零;而你用 GET 做有副作用的操作,骗的是全世界的中间设备,代价是数据被删、钱被扣。同样是”契约与实现分离”,一个无害,一个致命。

第 5 题答案

这是本章最深刻的一问,它逼你去抠”幂等”这个词的边界。

1. 返回结果不同,还叫幂等吗?

叫。因为幂等的定义,关注的不是返回结果,而是副作用(服务器状态)。

RFC 9110 的原话大意是:幂等的意思是”这个请求的副作用,与执行一次还是多次无关”。返回结果可以不同(第一次 201,第二次 200 或 409),只要服务器上留下的状态是一样的,就是幂等。

你:那”结果不同”会不会坑到客户端?

墨叔:会,但这属于另一个问题——结果的确定性(determinism)。幂等管副作用,确定性管返回值。一个好的幂等实现,应该尽量让重复请求返回相同的结果(这就是 Idempotency-Key 要缓存结果的原因),但它做不到也无损”幂等”这个身份。

2. 日志多了一条,访问时间变了,还叫幂等吗?

这就触及了”副作用”的定义边界。 严格说,任何请求都会产生副作用——日志、监控、访问计数、缓存刷新。如果把这些算进去,没有任何请求是幂等的。

工程上的答案是把副作用分层:

幂等只承诺”业务状态”的幂等,不承诺”观测性副作用”(日志、监控)的幂等。

日志多一条没关系(它不影响业务正确性),但”扣款两次”有关系(它改变了业务状态)。判断标准不是”有没有任何变化”,而是”业务上重要的状态有没有被改变”。

这背后的哲学是:协议里的每个概念,都是在某个”抽象层次”上成立的。 你换一个层次看,它就不成立了。幂等是在”业务状态”这一层成立,在”物理日志”这一层不成立。

3. 那”幂等”到底在承诺什么?

承诺的是:“在一个约定好的抽象层次上,重复执行不会造成额外的业务副作用,因此调用方可以在超时后安全地重试。”

它是一张简化世界的地图:它帮调用方把”我该不该重试”这个魔鬼问题,简化成了”这个方法幂不幂等”。它不是为了追求哲学上的精确,而是为了让自动化(重试、缓存、预取)成为可能。

最终答案:

“幂等”不是一个关于世界的绝对陈述,而是一个”约定好的、有边界的简化”。 它的价值不在于它严格为真,而在于它足够有用,能让几十亿台设备在不可靠的网络上安全地自动化协作。

这其实也是整个 HTTP 的方法论:用有边界的简化,换取系统级的自动化。 后面讲缓存(第 8 章)、状态码(第 4 章)时,你会一次又一次看到这个方法论。


一页纸小结

三个正交属性:Safe(无副作用)、Idempotent(重复无害)、Cacheable(可缓存)。安全⇒幂等,但幂等≠安全。

GET 的命是”安全”:世界上有无数设备会自发地重放 GET,所以 GET 绝不能有副作用。两个真实事故(Google Web Accelerator、爬虫删数据)。

POST 是万金油:官方语义只有一句”处理请求体”。PUT=整体替换,PATCH=局部修改,但精确有精确的代价,所以大家用 POST 一把梭。

幂等是三层责任:协议声明(哪些方法可重试)、服务端实现、客户端遵守。超时≠失败,重试要有决策树 + 指数退避 + 抖动 + 重试预算。

Idempotency-Key 的三个坑:并发竞态(唯一索引兜底)、幂等窗口(TTL)、失败也要存。

幂等的边界:幂等≠放心重试≠并发安全;它承诺的是”约定抽象层次上的业务副作用不重复”,日志监控不算。


下一章:方法搞清楚了,但服务器总得给个回话吧。那个回话——状态码——是被全世界误解最深的协议元素。我要讲一个 1998 年的愚人节玩笑,它后来变成了全球互联网基础设施里一个删不掉的部分,还差点在 IETF 引发一场”文化战争”。