《Go 语言编程实战》4.2 分页、过滤、排序与幂等

列表接口是接口设计里最容易被低估的一个。本节把 TaskHub 的任务列表做成可游标翻页、可白名单过滤、可白名单排序的接口,用真实 Postgres 对比 OFFSET 与键集分页在 20 万行下的耗时与扫描行数差距,再用幂等键中间件解决 POST 重试导致重复创建的问题,并给出重放与冲突的实测行为。

4.2 分页、过滤、排序与幂等

4.1 把 URL 和状态码定下来了,但列表接口只写了「列任务(分页)」四个字。这四个字背后是四件互相纠缠的事:怎么翻页(游标还是 OFFSET)、怎么过滤(字段白名单)、怎么排序(顺序不能由客户端随便指定)、怎么重试(POST 不幂等怎么办)。本节把它们一次做对。

本节把 TaskHub 推进到:任务列表接口支持游标分页、白名单过滤与排序,写接口支持 Idempotency-Key 重放,并用真实 Postgres 数据量验证分页方案的取舍。

4.2.1 三种分页方式的取舍

翻页方案只有三种,工程上能用的只有两种:

方案请求形态优点致命缺点
全量返回无实现最简单数据一多就打爆内存与带宽
OFFSET/LIMIT?page=3&size=20能跳页、能显示总页数深翻页 O(offset),且数据变动时会漏行/重行
键集(游标)?cursor=xxx&limit=20任意深度都是 O(limit)不能跳页、不能显示总页数

OFFSET 的第二个缺点常被忽略。假设你按 created_at desc 翻到第 3 页时,前面有人插入了一条新任务——那么原本第 2 页的最后一条会被挤到第 3 页,用户会重复看到它。反过来如果前面有人删除,就会漏掉一条。这是 OFFSET 的语义问题,不是性能问题,加索引也救不了。

TaskHub 的选择是:列表接口一律用游标分页,只在「管理后台需要跳页」这种明确场景下才提供 OFFSET 接口,并且在文档里写明它会漏行。总页数这种需求,用单独的 GET /stats 接口或异步统计解决,不要塞进列表接口。

4.2.2 游标是什么

游标不是「页码的加密」,而是上一页最后一条记录的排序键值。TaskHub 的排序键是 (created_at desc, id desc),所以游标就是这两个字段:

type cursor struct {
	T time.Time `json:"t"` // created_at
	I string    `json:"i"` // id
}

func encodeCursor(it Item) string {
	b, _ := json.Marshal(cursor{T: it.CreatedAt, I: it.ID})
	return base64.RawURLEncoding.EncodeToString(b)
}

三个设计决策:

  1. 用 base64.RawURLEncoding,不要用标准 base64。标准编码会产生 + 和 /,放进 URL 查询串会被转义,客户端一不留神就解码失败。RawURLEncoding 用 - 和 _,且不带 = 填充。
  2. 游标对客户端不透明。虽然 base64 是可解的,但要在文档里声明「结构随时可能变,不要解析」。否则客户端一旦依赖内部结构,你就再也不能改排序键了。
  3. 游标必须包含排序键的全部字段。只放 created_at 不够——同一毫秒内可能有多条任务,created_at 相同就无法定位唯一位置,翻页会卡死或重复。

对应的 SQL 用行值比较,语义干净且能吃上复合索引:

select id, created_at, title
from tasks
where tenant_id = $1
  and (created_at, id) < ($2, $3)
order by created_at desc, id desc
limit $4

(created_at, id) < ($2, $3) 是 Postgres 的行值比较,等价于 created_at < $2 OR (created_at = $2 AND id < $3),但写法短得多,且优化器能直接用它做索引定位。

4.2.3 实测:20 万行下 OFFSET 与键集的差距

空谈「OFFSET 慢」没有说服力。本机起了一个 postgres:17-alpine(实测版本 PostgreSQL 17.11),建 20 万行任务、建复合索引 (tenant_id, created_at desc, id desc),然后各跑 5 次取最好与最差。实测结果:

rows=200000
OFFSET 100                                 best=2.142ms    worst=5.923ms
OFFSET 100000                              best=9.022ms    worst=13.877ms
OFFSET 199900                              best=16.087ms   worst=19.813ms
KEYSET 首屏                                 best=1.727ms    worst=3.704ms
KEYSET 第 199900 行处                        best=2.001ms    worst=4.682ms

深翻页时 OFFSET 是 16ms,键集是 2ms——8 倍。但真正的差距在 EXPLAIN (ANALYZE, BUFFERS) 里:

EXPLAIN-OFFSET: Limit  (actual time=24.958..24.961 rows=20 loops=1)
EXPLAIN-OFFSET:   Buffers: shared hit=2653
EXPLAIN-OFFSET:   ->  Index Scan using idx_tasks_keyset  (actual time=0.014..18.846 rows=199920 loops=1)
EXPLAIN-OFFSET:         Index Cond: (tenant_id = 'tnt_1'::text)
EXPLAIN-OFFSET: Execution Time: 24.977 ms

