第 54 章 / 共 57 章 · 约 6 分钟
API 设计 2:分页与幂等
分页和幂等是 API 设计里最常被追问的两个细节,而且都和“请求会被重复执行”有关:翻页时数据在变化,重试时同一个请求被执行两次。这一章用一个支付 API 做案例:列出账户的支付记录,以及创建一笔支付。
分页:页码还是游标
页码分页(?page=3&size=50,对应 OFFSET 100 LIMIT 50)最直观,可以直接跳到第 N 页。它有两个问题:
- 越往后越慢。
OFFSET 100000意味着数据库要先找出 10 万行再扔掉,代价和偏移量成正比。 - 数据变化时会重复或遗漏。按时间倒序翻页,用户看第 1 页时又进来了 3 笔新支付,所有记录往后挪 3 位,第 2 页开头就会重复出现第 1 页末尾的 3 笔;如果有记录被删除,则会漏掉。
游标分页(也叫 keyset 分页)不记“第几页”,而是记“上一页最后一条在哪”,下一页从它之后接着取:
-- First page
SELECT id, amount, created_at FROM payments
WHERE account_id = :account
ORDER BY created_at DESC, id DESC
LIMIT 51; -- one extra row tells whether there is a next page
-- Next page: continue after the last row returned (created_at = :t, id = :id)
SELECT id, amount, created_at FROM payments
WHERE account_id = :account AND (created_at, id) < (:t, :id)
ORDER BY created_at DESC, id DESC
LIMIT 51;
只要有 (account_id, created_at, id) 上的索引,每一页的查询代价都一样,和翻到第几页无关;新插入的记录排在前面,不影响往后翻。代价是不能直接跳到第 N 页,但对“无限滚动”和导出全部数据这两种最常见的场景,这不是问题。
几个细节:
- 排序键要唯一。
created_at可能重复,两笔支付在同一毫秒创建,如果只按时间比较,翻页边界上的记录可能被跳过。加上id作为第二排序键,顺序就是确定的。 - 游标对客户端不透明。把
(created_at, id)编码成一个字符串(比如 base64 的 JSON)返回,客户端原样带回即可。这样服务端以后换排序方式,不会影响客户端。 - 限制每页大小,比如最多 100 条,防止一次请求拖垮数据库。
- 筛选条件和游标绑定。翻页过程中换了筛选条件,旧游标就没有意义了,可以把筛选条件也编进游标,或在服务端校验。
响应可以长这样:
{
"data": [{"id": "pay_9f2", "amount": 4200, "currency": "USD", "created_at": "2026-10-12T08:31:07Z"}],
"next_cursor": "eyJ0IjoiMjAyNi0xMC0xMlQwODozMTowN1oiLCJpZCI6InBheV85ZjIifQ",
"has_more": true
}
幂等:重试不能扣两次钱
客户端发出“创建支付”的请求后超时了,它不知道服务端是没收到,还是处理完了但响应丢了。重试是对的,但如果服务端把重试当成一笔新支付,用户就被扣了两次。
HTTP 语义上,GET、PUT、DELETE 本来就是幂等的:执行一次和执行多次效果相同。POST 不是。通用做法是幂等键:客户端为每个逻辑操作生成一个随机 ID(比如 UUID),放在 Idempotency-Key 请求头里,重试时带上同一个值。
图表可横向滚动,展开后可缩放查看。
查看图表源码
sequenceDiagram
accTitle: 带幂等键的支付请求:第一次超时,重试拿到同一个结果
participant C as 客户端
participant A as 支付 API
participant D as 数据库
participant P as 支付渠道
C->>A: POST /payments(Idempotency-Key: k1)
A->>D: 插入 k1,状态 started
A->>P: 扣款(同样带上 k1)
P-->>A: 成功
A->>D: k1 改为 done,保存响应
A--xC: 响应在网络中丢失
C->>A: 重试 POST /payments(Idempotency-Key: k1)
A->>D: 查到 k1 已完成
A-->>C: 201,返回第一次保存的响应
服务端的处理:
import hashlib
import json
def create_payment(db, processor, user_id: str, key: str, body: dict):
digest = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
claimed = db.execute(
"INSERT INTO idempotency_keys (user_id, key, request_hash, state) "
"VALUES (?, ?, ?, 'started') ON CONFLICT (user_id, key) DO NOTHING",
(user_id, key, digest),
).rowcount == 1
if not claimed:
row = db.one(
"SELECT request_hash, state, status, response FROM idempotency_keys "
"WHERE user_id = ? AND key = ?", (user_id, key))
if row.request_hash != digest:
return 422, {"code": "idempotency_key_reused"} # same key, different request
if row.state == "started":
return 409, {"code": "request_in_progress"} # the first attempt is still running
return row.status, json.loads(row.response) # replay the stored answer
result = processor.charge(body["amount"], body["currency"], body["source"],
idempotency_key=key) # the processor dedupes too
status = 201 if result.approved else 402
response = {"id": result.id, "status": result.status}
db.execute(
"UPDATE idempotency_keys SET state = 'done', status = ?, response = ? "
"WHERE user_id = ? AND key = ?", (status, json.dumps(response), user_id, key))
return status, response
要点:
- 用唯一约束抢占键。
INSERT ... ON CONFLICT DO NOTHING只有一个请求能插入成功,两个并发的重试不会都去扣款。 - 同一个键、不同的请求体,要报错。客户端拿同一个键发了金额不同的请求,说明客户端有 bug,既不能重放旧结果,也不能当成新请求执行。
- 失败的结果也要保存。银行拒付返回 402,重试应该得到同样的 402,而不是再扣一次试试。
- 把幂等键传给下游。如果服务在扣款之后、写回结果之前崩溃,键会停在
started状态。等它超过一定时间(比如 1 分钟)以后,可以允许重新执行:因为支付渠道也按同一个键去重,重新执行不会扣两次。 - 键要有过期时间,比如 24 小时后清理,否则表会无限增长。
扩展讨论
- 列表接口的总数:
total_count在大表上很贵(要数全表),可以不提供、提供估算值,或单独做一个计数接口。 - 双向翻页:游标里带上方向,
before和after各一个。 - 幂等键的作用域:按“用户 + 键”唯一,而不是全局唯一,避免不同用户的键冲突,也防止猜测别人的键来读取别人的响应。