第 3 章 方法与幂等:为什么刷新 POST 会弹窗,GET 不会
你每天写几十个接口,但你可能从没认真想过:同一个动作,选不同的动词,等于对全世界的中间设备签了一份不同的合同。
3.0 引子:一次只点了一下,却扣了两次款
小美:老哥,财务那边炸了。有个用户说他就下了一单,银行卡被扣了两次。
你:不可能,下单接口有防重的,同一订单号不会重复入账……
老张:查了。用户在支付页只点了一次,但我们的支付回调接口收到了两次,间隔 30 秒。
你:回调重试了?支付渠道的超时重试?
老张:对,支付渠道第一次回调我们没在 5 秒内返回,它超时了,30 秒后又推了一次。我们两次都处理了,入账两次。
你:……(冒汗)
墨叔:先别急着改代码。我问你三个问题,你想清楚,答案就自己浮出来了:
- 回调接口,你用的什么方法?POST 还是 PUT?
- 你写的处理逻辑,是”幂等”的吗?
- “重试”这件事,你控制得了吗?
你:……第三个问题,控制不了。回调是支付渠道推的,它要重试我拦不住。
墨叔:对。这就是今天这一章的全部意义——你永远无法阻止别人重试,所以你必须让你的接口”经得起重试”。
而”经得起重试”,在协议世界里有一个精确的词:幂等。
3.1 先把问题问对:三个被用烂的词,到底什么意思
墨叔:面试题里有一道烂大街的题:“GET 和 POST 有什么区别?”
标准答案是:GET 参数在 URL 里、POST 在 body 里;GET 有长度限制;GET 安全、POST 不安全……
这个答案,几乎全错,或者全是表象。
你:全错?
墨叔:参数在哪,是语法层面的,跟协议语义没关系——你完全可以让 POST 的参数也放 URL 里,GET 也可以带 body(虽然少见)。长度限制,是浏览器实现加的,不是协议规定的。
真正的区别,藏在三个协议语义属性里。这三个词,才是你判断一切方法该怎么用的根基:
三个正交属性
| 属性 | 含义 | 通俗说法 |
|---|---|---|
| Safe(安全) | 不改变服务器状态,没有副作用 | ”只看不动” |
| Idempotent(幂等) | 执行一次和执行 N 次,效果相同 | ”重复无害” |
| Cacheable(可缓存) | 响应可以被缓存复用 | ”结果能存” |
你:这三个词,我区分一下——“安全”是”不会改”,“幂等”是”改多少次都一样”?
墨叔:对,而且注意一个关键关系:
安全的方法,一定是幂等的(因为压根不改,自然改多少次都一样)。 但幂等的方法,不一定是安全的。
DELETE 就是典型:删一个资源,删第二次时它已经没了,结果还是”没了”——所以 DELETE 幂等,但它不安全(它有副作用,它真的删了东西)。
一张表看懂所有方法
| 方法 | Safe | Idempotent | 一句话语义 |
|---|---|---|---|
| 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? 三个原因:
- PUT 的”整体替换”在真实业务里几乎用不上。 真实业务都是”改一个字段""改一下状态”,哪有”把整个对象重发一遍”的?
- PATCH 的格式没统一,用了反而增加协作成本。
- PUT/PATCH 的幂等性要求更高,实现起来要更小心。
所以这不是”大家水平差”,而是**“语义精确”和”使用成本”之间的真实权衡**。你理解了这一点,再看那些”必须 RESTful”的教条,就能一笑了之了。
但注意:POST 不幂等,这是一个契约声明,不是你偷懒的理由。下一节就讲,当你的 POST 操作业务上其实希望幂等时,你该怎么办。
3.4 幂等到底是谁的责任?——这是本章的核心
墨叔:回到开头那个扣款两次的事故。核心问题来了:
HTTP 协议说”GET 幂等、POST 不幂等”,这是在规定什么?
你:规定……服务器的行为?
墨叔:错。协议规定不了你的行为。
协议说的”幂等”,不是”服务器必须实现幂等”——服务器实现成什么样,协议管不着。协议说的是:
“这个方法,调用方和中间设备被允许假设它是幂等的,因此可以安全地自动重试。”
看到了吗?幂等性是协议给”重试”发的通行证。
- GET/PUT/DELETE 是幂等的 → 中间设备可以在超时后自动重试,不用问你。
- POST 不是幂等的 → 中间设备默认不该重试,要重试得你显式授权。
所以”幂等是谁的责任”的答案是:
- 协议负责声明(哪些方法允许被重试)。
- 服务端负责实现(真正保证”重试 N 次效果一样”)。
- 客户端负责遵守(不该重试 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 题答案
| 操作 | 方法 | 幂等吗 | 为什么 |
|---|---|---|---|
| 查询用户列表 | 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 做幂等键。
并发控制方案(这是本题的难点):
“不能重复扣” 和 “不能超扣” 是两个不同的并发问题,要分开解决:
-
防重复扣:幂等键 + 数据库唯一索引。给”兑换记录表”的
idempotency_key加唯一索引,两个并发请求插同一条,只有一个能插入成功,另一个拿到”唯一键冲突”,转而去查已有结果返回。 -
防超扣:积分的扣减必须用原子的条件更新,不能用”先查再改”:
-- 错误:先查再改,并发下两个请求都查到余额=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 方法的相似之处,在于”契约”和”实现”的分离:
- **按钮的”契约”**是”按了门就关”。实现却是”按了也没用”。按钮承诺了它做不到的事。
- 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 引发一场”文化战争”。