面试时被问「GET 和 POST 的区别」,十个人有九个会答:GET 没 body,POST 有 body。这个答案在面试里能过,但在工程里会害死人。
我见过有人把删除接口设计成 GET /api/delete?id=1,理由是「GET 简单」。结果某天搜索引擎爬虫把整个后台数据删了一遍——爬虫只发 GET,而且它会递归爬所有链接。这就是不守 method 语义的代价:你以为在简化,实际上把一个「读」操作伪装成了「写」,所有按 GET 语义工作的中间件(爬虫、CDN、预取)都会帮你触发。
GET 和 POST 的真正差别,不在 body,在语义。RFC 9110 给每个 method 标注了三个属性,这套属性才是 method 的灵魂。
三个属性:安全、幂等、可缓存
method 不是动词,是一份契约。RFC 9110 给每个 method 贴了三个标签:
这三个属性不是装饰,它们决定了中间件怎么对待这个请求:
- 安全(safe):这个 method 不应该改变服务器状态。GET、HEAD、OPTIONS 是安全的。安全的请求可以被爬虫随意发起、可以被预取、可以被 CDN 缓存——因为发起它不会产生副作用。
- 幂等(idempotent):同一个请求发一次和发十次,服务器最终状态一样。GET、HEAD、PUT、DELETE 是幂等的,POST 和 PATCH 不是(PATCH 规范上没保证幂等)。
- 可缓存(cacheable):响应可以被缓存。GET、HEAD 是可缓存的,POST 理论上也可以但极少用。
这套属性解释了那个爬虫删库的案例:DELETE 不安全(会改状态),所以爬虫不会乱发 DELETE;但 GET 是安全的,爬虫才会放心地爬所有 GET 链接。把删除伪装成 GET,等于把一个危险的写操作交给了所有认为「GET 无害」的系统。
幂等:为什么它决定能不能重试
幂等性最直接的工程价值,是决定请求能不能安全重试。
网络是不可靠的:请求发出去了,服务器收到了、执行了,但响应在回来的路上丢了。客户端超时了,它不知道服务器到底执行没有。这时候该不该重发?
上图把六个常用 method 的属性归位。重试能不能做,直接看幂等那一列:
- 如果 method 是幂等的(PUT、DELETE、GET):重发安全。PUT
/users/1重发十次,用户 1 的状态还是「被覆盖成那个值」;DELETE/users/1重发十次,结果还是「用户 1 不存在」。 - 如果 method 是非幂等的(POST):重发危险。POST
/orders重发三次,可能创建了三个订单。
这就是为什么很多团队把扣款设计成 PUT /payments/123/status 而不是 POST /pay——PUT 的幂等语义让网络抖动后的重试变得安全。重试三次,支付状态最终都是「已完成」,不会扣三次款。这是把协议属性当设计工具用,而不是把 HTTP method 当成 RESTful 的语法糖。
PUT vs PATCH:为什么需要两个
PUT 和 PATCH 都是「改」,但改的方式不同,这个区分很多人忽略:
假设用户文档是 { "name": "十三", "email": "a@b.com", "role": "user" }:
- PUT
/users/1带上{ "name": "shisan" }:PUT 是整体替换,服务器会把整个文档替换成你发的,结果变成{ "name": "shisan" }——email 和 role 没了。这是 PUT 的严格语义:你发什么,最终就是什么。 - PATCH
/users/1带上{ "name": "shisan" }:PATCH 是部分修改,服务器只改你指定的字段,结果变成{ "name": "shisan", "email": "a@b.com", "role": "user" }——其他字段保留。
为什么需要 PATCH?因为 PUT 的「整体替换」在修改大文档时很笨重:你想改一个字段,得把整个文档先 GET 下来再 PUT 回去,中间如果别人改了别的字段,你的 PUT 会覆盖掉别人的修改(丢失更新)。PATCH 只发变化的部分,轻量得多。
代价是 PATCH 的语义复杂:部分修改怎么表达?RFC 6902 定义了 JSON Patch 格式(一串操作指令),RFC 7396 定义了 JSON Merge Patch 格式(直接合并)。两种格式并存,得在 Content-Type 里声明用哪种。而且 PATCH 不保证幂等——如果 PATCH 操作是「把计数器加 1」,重发两次就加了 2。
OPTIONS 和 HEAD:被低估的两个 method
GET、POST、PUT、DELETE 用得多,OPTIONS 和 HEAD 常被忽视,但它们各有不可替代的作用。
HEAD 是「只要响应头,不要 body 的 GET」。用途是检查资源是否存在、拿 Content-Length 预估大小、用 Last-Modified 做缓存验证,但不想下载整个 body。它和 GET 一样安全、幂等、可缓存,省的是带宽。
OPTIONS 是「询问服务器支持什么」。它问的不是某个资源的内容,而是「这个 URL 支持哪些 method」「跨域请求允许不允许」。OPTIONS 最常见的实战场景是 CORS 预检(preflight):
当浏览器要发一个「非简单请求」(比如带自定义 header、或者 method 是 PUT/DELETE),它不会直接发,而是先发一个 OPTIONS 请求问服务器:「我能不能用 PUT、带 Authorization 头、从 rubyfun.cn 访问你?」服务器在 OPTIONS 的响应里回答:Access-Control-Allow-Methods: PUT、Access-Control-Allow-Headers: Authorization、Access-Control-Allow-Origin: https://rubyfun.cn。预检通过了,浏览器才发真正的 PUT 请求。
这就是为什么跨域的 PUT 请求在 Network 面板里会看到两条记录——一条 OPTIONS、一条真正的 PUT。OPTIONS 不是多余的开销,它是浏览器替你做的一道安全闸。
取舍与边界
method 语义是契约,不是强制——服务器完全可以给 GET 实现成删数据,协议拦不住。但破坏契约的代价由破坏者承担:缓存会缓存你的「删除响应」并返回给其他用户,爬虫会反复触发,重试逻辑会重复执行。协议提供的是默认行为契约,所有中间件都假设你守约,你一违约它们就全错。
这套契约也有历史包袱:PATCH 到 2010 年(RFC 5789)才标准化,比其他 method 晚了很多年,所以老框架对它的支持参差不齐;CONNECT 看起来像个 method,其实它是为 HTTPS 隧道设计的「协议升级指令」,语义和普通 method 不在一个层面。
收束:method 是设计语言,不是 CRUD 别名
把 method 当成数据库 CRUD 的映射(GET=查、POST=增、PUT=改、DELETE=删)是入门快捷方式,但停留在这一层会漏掉真正的设计语言。method 真正在表达的是这次操作的语义意图:是只读还是写、是整体还是部分、能不能重复、中间件该不该碰。
下一篇讲状态码——它是 method 的对偶,method 表达「我要干什么」,状态码表达「干得怎么样」。两者合起来,才是完整的 HTTP 意图层。
关于十三Tech
我是十三,All in AI Agent 方向的架构师,专注 AI 工程实践。
我相信 AI 是程序员的最佳搭档,也希望帮助每一位开发者更好地驾驭 AI。
如果你想继续跟完这套「图解 HTTP」,欢迎关注公众号 「十三Tech」。后续会按 URL 与报文、连接与传输、缓存与协商、安全与边界、HTTP/2 与 HTTP/3 这条线更新。

