☰
分页查询稳定性陷阱:Keyset游标分页的根治方案
2026/10/7 11:09:01 网站建设 项目流程

分页查询,几乎所有后端开发都写过,LIMIT 10 OFFSET 200这种 SQL 闭着眼都能敲出来。但真正在生产环境扛过几年流量的人,看到分页这两个字心里都会多一根弦——它远没有表面看起来那么人畜无害。我接手过一个交易系统的订单列表页,平时 P99 也就 200ms,某天晚上大促压测,页码翻到 50 页以后,接口直接飙到 30 秒超时,数据库 CPU 被打满,连带其他核心服务一起抖。后来排查原因,就是最普通的ORDER BY create_time DESC LIMIT 20 OFFSET 1000。从那以后我养成了一个习惯:只要代码评审里出现分页查询,我一定追着问一句——你这个分页,稳吗?

这里说的"稳定性",很多时候比单纯"性能慢"更麻烦。性能问题至少表现明显,慢就是慢,你能看见;稳定性问题则是间歇性的、上下文相关的,明明昨天还正常,今天同样的参数就翻车了,而且翻车的姿势还千奇百怪。分页查询的稳定性陷阱,本质上是把"性能退化"、"数据一致性"、"环境依赖"三件事搅在了一起。这篇文章我不打算只讲 SQL 优化,而是要站在一个完整系统的视角,把分页查询从性能、数据、架构三个层面拆开看,然后给你一套能落地、能根治的组合方案。适合正在做业务后端、被接口性能和数据错乱问题折腾过的同学参考,也适合准备做系统重构的团队拿来当检查清单。

1. 内容整体设计与思路拆解

1.1 表面正确的分页 SQL,其实藏着线性退化的性能陷阱

先聊聊最基础的场景。大多数业务系统里的分页查询,写出来就是SELECT * FROM t_order ORDER BY create_time DESC LIMIT 20 OFFSET 1000。在小数据量、低并发的情况下,这条 SQL 跑起来毫无压力,因为 MySQL 的优化器可能直接走了覆盖索引或者内存排序,数据量小到可以忽略不计。但一旦表数据量超过百万,页码再往后翻,问题就出来了。

这里要理解一个关键点:OFFSET 1000的含义不是"从第 1001 行开始读",而是"先从表里把前 1000 行数据都捞出来,然后全部丢掉,再往后取 20 行"。你可以把它想象成在一本厚厚的书里找第 100 页的内容,图书管理员不是直接翻到第 100 页,而是从第 1 页开始,一页一页地翻过去,数到第 100 页才停下来。前面的 99 页他全都翻过了,但一页都不会给你看。页码越深,白白翻过的页数越多,耗时就越长——这就是所谓的线性退化。

有人会问,那给create_time加上索引不就行了吗?这是个很普遍的误解。索引确实能帮助 MySQL 快速定位到第一条create_time DESC的记录,但OFFSET的处理机制决定了,数据库依然需要从索引的第一个符合条件的叶子节点开始,沿着链表向后遍历 1000 次,每次遍历都伴随一次主键回表,才能拿到你要的那 20 行数据。换句话说,索引解决了"排序"的效率,但没有解决"跳过"的效率。深度分页的时候,回表的次数是OFFSET + LIMIT,而不是LIMIT,这就导致性能随着页码增大而稳定地恶化。

1.2 真正的稳定性陷阱,藏在性能之外的三层问题里

如果仅仅只是慢,那工程师早就想办法优化了。分页查询真正让人头疼的,是稳定性问题通常以三种面目出现,而且经常一起发作。

第一层是数据漂移问题。你在第 1 页看到了一条记录,等翻到第 3 页的时候,这条记录又出现了一次;或者反过来,第 2 页上有一条记录,翻到第 3 页就找不到了。原因很简单:在两次查询的间隙,有新的数据插入,或者有旧的数据被删除、修改了排序字段的值。举个例子,一个按create_time DESC排序的商品列表,用户在第 1 页看到第 20 条商品后,后台突然又上架了 3 个新商品。等用户翻到第 2 页时,因为排序位置被新商品挤占,原本在第 21 到 40 条的商品会整体后移,导致第 2 页重新出现了第 1 页已经看过的内容。这不是 SQL 写错了,而是数据集合在两次查询之间发生了变化,而分页查询默认假设数据集合是静态的。

