「回声」的点赞按钮被谁按了三次
「回声」的第一版点赞用的是 POST /api/posts/42/like/toggle,服务端把当前状态取反,返回新的点赞数。这个设计在测试环境没有任何问题:点一下,数字加一;再点一下,数字减一。
上线一周后,数据团队发现一个奇怪的现象:同一条内容在凌晨三点被点赞两次,间隔不到 200 毫秒,用户自己却说只点了一次。排查日志后原因很清楚:客户端在弱网下 800 毫秒没收到响应就重试,第一次请求其实已经成功,第二次又把状态翻了回去。用户看到的是“点赞没生效”,于是再点一次,通知和计数就乱了。
运营随后提出“能不能把点赞数做成可缓存的接口”。后端顺手把写操作也改成了 GET /api/posts/42/like?action=toggle,理由是“GET 更快,CDN 也能缓存”。第二天,一个内容抓取工具顺着页面里的链接把三万条帖子各点了一次赞。这条“优化”把产品语义彻底弄丢了。
这两个事故指向同一个问题:团队没有先回答“这个接口是在读状态,还是在改变状态”,就直接开始选方法和路径。
资源读状态,命令改状态
把产品动作分成两类,是 API 设计里最便宜、也最容易被跳过的一步。
资源(query) 回答“现在是什么样”。点赞数、我的点赞状态、订单详情都属于这一类。它不改服务端状态,可以重复读取,天然适合缓存和预取。
命令(command) 表达“请为我做一件事”。点赞、取消点赞、发评论、下单都属于这一类。它改变状态,能否重复执行取决于命令有没有自然键。
自然键回答三个问题:谁发起的、对什么发起、做什么。三个要素确定后,同一个命令执行多少次都应该只产生一次效果。点赞的自然键是“当前用户 + 帖子 42 + 点赞”;取消点赞的自然键是“当前用户 + 帖子 42 + 取消点赞”。把它们放到同一路径的不同方法上,幂等性就由 URL 本身表达了。
| 产品动作 | 语义 | 方法与路径 | 幂等 | 可缓存 | 自然键 |
|---|---|---|---|---|---|
| 查看点赞数 | query | GET /posts/42/likes | 是 | 是 | 无 |
| 查看我是否点过赞 | query | GET /posts/42/like | 是 | 否(个性化) | 无 |
| 点赞 | command | PUT /posts/42/like | 是 | 否 | 用户 + 帖子 + 点赞 |
| 取消点赞 | command | DELETE /posts/42/like | 是 | 否 | 用户 + 帖子 + 取消 |
| 发表评论 | command | POST /posts/42/comments + Idempotency-Key | 带键时是 | 否 | 用户 + 帖子 + 请求号 |
| 下单 | command | POST /orders + Idempotency-Key | 带键时是 | 否 | 用户 + 购物车版本 + 请求号 |
| 搜索内容 | query | GET /search?q=... | 是 | 是 | 无 |
这张表里有两个容易被误读的格子。
第一,“查看我是否点过赞”是 query,但不代表它能进共享缓存。响应依赖当前用户,缓存键必须包含用户或登录态;如果误标成 public,A 用户会看到 B 用户的点赞状态。这里的“可缓存”是 HTTP 层的可能性,不是“所有中间层都能存”。
第二,POST 带上 Idempotency-Key 之后变成幂等命令,但它仍然不可缓存。幂等解决的是“重复执行的安全”,缓存解决的是“重复读取的复用”,两者不是一回事。很多团队把“可重试”顺手写成“可缓存”,最后缓存层把一次下单的响应发给了另一个用户。
App、Web、小程序是不是同一个语义
同一个点赞,在三个端的代码长得完全不一样:App 用原生 HTTP 库,Web 用 fetch,小程序用 wx.request。但服务端看到的必须是同一套语义:同一个路径、同一个方法、同一个自然键。
端上可以有自己的适配层。Web 可以把点赞做成乐观更新,先把心形变红,失败再回滚;App 可以在本地队列里排队,等网络恢复再发;小程序可以禁用按钮 500 毫秒防连点。这些都是端上的体验策略,不能改变服务端的命令身份。
反过来,如果服务端为每个端做一套接口,比如 /app/like、/web/like、/mini/like,语义就会分叉。三个端的幂等键不同、错误码不同、缓存策略不同,最后连“点过赞”的判断都可能不一致。正确做法是端上适配请求格式,服务端只认资源和命令。
失败场景:把下单做成 GET
真正昂贵的错误发生在交易链路。某个版本的“立即购买”被实现成 GET /api/order/create?sku=123&qty=1,理由是“用户点一下就跳转,简单”。
这个 GET 会在这几个时刻被自动执行:
- 浏览器预取:页面里出现了这个 URL,浏览器或安全插件提前请求。
- 链接预览:用户把地址粘贴到聊天工具,抓取机器人访问一次。
- 失败重试:网关超时后按 GET 语义自动重试,认为它“只是读取”。
- 用户刷新:结果页刷新会重新执行一次。
一次点击可能产生四笔订单。库存被锁、优惠券被占用、支付单被重复创建。更麻烦的是,这些请求没有自然键,服务端无法判断“这是同一次购买意图”,只能人工对账退款。
修复不是加一个“防重复提交”按钮,而是把命令改成 POST /orders,要求客户端生成 Idempotency-Key,服务端用“用户 + 购物车版本 + 请求号”做唯一约束。重复请求返回第一次的结果,而不是再下一单。
用 Classify 把语义钉在入口
语义不能只写在文档里,否则第一个赶工期的人就会绕过去。下面这段代码来自 webapp-lab 的 internal/api 包,把“方法到语义”的映射做成一个纯函数,网关、BFF 和测试可以共用。
// file: internal/api/semantics.go
// tier: L1
// verify: go test ./internal/api/
package api
import (
"errors"
"fmt"
"strings"
)
// Kind 区分一次请求是在读状态还是在改变状态。
// query 可以安全重放,command 是否安全取决于接口契约。
type Kind string
const (
KindQuery Kind = "query"
KindCommand Kind = "command"
)
// Request 是分类器需要的全部输入。
// 它刻意不包含 body:语义由方法、路径和幂等键决定,
// 不应该依赖报文内容做猜测。
type Request struct {
Method string
Path string
HasIdempotencyKey bool
}
// Decision 是分类结果,也是 API 契约的一部分。
type Decision struct {
Kind Kind
Idempotent bool
Cacheable bool
}
// ErrUnknownMethod 表示方法不在本服务支持的集合内。
var ErrUnknownMethod = errors.New("unknown method")
// Classify 把 HTTP 方法映射为产品语义。
// GET/HEAD 是 query;PUT/DELETE 是幂等 command;
// POST 默认非幂等,只有带 Idempotency-Key 时才可安全重放;
// PATCH 按非幂等 command 处理,因为它的语义由接口自己定义。
func Classify(r Request) (Decision, error) {
if strings.TrimSpace(r.Path) == "" {
return Decision{}, fmt.Errorf("classify: empty path")
}
switch strings.ToUpper(strings.TrimSpace(r.Method)) {
case "GET", "HEAD":
return Decision{Kind: KindQuery, Idempotent: true, Cacheable: true}, nil
case "PUT", "DELETE":
return Decision{Kind: KindCommand, Idempotent: true, Cacheable: false}, nil
case "POST":
return Decision{Kind: KindCommand, Idempotent: r.HasIdempotencyKey, Cacheable: false}, nil
case "PATCH":
return Decision{Kind: KindCommand, Idempotent: false, Cacheable: false}, nil
default:
return Decision{}, fmt.Errorf("%w: %q", ErrUnknownMethod, r.Method)
}
}
Classify 的输入刻意不包含请求体。分类应该由方法、路径和幂等键决定;如果分类器开始读 body,它就会依赖某个业务的字段名,最后变成另一份难以复用的路由表。
GET 和 HEAD 直接落到 query,并且标记为可缓存。这里的“可缓存”是给上层中间件的一个提示,具体缓存键仍要由业务决定,尤其是带登录态的响应。
PUT 和 DELETE 被归为幂等命令。PUT 的语义是“把资源变成这个样子”,重复执行结果相同;DELETE 的语义是“确保它不存在”,第二次删除返回 404 或 204 都可以,但不应该产生第二次副作用。
POST 是唯一需要看幂等键的方法。没有键时它是非幂等的,客户端不能自动重试;带了键才允许安全重放。注意这里只改变了 Idempotent,Cacheable 始终是 false。把这两个字段分开,是为了避免“可重试等于可缓存”的误用。
PATCH 被保守地当成非幂等命令。PATCH 的语义由接口自己定义,可能是“追加一个标签”,也可能是“把价格设为 100”;在契约没有说清之前,不能假设它可以重复执行。
未知方法直接返回错误,而不是落到一个默认分支。默认成 command 会让 TRACE、CONNECT 这类方法悄悄进入业务逻辑;默认成 query 更危险,可能让写操作被缓存。入口处失败,比线上出现一条来源不明的写请求便宜得多。
测试:POST 永远不会变成可缓存
分类器的测试重点不是“正常路径对不对”,而是那些容易被顺手改坏的边界:POST 带键后是否仍然不可缓存、未知方法是否真的报错、空路径是否被拦住。
// file: internal/api/semantics_test.go
// tier: L1
// verify: go test ./internal/api/
package api
import (
"errors"
"testing"
)
func TestClassifyMethods(t *testing.T) {
cases := []struct {
name string
req Request
want Decision
}{
{
name: "GET is a cacheable query",
req: Request{Method: "GET", Path: "/api/posts/42/likes"},
want: Decision{Kind: KindQuery, Idempotent: true, Cacheable: true},
},
{
name: "HEAD is a cacheable query",
req: Request{Method: "HEAD", Path: "/api/posts/42"},
want: Decision{Kind: KindQuery, Idempotent: true, Cacheable: true},
},
{
name: "PUT is an idempotent command",
req: Request{Method: "PUT", Path: "/api/posts/42/like"},
want: Decision{Kind: KindCommand, Idempotent: true, Cacheable: false},
},
{
name: "DELETE is an idempotent command",
req: Request{Method: "DELETE", Path: "/api/posts/42/like"},
want: Decision{Kind: KindCommand, Idempotent: true, Cacheable: false},
},
{
name: "bare POST is not idempotent",
req: Request{Method: "POST", Path: "/api/orders"},
want: Decision{Kind: KindCommand, Idempotent: false, Cacheable: false},
},
{
name: "POST with a key becomes idempotent",
req: Request{Method: "POST", Path: "/api/orders", HasIdempotencyKey: true},
want: Decision{Kind: KindCommand, Idempotent: true, Cacheable: false},
},
{
name: "PATCH stays non-idempotent",
req: Request{Method: "PATCH", Path: "/api/posts/42"},
want: Decision{Kind: KindCommand, Idempotent: false, Cacheable: false},
},
{
name: "method case is ignored",
req: Request{Method: " get ", Path: "/api/posts/42"},
want: Decision{Kind: KindQuery, Idempotent: true, Cacheable: true},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := Classify(tc.req)
if err != nil {
t.Fatalf("Classify(%+v) error: %v", tc.req, err)
}
if got != tc.want {
t.Fatalf("Classify(%+v) = %+v, want %+v", tc.req, got, tc.want)
}
})
}
}
func TestClassifyPostIsNeverCacheable(t *testing.T) {
for _, key := range []bool{false, true} {
got, err := Classify(Request{Method: "POST", Path: "/api/orders", HasIdempotencyKey: key})
if err != nil {
t.Fatalf("key=%v: %v", key, err)
}
if got.Cacheable {
t.Fatalf("key=%v: POST must not be cacheable", key)
}
}
}
func TestClassifyRejectsUnknownMethod(t *testing.T) {
for _, method := range []string{"TRACE", "CONNECT", "", "LIKE"} {
_, err := Classify(Request{Method: method, Path: "/api/posts/42"})
if !errors.Is(err, ErrUnknownMethod) {
t.Fatalf("method %q: err = %v, want ErrUnknownMethod", method, err)
}
}
}
func TestClassifyRejectsEmptyPath(t *testing.T) {
if _, err := Classify(Request{Method: "GET", Path: " "}); err == nil {
t.Fatal("empty path should be rejected")
}
}
TestClassifyMethods 用表驱动覆盖了八个场景,每个场景都同时断言 Kind、Idempotent 和 Cacheable 三个字段。只断言其中两个,很容易漏掉“方法对了但缓存标记错了”这类问题。
TestClassifyPostIsNeverCacheable 单独把 POST 拎出来,分别检查带键和不带键两种情况。这个测试看起来重复,但它防的是一类真实修改:有人为了让“下单结果页”更快,给 POST 加上了 Cacheable: true。加上这一行,测试会立刻失败。
TestClassifyRejectsUnknownMethod 用 errors.Is 检查哨兵错误,而不是比较错误字符串。这样即使错误信息以后加上方法名或请求 ID,测试仍然有效。
TestClassifyRejectsEmptyPath 验证空路径被拒绝。路径是自然键的一部分,没有路径就无法判断“对什么”执行命令;让它在分类阶段失败,比让它带着空路径进入业务层更安全。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/api/ -count=1 -run 'TestClassify' -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestClassifyMethods
=== RUN TestClassifyMethods/GET_is_a_cacheable_query
=== RUN TestClassifyMethods/HEAD_is_a_cacheable_query
=== RUN TestClassifyMethods/PUT_is_an_idempotent_command
=== RUN TestClassifyMethods/DELETE_is_an_idempotent_command
=== RUN TestClassifyMethods/bare_POST_is_not_idempotent
=== RUN TestClassifyMethods/POST_with_a_key_becomes_idempotent
=== RUN TestClassifyMethods/PATCH_stays_non-idempotent
=== RUN TestClassifyMethods/method_case_is_ignored
--- PASS: TestClassifyMethods (0.00s)
--- PASS: TestClassifyMethods/GET_is_a_cacheable_query (0.00s)
--- PASS: TestClassifyMethods/HEAD_is_a_cacheable_query (0.00s)
--- PASS: TestClassifyMethods/PUT_is_an_idempotent_command (0.00s)
--- PASS: TestClassifyMethods/DELETE_is_an_idempotent_command (0.00s)
--- PASS: TestClassifyMethods/bare_POST_is_not_idempotent (0.00s)
--- PASS: TestClassifyMethods/POST_with_a_key_becomes_idempotent (0.00s)
--- PASS: TestClassifyMethods/PATCH_stays_non-idempotent (0.00s)
--- PASS: TestClassifyMethods/method_case_is_ignored (0.00s)
=== RUN TestClassifyPostIsNeverCacheable
--- PASS: TestClassifyPostIsNeverCacheable (0.00s)
=== RUN TestClassifyRejectsUnknownMethod
--- PASS: TestClassifyRejectsUnknownMethod (0.00s)
=== RUN TestClassifyRejectsEmptyPath
--- PASS: TestClassifyRejectsEmptyPath (0.00s)
PASS
ok webapp/internal/api 0.219s
-run 'TestClassify' 只匹配本节的四个测试,避免把错误码和版本测试混进输出。0.219s 是一次本机运行的耗时,会随机器和并发负载变化,不能当成性能结论。
小结
资源回答“现在是什么”,命令表达“请做什么”。方法、路径和幂等键共同决定语义;自然键由“谁 + 对什么 + 做什么”组成。三端可以有各自的交互,但服务端只能有一套语义。
把分类写成一个纯函数,并在入口拒绝未知方法,可以挡住大部分“顺手改成 GET”的修改。测试则守住两条底线:POST 带键后仍然不可缓存,未知方法必须显式失败。
阅读导航:上一节:目录与学习路径 · 下一节:6.2 错误码与用户可感知的失败 。
常见错误与边界
- 用 GET 执行写操作。预取、抓取、刷新和自动重试都会让一次点击变成多次执行,且无法用自然键兜底。
- 把 POST 当成“所有写操作”的默认方法。没有 Idempotency-Key 的 POST 不能被客户端安全重试。
- 把幂等当成可缓存。幂等说的是重复执行的安全,缓存说的是响应复用,POST 即使幂等也不能进共享缓存。
- 为每个端单独设计接口。三套路径会把幂等键、错误码和缓存策略拆成三份,最后语义对不上。
- 用
toggle作为命令。取反没有自然键,重试会把状态翻回去;应拆成明确的点赞与取消点赞。 - 分类器读取请求体。语义依赖报文内容后,分类器会退化成第二套业务路由。
- 未知方法落到默认分支。默认 command 会放行奇怪方法,默认 query 可能让写操作被缓存。
- 用个性化 GET 响应共享缓存。缓存键不含用户时,A 用户会看到 B 用户的点赞状态。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。