你是不是也遇到过这种情况:列表接口越写越慢,翻到第100页直接超时,于是看了一圈网上的文章,决定上“游标分页”。第一版写出来确实快,where id < last_id order by id desc limit 20,秒回,你心想这回稳了。结果上线没几天,线上就出了幺蛾子——要么翻页漏数据,要么游标解出来是个负数,要么同一个商品在这页出现完下页又冒出来。
游标分页这个方案本身没问题,有问题的是我们对它的理解大多停留在“用一个游标代替offset”这个层面。这篇文章我打算把自己这些年踩过的、帮别人擦过的坑全部摊开讲,包括UUID主键、时间戳重复、排序规则、并发更新这些常见雷区,也会给出一套可以直接落地、能抗住真实业务的游标分页实现。适合正在写列表接口、或者准备技术改造分页方案的后端开发,尤其那些被深分页折磨过的人。
1. 游标分页的本质:它凭什么比OFFSET分页快
1.1 游标分页的核心思想:书签思维
传统分页本质上是在“数行”。你告诉数据库我需要第N页的20条,数据库就得先把前N×20条全部找出来,再扔掉前面的。游标分页的思路完全不同:它不数行,只记录一个“上一页最后一条记录”的位置,下一页直接从那个位置往后找。类比一下就像看书,OFFSET是从第一页重新翻到当前页,游标则是拿书签直接插到上次读到的位置。
这个“书签”就是游标。它可以是一个ID、一个时间戳,更常见的是一个复合结构。因为列表总是有个明确顺序,比如按发布时间倒序、按更新时间倒序,那“当前读到哪了”就总能被一个具体的排序键值描述出来。只要这个值稳定、唯一,下一页就能通过索引直接定位,不需要扫描前面已经读过的记录。
这也是游标分页最高光的时刻:翻页的成本基本恒定。不管用户在第一页还是第一万页,数据库都只需要通过索引快速跳到游标位置,然后顺序向后取N条。复杂度从OFFSET的O(n)降到了O(log n + N),在大表和深翻页场景下完全是质变。
1.2 传统OFFSET分页为什么慢:深翻页的代价
OFFSET分页慢的根本原因,是“数行”这个动作本身。让一个不恰当的类比来说,就是你在图书馆里要找第10000本书,管理员每次都从第一本书开始数,数一万本才把你带到目标位置。数据库的处理方式更具体:OFFSET 100000 LIMIT 20意味着InnoDB需要沿索引扫描到第100000行,然后抛弃它们,只返回第100001到第100020行。扫描的行数随着页码线性增长,这就是“越翻越慢”。
还有一层经常被忽略的代价:数据变动会令人头大。用户在翻页过程中,如果前几页里有人插入或删除了记录,后面所有页的内容都会错位。比如第1页读到了id=1到20,刚翻第2页时id=15被删了,那么原本在第2页第一条的id=21就会“上移”到第1页,用户再翻第2页时按offset 20取到的是id=22到41,id=21被永远跳过了。数据变动越频繁,这个问题越严重。
游标分页恰好绕开了这两点。游标描述的是“值”而不是“数量”,数据增删不会改变已读位置的定义,因为位置是锚定在某一条具体记录上的。可以说,游标分页在“数据是流动的”这个前提下,比OFFSET分页逻辑上更自洽。
| 对比项 | OFFSET分页 | 游标分页 |
|---|---|---|
| 翻页成本 | 越深越慢 | 恒定,取决于索引定位 |
| 数据变动影响 | 插入/删除会串页、漏页 | 已读位置不随增删漂移 |
| 跳页能力 | 支持任意页码跳转 | 只能沿顺序翻 |
| 适合场景 | 后台表格、数据量小 | C端Feed流、无限滚动 |
1.3 理想化实现的“美丽”与“脆弱”
游标分页最常见的入门实现简单得让人上瘾:
SELECT * FROM article WHERE id < :last_id ORDER BY id DESC LIMIT 20;这段SQL用主键天然有序的特性,让“上一页最后一条id”带着条件走主键索引,速度飞快。看起来无懈可击,于是很多人就把所有列表都改成了这个写法。但问题恰恰藏在这个“看起来完美”里。
这段SQL能成立,依赖一个隐含前提:业务顺序刚好等于主键递增顺序,而且业务上根本没有第二个排序维度。现实业务里,很少有这么干净的模型。产品会说“我要按发布时间排序”,但你的主键可能是UUID;产品会说“按综合热度排序”,那排序键根本不是一个索引列;更别提“按更新时间排序”这种可变的排序键,会直接把游标分页的命门击中。第二章我就一个一个拆。
2. 游标分页的4个大坑:每一个都可能变成线上事故
2.1 坑一:UUID主键让排序名存实亡
很多系统喜欢用UUID做主键,理由很简单:全局唯一、不暴露业务量、方便数据合并。但UUID作为游标分页的排序键,几乎是灾难性的选择。
UUID是随机生成的,和插入顺序没有任何关系。如果你用UUID字符串排序,那排出来的是字典序,不是你想要的“新数据在前”。更麻烦的是,InnoDB的聚簇索引按主键物理组织,随机UUID会让B+树频繁页分裂,插入性能下降,索引碎片严重。当用户看到“最新发布”列表里出现的却是一条几个月前的数据,不要惊讶,因为order by uuid就是这样的结果。
我见过一个实际案例:文章表主键是varchar(36)的UUID,列表按主键倒序翻页,产品反馈新发布的文章“随机”出现在各页。查了一会儿才发现,UUID的字典序和写入时间完全脱节,所谓的“按最新翻页”根本是伪命题。
解决方案只有三条路:把主键改成自增ID或雪花ID;或者保留UUID但额外加一个单调递增的sort_id列专门用于分页;再或者把UUID转成binary类型并配合专门的序列字段。游标分页要求排序键必须单调、稳定,UUID本身做不到这点,就不要硬上。
2.2 坑二:排序键值重复,一翻页就漏数据
如果说UUID是模型设计问题,那时间戳重复就是随手就能踩中的高频雷。假设列表按created_at倒序,游标只传一个created_at:
SELECT id, title, created_at FROM article WHERE created_at < :last_time ORDER BY created_at DESC LIMIT 20;初看没毛病。但如果created_at精确到秒,同一秒内写入了超过20条数据,第一页已经把这一秒内的部分记录取走了,第二页的WHERE created_at < :last_time会直接把同秒剩下的记录全部丢掉。用户看到的第二页可能只有几条,甚至直接跳出大量空白。越是数据并发大的业务,这个问题越显著。
正确做法是彻底抛弃“单键游标”,改成“复合游标”:排序字段加主键。ORDER BY created_at DESC, id DESC,同时条件里把两个字段都带上:
SELECT id, title, created_at FROM article WHERE (created_at, id) < (:last_time, :last_id) ORDER BY created_at DESC, id DESC LIMIT 20;(created_at, id)整体参与比较,意思就是“先按时间,时间相同就按ID”,这样即使同一秒内有海量数据,游标也能精确定位到某一条,既不漏也不重。配套索引是(created_at, id)或者(created_at DESC, id DESC),单列索引在这里帮不上忙。
2.3 坑三:排序规则不一致,大小写和NULL都在搞事
游标分页还藏着一类非常隐蔽的问题:排序规则(collation)和NULL值的排序行为。数据库的排序规则和你的“直觉”不一致时,游标条件就会和实际顺序脱节。
先说大小写。MySQL默认的utf8mb4_unicode_ci排序规则是不区分大小写的,也就是说'Apple'和'apple'在比较时是相等的。如果按name排序做游标,游标值取到了'apple',下一批的WHERE name < 'apple'在大小写不敏感规则下会把'Apple'排除掉,但ORDER BY name DESC时'Apple'又可能排在'apple'前面。这种矛盾会让翻页结果和排序结果对不上,页面出现重复或者漏档。
再说NULL。在MySQL里,ASC排序时NULL排在前面,DESC排序时NULL排在最后。但SQL三值逻辑中NULL < 'a'的结果不是TRUE而是UNKNOWN,导致游标条件WHERE name < :last_name永远过滤不掉NULL记录。如果列表里存在排序字段为NULL的行,这些行要么一直排在开头、要么一直沉底,游标翻不到它们,用户就会反复看到同样的数据。
解决方向就两条:字符串排序尽量用二进制规则(utf8mb4_bin),或者干脆不要用字符串做排序键;排序字段设计成NOT NULL,空值统一用''或默认值兜底。记住一条:凡是参与游标比较的列,越“朴素”越好。
2.4 坑四:可变字段和并发写入让游标位置漂移
最后一个坑,是“排序字段本身会变”。最典型的例子是按updated_at排序的列表。假设用户翻完第一页,游标锚定在某条记录上,这时候一条还没被用户读到的老记录被人更新了,updated_at被刷新,它带着新的时间戳冲到了列表最前面。由于游标已经越过它的新位置,用户继续向后翻页时永远不会再看到它,数据就这样无声无息地漏掉了。
更常见的是记录更新后移动到列表前端,用户此时如果“刷新当前页”或者向上翻一页,会再次读到那条已经读过的记录,造成重复。无论是漏还是重,根源都一样:游标本质是对“排序键值”做断点,一旦排序键值可变,断点就失效了。
我对这类问题的处理原则是:游标里的排序键必须是不可变字段。如果业务确实需要按updated_at展示,那就接受“更新会改列表顺序”这个事实,同时可以在产品交互上弱化“跳页”,或者给每条记录增加一个固定的publish_seq列作为排序锚点,用不可变的锚点配合可变的更新时间显示。并发写入的边界抖动永远存在,但不可变排序键能把风险压到最低。
还有一个和并发相关的注意点:千万不要用“每个应用服务器本机时间”生成游标,尤其不要直接用new Date()格式化后塞给数据库。一旦应用服务器和数据库服务器时间不同步,游标区间就可能错位,出现“第一页正常,带游标就查不到数据”。生产里请在数据库侧生成时间,或者至少用同一台NTP时间源。
3. 手写一个抗坑的游标分页:编码、查询与接口设计
3.1 游标编码:别把数据库ID裸扔给前端
第一个反直觉的点:不要把last_id=12345原样放进接口参数里。这样做的坏处首先是暴露了业务量和增长速率,竞争对手可以根据ID增量估算你每天的写入量;其次没有版本概念,将来游标结构升级,老客户端传上来的参数根本没法兼容。
我的习惯是把游标编码成一个不透明的字符串,通常用JSON序列化后做Base64编码。编码的目的不是加密,而是让游标变成“不透明的值”,内部结构可以随时调整。Base64之后URL传参也安全,不会出现特殊字符。
type Cursor struct { Version int `json:"v"` Time time.Time `json:"t"` ID int64 `json:"i"` } // EncodeCursor 生成给前端的游标串 func EncodeCursor(t time.Time, id int64) string { c := Cursor{Version: 1, Time: t, ID: id} b, _ := json.Marshal(c) return base64.RawURLEncoding.EncodeToString(b) } // DecodeCursor 解析前端传回的游标串 func DecodeCursor(s string) (time.Time, int64, error) { b, err := base64.RawURLEncoding.DecodeString(s) if err != nil { return time.Time{}, 0, err } var c Cursor if err := json.Unmarshal(b, &c); err != nil { return time.Time{}, 0, err } if c.Version != 1 { return time.Time{}, 0, errors.New("unsupported cursor version") } return c.Time, c.ID, nil }注意代码里的Version字段,这是我从实际教训里沉淀出来的:游标结构一定会随业务演化,比如从“时间戳+ID”变成“时间戳+ID+租户ID”,有了版本号就能做兼容解析,而不是让老客户端直接挂掉。Base64选RawURLEncoding而不是标准编码,是因为后者会包含+和/,在URL里需要额外转义,Raw版本用-和_,干净得多。
3.2 复合条件查询:两种写法与索引匹配
游标查询的核心就是那个复合条件。以“按创建时间倒序”为例,第一页的SQL是:
SELECT id, title, created_at FROM article ORDER BY created_at DESC, id DESC LIMIT 20;第二页要带着上一页最后一条记录的(created_at, id)往下翻。在MySQL 8.0里我推荐直接写行构造器比较:
SELECT id, title, created_at FROM article WHERE (created_at, id) < ('2024-06-01 12:00:00', 98765) ORDER BY created_at DESC, id DESC LIMIT 20;MySQL对行构造器比较能直接利用复合索引做范围扫描,执行计划里会看到range访问类型,索引命中非常干脆。如果你的数据库版本比较老(5.7等),行构造器的索引优化不稳定,就用等价的OR写法:
WHERE created_at < '2024-06-01 12:00:00' OR (created_at = '2024-06-01 12:00:00' AND id < 98765)OR写法同样能命中(created_at, id)索引,但MySQL可能走索引合并或者用额外的rowid过滤,效率略逊于行构造器写法。我实际的建议是:生产库以EXPLAIN结果为准,两种写法都跑一遍,选执行计划更稳的那个。
配套索引一定要建对:
ALTER TABLE article ADD INDEX idx_created_at_id (created_at, id);注意复合索引的字段顺序:第一列是排序字段created_at,第二列才是主键id。如果你把id放前面,这个索引对(created_at, id)的复合比较几乎无用,因为索引前缀不是查询里最左边的那个等值条件。
3.3 排序键选型:不可变、唯一、可比较
选择一个排序键,我给它定三条铁律:不可变、唯一、可比较。这里的“不可变”指字段写入后永远不会被更新;唯一指任意两行不会在这个字段上取值相同,或者至少在复合维度上唯一;可比较指类型简单、没有NULL、没有英文大小写不敏感之类的坑。
用这三条规则去套,自增ID和雪花ID是最优解。它们单调递增、全局唯一、永远不变。其次是“业务排序字段+主键”的复合游标,比如(created_at, id),此时业务字段负责语义,主键负责稳定性。最差的选择是可更新的字段(如updated_at)、随机字符串(如UUID)、可空的数值字段(如score允许NULL)——这些都有各自的问题,前面几个坑基本都是这么来的。
还有一个容易忽略的点:浮点数不要直接做排序键。float和double存在精度误差,游标传回去再解析,可能得到99.9999999而不是100.0,范围判断就会偏移。如果业务里有“按评分排序”“按价格排序”,用decimal或把值放大成整数再存,比事后修浮点坑舒服得多。
-- 推荐的复合索引结构 CREATE TABLE article_v2 ( id BIGINT PRIMARY KEY, title VARCHAR(255), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, sort_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '不可变的排序锚点', KEY idx_sort_time_id (sort_time, id) ) ENGINE=InnoDB;上面这个表里我故意设计了一个sort_time列。它的值在插入时确定,之后不允许UPDATE。页面展示如果需要显示“更新时间”,那就在展示层取updated_at;但分页的排序逻辑永远走sort_time,这样列表顺序就完全不受数据更新影响。
3.4 接口设计:hasMore、双方向导航与重复防护
服务端接口的返回结构,我习惯这样设计:
{ "items": [...], "next_cursor": "eyJ2IjoxLCJ0IjoiMjAyNC0wNi0wMVQxMjowMDowMFoiLCJpIjo5ODc2NX0", "prev_cursor": "eyJ2IjoxLCJ0Ijoi...", "has_more": true }判断has_more的标准做法是多取一条。用户请求limit=20,SQL里实际LIMIT 21;如果查出了21条,说明还有下一页,返回前20条,用第20条生成next_cursor;如果只有20条以内,has_more就是false。这个技巧把“是否还有”的判断下沉到数据库,成本只有多取一行。
双方向导航需要处理“向上翻”的SQL。假设next方向是“向后翻”(游标变小、顺序倒序),那prev方向就是找游标之后的数据,条件变成反向,顺序反过来:
-- 向上翻一页:条件是大于 WHERE (created_at, id) > (:first_time, :first_id) ORDER BY created_at ASC, id ASC LIMIT 21;注意方向反了之后,ORDER BY也要反,得到的结果是正序还是倒序?这里最容易出错。查询结果在排序上是ASC,也就是旧的在前新的在后,但你要返回给用户的顺序应该是新的在前,所以服务端要把这一页结果整体reverse一下,再生成next_cursor和prev_cursor。每次翻页都重新计算这两个游标值,不要复用旧的,否则会出现“向上翻之后再向下翻又回到当前位置的裂缝”。
重复防护上,我能给的最实用建议是:客户端用游标做幂等,同一个next_cursor请求多次,返回内容必须完全一致。服务端不要接受“页码+游标”混合参数,只认游标。游标要么没有(第一页),要么合法(后续页),不存在“跳页”这种情况。这样设计,客户端天然被逼到“不可跳页”的交互模型上,反而省掉了很多状态同步的烦恼。
4. 排查实录:问题速查表与方案选型建议
4.1 常见问题速查表
这一节我按“症状-原因-解法”的格式整理一份速查表,都是我实际排查过的案例,可以直接当运维手册用。
| 症状 | 常见原因 | 定位方法 | 修复建议 |
|---|---|---|---|
| 下一页突然空 | 游标解析失败或记录被全删 | 解码游标,查日志 | 增加游标版本号和容错解析 |
| 翻页时数据重复出现 | 排序字段可变,记录更新后跳位 | 对比两次接口返回 | 改用不可变排序锚点列 |
| 列表顺序不符合预期 | UUID主键 +ORDER BY id | 对比插入时间和id字典序 | 增加自增或雪花ID排序列 |
| 同秒数据大量丢失 | 游标只包含时间字段 | 检查同一秒内的记录数 | 改成复合游标(created_at, id) |
| 带游标查询慢 | 复合条件没走索引 | EXPLAIN看访问类型 | 建(sort_col, id)复合索引 |
| 游标超长导致URL 413 | Base64带了填充或完整JSON | 看请求URL长度 | 用RawURLEncoding并精简字段 |
| 不同数据库时间不一致 | 应用服务器生成游标 | 比对各节点时间 | 统一NTP或数据库侧时间 |
| 翻半页顺序错乱 | 排序规则大小写敏感不一致 | 对字符串字段做排序实验 | 字符串排序用utf8mb4_bin |
排查时有个小技巧:把游标Base64解码后,对照数据库里的真实记录字段值,用SQL手写一个等价的查询,看能不能查出来。大部分问题都能在10分钟内定位到是“游标生成逻辑的问题”还是“数据库排序规则的问题”。
4.2 什么场景别用游标分页:反向选择清单
游标分页虽好,但它天然牺牲了一个能力:随机跳页。所有需要“直接跳到第6页”的场景,它都无能为力。后台管理系统里那种带页码列表、每页20条、要精确跳到某页查数据的,用OFFSET反而更省事,因为数据量通常不大,深翻页的痛点不存在。
还有两类场景要谨慎。第一类是用户可动态选择排序字段的列表,比如电商商品列表支持按销量、价格、上架时间切换排序。排序字段一变,游标的索引就失效,除非给每个可排序字段都建一套复合索引,成本吃不消。第二类是搜索结果页,结果本身来自搜索引擎或ES,ES有自己的一套search_after机制,和数据库游标分页原理类似但参数完全不同,不要在应用层强行用数据库游标去分页搜索聚合结果。
反过来,哪些场景是游标分页的主场?无限滚动Feed流、聊天记录、消息通知、推荐时间线,这些产品交互上根本没有“跳页”需求,用户只关心“往下滑还有没有”,游标分页几乎是唯一合理的选择。
4.3 扩展思路:从游标分页到keyset扫描
游标分页的底层思想还可以延伸到很多地方。最实用的一个是“大表数据导出”。以前导出全量数据经常用OFFSET循环,越导越慢;改成keyset扫描后,每轮按游标取1000条,处理完再以下一批的最后一条为新的游标,直到查不到为止。这个写法对线上业务的干扰远小于OFFSET,且天然抗数据变动。
还有一个思路是在游标里塞进“额外信息”。例如在游标里带上user_id、region等查询条件字段,服务端解码后直接作为强制过滤条件,可以防止用户在翻页过程中被拉到另一个维度。因为游标字符串是客户端回传的,理论上可以被改,但版本号加Base64至少能让普通用户改不出合法结构,真正敏感的接口应该在游标里加HMAC签名或者用服务端缓存校验。
关于缓存,游标分页很适合做边缘缓存:同一个next_cursor请求的结果在短时间内不会变化(前提是排序键不可变),所以可以在CDN或Redis层缓存“游标串-结果集”对。用户翻页时,即使后端某个节点抖动,缓存也能兜住同一游标的重复请求,这对高并发Feed流场景是实打实的成本优化。
说句大实话,游标分页是我现在做列表接口的首选,但这也是一路踩坑踩出来的。我印象最深的一次事故是线上一个活动页的翻页数据突然少了将近一半,查了两小时发现就是created_at精确到秒,同一秒里导入了1200条,而我们的游标只传了时间字段,一秒一页正好把同秒剩下的600条给丢了。那次之后我定制了一条铁律:凡是新写的分页接口,游标里必须带上主键,而且排序键必须是不可变字段。具体的代码和踩坑记录都写在上面了,拿去用的时候记得先EXPLAIN一下。最后再分享一个小技巧:游标字符串里记得放版本号,别问为什么,等你某天要改游标结构的时候就懂了。