EXPLAIN-KEYSET: Limit  (actual time=0.013..0.016 rows=20 loops=1)
EXPLAIN-KEYSET:   Buffers: shared hit=4
EXPLAIN-KEYSET:   ->  Index Scan using idx_tasks_keyset  (actual time=0.012..0.014 rows=20 loops=1)
EXPLAIN-KEYSET:         Index Cond: ((tenant_id = 'tnt_1'::text) AND (ROW(created_at, id) < ROW(...)))
EXPLAIN-KEYSET: Execution Time: 0.046 ms

关键数字不是耗时,而是扫描行数:OFFSET 扫了 199920 行才扔掉前 199900 行,缓冲区命中 2653;键集只扫 20 行,缓冲区命中 4。这意味着 OFFSET 的代价随页码线性增长,而键集恒定为「一页」。

一个诚实的补充:在 20 万行这个量级、数据全在内存里时,OFFSET 的绝对耗时并没有到不可接受的地步(16ms)。真正压垮它的是两件事同时发生——数据量继续增长到千万级,以及缓存装不下索引时 shared hit 变成 read。所以结论不是「OFFSET 一定慢」,而是「OFFSET 的代价不可控」。

4.2.4 过滤:白名单是唯一安全的做法

过滤参数不能直接拼进 SQL。?status=todo 看着无害,但 ?sort=created_at;drop table tasks-- 就是注入。正确的做法是把外部字段名映射到内部列名:

var filterable = map[string]string{
	"status":     "status",
	"assignee":   "assignee_id",
	"priority":   "priority",
	"created_at": "created_at",
}

func buildFilters(q map[string]string) ([]cond, error) {
	var cs []cond
	for k, v := range q {
		col, ok := filterable[k]
		if !ok {
			return nil, fmt.Errorf("unfilterable field: %q", k)
		}
		cs = append(cs, cond{
			sql:  col + " = $" + fmt.Sprint(len(cs)+1),
			args: []any{v},
		})
	}
	return cs, nil
}

实测输出:

filter 含非法字段 title -> unfilterable field: "title"
filter status=todo -> status = $1 args=[todo]

非法字段直接报错而不是静默忽略——静默忽略会让客户端以为过滤生效了,拿到错误结果却不自知。参数值走占位符 $1,永远不要拼接。

什么时候该用 =、什么时候用 IN、什么时候用范围查询?约定是:枚举用 =,集合用 IN(逗号分隔),时间用 gte/lte 后缀,例如 ?created_at_gte=2026-09-01T00:00:00Z。这样参数名自解释,不用在文档里额外说明每个字段支持什么操作符。

4.2.5 排序:白名单 + 稳定排序

排序和过滤一样要白名单,而且多一层讲究:排序必须是全序。如果只按 created_at 排,同一毫秒的多条任务顺序不确定,翻页就会重复。所以排序键的最后一定要补 id desc 兜底:

var sortable = map[string]string{
	"created_at": "created_at",
	"updated_at": "updated_at",
	"due_date":   "due_date",
	"priority":   "priority",
}

func buildOrderBy(raw string) (string, error) {
	parts := strings.Split(raw, ",")
	out := make([]string, 0, len(parts))
	for _, p := range parts {
		p = strings.TrimSpace(p)
		if p == "" {
			continue
		}
		dir := "asc"
		if strings.HasPrefix(p, "-") {
			dir, p = "desc", p[1:]
		}
		col, ok := sortable[p]
		if !ok {
			return "", fmt.Errorf("unsortable field: %q", p)
		}
		out = append(out, col+" "+dir)
	}
	if len(out) == 0 {
		return "created_at desc", nil
	}
	out = append(out, "id desc") // 稳定排序兜底
	return strings.Join(out, ", "), nil
}

用 - 前缀表示降序(?sort=-created_at,priority),比 ?sort=created_at&order=desc 更紧凑,也是常见约定。实测:

order "-created_at,priority"           -> ORDER BY created_at desc, priority asc, id desc
order "title"                          -> ERROR unsortable field: "title"
order "-due_date"                      -> ORDER BY due_date desc, id desc
order "created_at; drop table users--" -> ERROR unsortable field: "created_at; drop table users--"
order ""                               -> ORDER BY created_at desc

注意最后一行:不传排序时给一个默认排序(created_at desc),而不是让它随机。默认排序决定了游标分页能不能工作——如果默认顺序不确定,游标就无意义。

4.2.6 幂等:POST 重试的必修课

POST 不幂等,这在分布式环境里是真实故障源。场景:客户端发创建任务请求,服务端已经写库成功,但响应在网络上丢了,客户端超时重试——于是有了两条一样的任务。用户看到重复任务,运维接到工单,谁都说不清是客户端 bug 还是服务端 bug。

解法是幂等键:客户端为这次「业务意图」生成一个唯一键(通常 UUID),随请求带上;服务端记录「这个键对应哪次请求、返回了什么」,重复的键直接返回第一次的结果。

