2021 年我做一个后端服务,有一大堆列表页要分页——订单列表、操作日志、消息记录。分页这件事,我压根没多想。第一版我做得很省事:分页不就是 LIMIT 和 OFFSET?前端要第几页,我就 OFFSET (页码-1)*每页条数 LIMIT 每页条数,跳过前面那些、取这一页该取的。本地开发时——真不错:测试库里就几百条数据,我从第一页翻到最后一页,每一页都是瞬间出结果,顺畅得很。我心里很踏实:"分页嘛,不就是跳过 N 条、取 size 条?"可等这个服务真正上线、表里的数据涨到几百万行,一串问题冒了出来。第一种最先把我打懵:用户翻到很靠后的页——比如第 5000 页——这一页查询慢得离谱,可前面几页明明快得很;同样是取 20 条,凭什么靠后的页就慢?第二种最隐蔽:用户在一页页往后翻,翻页的间隔里有新数据插了进来——结果他在第 2 页看到的某条记录,翻到第 3 页又冒出来一次;还有的记录,他整个没看到、被跳过了。第三种最拖时间:有个定时任务要把整张表导出,我用循环 OFFSET 一页页拉,跑到后面每一页都慢得要死,整个导出耗时长得没法接受。第四种最不起眼却也在拖后腿:列表页要显示"共 1234 页",我每次翻页都 SELECT COUNT(*) 算一遍总数——可这个 COUNT 在几百万行的表上,本身就是一个慢查询。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"分页,就是跳过前面 N 条、取 size 条"。这句话把 OFFSET 当成了一个免费的"跳过"动作。可它不是。OFFSET N 的真相,根本不是"跳过 N 条",而是"先把前 N 条一行行全部读出来,再把它们整整齐齐地丢掉"。数据库没有任何办法"凭空闪现到第 N+1 条"——它必须从头开始数,数够 N 行、把这 N 行全部丢弃,才开始取你真正要的那 size 行。所以 OFFSET N 的代价,和 N 成正比:翻第 1 页几乎不花钱,翻第 5000 页,数据库要先白白扫过、再丢弃整整 10 万行,只为给你最后那 20 行。OFFSET 分页不是"慢",是"越往后越慢"——它的代价随页深线性增长。而且,"第几页"这个模型本身是基于"位置"的,位置会随数据增删而漂移,这就是翻页时记录会重复、会遗漏的根源。真正可扩展、可靠的分页,不该用"第几页"这种基于位置的模型,而该用"某条记录之后的下一批"这种基于锚点的模型——这就是游标分页。真正做好分页,核心不是"LIMIT 配 OFFSET",而是看清 OFFSET 的代价随页深增长、改用基于锚点的游标分页、给排序键加唯一决胜字段、配好复合索引、别每页都 COUNT 全表。这篇文章就把分页性能优化梳理一遍:为什么"LIMIT OFFSET 翻页"是错的、游标分页怎么按锚点取下一页、游标怎么编码、排序字段为什么必须唯一、总数怎么处理,以及索引配合、深度导出、页大小上限这些把分页真正做扎实要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一套"LIMIT 配 OFFSET"的分页,在数据量涨上来后冒出一串问题:翻到靠后的页查询极慢,前面的页却很快;翻页过程中有数据增删,同一条记录重复出现、或被整个跳过;循环 OFFSET 导出整表,越往后每页越慢,总耗时长得离谱;为显示总页数每次都 COUNT(*),这个 COUNT 自己就是慢查询。
我当时的错误认知:"分页,就是跳过前面 N 条、取 size 条。"
真相:这个认知错在它误解了 OFFSET 的工作方式。"跳过 N 条"这个说法,会让人以为数据库能像翻书一样,手指一点就跳到第 N 页。可数据库做不到这件事。一张表里的行,数据库没有办法不读它、就知道它是第几行——"第几行"这个信息不写在行里。所以当你写 OFFSET 100000,数据库唯一能做的,就是老老实实地从满足条件的第一行开始,一行一行地读、一行一行地数,数到第 100000 行,把这前 10 万行原封不动地丢弃,然后才开始取你要的 LIMIT 20。也就是说,翻第 5001 页,数据库实际处理的行数是 100020 行,而不是 20 行。这就解释了"为什么靠后的页慢":页越靠后,被白白读出又丢弃的行越多,代价随页深线性增长。它还解释了第二个问题:"第几页"是一个基于位置的坐标,而位置会随数据增删而漂移——你看第 2 页时它指向某 20 行,等你翻到第 3 页,前面要是插进了新数据,整个序列往后挪了一格,于是有的行被你重复看到、有的行从你眼皮底下溜过去了。一旦你看清 OFFSET 的这两个本质——代价随页深增长、坐标随数据漂移——就会明白:问题不在某个参数,在"用'第几页'来分页"这个模型本身。要换的,是一个不依赖位置、而依赖"锚点"的分页模型。
要把分页性能做对,需要几块认知:
- 为什么"
LIMIT OFFSET翻页"是错的——OFFSET 的代价随页深线性增长; - 游标分页——不问"第几页",问"某锚点之后的下一批";
- 游标编码——把锚点封装成对客户端不透明的 cursor;
- 排序键唯一性——游标分页要求排序键整体唯一;
- 总数处理、索引配合、深度导出这些工程坑怎么处理。
一、为什么"LIMIT OFFSET 翻页"是错的
先把这件最根本的事钉死:OFFSET N 这个写法,字面上是"跳过 N 条",可它在数据库里的真实执行,是"读出 N 条,然后丢弃"。这两者天差地别。"跳过"暗示着零成本——仿佛数据库能直接定位到第 N+1 条;"读出再丢弃"则意味着,你为那 N 条根本不要的数据,付了完整的读取代价。数据库为什么不能直接跳?因为一行数据的"序号"不是它的属性——表里没有任何地方记着"这是第 88 行"。行的顺序是 ORDER BY 在查询时临时算出来的,数据库只能顺着这个顺序从头数。于是 OFFSET 分页有一个致命的性质:翻每一页的代价不是恒定的,而是随页码增长的。第 1 页,数据库处理 20 行;第 5000 页,数据库处理 100020 行。同样是给用户看 20 条,后者的工作量是前者的五千倍。这就是为什么 OFFSET 分页在小数据量、浅页码下毫无问题,却会在大数据量、深页码下轰然倒塌——它不是"有点慢",它是"代价随页深线性膨胀"。
下面这条 SQL,就是我那个"翻到深页就慢成蜗牛"的第一版:
-- 反面教材:基于"第几页"的 OFFSET 分页
SELECT id, title, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000; -- 用户翻到了第 5001 页
-- 破绽:OFFSET 100000 不是"跳过",是"读出 10 万行,再全部丢掉"。
-- 数据库被迫从头扫 100020 行排序,只为返回最后那 20 行。
-- 翻得越深,被白扫的行越多 —— 代价和页码成正比。
这条 SQL 在本地开发时表现不错,因为本地测试库里只有几百行数据:你就算翻到"最后一页",OFFSET 也不过几百,数据库扫几百行眨眼就完——OFFSET 那个"读出再丢弃"的代价,在几百行的盘子里小到根本称不出重量。它的问题不在某一行 SQL 上——LIMIT OFFSET 的语法完全正确——而在一个被忽略的前提:它默认"OFFSET 多大,代价都差不多"。可线上恰恰相反:表里几百万行,深页的 OFFSET 动辄十万、百万。于是那串问题就有了解释:深页查询慢,是因为数据库为那一页要先白扫掉前面十万行;循环导出越来越慢,是因为每多翻一页,OFFSET 就更大、白扫的行更多,总耗时是平方级的。问题的根子清楚了:做好分页的工程量,全在"承认 OFFSET 的代价随页深增长、且'第几页'这个坐标会漂移"之后——你用它分页,它就会在数据量涨上来时,让靠后的每一页都越来越慢、还在数据变动时给用户看重影。所以要换一个模型——一个不数位置、只认锚点的模型。
二、游标分页:按"锚点"取下一页
换的这个模型,叫游标分页(也叫 keyset 分页、键集分页)。它的核心,是换一个问法。OFFSET 分页问的是:"把结果排好序,给我第 5001 页。"——这是基于位置的。游标分页问的是:"把结果排好序,给我'排在某条记录之后'的下一批 20 条。"——这是基于锚点的。这里的"某条记录",就是上一页最后那一条;它的排序字段的值,就是锚点。关键在于:"排在锚点之后"这个条件,可以直接写成一个 WHERE,而 WHERE 配上索引,数据库能一步跳到锚点的位置,根本不需要从头数:
-- 游标分页:不问"第几页",问"排在这个锚点之后的下一批"
SELECT id, title, created_at
FROM orders
WHERE (created_at, id) < (?, ?) -- ? 是上一页最后一条的 created_at 与 id
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 关键:WHERE 用锚点直接定位,配合 (created_at, id) 复合索引,
-- 数据库一步跳到锚点处,只读这 20 行 —— 和页深完全无关。
注意这条 SQL 里没有 OFFSET 了,取而代之的是 WHERE (created_at, id) < (?, ?)。无论你翻到第几页,这个 WHERE 的形状都一样,数据库的工作量也一样——永远只是"定位到锚点、往后读 20 行"。下面是它在 ORM(以 SQLAlchemy 为例)里的写法:
from sqlalchemy import tuple_
def fetch_page(session, after_created_at=None, after_id=None, size=20):
"""游标分页:给定上一页末尾的锚点,取排在它之后的下一批 size 条。"""
q = session.query(Order).order_by(Order.created_at.desc(),
Order.id.desc())
if after_created_at is not None:
# 复合条件:created_at 更早,或 created_at 相同但 id 更小
q = q.filter(
tuple_(Order.created_at, Order.id) < (after_created_at, after_id)
)
return q.limit(size).all()
# 第一页:不带锚点,直接取最新的 size 条;
# 后续页:带上一页末条的锚点,取它之后的 size 条 —— 永远 O(size)。
下面这张图,把一次翻页请求在游标分页下是怎么被服务的画出来:
这里的认知要点是:OFFSET 分页和游标分页,差别的根子在"用什么来定位下一页"。OFFSET 用的是"序号"——第 5001 页,意味着"第 100001 到 100020 行"。序号这个东西,数据库不能直接用,只能从头数着确认,所以代价随序号增长;而且序号是相对位置,前面一插入数据,所有序号就全部错位。游标用的是"锚点值"——上一页最后那条记录的 created_at 和 id。锚点值这个东西,恰恰是数据库最擅长处理的:它能写进 WHERE,能走索引,数据库一步就能跳到那个值的位置。更妙的是,锚点是一个具体的值,不是一个相对位置——你翻页的间隔里,前面插了多少条新数据,都影响不到"created_at, id 小于某个具体值"这个判断,所以游标分页天然不会重复、不会遗漏。一句话:OFFSET 问"第几个",游标问"哪个之后";前者的代价和数据规模纠缠在一起,后者永远只是 O(size)。但锚点是上一页最后一条的排序值——这个值不能让客户端直接看见、直接拼,得包装一下。
三、游标编码:把锚点封装成不透明的 cursor
锚点是 created_at 和 id 这两个内部字段的值。如果接口直接让客户端传这两个值,有两个坏处:一是暴露了内部实现(客户端从此知道你按这两个字段排序、分页);二是客户端可能自己瞎拼锚点,传进来奇奇怪怪的值。正确的做法是:把锚点编码成一个"不透明的字符串"——一个 cursor。对客户端来说,cursor 就是一个"下一页凭证",它不该去解析、也无从解析;它只负责原样带回来:
import base64, json
def encode_cursor(row):
"""把锚点封装成不透明 cursor —— 客户端只原样回传,不该解析它。"""
payload = {"created_at": row.created_at.isoformat(), "id": row.id}
raw = json.dumps(payload).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii")
def decode_cursor(cursor):
"""把 cursor 还原成锚点;非法 cursor 直接拒绝,绝不去猜。"""
try:
raw = base64.urlsafe_b64decode(cursor.encode("ascii"))
payload = json.loads(raw)
return payload["created_at"], payload["id"]
except Exception:
raise ValueError("非法的分页游标")
有了编解码,接口层就清爽了:吃进一个 cursor,吐出这一页的数据,外加"下一页的 cursor"。这里有个小技巧——多取一条,用来判断"后面还有没有数据":
@app.get("/orders")
def list_orders(cursor: str = None, size: int = 20):
"""列表接口:吃一个 cursor,吐这一页 + 下一页的 cursor。"""
after = decode_cursor(cursor) if cursor else (None, None)
rows = fetch_page(db, after_created_at=after[0],
after_id=after[1], size=size + 1) # 故意多取一条
has_more = len(rows) > size # 多出来的那条若存在,说明还有下一页
rows = rows[:size] # 真正返回给用户的还是 size 条
return {
"items": [serialize(r) for r in rows],
"next_cursor": encode_cursor(rows[-1]) if has_more and rows else None,
}
# next_cursor 为 None,前端就知道"到底了",不必再发翻页请求。
这里的认知要点是:cursor 对客户端必须是"不透明"的——这不只是为了好看,而是一种契约设计。一旦 cursor 是不透明的,客户端就只能做一件事:把它原样存下、原样回传。它不能解析、不能猜测、不能自己拼造。这就给你换来了宝贵的自由:今天你的锚点是 created_at 加 id,明天你想改成别的排序、加别的字段,只要 cursor 的编解码是你自己说了算的,客户端的代码一行都不用动——因为它从来就没依赖过 cursor 的内部结构。反过来,如果你让客户端直接传 created_at 和 id,你就等于把"我用这两个字段分页"这个实现细节焊进了客户端的代码,以后再想动就是一次破坏性变更。还有一点:decode_cursor 收到非法输入时,要明确地拒绝、报错,绝不能"猜一个默认值"糊弄过去——一个被篡改或损坏的 cursor,本就不该返回任何看似正常的结果。不过游标分页能成立,还压着一个容易被忽略的前提:排序键必须唯一。
四、排序键唯一性:游标分页的隐藏前提
游标分页的 WHERE 条件,本质是"排序键 小于 锚点"。这里藏着一个必须满足的前提:排序键的值,必须是整体唯一的。如果你只用一个 created_at 排序、也只用它当锚点,而 created_at 这个字段会重复(同一秒、甚至同一毫秒下了好几个订单,太常见了),那麻烦就来了:WHERE created_at < 锚点 这一刀切下去,那些 created_at 正好等于锚点的记录,会被要么全部漏掉、要么在边界上重复。解法是给排序键追加一个唯一的"决胜字段"——通常就是主键 id:
-- 反面:只用 created_at 排序和做游标 —— 而 created_at 会重复!
SELECT * FROM orders
WHERE created_at < ? -- 多条记录 created_at 完全相同时,
ORDER BY created_at DESC -- 这一刀切下去,边界上的记录要么漏、要么重
LIMIT 20;
-- 正解:排序键末尾追加唯一的 id 作"决胜字段"
SELECT * FROM orders
WHERE (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC -- (created_at, id) 整体唯一,游标才稳
LIMIT 20;
-- created_at 相同时,由 id 来决出唯一的先后 —— 不再有边界上的歧义。
这里的认知要点是:游标分页要正确,排序键必须能在所有记录之间排出一个"严格的、无歧义的"顺序——也就是排序键的值整体唯一。道理在于:游标的 WHERE 条件是拿锚点做一刀切割,切割点必须落在两条记录"之间"的一个明确缝隙里。如果排序键会重复,那么在那些值相同的记录中间,就没有"缝隙"——你这一刀要么把它们全切到上一页(下一页漏掉它们),要么处理不当就重复。给排序键追加一个唯一的 id 当决胜字段,就是为这些"撞在一起"的记录之间,人为地凿出一条确定的缝隙:created_at 一样,那就比 id,id 是主键、绝不重复,于是任意两条记录之间,顺序都是唯一确定的。这条规则可以推广:你按什么字段排序做游标,就要确保"那个字段 + 一个唯一字段"的组合整体唯一。这是游标分页能不重不漏的地基,不是可选项。主干到这就齐了,最后是几个真正把分页用到生产里才会撞见的工程坑。
五、工程坑:索引配合、总数处理、深度导出
三块设计之外,还有几个工程坑,不处理就会让你的分页要么快不起来、要么在别处出岔子。坑 1:游标分页强依赖一个匹配的复合索引。游标分页快,是因为 WHERE (created_at, id) < (?, ?) 能走索引一步定位。可要是排序键上压根没有对应的索引,数据库还是只能全表扫描再排序——游标分页就退化得和 OFFSET 一样慢。所以排序键上必须建一个顺序匹配的复合索引:
-- 游标分页的地基:排序键上必须有一个顺序匹配的复合索引
CREATE INDEX idx_orders_created_id ON orders (created_at, id);
-- 索引的列顺序,要和 ORDER BY 的列顺序一致。
-- 有了它,WHERE (created_at, id) 小于锚点 才能"一步跳到锚点处";
-- 没有它,游标分页一样会退化成全表扫描 —— 等于白改。
坑 2:别每次翻页都 COUNT(*) 全表。为了显示"共 N 页"而每次翻页都 COUNT 一遍,这个 COUNT 在大表上本身就是慢查询。务实的处理有三种,看场景选:
# 总数:别每次翻页都 COUNT 全表 —— 三种务实的处理,按场景选
# 方案 A:干脆不显示总页数,只给"下一页" —— 信息流、时间线都这么做
# 游标分页本来就只需要 next_cursor,根本不需要总数。
# 方案 B:用估算值 —— 从库表的统计信息里取近似行数,不精确但极快
def approx_count(session, table):
row = session.execute(
"SELECT table_rows FROM information_schema.tables "
"WHERE table_name = :t", {"t": table}).first()
return row[0] if row else 0
# 方案 C:要精确总数,就 COUNT 一次后缓存它,设一个较短的 TTL
def cached_count(session, cache):
n = cache.get("orders:count")
if n is None:
n = session.query(Order).count()
cache.set("orders:count", n, ttl=60) # 60 秒内复用,不再压数据库
return n
坑 3:全表导出要用游标遍历,不能用 OFFSET 循环。把整张表导出,如果用 OFFSET 一页页循环拉,每翻一页 OFFSET 就更大,总耗时是平方级。改用游标循环,每一页都是 O(size),整表导出就是线性的:
def export_all(session, size=1000):
"""全表导出:用游标一页页流式遍历,而不是 OFFSET 越翻越慢。"""
after = (None, None)
while True:
rows = fetch_page(session, after_created_at=after[0],
after_id=after[1], size=size)
if not rows:
break # 取空了,遍历结束
for r in rows:
yield r
last = rows[-1]
after = (last.created_at, last.id) # 锚点前移,下一轮从这里继续
# 每页都是 O(size),整表导出是线性的 —— OFFSET 循环版是平方级。
坑 4:页大小必须设上限。接口的 size 参数是客户端传的,要是有人传个 size=1000000,你就一把把百万行全查出来、全序列化——内存、带宽、数据库都得遭殃。size 一定要夹到一个合理上限:size = max(1, min(传入值, 100))。坑 5:游标里别塞敏感或可变信息。cursor 虽然对客户端"不透明",但它只是 base64,不是加密——任何人都能解开看。所以 cursor 里只放排序锚点,别放用户 ID、权限之类的东西;而且解出来的锚点要当作不可信输入,该做类型校验就做,别因为它来自"自己发出去的 cursor"就盲目信任。坑 6:游标分页天然适合"无限下拉",不适合"跳到第 100 页"。它的模型是"从这一页到下一页",没有"第几页"的概念。如果产品就是要让用户输入页码直接跳转,游标分页满足不了——这种场景要么接受 OFFSET 的代价(并把可跳页数限制在前若干页),要么用别的方案。选型前先看清:你的列表到底是"一直往下刷",还是"要精确跳页"。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| OFFSET 的真相 | OFFSET N 不是跳过,是先读出 N 行再全部丢弃 |
| 代价随页深增长 | OFFSET 分页越往后越慢,代价与页码成正比 |
| 位置坐标会漂移 | 第几页是相对位置,数据增删后会重复或遗漏记录 |
| 游标分页 | 按"排在某锚点之后的下一批"取数,代价与页深无关 |
| 锚点 | 上一页最后一条记录的排序键值,下一页从它之后开始 |
| 不透明 cursor | 把锚点编码成对客户端不透明的字符串,只原样回传 |
| 排序键唯一性 | 排序键须整体唯一,否则游标会在边界漏取或重取 |
| 决胜字段 | 排序键末尾追加唯一 id,保证组合整体唯一 |
| 复合索引 | 排序键上建顺序匹配的索引,游标才能一步定位锚点 |
| 总数处理 | 别每页 COUNT 全表,可不显示总数或估算或缓存 |
避坑清单
- 别用 LIMIT OFFSET 做深分页,OFFSET 越大越慢,代价随页深线性增长。
- 改用游标分页:按"上一页末条之后的下一批"取数,代价与页深无关。
- 游标分页的排序键必须整体唯一,否则边界上会漏取或重复取记录。
- 单一非唯一字段排序时,末尾追加唯一 id 作决胜字段。
- 锚点编码成不透明 cursor,客户端只原样回传,非法 cursor 直接拒绝。
- 排序键上必须建顺序匹配的复合索引,否则游标分页退化为全表扫描。
- 多取一条来判断"还有没有下一页",不必为此再查一次。
- 别每次翻页都 COUNT 全表;可不显示总数,或用估算,或缓存总数。
- 全表导出用游标循环遍历,不要用 OFFSET 循环,后者是平方级耗时。
- 页大小必须设上限,游标里别放敏感信息,解出的锚点要当不可信输入。
总结
回头看那串"深页查询慢、翻页看到重影、导出慢成平方级、COUNT 拖后腿"的问题,以及我后来在分页上接连踩的坑,最该记住的不是某一种 SQL 的写法,而是我动手前那个想当然的判断——"分页,就是跳过前面 N 条、取 size 条"。这句话错在它把 OFFSET 想成了一个免费的"跳过"。我以为数据库能像翻书一样,手指一点就跳到第 N 页。可我忽略了一件事:数据库里的行没有"序号"这个属性,"第几行"是排序时临时数出来的。所以 OFFSET N 不是"跳过 N 行",而是"从头读出 N 行、再原封不动地丢弃"——你为那 N 行根本不要的数据,付了完整的读取代价。翻得越深,被白读白丢的行越多,代价就随页深一路线性膨胀。而"第几页"这个坐标本身又是相对的:前面一插入数据,所有页码就集体错位,于是翻页时你会看见重影、也会漏掉记录。OFFSET 分页那两个绕不开的毛病——越翻越慢、还不稳定——根子都在这里。
所以做好分页,真正的工程量不在"把 OFFSET 算对"那几下上。算 OFFSET,谁都会。真正的工程量,在于你要换掉"用第几页来分页"这个基于位置的模型,改用"取某锚点之后的下一批"这个基于锚点的模型:翻页不再问"第几个",而是带着上一页末条的锚点,问"排在它之后的下一批";这个锚点,你编码成一个对客户端不透明的 cursor;锚点要能不重不漏地切割,你就给排序键追加一个唯一的 id 当决胜字段;游标要快,你就在排序键上建一个顺序匹配的复合索引;总数别拖后腿,你就不显示它、或估算它、或缓存它。这篇文章的几节,其实就是顺着这条线展开的:先想清楚"LIMIT OFFSET 翻页"为什么错,再讲游标分页怎么按锚点取数、游标怎么编码、排序键为什么必须唯一,最后是索引、总数、深度导出这几个把分页守扎实的工程细节。
你会发现,分页这件事,和现实里"在一本几千页的厚书里,接着上次的进度往下读"完全相通。一个不会读书的人会怎么做?他合上书就走,下次想接着读,只记得"我读到第 800 页了"——于是他从第 1 页开始,一页一页地翻,翻过 800 页,才找到上次的位置(这就是 OFFSET:每次都从头数过)。读到第 10 页时,这么翻还不算费劲;可等他读到第 3000 页,每次接着读之前,都要先白翻 3000 页——越往后,每一次"接着读"都越慢。而且,要是这本书是活页的、中途有人往前面插进了几十页,他记的"第 800 页"就对不上原来的内容了——他会重读一段,或者跳过一段(这就是位置坐标的漂移)。而一个会读书的人怎么做?他夹一个书签。书签夹的不是"第几页"这个数字,而是具体某一页的具体内容——下次他直接翻到书签处,往后接着读,无论这是书的第 10 页还是第 3000 页,找到位置的功夫都一样,翻一下就到(这就是游标:用锚点直接定位)。更妙的是,就算前面被插进了多少页,书签还死死夹在那段内容上,纹丝不动——他既不会重读、也不会漏读(这就是游标分页在数据变动下依然稳定的原因)。同样是"接着上次往下读",可不会读书的人被一本厚书活活拖垮,会读书的人翻到哪页都轻轻松松——差别不在"书厚不厚"这件事本身,只在他用的是"第几页"这个会漂移、且要从头数的坐标,还是"书签"这个一步到位、又稳如磐石的锚点。
最后想说,分页做没做对,差距永远不会在"本地开发、测试库里只有几百行"时暴露——本地表小得可怜,你翻到"最后一页"OFFSET 也不过几百,数据库扫几百行眨眼就完,深分页那个"代价随页深膨胀"的毛病,在几百行的盘子里根本称不出重量,你会觉得"跳过 N 条、取 size 条"已经够用。它只在真实的、表里堆着几百万上千万行、用户真的会翻到很深的页、还有定时任务要把整表导出的时候才显形。那时候它会用最难堪的方式给你结账:做不好,你会因为一个深页的 OFFSET 让数据库白扫几十万行而慢成蜗牛,会因为位置坐标漂移让用户在翻页时看见重影、又漏掉记录,还会因为循环 OFFSET 把一个本该线性的导出拖成平方级;而做对了,你的每一页查询不管翻到多深都是同样的 O(size),用户在数据不断增删时翻页也不重不漏,整表导出平稳地线性跑完。所以别等"一个深页查询把数据库拖垮"那一刻找上门,在你写下每一个分页接口的时候就该想清楚:这个分页是按位置还是按锚点、排序键唯一吗、复合索引建了吗、总数会不会每页都 COUNT、页大小有没有上限,这一道道工序,我是不是都替它想过了?这些问题有了答案,你交付的才不只是一个"本地能翻页"的列表接口,而是一套数据涨到千万行、用户翻到第几页都依然又快又稳的可靠分页。
—— 别看了 · 2026