别再盲目翻页:Python 后端必须讲透的三种分页方案——Offset、Cursor、Seek 的原理、性能与实战选型

Python 编程 久了,你会发现:一个列表接口,真正难的往往不是“把数据查出来”,而是“在数据越来越多、用户越来越频繁翻页、系统还在不断写入”的情况下,依然让它查得快、翻得稳、体验好。

很多初学者第一次做分页,几乎都会写出这样的 SQL:

SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;

第二页:

SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 20;

这没有错,甚至可以说是大多数项目的起点。但当表从几千行增长到几百万、几千万,问题就来了:为什么页码越往后,查询越慢?为什么会出现重复数据、漏数据?为什么消息流和审计日志不能简单用 page=123?

这篇文章就从实战视角,把分页这件事讲透。你会看到三种最常见的分页方式:offset 分页、cursor 分页、seek 分页。更重要的是,我们不只讲概念,而是讲它们在真实业务里的边界、代价与最佳落地方式。无论你是刚接触后端的新人,还是在做高并发接口优化的老手,希望都能从中找到答案。


一、先说结论:分页不是“写法问题”,而是“访问模式问题”

很多人以为分页只是 SQL 语法选择,其实不然。分页本质上是在回答三个问题:

  1. 用户是否需要“跳到第 N 页”?
  2. 数据是否持续新增、删除、更新?
  3. 你更在乎“任意跳页能力”,还是“稳定、高性能、连续加载体验”?

不同答案,会导向完全不同的分页策略。


二、Offset 分页:最直观,但不一定最适合大表

1. 什么是 offset 分页

这是最常见、最容易理解的一种方式。

SELECT id, user_id, amount, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;

这表示:按创建时间倒序,跳过前 40 条,取接下来的 20 条。对应常见接口参数:

page = 3
page_size = 20
offset = (page - 1) * page_size

2. 优点

offset 分页最大的优势,是简单、通用、适合后台管理系统

  • 易于实现
  • 支持直接跳页
  • 对前端很友好,页码语义清晰
  • 适合需要展示“第 1 页 / 第 2 页 / 共 50 页”的场景

Python 示例:

def get_orders_by_offset(db, page: int, page_size: int = 20):
    offset = (page - 1) * page_size
    sql = """
        SELECT id, user_id, amount, created_at
        FROM orders
        ORDER BY created_at DESC, id DESC
        LIMIT %s OFFSET %s
    """
    return db.fetch_all(sql, (page_size, offset))

注意这里我把 id 也加进排序字段里了,这是一个很重要的 Python最佳实践排序必须稳定。如果只按 created_at 排序,而很多记录时间相同,那么翻页时数据顺序可能飘忽不定。

3. 缺点

offset 的问题有两个:性能一致性

性能问题

数据库不是“直接跳到第 1000001 行再读 20 条”。它通常需要先找到前面那些记录,再把它们丢掉。

也就是说:

LIMIT 20 OFFSET 1000000

往往意味着数据库要处理 1000020 条记录,最后只返回 20 条。前面的 1000000 条,用户根本看不到,但数据库已经为它们付出了扫描、排序、过滤的成本。

一致性问题

如果列表数据在不停变化,比如新订单持续写入,那么用户在翻页过程中,可能遇到:

  • 第 1 页看过的数据,第 2 页又看到了
  • 某些数据明明存在,却被跳过去了

原因很简单:offset 是按位置切片,不是按数据边界切片。当前面插入新数据后,原本的“第 41 条”可能变成“第 61 条”。


三、为什么大表分页里 offset 往往越翻越慢?

这是后端面试和性能优化里非常经典的问题。

1. 因为 offset 的本质是“先读,再丢”

假设有一张千万级订单表,你要查第 50001 页,每页 20 条:

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

数据库常见的执行思路是:

  1. 找出满足条件的数据
  2. created_at DESC 排序
  3. 扫过前 1000000 行
  4. 丢弃这 1000000 行
  5. 返回后面的 20 行

问题不在 LIMIT 20,而在 OFFSET 1000000offset 越大,前面被“白白扫描和丢弃”的数据越多。

2. 排序和回表会进一步放大成本

如果 ORDER BY 字段没有合适索引,数据库还可能发生额外排序;如果索引不能覆盖查询字段,还会发生“回表”读取整行数据。

于是一次深分页,可能同时包含:

  • 大量索引扫描
  • 排序开销
  • 回表开销
  • 丢弃大量无用记录

这就是为什么很多接口在前几页飞快,翻到后面突然变慢。

3. 数据越活跃,体验越差

如果表还在持续写入,那么 offset 分页不仅慢,还不稳定。对用户来说,这比单纯的“慢”更难受,因为他会怀疑系统是不是“少数据了”。

