别再盲目翻页:Python 后端必须讲透的三种分页方案——Offset、Cursor、Seek 的原理、性能与实战选型
本文深入探讨了Python后端开发中三种常见的分页方案:Offset、Cursor和Seek分页。Offset分页简单直观但性能较差,适合后台管理系统;Cursor分页通过游标实现高效翻页,适合移动端和消息流;Seek分页利用索引边界扫描,是大表分页的最佳实践。文章分析了每种方案的原理、优缺点及适用场景,并指出分页选择应基于业务需求和数据访问模式,而非单纯技术考量。对于订单列表,后台管理适合Off
别再盲目翻页: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 语法选择,其实不然。分页本质上是在回答三个问题:
- 用户是否需要“跳到第 N 页”?
- 数据是否持续新增、删除、更新?
- 你更在乎“任意跳页能力”,还是“稳定、高性能、连续加载体验”?
不同答案,会导向完全不同的分页策略。
二、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;
数据库常见的执行思路是:
- 找出满足条件的数据
- 按
created_at DESC排序 - 扫过前 1000000 行
- 丢弃这 1000000 行
- 返回后面的 20 行
问题不在 LIMIT 20,而在 OFFSET 1000000。offset 越大,前面被“白白扫描和丢弃”的数据越多。
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_at 和 id。它更多是接口层协议,不一定是数据库层实现细节。
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教程 层面的最佳实践:
-
排序字段必须唯一可比较
只按created_at不够,最好加id兜底。 -
游标尽量不透明
不要让前端直接拼 SQL 条件。 -
索引要和排序一致
否则 seek 的优势发挥不出来。 -
限制最大 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实战 和后端设计里反复验证过的一条经验:别先问“数据库支持什么分页”,先问“用户到底怎么消费这批数据”。
留给你的两个思考题
你在日常开发中,是否遇到过“分页越翻越慢”或者“翻页出现重复/漏数”的问题?你最后是怎么定位和解决的?
面对越来越多实时化、流式化的数据场景,你觉得未来的接口设计里,“页码分页”会不会逐渐退居二线?
更多推荐



所有评论(0)