ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

游标分页原理与实战:四大坑及抗坑实现方案

2026/9/28 7:03:19 拓冰建站 浏览量
游标分页原理与实战:四大坑及抗坑实现方案 你是不是也遇到过这种情况列表接口越写越慢翻到第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页读到了id1到20刚翻第2页时id15被删了那么原本在第2页第一条的id21就会“上移”到第1页用户再翻第2页时按offset 20取到的是id22到41id21被永远跳过了。数据变动越频繁这个问题越严重。游标分页恰好绕开了这两点。游标描述的是“值”而不是“数量”数据增删不会改变已读位置的定义因为位置是锚定在某一条具体记录上的。可以说游标分页在“数据是流动的”这个前提下比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_atSELECT 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_id12345原样放进接口参数里。这样做的坏处首先是暴露了业务量和增长速率竞争对手可以根据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) ) ENGINEInnoDB;上面这个表里我故意设计了一个sort_time列。它的值在插入时确定之后不允许UPDATE。页面展示如果需要显示“更新时间”那就在展示层取updated_at但分页的排序逻辑永远走sort_time这样列表顺序就完全不受数据更新影响。3.4 接口设计hasMore、双方向导航与重复防护服务端接口的返回结构我习惯这样设计{ items: [...], next_cursor: eyJ2IjoxLCJ0IjoiMjAyNC0wNi0wMVQxMjowMDowMFoiLCJpIjo5ODc2NX0, prev_cursor: eyJ2IjoxLCJ0Ijoi..., has_more: true }判断has_more的标准做法是多取一条。用户请求limit20SQL里实际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 413Base64带了填充或完整JSON看请求URL长度用RawURLEncoding并精简字段不同数据库时间不一致应用服务器生成游标比对各节点时间统一NTP或数据库侧时间翻半页顺序错乱排序规则大小写敏感不一致对字符串字段做排序实验字符串排序用utf8mb4_bin排查时有个小技巧把游标Base64解码后对照数据库里的真实记录字段值用SQL手写一个等价的查询看能不能查出来。大部分问题都能在10分钟内定位到是“游标生成逻辑的问题”还是“数据库排序规则的问题”。4.2 什么场景别用游标分页反向选择清单游标分页虽好但它天然牺牲了一个能力随机跳页。所有需要“直接跳到第6页”的场景它都无能为力。后台管理系统里那种带页码列表、每页20条、要精确跳到某页查数据的用OFFSET反而更省事因为数据量通常不大深翻页的痛点不存在。还有两类场景要谨慎。第一类是用户可动态选择排序字段的列表比如电商商品列表支持按销量、价格、上架时间切换排序。排序字段一变游标的索引就失效除非给每个可排序字段都建一套复合索引成本吃不消。第二类是搜索结果页结果本身来自搜索引擎或ESES有自己的一套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一下。最后再分享一个小技巧游标字符串里记得放版本号别问为什么等你某天要改游标结构的时候就懂了。