☰
游标分页避坑指南:排序字段不唯一与索引设计全解析
2026/10/2 2:58:11 网站建设 项目流程

看到标题我就想起上个月刚处理过的一个线上事故:内容社区的分页接口在发布新版后,运营同学反馈第二页和第一页重复了三条数据,同时还有一批内容怎么翻都翻不出来。查到最后,锅不在接口逻辑,而在那个被团队公认"最稳定"的游标分页方案上。今天就把这类问题的根源、复现过程,以及真正能落地的写法一次讲清楚,希望能帮你少踩几个我已经踩过的坑。

1. "看似完美"的真正边界在哪里:游标分页到底解决了什么问题

先说清楚游标分页为什么会被推到"银弹"的位置。传统的偏移分页写法是LIMIT offset, size,它的性能瓶颈在深翻页:你请求第 100 万条数据,数据库要先扫描并丢弃前面 100 万行,再返回目标行。随着页码增大,IO 成本线性上升,到后期一个简单的列表页能把 CPU 打满。更麻烦的是动态数据下的不一致——第一页和第二页之间如果有人插入了一条新记录,第二页的所有数据整体往后错一位,用户会看到重复或者遗漏。

游标分页的解决思路很直接:不再用"跳过多少行"来定位,而是用"上一批最后一条记录的位置"继续往后查。核心 SQL 长这样:

-- 按主键 id 升序翻页 SELECT * FROM articles WHERE id > :last_id ORDER BY id LIMIT 20;

代码量极小,但效果显著:不管翻到多深,只要索引命中,每次查询的代价基本恒定,能稳定支撑几十万甚至上亿行的深翻页。而且由于定位锚点是"最后一条记录的 ID"而不是"相对偏移量",在翻页间隙插入的数据不会影响后面几页的顺序和连续性。单从这个角度看,它确实比偏移分页更适合瀑布流、无限加载、消息记录这类场景。

但注意,上面这一切成立的前提是:排序键唯一并且稳定。如果你的排序条件不满足这两点,游标分页会以另一种形式把偏移分页的坑全部还给你,甚至坑得更隐蔽。我见过太多人拿着ORDER BY created_at DESC就往线上怼游标分页,直到某天数据开始莫名重复、缺失,才意识到问题的严重性。

2. 头号陷阱:排序字段不唯一,重复和漏数据是怎么同时发生的

这是游标分页所有坑里最常见、也最致命的一个:排序字段本身不唯一,但你只用它做游标。

2.1 现场复现:一次真实的重复与漏数据

假设你的文章表有 6 条数据,按发布时间倒序排列如下:

id标题publish_time
1文章A12:00:05
2文章B12:00:05
3文章C12:00:05
4文章D12:00:04
5文章E12:00:04
6文章F12:00:03

按常见写法,第一页取 4 条:

SELECT * FROM articles ORDER BY publish_time DESC LIMIT 4;

拿到的是 A、B、C、D 四条,其中 D 是 12:00:04 这一秒里的第一条。游标取最后一条记录的publish_time = '12:00:04'。第二页查询如下:

SELECT * FROM articles WHERE publish_time < '12:00:04' ORDER BY publish_time DESC LIMIT 4;

问题出现了:同一秒(12:00:04)里还有一条记录 E,它的 publish_time 等于游标值 12:00:04,publish_time < '12:00:04'直接把它过滤掉了。第二页实际只返回 F 一条,E 永远翻不出来。反过来,如果把条件改成publish_time <= '12:00:04',E 能查到,但 D 会再次出现在第二页,造成重复。单字段游标在排序字段有重复值时,重复和遗漏必然至少发生一个。

2.2 根因分析:单字段游标为什么必然出错

根本原因在于游标必须能唯一标识"上一条记录的精确位置"。publish_time这一类业务字段只能表达"时间位置",表达不了"同一时刻下具体是哪一条"。当LIMIT的截断点正好落在同一时间值的多条记录中间时,单字段游标根本不知道边界切断在哪一行,于是只能用<或<=二选一,无论选哪个都会错。

这就像一个书签只记录了你读到第 220 页,但同一页上有两段内容,你没法精确说出"读到这一段的哪一句话"。要精确定位,书签上必须再写一行信息,比如"第 220 页第二段第三行"。

2.3 解法方向:复合游标