一句话总结:offset 分页慢,不是因为它语法复杂,而是因为它要求数据库“为你走很远的路,再假装什么都没发生”。


四、Cursor 分页:给前端一个“游标”,而不是一个页码

1. 什么是 cursor 分页

cursor 的核心思想是:不要告诉我“第几页”,告诉我“从哪里继续”

第一次请求:

GET /messages?limit=20

返回:

{
  "items": [...],
  "next_cursor": "2026-04-03T10:30:00_987654"
}

下一次请求:

GET /messages?limit=20&cursor=2026-04-03T10:30:00_987654

cursor 通常会封装排序边界,比如最后一条数据的 created_atid。它更多是接口层协议,不一定是数据库层实现细节。

2. 优点

  • 适合无限下拉、加载更多
  • 不需要深度跳过前面所有记录
  • 在高并发写入场景下更稳定
  • 更适合移动端、消息流、时间线

3. 缺点

  • 不天然支持“跳到第 37 页”
  • cursor 通常是“上一次结果的延续”,更偏流式访问
  • 设计不当会暴露内部排序字段,或者造成游标伪造问题

因此,实际项目里常把 cursor 做成不透明字符串,经过编码处理:

import base64
import json

def encode_cursor(created_at: str, row_id: int) -> str:
    payload = {"created_at": created_at, "id": row_id}
    return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()

def decode_cursor(cursor: str) -> dict:
    return json.loads(base64.urlsafe_b64decode(cursor.encode()).decode())

五、Seek 分页:数据库层真正高效的“沿索引继续走”

1. 什么是 seek 分页

seek 分页也常被叫做 keyset pagination。它的思想非常直接:

不是跳过前 N 条,而是基于上一页最后一条记录的排序键,继续往后查。

例如按 created_at DESC, id DESC 排序:

SELECT id, created_at, content
FROM messages
WHERE (created_at, id) < ('2026-04-03 10:30:00', 987654)
ORDER BY created_at DESC, id DESC
LIMIT 20;

这个写法的关键是:它把“翻页”变成“从上一个边界继续扫描索引”,而不是“从头数到这里”。

2. 为什么它快

因为数据库可以直接利用索引定位到边界位置,然后顺着索引取接下来的 20 条,而不是从第一页一路数过来。

如果有联合索引:

INDEX idx_messages_created_id (created_at DESC, id DESC)

那么 seek 分页会非常高效,尤其适合大表。

3. Seek 和 Cursor 的关系

这是一个很容易混淆的点。

  • cursor:更像接口设计方式,告诉客户端“下次从这个位置继续”
  • seek:更像数据库查询策略,基于排序键继续扫描

在很多成熟系统里,cursor 是对外协议,seek 是底层实现


六、三种分页方式怎么选?

这才是最重要的实战问题。

1. 订单列表:后台管理通常适合 offset,用户侧列表更适合 cursor/seek

如果是运营后台、财务后台,用户经常有“跳到第 12 页”“查看总页数”“导出某一页”的需求,那么 offset 很常见,也很合理。

SELECT id, order_no, amount, status, created_at
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 200;

但要注意两点:

  • 给排序字段建索引
  • 控制最大翻页深度,比如超过 1000 页改为筛选查询

如果是用户 App 里的“我的订单”,通常不需要真正跳页,更多是“继续加载”,这时更建议 cursor/seek。

结论:

  • 管理后台订单列表:offset 可用
  • 用户端订单流:cursor/seek 更优

2. 消息流:优先 cursor + seek

消息流天然是时间序列,而且数据持续新增。你几乎不需要“跳到第 138 页消息”,你需要的是:

  • 继续向下加载旧消息
  • 拉取比当前更新的新消息
  • 保证不重不漏

典型查询:

