ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

游标分页实战指南:避开深分页陷阱,让列表查询又快又稳

游标分页实战指南:避开深分页陷阱,让列表查询又快又稳 1. 为什么游标分页看着很美用起来却总在崩先说个我自己的真实经历。前两年维护一个订单查询接口日活不算高但单表数据量已经跑到几千万行。早期用的是传统的offset limit分页用户翻到第 50 页之后明显变慢一次请求经常要两秒多。后来我“自信满满”地改成了游标分页以为一套WHERE id lastId ORDER BY id LIMIT 20就能解决问题。结果上线不到一周问题接踵而至有用户反馈数据重复有页面翻不到底还有人投诉列表顺序突然变了。我最初还以为是缓存或并发的问题排查到最后才发现游标分页本身就有几个非常隐蔽的坑而我几乎全踩了一遍。游标分页也叫键集分页、Seek Method思路确实比offset高明不计算偏移量而是基于上一页最后一条记录的某个有序字段比如id继续往后取。数据库只需走索引定位到那条记录然后往后扫 N 条性能几乎是恒定的不受翻页深度影响。这个思想本身没有错但问题在于很多人把“游标分页”简单等同于“用主键分页”并且忽略了对游标字段、排序规则、数据变动状态的约束。现实中的表不会像教科书中那样永远有序、永远不变于是各种“看似完美”的方案就开始翻车。这篇文章不会跟你讲“offset 是垃圾、游标是万能”这种二极管话术而是把我踩过的坑、代码里走过的弯路、以及最终沉淀下来的稳定方案全部摊开。适合正在设计列表接口、数据量大到明显卡顿、或者已经被游标分页坑过的后端开发同学参考。不管你是用 MySQL、PostgreSQL 还是其他关系型数据库核心思路基本通用。2. 游标分页的核心设计远不止“记个 lastId”2.1 你以为的游标和你真正需要的游标很多人实现的第一个游标分页代码长这样-- 第一页 SELECT * FROM orders ORDER BY id LIMIT 20; -- 拿到这页最后一条记录的 id 101 -- 第二页 SELECT * FROM orders WHERE id 101 ORDER BY id LIMIT 20;运行起来没问题数据量一大也能扛住。但这里有几个隐性前提表有一个单调递增且不回退的主键id列表的排序唯一依据就是这个id数据一旦插入后id不会改变没有并发写入导致id跳跃以外的异常。只要业务稍微复杂一点这些前提就开始松动。举个例子你需要按“创建时间倒序”展示订单。如果默认用created_at做游标那么同一秒内可能创建多条订单它们的时间戳完全一样单靠created_at根本无法唯一确定游标位置。这时候要么把游标字段升级为(created_at, id)组合要么干脆改用id反向排序——但后者又和你需要的业务排序不一致。所以真正的游标应该是一组有序字段的元组而不是单个字段。只有在排序条件恰好与主键一致时单字段id才够用。2.2 唯一性才是游标的命门游标分页最关键的一点排序字段必须保证全局唯一。如果排序字段不唯一当你用上一页的最后一条记录的值做下一次查询条件时就会把和该值相同的其他记录全部漏掉。这就像你在排队时只看身高 175cm 的人然后说“下一个从 175cm 之后开始找”但队伍里还有一堆 175cm 的兄弟被跳过了。最稳妥的做法是构造一个“有序元组 主键”的组合排序条件。例如-- 按 created_at 倒序再按 id 倒序作为 tie-breaker SELECT * FROM orders WHERE (created_at ?) OR (created_at ? AND id ?) ORDER BY created_at DESC, id DESC LIMIT 20;这里的?是上一页最后一条记录的(created_at, id)。由于id具有唯一性整个元组(created_at, id)在全局必然是唯一的所以每一行都能被精确锚定不会漏数据也不会在下一页查询时把上一页的尾巴重复取回来。2.3 排序方向不同游标条件就得镜像这是一个非常容易写错的细节。如果你的排序是“升序”那么下一页的游标条件是如果是“降序”下一页条件就是。听起来很简单但业务中往往存在“默认列表升序但允许用户切换倒序”的场景。切换之后很多人只是简单反转游标值却没有反转比较运算符结果要么翻页死循环要么把上一页数据重新返回。我建议把游标封装成一个不透明字符串比如base64(createdAt , id)在接口里解析后根据当前请求的方向动态生成比较条件。这样前端拿到的只是“一串令牌”不感知内部的复杂逻辑也不会有人手滑搞错比较符号。3. 从“能用”到“好用”游标分页的常见坑与实操解法3.1 坑一翻页过程中数据变了永远“差一条”这是游标分页最经典的“灵异事件”。用户在翻页过程中恰好有新的订单插入到列表头部或者某条记录被删除就会出现“翻到下一页时某一条数据被跳过”或“某条数据重复出现”。原因很简单游标分页的状态是“上一页最后一行的位置”但它没有锁定数据视图。新增数据如果排在游标前方下一页会继续从游标往后取新数据不会出现在当前页后续却会出现而删除数据会导致原本应该展示的位置被后面的记录顶上但游标还停留在被删除记录的下一个位置于是就有记录没有被看到。严格说这不能算游标分页的缺陷这是任何“非快照读”方案都存在的问题。传统offset也有同样的问题但由于游标分页通常用于高频翻页场景用户的感知会更明显。解决思路有两个业务上允许“轻度不一致”。比如动态信息流、评论列表用户不会逐条核对你要做的只是避免“严重的重复或遗漏”比如确保同一批数据在同一轮翻页中不会出现两次。给游标加上版本位。在表结构中增加一列类似seqno的全局自增版本号每次记录发生变更插入、删除、修改排序字段时更新。游标携带seqno查询时只定位满足seqno lastSeqno的记录。这样至少能保证删除或更新导致的错位能被感知到。代价是写入逻辑变复杂一般只用于强一致性要求极高的场景。大多数业务场景我推荐坦率地接受“翻页期间数据变动会导致部分数据偏移”但要在前端交互上做一些缓冲比如允许用户点击“上一页”回到之前的位置时自动刷新列表。3.2 坑二排序字段是时间戳精度不够直接翻车我们系统里早期有一个“按活动开始时间倒序”的列表活动创建时间精确到秒。运营频繁在测试环境造数据同一个秒级时间戳下可能有十几条记录。游标如果只带时间字段翻页必然丢数据。这种问题看起来好解决把时间戳精度调到毫秒、甚至微秒就行。但要注意两点精度提高不代表绝对不可能重复除非你用全局唯一主键做最后的 tie-breaker如果把时间字段作为索引的左前缀精度提高索引体积也会变大查询性能需要实测确认。更稳妥的方案是“业务排序字段 主键”组合并且把主键纳入索引中。也就是说如果你要按start_time DESC那索引最好是(start_time, id) DESC或者按业务需要建升序索引查询时反向扫描。3.3 坑三游标字段必须索引否则性能直接回退我非常理解有些人会在查询时同时加WHERE过滤条件和ORDER BY然后只给WHERE里的字段建索引忽略了游标排序字段。这时数据库为了满足排序极大概率会走filesort。数据量一旦上去一次排序可能就要几百毫秒甚至上秒。游标分页的“高性能”名头直接被打回原形。正确的索引策略应该是-- 业务场景status1按 created_at DESC, id DESC -- 索引设计 (status, created_at, id)查询语句要尽量让索引同时覆盖过滤、排序和游标定位SELECT id, title, created_at FROM articles WHERE status 1 AND (created_at ? OR (created_at ? AND id ?)) ORDER BY created_at DESC, id DESC LIMIT 20;这样数据库可以利用复合索引直接定位游标位置并顺序扫描 20 条避免排序。我实测过在百万级数据表上正确索引下接口耗时从 1.2s 降到 20ms 以内效果立竿见影。3.4 坑四用户手动跳页时游标应该怎么处理“上一页/下一页”是游标分页的天然舒适区但很多产品有“直接跳到第 5 页”的需求。严格来说游标分页不太适合任意页跳转因为缺少偏移上下文。这时有两种选择继续用offset实现跳页但限定只能跳少量页数比如 100 页以内记住每页首尾元素的关键信息用“书签”逻辑代替页数跳转。其实某些场景下用户根本不需要“精确跳转到第 N 页”。像 Google 搜索结果也只有前几页可以精确跳转后面都是“下一页”。如果你做的是资讯流、订单历史、消息列表完全可以只提供“下一页 刷新”把“跳页”需求弱化掉。实践证明大多数用户并不在乎你是不是真的第 46 页他们只在乎能否持续往下浏览。4. 实战一个订单列表的游标分页完整改造4.1 原始需求与表结构我这里用一个典型的电商订单列表举例。需求是买家查看自己的订单按下单时间倒序每次加载 10 条。表结构简化后如下CREATE TABLE orders ( id BIGINT PRIMARY KEY, buyer_id BIGINT NOT NULL, status TINYINT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, KEY idx_buyer_created (buyer_id, created_at, id) ) ENGINEInnoDB;注意这里我把索引建成了(buyer_id, created_at, id)目的就是覆盖“按买家过滤 按时间排序 主键去重”的完整访问路径。4.2 游标编码与解析我在接口入参里定义一个游标字符串第一页传入空字符串。游标内容包含created_at和id两个字段使用 Base64 编码避免前端直接篡改或猜测// 游标编码示例Node.js 伪代码 function encodeCursor(row) { const plain ${row.created_at.getTime()}_${row.id}; return Buffer.from(plain).toString(base64url); } function decodeCursor(cursor) { const plain Buffer.from(cursor, base64url).toString(utf8); const [createdAtMs, id] plain.split(_); return { createdAt: new Date(Number(createdAtMs)), id: Number(id) }; }后端拿到游标后动态拼接查询条件SELECT * FROM orders WHERE buyer_id ? AND ( created_at ? OR (created_at ? AND id ?) ) ORDER BY created_at DESC, id DESC LIMIT 10;这一页查出来后取最后一行生成新的游标返回给前端。前端无需关心游标内部逻辑只需要原样把游标字符串传回来即可。4.3 空结果与最后一页的边界处理如果查询结果正好是 10 条但这是最后一页呢你没法从“取出 10 条”这个事件本身判断后面还有没有数据。常见做法是多取一条即LIMIT 10 1如果能取到第 11 条说明还有下一页否则当前页就是最后一页。然后再把前 10 条返回第 11 条丢弃。这个多取一条的策略虽然是老套路但在游标分页里依然有效。要注意的是如果你为了区分是否存在下一页多取了一条那么生成游标时只能用第 10 条而不能用第 11 条否则下一页会把第 11 条当作起点从而漏掉它。4.4 并发写入下虽然“不准”但绝不崩溃线上环境不可避免会有新的订单不断生成。买家翻订单列表时如果正好有新订单下单那列表头部会多出一条而游标定位是“上一页最后一条”新下单的订单只会出现在后续刷新中当前翻页不会有异常。这是游标分页相对于offset分页很大的一个优势即使有插入也不会影响已翻过的页面稳定性。删除订单也是同理。如果中途删除了某条订单用户再次翻页时该位置被后一条记录顶上但游标锚定的位置不变顶上的那一条会在下一页出现。这样用户可能会觉得“有一条没看过的数据出现在下一页”但不会有重复数据的强烈违和感。相比offset在深分页下出现的页码错乱这已经算很温和了。5. 游标字段指数的精度与选择技巧5.1 字符串、UUID、雪花 ID 当游标时怎么办不是所有表都有自增主键。字符串主键、UUID、雪花 ID 都可以作为游标字段但需要满足两个条件全局唯一顺序稳定。字符串主键如果业务上有规则递增也可以使用但实践下来它没有自增整数直观。雪花 ID 本身是趋势递增的理论上可以作为游标。但雪花 ID 的生成顺序依赖机器时钟如果部署多节点节点间时钟回拨会导致 ID 顺序与真实插入顺序不一致。如果你只需要“按主键排序”用雪花 ID 没问题如果列表需要按创建时间倒序那雪花 ID 不能替代创建时间字段必须用(created_at, id)组合。UUID 则要特别注意。传统随机 UUID 毫无顺序性用于游标等于自废武功。如果一定要用 UUID 做主键建议用 UUIDv7 或其他时间有序版本或者单独生成一列专门用于排序。5.2 复合索引的创建细节复合索引的顺序很讲究千万别乱排。比如索引(buyer_id, created_at, id)适用于按买家过滤后再按时间排序。但如果你有“按全局最新订单列表”的需求那过滤条件里没有buyer_id只能把created_at放在索引最左边KEY idx_created_id (created_at, id)判断索引是否生效的最快方法是用EXPLAIN看执行计划中是否出现Using index condition而不是Using filesort。我见过很多项目建了一堆索引但ORDER BY字段没在索引里查询照样慢得离谱。另外MySQL 8.0 支持降序索引。如果你的排序方向固定为DESC可以按(created_at DESC, id DESC)建索引这样执行计划不会再做反向扫描性能略有提升。但要注意降序索引在早期版本没有需要确认你所在 DB 版本。5.3 为什么“记录数不够多”时不用过度优化很多团队看到别人说游标分页好就把小表也改了。但实际上当数据量只有几万行时用offset分页和游标分页的性能差距几乎感知不到。过早引入游标分页不仅增加接口复杂度还会引入上面提到的漏数、跳页等边界问题。我一般建议当单表数据量达到百万级、并且列表翻页深度超过 20 页以上时才值得切换到游标分页。6. 问题排查实录三个真实翻车案例6.1 案例一列表数据“页页重叠”现象接口返回第二页时第一页的最后两条数据又出现了一遍。排查后发现游标字段用的是created_at没有包含id。由于同一毫秒内有多条记录游标定位时created_at ?条件把上一页最后一条所在的整个“时间桶”都包含了所以下一页从该时间点重新取导致重复。修复改成(created_at ?) OR (created_at ? AND id ?)问题立刻消失。6.2 案例二翻到某页后永远加载不出来现象从某一页开始接口一直返回空数组但实际上后面还有数据。排查发现原因是上一页取到的最后一条记录其status字段违反了下一条查询的WHERE条件例如上一页末尾是一条status1的记录但下一页查询条件要求status2。游标已经越过所有status2的记录。这类问题一般发生在过滤条件和游标定位字段“不同维度”时。解决办法要么把过滤字段也纳入复合索引并保持排序条件一致要么把游标定位改为基于过滤后的结果集而不是物理表行。实际情况中我的建议是重新审视业务是否真的需要在一个动态变化的过滤条件下做深分页。如果过滤条件频繁变化游标分页的收益会大幅缩水。6.3 案例三倒序接口和正序接口共用一套游标现象同一个列表提供了“按时间升序”和“按时间倒序”两种排序前端把升序游标直接拿给倒序接口用导致结果乱成一团。修复方式是彻底拆分两套游标编码逻辑在编码时携带排序方向标识解析时拒绝不匹配的游标并给前端返回明确的错误提示。这种做法看似多此一举但是能节省大量联调阶段的问题排查时间。游标字符串不透明化之后前端根本不会手撸条件自然也不会搞混。7. 游标分页有没有更优雅的替代方案我经常被问到能不能用“时间戳快照 窗口函数”或者“keyset 缓存”替代实际上没有银弹只能根据业务权衡。如果你的数据量大到几千亿级别且在分布式环境下单纯的数据库游标分页也可能成为瓶颈。此时通常会用搜索引擎如 Elasticsearch的search_after分页或者消息队列里的分区游标。这些本质上都是“游标”思想的延伸只是载体不同。另外如果你的业务要求用户能实时看到最新插入的数据并且不计较翻页过程中的轻微偏移可以考虑“快照游标”在内存或者 Redis 里维护一个当前页快照翻页时基于快照生成游标。这样能杜绝大部分重复和遗漏但代价是额外存储和分布式一致性成本。我的经验是复杂度要和生产环境实际故障频率成正比如果一个月也遇不到一次数据错乱就不要为理论上的完美付出过高的工程代价。如果让我选一种最“皮实”的通用方案我还是推荐“排序列 主键”的复合游标并且把游标字符串设计成不透明的。它能应对 95% 以上的业务场景也足够简单团队新人看一遍代码就能上手。8. 称手的游标分页代码模板可直接抄最后分享一份我目前在项目中使用的游标分页代码模板核心逻辑都封装在一个函数里。你可以直接拿去改但注意替换成你项目的 ORM 语法。以下是一个 Python SQLAlchemy 风格示例def paginate_orders(session, buyer_id, cursorNone, limit10): if cursor: # 假设游标格式是 base64(created_at_timestamp_id) decoded base64.urlsafe_b64decode(cursor).decode(utf-8) created_at_ms, last_id decoded.split(_) created_at datetime.fromtimestamp(int(created_at_ms) / 1000) last_id int(last_id) else: created_at, last_id None, None query session.query(Order).filter(Order.buyer_id buyer_id) if created_at is not None: query query.filter( or_( Order.created_at created_at, and_(Order.created_at created_at, Order.id last_id), ) ) # 多取一条判断是否还有下一页 records query.order_by( Order.created_at.desc(), Order.id.desc() ).limit(limit 1).all() has_next len(records) limit records records[:limit] if records: last_row records[-1] cursor_plain f{int(last_row.created_at.timestamp() * 1000)}_{last_row.id} next_cursor base64.urlsafe_b64encode(cursor_plain.encode()).decode() else: next_cursor None return records, next_cursor, has_next这份模板的执行逻辑非常简单解析游标拿到上一页最后一条记录的时间和 id拼接游标定位条件按排序规则取limit 1条判断是否多于 limit如果是则生成代表“还有下一页”的has_next用最后一行的字段生成新的游标。你唯一要保证的就是orders表上有(buyer_id, created_at, id)索引。9. 我踩过所有坑之后给你的一份避坑清单游标字段绝不能只用业务时间字段必须带上主键做唯一锚定复合索引必须匹配查询的过滤、排序、定位路径少一个字段都可能走 filesort游标字符串尽量做编码和校验不要暴露内部字段含义给前端翻页期间的数据插入/删除会造成轻微数据偏移这是合理且可以接受的需要在第一页和最后一页都返回“是否还有上一页/下一页”的状态多取一条是最简单可靠的判断方法当排序方向变化时把游标解析和 SQL 构造逻辑分离按方向选择比较符如果业务要求“实时 精确 任意跳页”你会需要一套快照系统这不是游标分页的职责范围。最后再说一句所有分页方案本质上都是“在一致性、实时性、成本之间做取舍”。游标分页不是终点但如果你能把上面这些坑都理顺至少在大多数列表场景里你都能做出一个既快又稳的方案了。
返回列表