分页这东西表面上看就是“LIMIT offset, size”或者“page 1, page_size 20”几个参数的事但真到了C端产品、数据量上了百万、接口延迟飙红线的时候很多人就开始头皮发麻了。我做过好几个千万级数据量的业务系统从最早用 page/page_size 被人投诉“加载慢”到后来自己设计游标分页方案中间踩的坑确实不少。这篇文章不聊虚的直接从两种方案的底层查询逻辑拆起把原理讲透把遇到过的经典故障和排查手段全盘托出帮你彻底搞懂“什么时候该用哪种分页”。这个内容适合所有写后端接口的开发者无论你用 MySQL、PostgreSQL 还是 Oracle无论你是在做 To B 管理系统还是高并发的 C 端产品读完这篇文章你应该能自己判断一个分页接口到底该怎么设计才扛得住真实流量。不会有那种“看似能跑、一压就死”的心虚感。1. 两种分页方案的底层逻辑差异1.1 offset 分页的“翻书式”查询代价先聊聊大家最熟悉的 page/page_size 方案。前端传page3page_size20后端解析成SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 40;看着挺简单对吧但这个简单是表象。数据库执行这条 SQL 的时候工作顺序其实是先按ORDER BY字段把整个结果集排序。从排序后的第一条开始扫描计数字数。跳过前 40 条取出接下来 20 条返回。前面那 40 条已经扫过的数据全部丢弃。也就是说你翻到第 1000 页的时候数据库仍然要先把前面那 19980 条记录全部扫一遍、排序一遍然后丢掉只留下最后 20 条给你。这个“先全量干活、再丢垃圾”的特性决定了 offset 分页越往深翻慢得越离谱。时间复杂度大致是 O(offset page_size) 的排序代价不是你以为的“只查一页”。我见过一个最典型的例子一个订单列表接口用户从第 1 页翻到第 50 页时页面响应从 80ms 飙升到 5 秒最后定位到是深分页导致全表扫描加 filesort。1.2 游标分页的“书签式”定位逻辑游标方案也叫 keyset pagination、seek method换了一个完全不同的思路。它不记录“我要第几页”而是记录“我上次看到哪一条了”然后下次查询直接从那条记录之后开始找。前端不再传页号而是传上一页最后一条数据的某个排序字段值SELECT * FROM products WHERE created_at 2024-06-01 12:30:45 ORDER BY created_at DESC LIMIT 20;这种情况下数据库直接利用created_at索引定位到游标位置然后往后顺序扫 20 条就行。它前面有多少数据、你翻了多少页跟查询代价一毛钱关系都没有。每页的查询代价基本恒定不管翻到第 10 页还是第 10000 页性能完全一样。生活化的类比是offset 分页相当于你每读一章都要从书的第一页重新翻到那一章游标分页则是在书页里夹了一个书签每次直接从书签位置往下一章读。C端产品用户量大、翻页深显然是书签方案更靠谱。1.3 为什么“第 N 页”和“第 N 条之后”是完全不同的查询很多人没转过弯来觉得“游标不就是把 offset 换成 where 条件吗性能能差多少”这个感受是因为数据量小的时候两种方案可能都是毫秒级返回根本看不出差异。一旦数据量涨到百万、千万差异就会被指数级放大。offset 方案里数据库的优化器无论如何优化都要处理“跳过的行”。而游标方案天然把“跳过”这件事交给了索引 BTree 的快速定位能力走的是range scan而不是full scan sort。还有一点很多人忽略offset 分页在数据变更频繁的场景下会出现重复和漏数据的问题。用户正翻着列表新数据插入到头部下一页会因为 offset 位移而漏掉一条旧数据。游标方案永远锚定“上次看到的位置”天然免疫这种问题。2. 核心实现细节与关键技术选型2.1 游标分页的字段选择为什么必须唯一且有序列这是游标方案设计里最核心的一环很多人在这里翻车。游标字段至少需要满足两个条件有序且唯一。只有序但不唯一会出问题。比如你用created_at做游标列表里有一百条数据都是同一秒创建的那你的WHERE created_at 2024-06-01 12:30:45就会把这一秒的所有数据全跳过或者全部重复返回分页直接乱套。只唯一但无序的字段也不能用。比如用 UUID 主键做游标虽然每条记录唯一了但 UUID 是随机生成的和业务排序规则没关系用户看到的列表顺序会乱。我的建议是使用复合游标。最常见的搭配是“排序字段 主键”WHERE (created_at 2024-06-01 12:30:45) OR (created_at 2024-06-01 12:30:45 AND id 10245) ORDER BY created_at DESC, id DESC LIMIT 20;这个条件的逻辑是先按created_at找到比游标更早的数据如果有同秒的数据再按id精确切分到具体一条。这样游标定位就是既有序又唯一的。对应的索引要建(created_at, id)联合索引查询就能命中索引异步扫描效率极高。只建created_at单列索引也行但遇到同秒大数据量时会有微弱的性能回退排查起来也更费劲。2.2 减小游标体积用编码器处理复杂游标上面那个复合条件写起来很啰嗦。你在接口里把created_at和id作为两个参数传给前端前端第二次请求时就要原样传回来URL 参数看起来像这样?cursor2024-06-01T12:30:45.000Z|10245更优雅的做法是把游标编码成一个不透明字符串public String encodeCursor(LocalDateTime createdAt, Long id) { String raw createdAt.toString() _ id; return Base64.getUrlEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); } public Cursor decodeCursor(String cursor) { String decoded new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8); String[] parts decoded.split(_); return new Cursor(LocalDateTime.parse(parts[0]), Long.parseLong(parts[1])); }这样前端把游标当不透明字符串处理完全不需要理解内部含义。后端接口返回next_cursor字段用户往下翻的时候直接把next_cursor原样传回来即可。分页响应体的标准结构可以做成这样{ data: [...], next_cursor: MjAyNC0wNi0wMVQxMjozMDozNS4wMDBafDEwMjQ1, has_more: true }has_more的判断方法是查LIMIT page_size 1多查一条发现存在就说明还有下一页。这个技巧比“查总数再减去已加载数”要省一次 count 查询在 C 端高流量场景下能省不少数据库开销。2.3 MySQL 与 Oracle 的游标分页差异点老项目里 Oracle 很常见Oracle 的ROWNUM方案和 MySQL 的LIMIT方案写起来不一样但游标思路是同构的。Oracle 早期版本没有LIMIT常写成SELECT * FROM ( SELECT t.*, ROWNUM rn FROM ( SELECT * FROM products ORDER BY created_at DESC, id DESC ) t WHERE ROWNUM 20 ) WHERE rn 10;换成游标方案后变成SELECT * FROM products WHERE (created_at, id) (2024-06-01 12:30:45, 10245) ORDER BY created_at DESC, id DESC FETCH FIRST 20 ROWS ONLY;注意 Oracle 的元组比较是完整的行值语法(created_at, id) (..., 10245)完全合法。MySQL 里虽然不写元组条件但OR写法表达的是同一个逻辑。PostgreSQL 的FETCH FIRST 20 ROWS ONLY同样支持写的思路保持一致。换数据库时主要是查语法细节核心设计不需要重来。3. 两种方案的适用场景对比与选型决策3.1 各维度横向对比表性能、一致性、灵活性我做了一张对比表方便你根据业务场景快速拍板对比维度offset 分页游标分页浅分页前 20 页查询性能优秀毫秒级优秀毫秒级深分页第 10000 页性能严重劣化会产生全表扫描 filesort恒定不随页数增加而变慢实现复杂度极低两个数字参数即可需要设计游标字段、编码解码、接口改造随机跳页完全支持想跳哪页跳哪页不支持只能一页一页往下翻数据变动场景下的稳定性会重复或漏数据稳定每次锚定上次位置实时总数统计天然带 total 字段方便分页器不能直接获取总数需要额外 count多条件复杂筛选支持度好组合条件随便拼游标字段和筛选条件需要维护好关系C 端无限滚动场景可用但深翻页隐患大首选专为无限滚动设计管理后台翻页器场景选它没商量体验非常差不推荐3.2 C 端“无限滚动”为什么几乎默认游标现在的 C 端产品尤其是内容流、商品列表、消息列表几乎全是“下拉加载更多”而不是“点击第 7 页”。这种交互形态的骨子里就是游标分页。无限滚动的用户行为是连续翻页不存在“我直接跳去第 500 页”的需求。你完全可以隐藏掉数据的绝对位置信息只每次返回next_cursor。这样前端代码也简单后端性能也稳定。我用游标方案做过一个消息中心列表对接千万级用户数据接口 10 毫秒内返回数据库压力几乎全在索引扫描上。3.3 管理后台翻页为什么反而应该继续用 offset有些团队听到游标性能好就什么都想换。真把所有管理后台的分页都改成游标你会疯掉的没法快速跳到特定页、没法按页码分享链接、没法做全选跨页操作、没法一眼看到“共 137 万条”的总数。管理后台的使用者是运营和客服他们需要的是“看清全局、精准操作”。offset 分页虽然深翻页性能差但配合page_size限制和合理的默认排序方式在百万级数据的管理后台里完全够用。压测表现一般也不会太差因为管理后台并发量远低于 C 端。3.4 混合架构一个系统用两套分页的实践经验一个聪明务实的做法是“双轨制”C 端用户端接口用游标分页管理后台用 offset 分页。数据表是同一张查询字段也是一样的但底层接口各自独立实现。我在实际项目中维护过这种架构共享同一套查询服务层、数据访问层只在 controller 层做参数解析和响应结构转换。这样既保证了用户端的极致体验又不牺牲后台管理功能代码维护成本也没有想象中翻倍。4. 深分页优化实战与故障排查实录4.1 深翻页带来的典型问题慢查询、锁等待、内存膨胀最常见的故障是高并发场景下深分页慢查询把数据库连接池耗尽。核心现象是接口超时率突增、数据库 CPU 飙升、SHOW PROCESSLIST里全是同一个带大 OFFSET 的 SELECT 语句。MySQL 的filesort是重灾区。深分页会触发优先队列排序在内存不够时还会落盘写临时文件造成磁盘 IO 升高和临时表空间膨胀。如果你还开着慢查询日志能清楚看到同一个 SQL 的执行时间从 100ms 涨到 4 秒的过程。第二个典型问题是COUNT(*)和深分页同时出现时雪上加霜。前端分页器要显示“共 XX 页”后端就得先执行一次COUNT(*)全表扫描再执行一次深分页全表排序两座大山叠在一起接口性能直接完蛋。4.2 深分页优化方案一延迟关联Late Row Lookups如果因为种种原因还是需要 offset 分页可以用延迟关联的技巧绕过一部分性能坑。核心思路是先用覆盖索引查出所需的id列表再通过主键回表取整行数据SELECT p.* FROM products p INNER JOIN ( SELECT id FROM products ORDER BY created_at DESC LIMIT 200000, 20 ) tmp ON p.id tmp.id ORDER BY p.created_at DESC;子查询里只查id和排序字段这两个字段都包含在联合索引里InnoDB 可以直接用索引完成排序和 LIMIT 操作不需要回表读取整行。200000 行的偏移仍然是扫描量但扫描的对象是索引页而不是聚簇索引数据页IO 量能降一个量级。这个方案实测在 MySQL 5.7 和 8.0 上能提升数倍到十倍的深分页查询性能。但核心问题深翻页越来越慢仍然存在只是把爆炸时间点往后推了。4.3 深分页优化方案二游标替代改造为“加载更多”C 端场景下与其费劲优化 offset不如直接改成交互模式。把“上一页/下一页”换成“加载更多”前端记住上一个游标。排序字段用主键自增 ID 时游标最方便生成的查询条件就是WHERE id 10245 ORDER BY id DESC LIMIT 20;连联合索引都不用建主键索引天然有序性能拉满。如果是时间倒序需求可以用id DESC近似替代created_at DESC只要业务上不严格依赖按时间绝对排序这个方案是我最推荐优先试水的。4.4 排查实录一个 C 端列表接口超时的完整定位过程曾经有一个列表接口平时 P99 在 200ms 左右某次大促后被运营反馈“翻到十几页就开始转圈”。我的排查步骤是第一步看监控面板确认是数据库耗时升高还是应用层耗时升高。当时数据库耗时中位数 1.8s锁死了应用层问题。第二步EXPLAIN那条慢 SQL看到typeALL、rows3700000、ExtraUsing filesort确认是深分页全表扫描。第三步确认索引情况查看表结构中排序字段是否有索引发现只有单列索引但 WHERE 条件里还混了其他筛选列组合条件无法命中索引。第四步和产品沟通交互形态确认其实页面是“下拉加载更多”而非点击页码果断把分页改成游标方案。第五步加复合索引(status, created_at, id)支持业务筛选条件接口查询从 1.8s 降到 15ms。这个排查流程本身也可以抽象成方法论先确认瓶颈在数据层还是应用层 → EXPLAIN 看执行计划 → 核对索引与查询是否匹配 → 考虑更深层的交互替代方案。4.5 分页故障速查表常见问题与解决方案故障现象可能原因解决方案翻页越深速度越慢offset 全量扫描 filesort改游标分页 / 延迟关联数据新增后下一页有重复数据offset 位移导致改游标方案数据删除后出现漏数据offset 位移导致改游标方案下一页数据为空但实际有数据游标字段非唯一导致跳过使用复合游标排序字段ID总数统计特别慢COUNT(*) 全表扫描使用缓存统计值 / 移除总数展示跳转指定页失败游标不支持随机跳页混合方案/提供页码跳转接口走 offset分页结果排序错乱排序字段无索引或存在 NULL给排序字段建索引处理 NULL 排序规则4.6 分页工具设置的避坑经验pageSize 大小与超时设置用 MyBatis-Plus 或 PageHelper 这类框架时会有一些非常容易踩的坑。我最常遇到的是pageSize有人传 10000一次查出上万条数据不仅内存扛不住响应体也巨大无比。后端一定要做参数约束if (pageSize 100) { pageSize 100; }不同产品的合理默认值不同。C 端移动端建议 10-20 条PC 端 Web 建议 20-50 条管理后台可以放宽到 50-100 条但绝对不要无上限。框架自带的“自动 count 查询”也要注意。MyBatis-Plus 的分页插件默认会多执行一条 count 语句这在深分页上又会叠加性能消耗。如果你的场景不需要总页数展示可以禁止自动 count省掉一条查询语句。PageHelper 则要注意它基于 ThreadLocal 的分页参数传递机制。如果你在查询前改了线程、或者在 executor 里异步执行分页参数会失效。这个坑隐蔽得很排查起来常常让人怀疑人生。5. 分页字段设计与索引优化深度指南5.1 排序字段选型主键、时间戳还是业务字段上面提到了游标字段要既有序又唯一但从索引优化角度还有更多讲究。最简单的方式是用自增主键id做游标因为主键就是聚簇索引天然有序长度短性能最好。但缺点是和业务上的时间排序可能不完全一致。退而求其次的方式是用created_at id组合游标既能按业务时间排序、又能避免同秒数据问题索引建(created_at, id)就行。最不建议的方式是用业务编号字段做游标比如订单号。虽然唯一但很多订单号是随机生成的排序规则和业务时间无关用了会导致列表顺序乱掉。别问我怎么知道的说多了都是泪。5.2 联合索引内部结构对分页查询的影响谈索引优化就得谈 BTree 的存储结构对查询的影响。联合索引(created_at, id)在 BTree 里会先按created_at排序同一个时间内的再按id排序。查询WHERE created_at ... ORDER BY created_at DESC, id DESC时MySQL 可以直接从索引的最右边开始反向扫描找到游标位置后顺序读取 20 条全程不需要回表不需要临时表排序。这也就是为什么游标分页快它把“排序分页”彻底变成了“索引区间扫描”。如果你只有一个created_at的普通索引游标条件里的id部分就无法直接从索引获得需要回表后再排序性能会有明显下降。这个经验是我在对比测试中确认过的差距在两倍以上。5.3 几个分页索引设计经验总结第一组合索引的列顺序一定是“等值查询列在前排序条件列在后”。比如筛选条件里有status 1游标是created_at索引就建(status, created_at, id)。第二Oracle 和 MySQL 的索引结构虽然都是 BTree但优化器行为有差异。Oracle 对ROWNUM和FETCH FIRST的处理方式不完全一样不能在 MySQL 上验证完就贸然拿到 Oracle 用建议在目标库上重新执行计划分析。第三不要滥用多列索引。如果业务上有多个维度的筛选需求比如按状态筛选、按商家筛选、按城市筛选你不可能为每个组合都建索引这会导致索引冗余和写入性能下降。实际经验是最多维护 2-3 个高命中的组合索引其余的交给查询侧优化。6. C 端分页方案的工程落地建议6.1 从 RESTful API 设计角度设计分页接口接口设计时游标分页的响应格式和请求参数跟 offset 有明显的差异。尽量保持前端无感知不要让前端同时处理两套分页逻辑。推荐 C 端统一格式请求参数cursor不透明字符串第一次请求留空。limit每页条数默认 20最大 50。响应结构items当前页数据数组。next_cursor下一页游标没有更多数据时为空字符串。has_more布尔值方便前端判断是否继续渲染加载组件。{ items: [{ id: 10245, title: 商品A }], next_cursor: MjAyNC0wNi0wMVQxMjozMDozNS4wMDBafDEwMjQ1, has_more: true }这样前端只需要在滚动到底部时把next_cursor塞进下一次请求即可。6.2 分页与缓存结合用 Redis 缓存热数据C 端分页接口通常有热点效应前几页的数据被高频访问。可以给“首页”部分加缓存比如前 5 页String cacheKey product_list_ cursor _ limit; Object cached redis.get(cacheKey); if (cached ! null) { return cached; } // 正常查询数据库然后写入缓存设置 60s 过期游标分页的缓存设计比 offset 更友好因为游标通常不暴露明细位置信息缓存命中后不需要担心数据错乱。而 offset 翻页缓存如果搭配数据变动容易出现“第 1 页缓存是新的第 3 页缓存是旧的”这种诡异体验。6.3 数据一致性与并发写入下的分页稳定性如果列表数据在用户浏览过程中发生写入两种分页的表现完全不同。这个点我又得强调一遍offset 分页会出现“下一页”和“上一页”内容交叠的问题游标分页则能保证用户看到的列表像一条连续流动的数据流天然不会交叠。对于瀑布流、信息流这类产品这是核心体验指标。业务方常问“为什么用户刷到第 5 屏时和第 4 屏尾部重复了一条”这个问题绝大多数情况就是 offset 分页位移导致的。7. 分页技术选型的决策矩阵与个人经验7.1 一张图理解“什么时候用哪种方案”决策思路我一般按五个维度做决策数据规模单表小于 50 万、深翻页场景极少直接 offset省事。交互形态无限滚动优先游标点击页码优先 offset。随机访问需求需要跳页、跨页全选offset。可接受的最大延迟P99 必须在 100ms 内优先游标。历史数据倾斜数据集中倾向访问前几页加缓存两种都能用数据均匀分布、翻页深度很大游标是唯一稳妥解。7.2 针对不同业务场景的推荐组合有些业务有明确的组合方案这里直接给出来供参考电商商品列表 C 端游标分页 前几页 Redis 缓存 排序字段用(price, id)或(sales_volume, id)。社交 Feed 流游标分页 预加载 缓存最近 N 条数据因为用户翻页行为高度集中在前几屏。管理后台订单/用户管理offset 分页 默认最大 pageSize 100 禁止深翻页超过一万条就提示使用筛选条件、不允许继续翻。7.3 我的几次真实选型经历早期做电商后台时用过 pure offset百万订单管理起来没有大问题因为后台用户少、并发低。后来负责用户端订单列表流量上来后被迫改成游标查询时间从“痛苦慢查询”变成“稳定毫秒级”。改造过程中最大的工作量其实不在 SQL而在前端配合改交互——从页码到加载更多的切换前后端联调了一整天。还有一次做数据报表系统因为需要频繁跳转月份、跨页勾选我坚持保留了 offset 分页但限制了“只能查看最近 100 页”并在后端加了一层大数据量深翻页拦截器超过阈值返回明确提示请用户补筛选条件。产品体验很顺滑也没有性能问题。这里补充一个关于用户查看“时间范围筛选”和“游标”配合的小经验如果筛选条件很多比如“状态成功 AND 日期范围最近30天”游标字段可以用(status, created_at, id)的联合索引但千万别把日期范围也塞进游标条件里。日期范围更适合作为普通 WHERE 条件游标只管确保排序稳定性。7.4 关于“总数”的执念C 端产品需要放下精确 total 吗很多产品经理和老板要求列表必须显示“共 15543 条”这个需求在 C 端无限滚动场景里其实是反人性的。用户在手机上往下拉加载谁需要知道总数呢精确 totaal 带来的数据库 count 查询压力非常大尤其在深分页查询上叠加时更严重。实际产品里完全可以改成“实时估算值”“只显示前 N 条”或者干脆不显示数量换来的是接口更快、数据库压力更小。有一次我把某个 C 端接口的精确 count 查询去掉了数据库主库压力直接降了 15%这个收益不值得吗8. 分页故障高频问题排查技巧补全8.1 MyBatis-Plus 分页失效问题的几种场景最近热门的话题里总有人提“mybatisplus分页失效”我遇到过几次常见原因有三类。第一类是PaginationInnerInterceptor没注册进配置。新版 MyBatis-Plus 需要显式声明分页插件很多人忽略了这个配置结果查出来全表数据前端 mock 数据全乱了。第二类是分页被自定义 SQL 覆盖。如果你写了自己的 SQL 且没有正确使用${ew.customSqlSegment}分页插件无法改写你的 SQL导致分页失效。排查方式是打印日志看最终执行的 SQL 是否带 LIMIT。第三类是线程间参数传递问题。Page 对象的 ThreadLocal 在当前线程被异步处理时丢失比如用了CompletableFuture并发查询就会出现查不到分页效果的诡异情况。8.2 SQL Server / Oracle 的分页语法陷阱SQL Server 的OFFSET FETCH语法要求必须搭配ORDER BY如果你没写排序直接执行就报语法错误。Oracle 12c 以后用FETCH FIRST n ROWS ONLY但早期版本必须用ROWNUM子查询嵌套。对这些差异不熟悉换数据库时就容易踩坑。最简单的规避方法是在所有分页 SQL 里都显式写明排序字段且排序字段必须在 SELECT 列表中存在。这个习惯能同时规避语法错误和排序不稳定两个坑。8.3 非分页缓冲池占用过高与分页的关系热搜词里有条“非分页缓冲池占用很高怎么解决”这个其实和分页有一定关联但不是同一个层面的东西。非分页缓冲池是内存管理概念如果查询大量使用 filesort 并且排序缓冲不够数据库会占用额外内存或磁盘空间做排序间接导致内存压力升高。排查思路上如果数据库实例内存长期高位同时分页慢查询不断很可能是深分页导致大量排序操作耗内存。解决办法就是按前面优化的路子走——建对索引、改游标方案从源头减少排序需求比盲目调内存参数更有效。8.4 分页 SQL 参数规范化与安全限制接口层面必须做参数校验limit不能为负数、不能超大cursor必须能正确解码解码失败的请求直接返回参数错误。防止有人通过手动改 URL 玩 SQL 注入或者拖库。再有就是游标字符串要设置过期时间。因为游标本质上包含了一个“定位点”如果用户拿到一个很久之前的游标可能对应的数据已经被删除此时查询会返回空结果或异常。实际做法是过期游标返回明确错误码前端自动刷新列表首屏。9. 分页技术演进方向与架构扩展9.1 从 REST 接口到 GraphQL 的分页规范差异GraphQL 的Connection规范其实就是游标分页的标准格式edges、node、pageInfo.endCursor、pageInfo.hasNextPage这些字段就是游标的另一种表达。这侧面证明游标分页已经成为现代 API 设计的默认选择。如果你的团队在评估 GraphQL那就不需要纠结选择哪种分页了这个规范直接把游标方案焊死在里面。9.2 大数据量下 Elasticsearch 的游标应用项目里做搜索列表页时经常遇到“java 的 ES 分页查询超过 10000”的问题。ES 默认from size不能超过 10000就是因为深分页在分布式搜索引擎里的代价比关系型数据库更恐怖。ES 自身的解决方案是search_after这正是游标分页的分布式版本而scroll则适合大批量导出场景不太适合实时分页接口。9.3 使用消息队列和预加载技术弥补游标不足游标分页的短板是用户无法查看旧数据比如消息列表翻了 30 屏再想回到第 2 屏很费劲。大厂的处理手法通常是上拉加载历史消息时用游标往下翻同时配合本地缓存或内存分页保证用户回看时响应快。这已经超出数据库分页的范畴了属于端侧体验和缓存策略结合的工程能力。理解这个思路就行细节各家实现不尽相同。9.4 分页工具的演进趋势与个人判断未来 API 网关和 BFF 层可能会自动帮你把分页协议从 offset 换成游标前后端完全不感知。云数据库内置的自动分页优化也可能把“深翻页慢”问题压到引擎层面解决。但内核这些东西还是值得在一线亲手用一遍、踩一遍坑的这样看问题时思路会更通透。我自己经历过从“只知道写 LIMIT”到“主动设计游标方案”的转变最大的体会是分页并不只是 SQL 写法问题它是产品体验和数据架构的交叉设计。最后分享一点个人心得。有一次压测数据到了百万级深翻页从 50 页往后走offset 方案的 RT 曲线几乎是直线上升而游标方案是一条平稳的横线。那一刻实在太直观了。如果你正在现有系统里遇到分页变慢的问题别急着上缓存、上读写分离先看看你的分页方式是不是从一开始就选错了。这个内容后续还可以继续扩展的方向是分布式数据库下的游标实现、分页与数据权限的融合设计、服务端预取队列在 Feed 流里的应用。但目前这个阶段先把这两种分页的底层逻辑吃透就已经能解决 90% 的线上分页问题了。