cswork / 系统设计

第 54 章 / 共 57 章 · 约 6 分钟

API 设计 2:分页与幂等

本页目录
  1. 分页:页码还是游标
  2. 幂等:重试不能扣两次钱
  3. 扩展讨论
  4. 章节测验

分页和幂等是 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 各一个。
  • 幂等键的作用域:按“用户 + 键”唯一,而不是全局唯一,避免不同用户的键冲突,也防止猜测别人的键来读取别人的响应。

章节测验

3 道选择题
  1. 用户按时间倒序翻支付记录,看第 1 页时又进来了 3 笔新支付。用页码分页,第 2 页会怎样?
  2. 游标分页时,为什么游标里除了 created_at 还要带上 id?
  3. 客户端用同一个幂等键又发了一次创建支付的请求,但金额从 42 元变成了 24 元。服务端应该怎么做?

章节讨论

正在加载讨论…