解法是把排序字段和一个唯一字段组合成"复合游标"。上面这个场景,游标应该是(publish_time, id),第二页查询改成:

SELECT * FROM articles WHERE (publish_time < '12:00:04') OR (publish_time = '12:00:04' AND id < 4) -- 4 是第一页最后一条 D 的 id ORDER BY publish_time DESC, id DESC LIMIT 4;

这样 12:00:04 这一秒中 id 小于 4 的记录(即 E)会被精准捞出来,既不会漏,也不会重复。这也是绝大多数游标分页教程最终会给出的标准形态。

这里有个细节要提醒:第二锚点字段必须本身唯一且与排序位置强相关,一般直接用主键 id。如果 id 也是可重复或非单调的,复合游标同样会翻车,这就是下一节的内容。

3. 你以为安全的 ID 游标:分布式ID、字符串排序与时间回拨的连环坑

知道游标不能只有一个业务时间字段之后,很多人会走向另一个极端:排序只用ORDER BY id DESC,游标直接取 id。看起来主键唯一又单调,总该稳了吧?真实工程里,这里还有四五个连环坑等着你。

3.1 分布式ID 不是严格单调的

雪花算法(Snowflake ID)生成的 ID 在单机单毫秒内是有序的,但跨实例时,全局顺序和时间顺序并不是严格一致的。比如实例 A 在 10:00:00:100 生成了一批 ID,实例 B 在同一毫秒内也生成了一批 ID,由于两个实例的序列号起点不同,后面实例生成的 ID 可能比前面实例生成的 ID 小。如果你把"按 ID 排序"理解成"按创建时间排序"来设计业务逻辑,用户可能在翻页时看到"更早的数据出现在列表后面"这种错位感。

更极端的场景是分库分表:每个分片的自增 ID 各自独立,ID 不是全局唯一,更谈不上全局单调。此时直接用单表自增 ID 做游标,分页结果会跨库交错,乱到没法看。

3.2 字符串ID 的字典序问题

还有一种隐蔽场景:主键 是 varchar 类型,里面存的是纯数字字符串。'100'和'99'按字典序排序时,'100' < '99',翻页顺序会和你预期的数值顺序完全相反。业务表如果是从旧系统迁移过来、主键 还保留着字符串格式,分页时这个坑很容易突然爆出来。

3.3 时间回拨与数据迁移

使用带时间戳的 ID 生成器时,如果发生时钟回拨,同一生成器后面产出的 ID 会比前面已经产出的 ID 更小,游标分页的单调性假设被直接打破。数据库主从切换、数据迁移后重新导入 ID,也可能改变 ID 的全局顺序——原来按 ID 递增就是按时间递增,迁移后这条链路就断了。

3.4 排序字段被更新后,游标指向"消失的位置"

比上面三种更常见的情况是排序字段本身在变。比如列表按updated_at排序,游标取(updated_at, id)。第一页返回了某条记录 X,用户还没翻到第二页时,有人更新了 X 的updated_at,把它顶到了列表第一页。等用户发起第二页查询时,X 已经不在游标后面的区间里了,于是这条记录"消失"了;反过来,如果它被顶到用户还没翻到的深页,它又会重复出现。

这不是游标分页的 bug,而是动态排序的固有语义:排序键一变,页面内容的连续性就无法保证。解决方案取决于产品要什么:要么接受"实时优先、可能有重复/飘移"的实时流语义,要么做一个快照游标(先把排序结果固化成快照再逐页取),后者代价高但能保证严格一致。

4. 复合游标的正确写法和索引设计:从SQL细节到EXPLAIN验证

前面说了复合游标是正解,但很多人写了复合游标之后性能反而更差了,因为 SQL 写法、排序方向、索引设计三件事没有对齐。这一节把最容易出问题的细节一次性讲透。

4.1 两种等价 SQL 写法

要表示(created_at, id)这个复合游标的位置,有两种常见写法。

写法 A,展开式,兼容所有数据库,逻辑直观:

-- 倒序向下翻页(下一页是更旧的数据) WHERE (created_at < :last_created_at) OR (created_at = :last_created_at AND id < :last_id) ORDER BY created_at DESC, id DESC LIMIT 20;

写法 B,元组比较式,SQLite、PostgreSQL、MySQL 8.0 都支持:

WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 20;