POST /api/v1/projects/prj_7/tasks
Idempotency-Key: 7d3f1c2a-...

{"title":"写卷二第 4 章"}

三条规则:

情况服务端行为
键没见过正常执行,把「键 → 指纹 + 响应」存起来
键见过,请求指纹相同不执行,直接重放第一次的响应,带 Idempotency-Replayed: true
键见过,请求指纹不同回 409 conflict,说明这个键被复用了

指纹是「方法 + 路径 + 请求体」的哈希。为什么要比指纹?因为客户端可能复用同一个键去发不同的请求,那是客户端 bug,服务端必须能识别出来并拒绝,而不是傻乎乎地返回上一次的结果。

4.2.7 幂等中间件的实现

幂等是横切关注点,适合做成中间件(卷一的中间件模式在这里直接复用):

func idempotent(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		key := r.Header.Get("Idempotency-Key")
		if key == "" || r.Method != http.MethodPost {
			next.ServeHTTP(w, r) // 没有键就不管
			return
		}
		body := readAll(r)                       // 读完要放回去
		fp := fingerprint(r, body)
		if e, ok := idemStore[key]; ok {
			if e.fp != fp {
				w.WriteHeader(http.StatusConflict)
				fmt.Fprint(w, `{"code":"idempotency_conflict"}`)
				return
			}
			w.Header().Set("Idempotency-Replayed", "true")
			w.WriteHeader(e.status)
			fmt.Fprint(w, e.body)
			return
		}
		rec := httptest.NewRecorder()            // 先录后放
		next.ServeHTTP(rec, r)
		idemStore[key] = idemEntry{fp: fp, status: rec.Code, body: rec.Body.String()}
		copyHeaders(w, rec)
		w.WriteHeader(rec.Code)
		fmt.Fprint(w, rec.Body.String())
	})
}

三个实现要点:

  1. 请求体只能读一次。r.Body 是流,中间件读完之后必须把内容重新包成 io.NopCloser(bytes.NewReader(body)) 放回 r.Body,否则下游 handler 读到的永远是空。这是最经典的坑。
  2. 用 httptest.NewRecorder 录制响应。想在「透传响应」的同时把它存下来,最简单的方式就是先录进 recorder,再原样抄给真正的 w。
  3. 生产实现要把存储换成 Redis 或数据库,并设 TTL(通常 24 小时)。内存 map 在重启后失效,多实例部署时也不共享——第 7 章接 Redis。

4.2.8 实测:重放与冲突

用真实的 httptest 跑一遍三种情况,并统计底层 handler 到底被调用了多少次:

key="k-1"        -> 201 replay=      body={"id":"tsk_01"}
key="k-1"        -> 201 replay=true  body={"id":"tsk_01"}
key="k-1"        -> 409 replay=      body={"code":"idempotency_conflict"}
key=""           -> 201 replay=      body={"id":"tsk_02"}
key=""           -> 201 replay=      body={"id":"tsk_03"}
handler 实际执行次数 = 3

逐条读:第一次 k-1 真正执行,返回 tsk_01;第二次同样的键同样的体,没有执行 handler,直接重放 tsk_01 并带上 replay=true;第三次键相同但请求体变了,回 409;后两次没带键,各自执行,于是有了 tsk_02、tsk_03。最终 handler 只执行了 3 次(第 1、4、5 次),而不是 5 次——这正是幂等键要的效果。

同一套机制我也在内存分页上验证了游标正确性:造 1000 条 created_at 大量重复的任务(每 10 条同一毫秒),每页 7 条翻完:

pages=143 total=1000 unique=1000

143 页 × 7 = 1001,最后一页只有 6 条,合计正好 1000;unique 也是 1000,没有任何重复或遗漏。这里有个容易写错的点:sort.Search 要求谓词「先 false 后 true」,而降序排列下「晚于游标」的谓词恰好相反。写反了不会报错,只会静默返回空页——我第一次写就踩了这个坑,页 2 永远是空数组。

4.2.9 小结

  • 列表接口默认用游标分页;OFFSET 只给需要跳页的后台,且要接受漏行。
  • 游标是「上一页最后一条的排序键」,用 RawURLEncoding 编码,对客户端不透明,必须含全部排序键字段。
  • 实测 20 万行下深翻页:OFFSET 扫 199920 行 / 2653 次缓冲命中,键集扫 20 行 / 4 次命中。
  • 过滤与排序必须走白名单映射,非法字段报错不静默;排序键末尾补 id desc 保证全序。
  • Idempotency-Key 让 POST 可安全重试:新键执行、同键同体重放、同键异体 409。
  • 幂等中间件的三个坑:请求体只能读一次、响应要先录后放、生产存储要换 Redis 并设 TTL。

接口的行为定完了,但还有一件更根本的事没做:接口的契约目前只存在于代码里。客户端拿不到类型定义,文档靠手写会过期,字段改动没人知道。下一节用 OpenAPI 把契约前置。

阅读导航:上一节:4.1 REST 资源建模与状态码 · 下一节:4.3 OpenAPI 契约优先与版本化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练