《互联网产品的服务端架构·产品、流量与基础能力》6.1 资源、命令与产品语义

从「回声」点赞按钮被预取和重试重复执行的事故写起,说明资源查询与命令的本质区别、命令自然键的组成,以及同一动作在 App、Web、小程序三端为什么必须共用一套语义;附 HTTP 方法分类器的 Go 实现与表驱动测试,把幂等和可缓存钉在接口入口。

「回声」的点赞按钮被谁按了三次

「回声」的第一版点赞用的是 POST /api/posts/42/like/toggle,服务端把当前状态取反,返回新的点赞数。这个设计在测试环境没有任何问题:点一下,数字加一;再点一下,数字减一。

上线一周后,数据团队发现一个奇怪的现象:同一条内容在凌晨三点被点赞两次,间隔不到 200 毫秒,用户自己却说只点了一次。排查日志后原因很清楚:客户端在弱网下 800 毫秒没收到响应就重试,第一次请求其实已经成功,第二次又把状态翻了回去。用户看到的是“点赞没生效”,于是再点一次,通知和计数就乱了。

运营随后提出“能不能把点赞数做成可缓存的接口”。后端顺手把写操作也改成了 GET /api/posts/42/like?action=toggle,理由是“GET 更快,CDN 也能缓存”。第二天,一个内容抓取工具顺着页面里的链接把三万条帖子各点了一次赞。这条“优化”把产品语义彻底弄丢了。

这两个事故指向同一个问题:团队没有先回答“这个接口是在读状态,还是在改变状态”,就直接开始选方法和路径。

资源读状态,命令改状态

把产品动作分成两类,是 API 设计里最便宜、也最容易被跳过的一步。

资源(query) 回答“现在是什么样”。点赞数、我的点赞状态、订单详情都属于这一类。它不改服务端状态,可以重复读取,天然适合缓存和预取。

命令(command) 表达“请为我做一件事”。点赞、取消点赞、发评论、下单都属于这一类。它改变状态,能否重复执行取决于命令有没有自然键。

自然键回答三个问题:谁发起的、对什么发起、做什么。三个要素确定后,同一个命令执行多少次都应该只产生一次效果。点赞的自然键是“当前用户 + 帖子 42 + 点赞”;取消点赞的自然键是“当前用户 + 帖子 42 + 取消点赞”。把它们放到同一路径的不同方法上,幂等性就由 URL 本身表达了。

产品动作语义方法与路径幂等可缓存自然键
查看点赞数queryGET /posts/42/likes是是无
查看我是否点过赞queryGET /posts/42/like是否(个性化)无
点赞commandPUT /posts/42/like是否用户 + 帖子 + 点赞
取消点赞commandDELETE /posts/42/like是否用户 + 帖子 + 取消
发表评论commandPOST /posts/42/comments + Idempotency-Key带键时是否用户 + 帖子 + 请求号
下单commandPOST /orders + Idempotency-Key带键时是否用户 + 购物车版本 + 请求号
搜索内容queryGET /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 用户的点赞状态。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查