元组写法简洁很多,但要注意它在 MySQL 里对优化器不一定友好。我曾遇到过 MySQL 5.7 上写(created_at, id) < (...)时优化器没有正确走联合索引,而是退化成全表扫描的案例。如果你在用 MySQL 且版本较老,优先用写法 A,并对两条 SQL 分别跑一遍 EXPLAIN,确认执行计划都在走索引。这个习惯我后面还会强调,它救过我很多次。

4.2 排序方向与比较符号的对应关系

这里非常容易搞混。规则一句话:游标比较的方向必须和排序方向相反。倒序翻页时,游标值越来越小,所以要往"更小"的方向比较;正序翻页时,游标值越来越大,所以要往"更大"的方向比较。对应关系如下:

页面排序比较条件实际含义
ORDER BY created_at DESC, id DESC(created_at, id) < (:t, :id)取比当前游标更旧的记录
ORDER BY created_at ASC, id ASC(created_at, id) > (:t, :id)取比当前游标更新的记录

如果把倒序列表的游标条件写成>,你会拿到刚翻过的数据,而且还会无限循环——第一页的游标区间永远包含旧数据。这个 bug 在单元测试里容易测出来,但如果你只是手工点了两页看到"能翻页"就上生产,很容易漏掉。

4.3 索引设计:为什么不能只建单列索引

ORDER BY created_at DESC, id DESC加上WHERE (created_at, id) < (:t, :id)这个条件,对索引的要求其实很高。理想索引是(created_at, id)联合索引,索引键顺序与排序顺序一致,这样数据库可以直接按索引正序或倒序扫描,每页查询只读取目标区间那一小段数据,代价极低。

但很多人会犯一个错误:只给created_at建单列索引。理由是"反正 InnoDB 的二级索引叶子节点自带主键 id"。问题在于 MySQL 优化器不会自动把二级索引里隐含的主键当作可参与范围扫描的普通索引列。WHERE created_at = :t AND id < :last_id的id过滤条件在单列索引下只能回表后再判断,性能会退化成一个一个主键 回表再过滤的过程。

正确做法是显式创建联合索引:

ALTER TABLE articles ADD INDEX idx_created_at_id (created_at, id);

如果你是 MySQL 8.0+ 或者 PostgreSQL,还可以直接创建方向匹配的索引,让排序彻底不走 filesort:

-- MySQL 8.0+ CREATE INDEX idx_created_at_id_desc ON articles (created_at DESC, id DESC); -- PostgreSQL CREATE INDEX idx_created_at_id_desc ON articles (created_at DESC, id DESC);

MySQL 5.7 则不需要建方向相反的索引,普通正向索引配合倒序扫描也能满足created_at DESC, id DESC的排序需求,关键点是两个排序字段方向必须一致;如果出现DESC, ASC这种混合方向,旧版本就无能为力了,只能 filesort。

4.4 EXPLAIN 验证的三个检查点

写完 SQL 和索引后,任何基于经验的判断都不如 EXPLAIN 可靠。你只需要看三个关键位置:

  • key:是否命中联合索引,而不是 NULL。
  • Extra:有没有Using filesort,有就说明排序方向和索引顺序不一致,需要调整索引。
  • rows:估算扫描行数是否接近LIMIT值,如果接近全表行数,说明条件没走下索引。

我之前在一个订单列表上就亲眼见过:复合游标 SQL 写得完全正确,索引也建了,但因为 OR 条件的写法问题,MySQL 优化器始终不选联合索引,而是走了主键扫描 + filesort,接口 QPS 一高就雪崩。最后把 SQL 拆成 UNION 或者调整 OR 顺序才解决。所以别嫌麻烦,上线前把 EXPLAIN 结果截图留档,后面排查性能问题会省很多时间。

5. JOIN、实时数据与动态排序:游标位置不能拿"最后一条"硬推

游标分页在单表简单排序下表现很好,一碰到 JOIN、GROUP BY、实时变化的数据集,很多人的第一反应还是"取最后一条记录的 ID 往下推",这里面的坑特别密集。

5.1 JOIN 查询中锚点必须和排序字段同表

看一个典型场景:SELECT ... FROM users u JOIN orders o ON o.user_id = u.id ORDER BY u.score DESC。游标如果取(o.created_at, o.id),而排序字段是u.score,锚点和排序键不在同一张表上。当一个用户的score变化时,它的所有订单行都会整体移动,但你手里的游标还停留在旧位置,翻页结果会错乱到无法解释。