SELECT id, created_at, sender_id, content
FROM messages
WHERE (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 20;

这正是 seek 分页最擅长的场景。

结论:消息流最适合 cursor/seek。

3. 审计日志:强烈建议 seek,必要时配合时间过滤

审计日志往往有几个特点:

  • 数据量很大
  • 只读多、写入持续
  • 查询经常按时间倒序
  • 一致性要求高,不能漏关键记录

这类场景如果使用深 offset,会非常痛苦。更推荐:

  • event_time DESC, id DESC 排序
  • 用 seek 分页
  • 再叠加时间范围筛选、用户筛选、事件类型筛选
SELECT id, event_time, actor, action, resource
FROM audit_logs
WHERE event_time >= '2026-04-01'
  AND (event_time, id) < (?, ?)
ORDER BY event_time DESC, id DESC
LIMIT 100;

结论:审计日志最适合 seek,外部接口可以包装成 cursor。


七、Python 实战:一个更靠谱的分页实现思路

下面给一个简化版的 Python实战 例子,展示 seek/cursor 的常见写法。

import base64
import json
from typing import Optional

def encode_cursor(created_at: str, row_id: int) -> str:
    payload = {"created_at": created_at, "id": row_id}
    raw = json.dumps(payload).encode("utf-8")
    return base64.urlsafe_b64encode(raw).decode("utf-8")

def decode_cursor(cursor: str) -> dict:
    raw = base64.urlsafe_b64decode(cursor.encode("utf-8"))
    return json.loads(raw.decode("utf-8"))

def list_messages(db, limit: int = 20, cursor: Optional[str] = None):
    if cursor:
        data = decode_cursor(cursor)
        sql = """
            SELECT id, created_at, sender_id, content
            FROM messages
            WHERE (created_at, id) < (%s, %s)
            ORDER BY created_at DESC, id DESC
            LIMIT %s
        """
        rows = db.fetch_all(sql, (data["created_at"], data["id"], limit))
    else:
        sql = """
            SELECT id, created_at, sender_id, content
            FROM messages
            ORDER BY created_at DESC, id DESC
            LIMIT %s
        """
        rows = db.fetch_all(sql, (limit,))

    next_cursor = None
    if rows:
        last = rows[-1]
        next_cursor = encode_cursor(str(last["created_at"]), last["id"])

    return {
        "items": rows,
        "next_cursor": next_cursor
    }

这个实现里,有几个值得坚持的 Python教程 层面的最佳实践:

  1. 排序字段必须唯一可比较
    只按 created_at 不够,最好加 id 兜底。

  2. 游标尽量不透明
    不要让前端直接拼 SQL 条件。

  3. 索引要和排序一致
    否则 seek 的优势发挥不出来。

  4. 限制最大 limit
    避免一次拉太多数据。


八、真正落地时,你还需要注意这些细节

1. 不要迷信“总条数”

很多人做分页时,第一反应是返回:

  • 当前页
  • 每页条数
  • 总条数
  • 总页数

但在超大表里,COUNT(*) 本身也可能很贵。对于消息流、日志流,很多时候只返回“是否还有下一页”就够了。

2. 排序一定要稳定

错误写法:

ORDER BY created_at DESC

更稳妥的写法:

ORDER BY created_at DESC, id DESC

否则同一秒内写入多条数据时,分页边界会很危险。

3. Offset 不是原罪

别把 offset 说得一无是处。它在以下场景依然很有价值:

  • 数据量不大
  • 需要跳页
  • 需要明确页码
  • 典型后台系统
  • 报表类界面

技术选型的关键,不是“哪种最先进”,而是“哪种最适合你的访问模式”。


九、一张表记住三种分页的选型逻辑

场景 推荐方案 原因
后台订单列表 Offset 支持页码、跳页、总页数展示
用户侧订单流 Cursor/Seek 连续加载体验更好,性能更稳
消息流 Cursor + Seek 数据实时变动,不适合 offset
审计日志 Seek 大表高性能、按时间顺序稳定遍历
小型配置列表 Offset 简单直接,维护成本低

十、总结:分页从来不只是“第几页”,而是系统设计观的体现

分页看似是一个小问题,实际上能非常真实地反映你对数据库、接口设计和用户体验的理解。

  • Offset 简单直观,适合页码明确、数据量可控的场景。
  • Cursor 更适合面向客户端的连续加载,是一种更现代的接口设计。
  • Seek 则是大表、高并发、时间序列数据的性能利器,本质上是“沿索引继续走”。

所以,回到开头那个追问:

为什么大表分页里 offset 往往越翻越慢?

因为它不是在“从当前位置开始取数据”,而是在“从头开始数,然后把前面大部分结果扔掉”。页越深,白白浪费的扫描、排序和回表成本就越高。

而对于你提到的三个实践案例,我的建议非常明确:

  • 订单列表:后台管理偏向 offset,用户端偏向 cursor/seek
  • 消息流:优先 cursor + seek
  • 审计日志:优先 seek,必要时对外封装成 cursor

这也是我在长期 Python实战 和后端设计里反复验证过的一条经验:别先问“数据库支持什么分页”,先问“用户到底怎么消费这批数据”。


留给你的两个思考题

你在日常开发中,是否遇到过“分页越翻越慢”或者“翻页出现重复/漏数”的问题?你最后是怎么定位和解决的?

面对越来越多实时化、流式化的数据场景,你觉得未来的接口设计里,“页码分页”会不会逐渐退居二线?

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