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)
}
三个设计决策:
- 用
base64.RawURLEncoding,不要用标准 base64。标准编码会产生+和/,放进 URL 查询串会被转义,客户端一不留神就解码失败。RawURLEncoding用-和_,且不带=填充。 - 游标对客户端不透明。虽然 base64 是可解的,但要在文档里声明「结构随时可能变,不要解析」。否则客户端一旦依赖内部结构,你就再也不能改排序键了。
- 游标必须包含排序键的全部字段。只放
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())
})
}
三个实现要点:
- 请求体只能读一次。
r.Body是流,中间件读完之后必须把内容重新包成io.NopCloser(bytes.NewReader(body))放回r.Body,否则下游 handler 读到的永远是空。这是最经典的坑。 - 用
httptest.NewRecorder录制响应。想在「透传响应」的同时把它存下来,最简单的方式就是先录进 recorder,再原样抄给真正的w。 - 生产实现要把存储换成 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 契约优先与版本化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。