所以 JOIN 场景的第一原则:游标锚点所属的表,必须就是排序键所属的表。上面的查询如果想做游标分页,应该先确定你到底要按用户排序还是按订单排序。如果按用户排序,锚点应该取(u.score, u.id),查询时必须用某种方式把用户维度的唯一位置传下去,而不是拿"结果集最后一行"的订单 ID 硬充游标。

5.2 GROUP BY 和聚合查询:单行锚点失效

当查询带GROUP BY或DISTINCT时,结果集已经不是底层表的行集合了。比如GROUP BY user_id ORDER BY COUNT(*) DESC,聚合结果里没有唯一行标识,你是没法用一个底层表的 ID 去定位"下一个分组"的。这种情况下继续硬做游标分页,通常会出现漏数据或者死循环——你以为取到了最后一行,但下一批聚合结果可能包含它,又可能跳过它。

对于聚合结果集,我更推荐换个思路:能预计算就先物化聚合结果,在物化表上做游标分页;结果集不大就老老实实用偏移分页,几千行的聚合结果分页成本并不高。非要在超大聚合结果集上实时分页,那基本是无解的,工程上没人会这么干。

5.3 实时数据流:返回行数不足不代表结束了

无限滚动手游标分页还有一个常见误判:这一页返回了 15 条,小于LIMIT 20,就直接把next_cursor置空告诉前端"没有更多了"。这在静态数据集上没问题,但在实时数据流里,数据随时在增长,返回 15 条只是"当前时刻没有更多",下一秒可能又多了 20 条。

我的做法是:只要这一页返回了数据,就把next_cursor正常传给前端,由前端决定是继续拉取还是显示"没有更多"提示。如果返回 0 条,才真正表示游标位置的查询结果为空。是否结束的判定要交给业务,不能由后端基于"是否填满一页"替用户做主。

5.4 筛选条件变化时,游标必须重置

还有一个高频会踩的坑:用户翻到第 5 页时,突然把筛选条件从"全部"改成了"只看热度大于 100 的",前端还带着旧游标去请求。结果是什么?要么查出一堆不符合新筛选条件的数据,要么直接空页。原因是游标是基于旧筛选条件下的位置计算的,新条件完全失效。

严谨的做法是:筛选条件变更时前端清空游标,从第一页重新拉取。这个约定要在接口文档里写清楚,最好在数据结构上用请求参数版本号或筛选条件哈希来做兜底,后端发现游标对应的查询条件哈希不一致时,直接返回参数错误,让客户端重置。

6. 游标 token 的权限、过期与双向翻页:容易被忽略的工程暗礁

技术选型和 SQL 都做对之后,游标分页还有一些工程层面非常容易翻车的地方,很多人直到被安全测试或用户投诉打爆才发现。

6.1 Base64 只是编码,不是加密

最常见的安全误区:把(created_at, id)拼成 JSON 再 Base64 编码,就当作"不透明 token"发给前端。用户随便找个在线 Base64 解码工具就能看到明文,还能手动改字段。如果游标跟用户身份有关,比如游标里带了user_id,攻击者把它改掉再请求,就可能读到别人的数据。

两步解法任选其一:

  • 服务端缓存映射:游标就一个随机字符串,后端存cursor_id -> (created_at, id, user_id, expire_at),彻底不泄露原始数据。
  • 自包含 + 签名:游标内容包含签名值payload = (created_at, id, user_id, expire_at),再加上HMAC(payload, server_secret),后端先验签再使用。签名密钥放服务端,用户改一个字节都校验失败。

我个人的倾向是:数据比较敏感的场景用服务端缓存,简单列表用 HMAC 自包含就够了。需要注意,无论哪种方案,游标都只负责定位,不负责授权,查询条件里必须仍然带上用户的数据权限范围(比如WHERE user_id = :current_user_id),否则改游标照样能横向越权。

6.2 游标过期与数据失效

带缓存的游标有过期时间,通常 10 分钟到 1 小时,过期后要么让用户重新拉第一页,要么设计续期机制。自包含游标本身不过期,但如果排序字段的值变化了,或指向的记录被删除,查询时只需要正常按条件过滤即可——游标只是位置,不保证记录仍存在,这一点是游标分页相对偏移分页更健壮的地方。

6.3 双向翻页:prev cursor 的实现复杂度

