API 分页设计:Offset vs Cursor(Keyset)怎么选

后端接口拉列表几乎都要分页。最常见的是 current + pageSize

{
  "current": 1,
  "pageSize": 20
}

简单直观,但数据量大 + 频繁翻页 + 数据在变化的场景下有致命问题。生产上大厂 API(GitHub、Slack、Stripe)都在推 Cursor 分页。

Offset 分页的两个坑

1. 深度分页越来越慢

SQL 长这样:

SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 1000000;

数据库要扫过前 100 万行才能扔掉、再取 20 行。1000 万条数据翻到第 500 页,实测能到秒级。

原因:MySQL 不知道”跳过 100 万行”能不能走索引——即使能,也要真的扫过去。

2. 数据漂移

分页过程中数据在增删——用户看到重复或者漏掉。

例子:翻第 1 页看到 20 条 → 有人删了第 5 条 → 翻第 2 页时,原本第 21 条变成了第 20 条,被跳过。

对于列表实时更新的场景(订单、动态、评论流),Offset 分页几乎必然会有这种漏项。

Cursor 分页(Keyset)

思路:用”上一页最后一条的位置”作为下一页起点,不用 offset。

-- 第一页
SELECT * FROM products
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- 第二页(用上一页最后一条的 created_at + id 作为起点)
SELECT * FROM products
WHERE (created_at, id) < ('2026-06-04 13:00:00', 998765)
ORDER BY created_at DESC, id DESC
LIMIT 20;

关键点:

  • 有索引的排序字段created_atid
  • 元组比较处理并列时间
  • 走索引 range scan,速度不随深度衰减

接口设计

Offset 版本

// 请求
{ "current": 3, "pageSize": 20 }

// 响应
{
  "list": [...],
  "total": 12345,
  "current": 3,
  "pageSize": 20
}

Cursor 版本

// 请求
{ "cursor": "eyJ0IjoxNzE3NDg...", "pageSize": 20 }

// 响应
{
  "list": [...],
  "nextCursor": "eyJ0IjoxNzE3NDg2...",
  "hasMore": true
}

cursor 通常是 Base64 编码的 {time, id} 之类的组合,客户端不需要理解内容,直接透传。

Cursor 具体怎么生成

import base64, json

def encode_cursor(last_row):
    payload = {"t": last_row.created_at.isoformat(), "id": last_row.id}
    return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()

def decode_cursor(cursor):
    return json.loads(base64.urlsafe_b64decode(cursor))

# API 侧
if cursor:
    c = decode_cursor(cursor)
    rows = db.execute("""
        SELECT * FROM products
        WHERE (created_at, id) < (?, ?)
        ORDER BY created_at DESC, id DESC
        LIMIT ?
    """, [c["t"], c["id"], page_size])
else:
    rows = db.execute("""
        SELECT * FROM products
        ORDER BY created_at DESC, id DESC
        LIMIT ?
    """, [page_size])

next_cursor = encode_cursor(rows[-1]) if len(rows) == page_size else None

两者对比

维度OffsetCursor
简单度✅ 直观需要理解元组比较
深度分页性能❌ 越来越慢✅ 常数时间
支持随机跳页✅ 可以直接第 N 页❌ 只能顺序翻
数据变化时稳定❌ 漏项 / 重复✅ 稳定
需要 total 计数✅ 好算❌ 通常不返回
支持排序任意排序字段必须唯一 + 有索引

什么时候用哪个

用 Offset

  • 后台管理系统的表格(数据不常变、用户会跳页)
  • 数据总量不大(几千到几万条)
  • 客户端要显示”第 5 / 100 页”这种明确导航

用 Cursor

  • 时间流列表(订单、聊天、动态、日志)
  • 数据量大(十万+)
  • 客户端做”上拉加载更多”(不需要跳页)
  • 数据实时增删

两个都做:GitHub API 大多支持 ?page=N(Offset)和 ?before=cursor(Cursor),场景多的时候都提供。

顺带:total 慢查询

Offset 分页返回 total 时,SELECT COUNT(*) 在千万级表上也会慢。优化:

  • 缓存 total(Redis,几分钟一次异步刷新)
  • 估算 total(PostgreSQL pg_class.reltuples、MySQL INFORMATION_SCHEMA.TABLES
  • 只算前几页的精确 total,之后返回 total: nullhasMore: true 就够

一句话总结

大数据 + 时间流用 Cursor(速度不衰减、结果稳定)、后台管理小表格用 Offset(简单、支持跳页)。别把 Offset 用在千万级实时数据的 API 上。