第二层是环境一致性陷阱。现在绝大多数中大型业务系统都是读写分离的架构,主库负责写入,从库负责查询。为了减轻主库压力,列表页的分页查询通常会打到从库上。问题在于,主从复制是有延迟的,哪怕正常情况下延迟只有几十毫秒。用户在前端刚提交了一个订单,前端立刻跳转到订单列表第 1 页,结果发现刚下的单不见了——因为查询请求走的是从库,而那条新记录还没来得及从主库同步过来。这种"写入后立即查询"的场景,在分页接口上表现得尤其明显,而且难以通过加索引来解决。

第三层是索引与写入模式的稳定性问题。你以为索引建好了就万事大吉,但建索引的字段和主键的设计方式,本身就会影响查询的稳定性。比如用 UUID 作为主键,这是一种随机散列的值,InnoDB 在插入新记录时,索引页需要频繁地做页分裂和合并,产生大量碎片。后果就是,同一张表的查询,今天走索引耗时 50ms,明天同样的查询却要 200ms,性能抖动剧烈。这种不稳定是"藏在底层"的,你不用EXPLAIN仔细看,根本发现不了问题出在主键设计上。

1.3 根治思路:把"翻页思维"切换成"摘取思维"

理解了上面三层陷阱,根治方案也就自然浮出水面了。核心思路是从"翻页"切换到"摘取"。

传统的OFFSET分页,本质上是一种"我要第 N 页的东西,你帮我跳到那一页"的思维。为了实现"跳"这个动作,数据库必须消耗大量资源去数前面所有的行——这就是所有麻烦的根源。

而另一种分页思路——Keyset 分页(游标分页 / Seek Method)——则完全不同。它不关心"页"的概念,只认"上一次看到的那条记录的位置"。SQL 会写成WHERE create_time < '2024-05-01 10:00:00' ORDER BY create_time DESC LIMIT 20,意思是"给我拿排在最后一条已展示记录后面那 20 条"。数据库可以直接通过索引精确定位到create_time = '2024-05-01 10:00:00'的位置,然后从那里开始顺序往下取 20 条。整个过程中,需要扫描的数据量和翻页深度完全无关,永远只和"本次要取多少条"相关。这就是它性能稳定的核心原因。

从项目整体设计的角度来说,我建议把所有分页需求先做一次分类:如果是给用户后端管理用的、数据量可控的表格,可以继续用OFFSET;但凡是 C 端用户直接访问、数据量可能快速增长、对响应时间和数据一致性有要求的列表页,都必须走 Keyset。这次重构的思路,就是基于这个分类原则展开的。

2. 核心细节解析与实操要点

2.1 Keyset 分页的正确姿势:单字段排序要小心,多字段排序必须有锚点

Keyset 分页的原理说起来简单,但真正落地写 SQL 的时候,坑比想象中多。第一版改造的时候我们只用了最简单的写法WHERE id < ? ORDER BY id DESC LIMIT 20,这对于单字段自增主键排序的场景是完全够用的,但现实业务里,几乎没有哪个列表是按主键排序的,大家习惯按create_time或者update_time这样的业务时间字段排。

这就引出了 Keyset 分页最经典的陷阱:排序字段重复。假设订单表里有 100 条记录,它们的create_time都精确到秒,其中第 40 到第 60 条恰好是同一秒内创建的。用户在翻第 2 页的时候,最后一条记录的create_time是2024-05-01 10:05:00,于是第 3 页的 SQL 就写成WHERE create_time < '2024-05-01 10:05:00'。结果呢?这 20 条同一秒创建的记录里,只要查询条件稍微偏一点点,就会发生两种情况:要么一条不漏地全部带上,要么全部丢掉。因为在数据库看来,create_time < '2024-05-01 10:05:00'根本不包含10:05:00这一秒的数据,而上一页最后展示的那条数据,恰好就是10:05:00,于是这一秒的数据整体丢失了。

正确的做法,是引入一个唯一且稳定的锚点字段作为次级排序条件。MySQL 里最方便的锚点就是自增主键id。完整的多字段 Keyset 分页写法是:

-- 按 create_time 倒序,id 倒序作为 tie-breaker SELECT * FROM t_order WHERE (create_time < #{lastCreateTime}) OR (create_time = #{lastCreateTime} AND id < #{lastId}) ORDER BY create_time DESC, id DESC LIMIT 20;

这样写,MySQL 可以完美命中(create_time, id)这个联合索引。排序字段create_time和锚点字段id组合成了一个序列化的、严格递增的游标,任何一行数据的排序位置都是唯一的,不会因为字段重复而出现"边界数据被漏掉或重复读取"的问题。

这里要特别强调一个实操细节:锚点字段必须与排序字段一起建联合索引,而且顺序不能反。你自己类比一下,你要在一排书架里按"出版日期+书编号"找书,索引就是那个目录,目录必须先按出版日期排好,再按编号排,你才能快速翻到那一页。如果你只建了单列索引create_time,条件里的id < #{lastId}就只能在索引过滤后做二次筛选,性能会明显下降,数据的稳定性没问题,但查询速度会退化成扫表。

2.2 数据漂移的根源:从 MVCC 快照到"翻页会话"的隔离取舍

聊完性能和 SQL 写法,再回头解决数据漂移问题。前面提到,数据漂移的根源是两次查询之间数据集发生了变化。要根治,有两个思路。

第一个思路是利用 InnoDB 的 MVCC 快照读。在默认的REPEATABLE READ隔离级别下,一个事务内第一次执行普通SELECT时,InnoDB 会生成一个一致性读视图(ReadView),之后这个事务里所有普通查询都基于这个视图读到一致的数据快照。换句话说,如果我把"从第 1 页翻到第 10 页"这个过程放进同一个数据库事务里,那么无论中途有多少新人下单、旧记录被删,我看到的始终是同一个历史快照,数据完全一致,不会漂移。

但这里有个很现实的问题:一个 HTTP 请求通常只对应一个短事务,用户翻页的操作是多个独立的 HTTP 请求,跨事务的 MVCC 快照是保护不了你的。把整个翻页会话包进一个长事务,又会导致数据库连接被长期占用,事务隔离性的设计初衷也不是干这个的。所以我的取舍是:能用 MVCC 快照解决的,就用;解决不了的,就接受并做好兜底。比如对于一些数据集合变化不频繁的后台报表,可以直接在分页接口里开启一个只读事务,保证一次翻页内的数据一致性。

第二个思路是在应用层做一个游标锚点+增量补全机制。在 Keyset 分页的基础上,当发现下一页返回的记录里,出现了与上一页完全重复的 ID 集合时,可以主动触发一次"按 ID 范围重新拉取"的逻辑,用主键把重复的记录补齐。这种做法不能百分之百消除漂移,但可以把错误率降低到业务可接受的范围。说到底,数据漂移问题有一个经济学上的本质:完全没有数据漂移的系统,要么是静态数据,要么性能极差。你需要结合业务场景决定容忍度,然后选择对应的方案。

2.3 主键设计对稳定性的隐性影响:UUID 的代价你未必付得起

前面提到的第三层稳定性隐患——主键设计,这里单独展开说。很多团队在数据库设计阶段习惯用 UUID 或者雪花 ID 作为主键,理由很充分:分布式生成、不用依赖自增、全局唯一。这个选择在分布式场景下没错,但如果你在这个主键上建索引,同时表又有大量写入,那么查询稳定性就会受到极大的挑战。

原因在于 InnoDB 的索引结构是 B+Tree,聚簇索引(也就是表数据本身)按照主键值的顺序物理组织数据。自增主键插入数据时,新行总是追加到树的右边缘,操作非常顺畅,不需要移动已有数据。但 UUID 主键是随机值,新行会被插入到索引树的中间位置,为了给新值腾地方,InnoDB 不得不频繁地做页分裂:把当前页一半的数据搬到新页,然后重新调整指针。这个过程会产生大量索引碎片,并带来随机的磁盘 IO 抖动。结果就是,即使你的分页查询写得再完美,表结构本身的物理组织不稳定,性能数据也会忽高忽低。

如果你已经用了 UUID 主键,分页查询要做的补偿至少有两个:一是所有业务排序字段都不要依赖主键的物理位置,尽量用显式的业务字段排序;二是定期做索引碎片整理,ALTER TABLE ... ENGINE=InnoDB或OPTIMIZE TABLE,在低峰期重建表数据。如果你的项目还在设计阶段,我的建议很直接:单机用自增主键,分布式用带时间序的雪花 ID 变体(比如按位分配时间的自定义方案),尽量不要用纯随机 UUID 作为聚簇索引主键。这条建议值得放进你们的数据库设计规范里。

2.4 大表 COUNT(*) 的稳定性策略:别让总数统计拖垮列表页

分页查询还有最后一个"暗雷"——总条数统计。前端要显示"共 10 万条记录,共 5000 页",就必须执行SELECT COUNT(*) FROM t_order WHERE create_time > ...。在 InnoDB 引擎下,COUNT(*)没有快速跳过的手段,它必须把满足条件的索引项从头到尾扫一遍并计数。当表数据量超过千万级、命中条件的数据有几十万行的时候,这个COUNT(*)扫索引的耗时可能比列表本身还长。

最直接的替代方案,是不统计总数,只判断有没有下一页。方法很简单:每次查询多取一条数据,即LIMIT 21,如果返回了 21 条,就说明还有下一页,然后只展示前 20 条。这个方案配合 Keyset 分页非常丝滑,因为在数据量大的场景下,"有没有下一页"才是用户真正关心的事,精确到个位数的"共多少条"对 C 端体验毫无意义。

如果业务方死活要显示精确总数,那就只能用计数表或者缓存了。我踩过的一个实践是在 Redis 里维护一个计数器,每次插入或删除订单时更新,但要注意两点:一是计数和实际数据的一致性必须通过事务消息或者对账任务保障,因为 Redis 一旦过期或者宕机就丢数据;二是最后展示给用户时,数字和实际列表可能误差几条,产品上要能接受这种"近似值"。稳定的分页系统,从来都是把成本花在刀刃上,而不是花在"看得舒服"上。

3. 实操过程与核心环节实现

3.1 一套可落地的 Keyset 分页接口改造方案

直接上活儿。以一个典型的电商订单列表为例,接口原本长这样:

请求:GET /api/orders?page=1&pageSize=20 返回:{ "list": [...], "total": 12345, "page": 1 }

改造后的接口参数设计变成了这样:

请求:GET /api/orders?cursor=2024-05-01T10:05:00,102345&pageSize=20 返回:{ "list": [...], "nextCursor": "2024-05-01T09:59:00,102321", "hasMore": true }

这里cursor不是一个简单的页码,而是把排序字段的值和主键 ID 打包成了一个不透明字符串,通常是base64(create_time + ',' + id)的形式。每次请求只带上一页最后一条记录的游标,服务端解析游标后生成对应的 SQL。完整的改造步骤我拆成了六步,照着走不会踩大坑:

  1. 确认排序字段与锚点字段。排序字段用业务字段(如create_time),锚点字段用主键id,并确保两者按顺序建立了联合索引(create_time, id)。
  2. 设计接口的游标参数格式。建议用base64({lastCreateTime},{lastId})作为隐藏参数,前端不用理解含义,服务端负责编解码。这样还能防止用户手改参数,因为游标里带了校验信息。
  3. 改查询 SQL。把原来的OFFSET条件换成WHERE (create_time < ? OR (create_time = ? AND id < ?)) ORDER BY create_time DESC, id DESC LIMIT ? + 1。
  4. 改返回体。不再返回page和total,返回nextCursor和hasMore,其中hasMore由 LIMIT+1 判断。
  5. 兼容首屏请求。首屏没有游标,走全量排序取前 N 条,逻辑上等价于cursor为空。
  6. 改造前端组件。把"上一页/下一页 + 页码跳转"换成"首页/上一页/下一页",去掉页总数显示,或者显示"已加载 N 条"。

这里用一个完整的 MyBatis 代码片段来说明 SQL 层的写法。注意 Mapper XML 里游标条件要动态拼接:

<select id="selectOrderPage" resultType="Order"> SELECT id, order_no, create_time, amount, status FROM t_order <where> <if test="cursorCreateTime != null"> (create_time &lt; #{cursorCreateTime} OR (create_time = #{cursorCreateTime} AND id &lt; #{cursorId})) </if> </where> ORDER BY create_time DESC, id DESC LIMIT #{pageSize} </select>

这段 SQL 里最需要注意的就是OR条件的写法。有些同学会把两个条件合并成一个create_time <= ? AND (create_time < ? OR id < ?),这样写结果可能是错的,因为联合索引的匹配规则会失效。老老实实按上面这种"第一个排序条件的分支 + 等值匹配下的锚点分支"来写,执行计划才会是 Range 访问,用上联合索引。

3.2 如何把"稳定性"量化:三个监控指标与巡检方法

改完 SQL 只是第一步,怎么验证稳定性确实好了,得靠数据说话。我建议在生产环境上线之前和之后,分别盯紧三个指标。

第一个指标是深度分页接口的 P99 耗时曲线。改造前,你可以写一个压测脚本,模拟用户随机点击第 1、20、50、100 页,记录各场景下的响应时间分布。改造后,同样模拟随机游走翻页,但此时游标会随机指向任意深度的位置。改造前 P99 会随着页号显著上升,改造后应该是一条近似水平的直线,这就说明分页深度不再影响性能了。

第二个指标是慢 SQL 数量和数据库 CPU 使用率。上线之后重点观察 DBA 平台上的慢查询日志,确认分页相关的 SQL 语句不再出现在 Top N 慢查询里。同时数据库 CPU 的波动率应当明显下降,尤其是大促期间,不会再出现某个时间段 CPU 突然飙升的问题。

第三个指标是翻页错乱的上报量。这个指标需要在前端埋点,把用户翻页时出现的"下拉刷新后重复显示已读条目"或者"点击下一页但仍在原页"等行为自动上报。这个数据不会很多,但只要出现,就说明系统里还有某个角落用了老式的分页方案。我建议巡检频率是每周一次,把这类错乱率控制在万分之五以下才算合格。

巡检的方法也很简单:每周抽一天,在业务低峰期对线上库做EXPLAIN抽查,重点看分页查询的执行计划是否稳定命中联合索引,type是否为range而不是ALL或index。一旦发现执行计划退化,基本可以断定索引被谁删了或者数据分布发生了重大变化,需要人工介入。

3.3 前端跳页需求与游标分页的妥协方案

Keyset 分页的一大"缺陷",就是无法直接支持"跳到第 20 万页"这种操作。因为游标只知道上一页最后一条记录的位置,并不知道第 20 万页从哪开始。但这个需求在 C 端产品里真的很少见,通常只是后台管理系统需要。如果你实在绕不开跳页,我给两个补救思路。

第一个思路是时间点定位法。给用户提供一个"按时间段跳转"的入口。比如用户想回到两周前的订单,可以在前端选一个日期,后端把"该日期当天 00:00:00"作为游标传入,从那个位置开始往下翻。这种方案非常适合订单、日志、操作流水这类天然带有时间维度的数据,本质上是用一个时间段锚点替代页码锚点,既满足用户"很久以前有某条记录"的诉求,又保住了查询性能。

第二个思路是独立的深翻页服务。如果业务真的有"查看第 500 页"这种刚需,比如后台管理表格,那就不要把深分页的压力打到业务表上。可以做一个独立的分片表或者专用的只读副本,配合离线聚合、定期抽样等方式,把这个高频深翻页场景隔离开。它的准确性和实时性可以适当放宽,因为管理后台的数据精确到分钟级别完全够用。这属于架构上的妥协:牺牲一点实时性的"准",换回整个系统的"稳"。

4. 常见问题与排查技巧实录

4.1 翻页翻到一半,数据重复或遗漏怎么办?

这是分页系统里最经典的"灵异事件"。我遇到过一次非常典型的定位过程:用户反馈订单列表里出现了重复订单,而且只在翻页超过 5 页之后才偶发。我先确认了接口用的是OFFSET分页,紧接着检查排序字段create_time,发现这个字段在那一批次里有大量完全相同的值(因为是批量导入的)。用户翻到第 5 页时,排序边界上恰好有几条数据create_time相同,数据库在两次独立查询中对这些"同位置记录"的返回顺序并不稳定,就会发生重复。

排查这类问题的固定套路是:先看排序字段有没有唯一性,如果排序字段值不唯一,100% 会出现数据重复或遗漏。修复方案也简单,要么给排序条件自动追加主键作为次级排序条件(即使还是OFFSET,把ORDER BY create_time DESC, id DESC加上,也能大幅降低错乱概率),要么直接上 Keyset 分页。这是投入产出比最高的一处改动。

4.2 刚写入的数据,在列表页查不到

这个问题的排查链路稍微长一点。某次业务方反馈:用户在小程序里刚提交了一个商品,回到列表页却看不到,非要隔一两秒刷新才有。起初我以为是缓存问题,检查后发现列表接口根本没有加缓存,于是把目光转向主从架构。订单写入走的是主库,列表查询走的是从库,主从复制延迟在正常情况下只有几十毫秒,但瞬时写入压力大的时候,延迟可以放大到几百毫秒甚至秒级,用户快速操作时就会撞上这个窗口。

排查手段很简单,先在列表查询接口里加一个开关,临时把流量全部路由到主库验证,如果问题消失,就确认是主从延迟导致的秒级不可见。根治方案分两步走:一是对"写后立即读"的场景做特殊路由,比如用户刚提交完订单,前端在 2 秒内发起的列表请求带上标记,后端根据标记强制走主库;二是优化主从复制链路,使用并行复制、减少大事务来降低延迟。注意,这个问题的本质不是分页查询本身,而是读写分离架构下的一致性边界,但分页接口往往是最先暴露问题的受害者。

4.3 order by 字段重复导致下一页错乱,但 EXPLAIN 显示走了索引

这种现象最迷惑人。你明明加了索引,执行计划也是range,但翻页结果就是不对劲。有一个真实的坑:我遇到过一张表用status做排序字段,status的值其实只有 0、1、2 三种。这样的索引选择性极差,虽然优化器认为它会使用索引,但实际上满足条件的数据有几十万行,MySQL 在执行WHERE status = 1 ORDER BY status LIMIT 20 OFFSET 200时,无法从索引中快速跳过前 200 行符合条件的记录,只能沿着索引逐个扫描到 220 行才停下来。从执行计划看确实用了索引,但性能依然随偏移量线性下降。

遇到这种问题,先做一个简单的测试:把排序字段换成高选择性的字段(比如创建时间),看查询性能是否明显改善。如果改善,就说明排序字段的选择性太差,不适合作分页排序条件。方案也简单:改用主键或时间字段排序,功能上的差异可以通过索引设计和业务逻辑来补偿。分页查询的排序字段,应当优先从"唯一性高、有单调趋势、查询频率高"的字段里选,这是我在这个 case 之后总结出的选型铁律。

4.4 count(*) 返回很慢,拖垮了整个分页接口

这是最容易被人忽略的稳定性杀手。有一个列表页,查询列表本身 50ms,但count(*)花了 900ms,直接导致接口 P95 破秒。根因就是条件字段上虽然有索引,但count(*)在 InnoDB 里必须扫描所有满足条件的索引项做计数。我给出的方案是二选一:第一,去掉 count,用LIMIT 21判断hasMore,前端展示"已经加载了 N 条",这是最彻底的做法;第二,如果产品不能去掉总数,就维护一张独立的计数器表,在业务事务里同步更新,查询时单独取计数。注意计数器表更新时不能直接操作业务表,尽量用异步消息或本地事务事件的方式,避免事务范围过大导致写放大。

4.5 分页参数被恶意调大,数据库瞬间打爆

还有一个稳定性问题,不是由数据产生的,而是由人产生的。分页接口的pageSize参数如果不设上限,攻击者可以把pageSize调到 10000,一条 SQL 就把数据库的 IO 打满。这种问题处理起来最简单最直接:服务端必须严卡pageSize上限(比如 100),超过直接拒绝;同时为了防止深度翻页请求把数据库连接池耗尽,要对分页接口做并发限制和熔断。我习惯在网关层加一条规则:列表类接口单 IP 每秒请求数上限,分页接口再单独收紧,一旦触发就返回友好的提示。系统稳定性从来不是一个单点问题,SQL 写得好只是起点,参数校验和流控必须跟上。

写在最后

坦白说,分页查询是我见过的最容易被低估的技术点之一。它写起来只要几行代码,跑起来却能把整个数据库拖垮;它看起来只是"上一页下一页"的交互,背后却牵扯着索引设计、主键策略、事务隔离、主从架构和参数安全。我在实际项目中踩得最深的一个坑,就是早期把所有分页需求都无脑统一成LIMIT/OFFSET,结果大促压测的时候,分页接口成了第一个崩溃的环节。那次教训之后,我把这套 Keyset 分页 + 深翻页降级 + 监控巡检的方案沉淀成了团队内部的代码模板和检查清单,后续再上新的列表页,只要是 C 端入口,默认就走游标分页。最后再分享一个实用的小技巧:游标参数里可以带上一个校验位,比如把lastId和lastCreateTime做一次加盐哈希拼在游标末尾,服务端解码时先校验,能有效地防止用户伪造游标去查询不存在或不属于自己的数据,顺便也堵住了不少刷接口的乱来。分页稳定的关键,不在于某一个精妙的 SQL 技巧,而在于你肯不肯在业务膨胀之前,就想清楚数据、索引和架构之间那层隐形的关联。

希望这篇总结能帮你少走一些弯路,如果你们团队也在做分页相关的改造,可以对照目录里的检查项逐条过一遍,至少能筛掉不少常见雷区。

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

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

立即咨询