很多管理后台不只要求"加载更多",还要求能点"上一页"。游标分页做next很容易,但做prev就要小心:需要额外保存当前页第一条记录的游标,然后用相反方向排序去查"当前页之前的记录集",再把结果反转顺序返回。

举例,列表排序是created_at DESC, id DESC时,要查当前页的前一批记录,条件是:

WHERE (created_at, id) > (:first_created_at, :first_id) ORDER BY created_at ASC, id ASC LIMIT 20;

拿到结果后反转成倒序返回。这个逻辑本身不难,但加上筛选条件、授权校验之后,错误率会直线上升。我的建议是:面向 C 端的列表优先做 next-only(加载更多),管理端确实需要页码翻页时,就老实评估一下是否应该退回基于排序键快照的偏移分页,而不是硬撑着在游标上实现完整的双向导航。双向导航和"新数据不断插入"组合起来,逻辑复杂度会失控。

7. 可直接抄作业的模板与自检清单:最后再聊什么时候别用

前面坑讲了这么多,最后给一套我在项目里实际用了很久的通用模板,外加一份每次写游标分页都会过一遍的自检清单。

7.1 一个可落地的通用模板

以 Python/MySQL 为例,列表按created_at DESC, id DESC排序:

def list_items(db, cursor=None, page_size=20): filters = [] params = [] if cursor: last_created_at, last_id = decode_and_verify_cursor(cursor) # 倒序向下翻页:取比当前游标更旧的记录 filters.append("(created_at < %s OR (created_at = %s AND id < %s))") params.extend([last_created_at, last_created_at, last_id]) sql = f""" SELECT id, title, created_at FROM articles WHERE 1=1 {('AND ' + filters[0]) if filters else ''} ORDER BY created_at DESC, id DESC LIMIT {page_size + 1} """ rows = db.query(sql, params) has_more = len(rows) > page_size rows = rows[:page_size] next_cursor = None if has_more: last = rows[-1] # 这里必须对游标做签名或改为服务端缓存,不要裸编码 next_cursor = encode_signed_cursor(last['created_at'], last['id']) return rows, next_cursor

这个模板有三个关键点:多查一条判断has_more、用page_size + 1而不是精确LIMIT page_size、游标必须签名。多查一条是游标分页判断是否还有下一页的通用做法,能避免"刚好填满一页时误判没有更多"的问题。

7.2 上线前过一遍的自检清单

检查项怎么查
排序字段是否唯一?若否,是否已经用 (排序字段, id) 组成复合游标
排序字段是否会高频更新?更新时页面语义是否接受"飘移";不接受则考虑快照方案
索引是否完整匹配 WHERE + ORDER BY?EXPLAIN 看 key、rows、Extra 是否有 Using filesort
游标是否会被用户篡改?Base64 裸编码等于裸奔,必须签名或服务端缓存
筛选条件变化后游标是否重置?前端清空游标或后端做条件哈希校验
JOIN/GROUP BY 后锚点是否和排序表一致?不一致则不能直接用底层表 ID 硬推
双向翻页是否必要?前端只做"加载更多"就尽量别做 prev 游标

7.3 什么时候别用游标分页

最后必须泼一盆冷水:游标分页不是哪里都好用。下面这些场景我建议你直接放弃游标:

  • 需要跳页:管理后台"跳转到第 58 页",游标分页没有页码概念,做不了。
  • 必须要总数:游标分页本身不返回总数,要算 count 就得额外扫整个结果集,深翻页的性能优势被 count 吃掉一大半。
  • 排序依据高频变化:比如按"热度权重"实时排序,每次查询排序都不同,游标锚点的位置转瞬即逝,分页结果必然跳变。
  • 数据量小:几千行的列表用偏移分页加主键排序毫无压力,引入游标只会增加前后端联调复杂度。

我在实际项目里的判断标准就一句话:排序键是否天然唯一且稳定,是否需要深翻页和实时增量。两个都是"是"就上游标分页;否则就该选偏移分页或混合方案,别为了炫技给自己挖坑。

拿我自己来说,现在每做一个列表接口,都会先写一套数据随机插入和删除的自动化测试,专门校验跨页数据不重不漏。这套测试在开发期就能把排序键设计的漏洞暴露出来,远比上线后被运营通知"数据翻不出来"再手工查表来得舒服。希望这篇复盘能让你在代码评审时及时挡住那些"看似完美"的游标方案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询