
干这行久了就会发现分页这事儿在传统业务系统里根本不叫事LIMIT 10 OFFSET 20一把梭谁都会写。但一旦数据量上了千万、亿级前端要的是“查询结果分页”问题就全来了。我在做数据大屏和自助分析平台时没少被深翻页性能、结果集漂移、导出超时这些事折腾过。这篇就专门聊聊大数据OLAP场景下的分页怎么搞从原理到实操再到避坑尽量一次说透。不管是做数据产品的开发还是刚转大数据方向的工程师只要你的查询要面向亿级数据做交互式分析、报表导出或大屏滚动加载这篇文章都值得看完。内容会比较长涉及的方案也不会只有一种因为分页从来没有银弹关键在于搞清楚你自己的场景到底能接受什么样的代价。1. LOOK 清楚OLAP 分页和普通分页根本不是一回事1.1 OLAP 场景下分页到底难在哪先明确一个概念OLAP联机分析处理典型动作是聚合、关联、排序、窗口计算处理的对象是海量明细或大规模聚合结果。这类查询本身就是重的执行时间经常以秒甚至分钟计。而分页恰恰要在这种重查询的结果集上做切片展示。普通业务系统的分页表数据撑死几十万行走个索引两条 SQL 就搞定了。OLAP 分页难难在几个地方结果集极大。一个查询可能返回几十万、上百万行用户真实需要看的可能只有前几十页但系统为了“翻到后面某一页”往往得把整个结果集算一遍。查询计算延迟高。每次翻页都要重新跑一次聚合、关联如果还要排序成本更是成倍上涨。数据在持续变化。明细表每时每刻都可能写入新数据用户翻到第 5 页的时候第 1 页的数据可能已经和刚才不一样了。引擎是分布式的。数据分散在多个节点排序、去重、聚合都要跨节点协调分页逻辑如果处理不好会放大网络和内存压力。如果还是用“每页请求一次、每次重新查一遍”的常规思路深翻页的性能基本是线性恶化。你让用户点第 100 页系统可能比点第 1 页多花 10 倍时间体验直接归零。1.2 传统 OFFSET 在大数据量下翻车的根源先看这个最常见的写法SELECT * FROM order_detail ORDER BY gmt_created DESC LIMIT 20 OFFSET 9980;看起来没毛病逻辑上也很直观跳过前面 9980 行取接下来 20 行。但执行引擎得先把整个排序做出来才能知道哪一行是第 9981 行。也就是说哪怕你只取 20 行引擎也得把参与排序的所有记录全都排完再丢弃前 9980 行。这就是深翻页慢的根源——offset 越深引擎需要处理并丢弃的数据越多。在大数据引擎里这个行为会更复杂。以 Spark 和 Hive 为例如果 SQL 里带ORDER BY会把数据全部拉到一个或多个 reducer 做全局排序即使不带排序带OFFSET也需要扫描出所有符合条件的数据才能确定偏移位置。所以你会发现一个反直觉的现象查询加了LIMIT反而可能比不加更慢因为引擎为了精确计算偏移量做了额外的全局排序或全量扫描。还有一个容易忽略的问题OFFSET 分页在数据变化时结果会漂移。假设用户在第 1 页看到了 A 记录翻到第 2 页时系统重新执行了查询此时表里新增了一条符合条件的新数据。由于排序位置整体后移原本第 2 页最前面的一条记录就会被挤掉用户会感觉“数据跳了”或者第 1 页和第 2 页出现重复。联机分析的表通常不是全量快照这个问题几乎必然遇到。1.3 OLAP 分页真正的需求边界要先问清楚在动手设计分页方案之前先要搞清楚一件事你要的到底是“交互式深翻页”还是“批量化数据抽取”。这两种需求的技术路线完全不一样。交互式翻页用户在前端一页一页点通常只看前几十页对响应时间极度敏感允许用缓存换速度。分批导出用户要把几百万行全量导到 CSV 或大屏组件关注的是完整性和吞吐可以容忍更长等待时间。下钻探索用户在数据里一层层往下看每一层都要重新聚合此时“分页”其实更像“切片”更看重查询下推和预聚合。很多问题的发生就是因为把这三类混在一起处理了。比如做导出用交互式翻页接口一次拿一万行每页几十毫秒光翻页请求就要几十次中间任何一次数据变动都会导致结果不完整。反过来做交互式翻页用全量导出方案用户点一下等三秒也完全不合理。所以我给团队定的第一个规矩就是先跟产品对清楚“分页”的语义再谈技术方案。这个动作能避免一半以上的返工。2. 几种主流分页方案适用场景和性能特点拆一遍2.1 游标分页Keyset Pagination深翻页性能最稳的解法游标分页也叫键集分页或 Seek 分页核心思路是不告诉引擎“跳过多少行”而是告诉引擎“从哪个位置继续取”。翻第 N 页时把上一页最后一条记录的排序字段作为条件传进去比如SELECT * FROM order_detail WHERE gmt_created 2025-06-01 12:00:00 ORDER BY gmt_created DESC LIMIT 20;第一页没条件第二页把第一页最后一条的gmt_created传进去第三页再把第二页最后一条传进去。每一页的查询都只扫描满足条件的数据而且排序字段上如果有索引或PRIMARY KEY前缀可以直接定位到起始位置查询代价跟页码深度几乎无关。这个方案在 OLAP 里尤其适用因为很多分析查询本身就带时间范围过滤时间字段天然适合做游标。你设计翻页时可以顺便做一个约束必须带上一个唯一且有序的游标字段。游标分页有一个需要特别注意的点排序字段必须唯一。如果gmt_created有重复值翻页时就会出现数据「漏一行、重一行」的问题。解决办法是加一个唯一 ID 作为次级排序依据SELECT * FROM order_detail WHERE (gmt_created, id) (2025-06-01 12:00:00, 12345) ORDER BY gmt_created DESC, id DESC LIMIT 20;这种复合游标条件非常稳即使同一秒有大量数据写入也能精确定位到上一页最后一条。不少大数据引擎对这种(col1, col2) (val1, val2)的表达支持得不是很好但你可以用等价的普通条件来实现WHERE gmt_created 2025-06-01 12:00:00 OR (gmt_created 2025-06-01 12:00:00 AND id 12345)实测下来这种写法在 ClickHouse、Doris、TiDB 上都能正确走索引裁剪性能非常可观。2.2 结果集缓存多页连续翻看的最优解很多前端交互场景里用户不是随机跳页而是从第 1 页开始逐页往后翻。这种场景下有个被很多人忽略的简单粗暴方案把完整的查询结果存起来分页只是切片。具体做法是首次请求时执行完整的分析查询把结果集写入 Redis、对象存储或临时表同时把结果集的唯一标识返回给前端后续的翻页请求都带着这个标识直接从缓存结果里取指定范围的记录不再重新执行查询。这个方案的最大优点是完全避免了重复计算单页响应时间能做到个位数毫秒。缺点是首次查询的等待时间会比较长另外如果结果集特别大缓存存储和传输也有成本。这里有个工程细节结果集缓存不一定要等全部算完再返回。你可以利用流式读取把查询结果边出边写比如 Spark 的foreachPartition流式写、ClickHouse 的INTO OUTFILE算完第一批数据就开始给前端返回。这样首屏速度几乎只取决于第一条记录的产生时间。我自己的经验是这个方案最适合“大屏滚动加载”这类场景。用户往往从前端看到的是一个持续加载的图表后台批量拉数据第一个请求等待时间长一点完全可接受但后续的每次滚动都必须零延迟。如果每个滚动都去重新跑聚合不仅浪费算力体验也会很糟。2.3 物化视图与预聚合让分页建立在更小的数据上有些分页场景卡点不在分页本身而是整个查询太重。典型例子用户要对一张 10 亿行的事实表做聚合然后对聚合结果分页。如果你每次翻页都重新扫全表做GROUP BY无论用什么分页方案都救不了你。解决办法是让“查询本身变轻”。把聚合结果提前物化出来比如在 ClickHouse 里建物化视图实时维护聚合表在 Doris 里用 Rollup 表做多层聚合或者定时跑 Spark 任务把汇总结果写入专用表。分页时直接查已经物化好的小表性能自然就上来了。用物化视图有个关键点物化表里要保留分页需要的排序字段。有些团队建物化视图时只保留了指标字段忘记保留gmt_created这类时间维度导致分页想按时间排序时无从下手只能重新查明细表绕了一大圈又回到原点。建物化之前先把“展示层需求”和“分页游标需求”一起列出来再设计维度组合。另外物化视图维护会有延迟。对于实时性要求不高的报表延迟个几分钟完全没问题但对于需要看分钟级实时数据的场景物化策略就要改成流式更新或者接受“最终一致”。这一点最好在产品需求阶段就达成共识别等上线了再扯皮。2.4 不同 OLAP 引擎的分页实现差异同一套分页逻辑在不同引擎里的表现差异巨大写代码前要先摸清底细。ClickHouseLIMIT N BY是它的特色可以按分组内取前 N 条非常适合分组 TopN 场景。常规的LIMIT offset, size也能用但深翻页同样会越来越慢。好在 ClickHouse 的过滤和排序能力极强配合游标分页千万级数据翻几百页也能保持几十毫秒响应。Doris支持标准的LIMIT和OFFSET并且内部做了不少优化。在实际使用中Doris 3.0 之后对深翻页的支持明显改善但数据量大时依然推荐游标方案。Doris 还有一个ORDER BY ... LIMIT的 pipeline 执行优化适合大结果集取前 N 条。Apache Spark SQL / Hive这里的坑最深。OFFSET在 Spark 里需要全量聚合后截断数据量大时性能灾难。更常见的问题是一不小心触发ORDER BY的全局排序整个查询变成全量 shuffle。对于 Spark/Hive 分页最好的办法是干脆不要用 SQL 分页用 DataFrame 的filter做游标条件配合limit操作让 Spark 只处理满足条件的数据。TiDB / StarRocks两个都对 OLTP 和 OLAP 混合场景有优化。TiDB 对带主键的游标分页支持得非常好并且支持tidb_snapshot做一致性快照可避免分页漂移。StarRocks 的执行引擎在排序和分页上也做了不少优化深翻页比 Hive 好太多不过超大结果集也建议用缓存方案。我见过不少团队一套 “通用分页接口” 跑遍所有引擎结果在 A 引擎上没问题换到 B 引擎就卡成狗。分页方案的选型一定要结合你底层引擎的执行机制来判断而不是只看表面 SQL 能不能跑。3. 实操案例把大屏查询从 60 秒稳定分页改造成秒级3.1 一个真实场景的现状分析去年做一个网约车运营数据大屏项目有一张订单明细表日均新增几千万行一个月下来近 10 亿行。大屏上有一个“实时订单流水”列表要求每 10 秒刷新一次展示最近订单的翻页浏览。初始实现是用的 Spark SQL 查询 LIMIT/OFFSET分页。第一版上线后第 1 页需要约 10 秒第 2 页直接飙到 20 秒到第 10 页已经超过 60 秒数据量一大查询还会偶发内存溢出把整个 ETL 任务都带崩过。我用EXPLAIN看了执行计划发现两个问题引擎对gmt_created DESC LIMIT 20 OFFSET 400做了全量分区扫描参与排序的数据行数约为 3 亿行严重拖慢性能。由于表在持续写入每次翻页查到的结果集都不完全一样前端偶尔出现一条订单重复或闪跳。这是典型的“功能实现没问题但完全没考虑 OLAP 数据特性”的示例。分页逻辑在不考虑数据规模和一致性时性能和安全边界都非常脆弱。3.2 改造方案设计时间窗口裁剪 游标分页 结果缓存针对上面的问题我在不改业务逻辑的前提下分三步做了改造。第一步增加时间窗口裁剪。这个列表是“最近订单”本来就不需要查全量历史数据。在业务 SQL 里强制加一个gmt_created now() - interval 6 hour的过滤条件。这个改动让参与排序的数据量直接减少到几百万行执行计划里的扫描分区从 200 个降到 20 个以内。第二步换成游标分页。前端不再传offset而是传上一页最后一条记录的gmt_created order_id。后台 SQL 改成SELECT * FROM order_detail WHERE gmt_created now() - interval 6 hour AND (gmt_created #{lastGmtCreated} OR (gmt_created #{lastGmtCreated} AND order_id #{lastOrderId})) ORDER BY gmt_created DESC, order_id DESC LIMIT 20;第三步给首屏加了结果缓存。第一次进入列表页时后台把第一批 200 条预取结果存入 Redis后续的滚动请求优先打缓存只有缓存未命中时才回源查询。由于数据仍然在实时写入我把缓存有效期设成了 30 秒超过 30 秒后强制回源查一次保证“实时”不变成“假实时”。这三步合到一起效果立竿见影第 1 页响应从 10 秒降到了 1 秒以内深翻页也基本稳定在 100~300 毫秒内存溢出的告警彻底消失整个大屏组件的稳定性提高了两个档次。3.3 参数细节page_size 怎么定、排序字段怎么选、NULL 怎么处理很多开发会忽略一个事实分页的稳定性和 page_size 强相关。page_size 设太大单次查询返回的数据多网络传输和前端渲染都会变慢设太小翻页次数成倍增加又放大了深翻页的代价。我一般的建议是交互式列表page_size 控制在 20~50 之间一屏刚好能看完又不会让接口太重。大屏滚动加载page_size 可以放宽到 100~200但要根据网络带宽和前端渲染性能实测调整。导出场景不要用页面分页接口要单独做批量流式导出接口。排序字段的选择上有个容易踩的坑不要用时间精度太低的字段做唯一排序依据。比如只用gmt_created精度到秒做排序同一秒生成的订单会有几十条翻页时必然出问题。解决思路是给排序加一个唯一 ID 作为 tiebreaker也就是前面那个复合游标条件。还有一个我踩过好几次的坑NULL 值的排序。在 ClickHouse 里 NULL 默认排在最前在 MySQL 里 NULL 默认排在最后同一套 SQL 在不同引擎上排序结果完全不一样。如果排序字段本身允许 NULL分页逻辑会彻底乱掉。解决办法有两个要么通过COALESCE把 NULL 转成默认值要么在业务上约定排序字段不允许为空。我强烈建议后者因为前者会让索引失效性能受损。3.4 验证和上线前要做的几件事改造完后不要直接上线一定要做这几项验证一是深翻页压测。用 10 万、100 万、1000 万三种量级的数据分别测第 1、50、200、1000 页的响应时间确认性能曲线是平的而不是直线上升。二是数据一致性验证。并发写入状态下连续翻 10 页统计是否有重复或缺失记录。这一步尤其要模拟高峰期写入因为很多数据漂移问题只有在高并发写入时才会暴露。三是超大结果集边界测试。比如单次查询返回 50 万行确认不会触发引擎的max_result_rows限制也不会撑爆内存。四是降级预案。如果引擎负载过高查询超时前端是否有兜底提示缓存服务挂了是否还能回源查询这些都要提前设计好。4. 常见的分页疑难问题与排查速查4.1 高频问题清单和解决方案我把这几年遇到的分页问题做了一个速查表方便大家排查时直接对照症状根因处理方案翻页越翻越慢OFFSET 深分页导致全量排序/丢弃改游标分页基于排序字段定位翻页过程中数据重复或跳号查询之间数据发生变化无一致性快照使用游标分页加唯一排序字段或开启快照读分页排序混乱排序字段不唯一增加唯一 ID 作为次级排序字段NULL 导致排序位置飘忽不定不同引擎对 NULL 排序规则不一致业务约定排序字段非空或用 COALESCE 兜底查询超时/内存溢出全表扫描 大结果集缓存增加时间窗口分区裁剪物化预聚合结果首页快、后面的页明显变慢每次翻页都重新执行完整聚合首次查询结果写入缓存翻页只做切片导出 Excel 时数据不完整使用交互式分页接口逐页拉全量数据单独做流式导出接口绕过分页机制这张表我自己工作中经常拿来做 review checklist建议你复制一份贴到团队文档里。4.2 聊聊那些容易忽略但致命的细节第一个细节排序字段要占用存储和内存资源。在 OLAP 引擎里ORDER BY一个高基数字符串字段比排序一个整数 ID 代价高得多。分页需求如果经常命中排序尽量把排序字段设计成数值型或者用低基数编码。我之前遇到一个案例把user_id的字符串排序改成数字 ID 排序查询耗时降了 60%效果非常夸张。第二个细节大字段别放进分页查询的 SELECT 里。有些表里会存大 JSON 或长文本SELECT 全字段会让每条记录占用大量内存和带宽严重拖慢分页速度。我的经验是分页列表只查展示需要的几个核心字段详情点击时再用主键查全字段。这个改动简单但是收益极大。第三个细节不要在事务里做 OLAP 分页。OLAP 查询本身执行时间长如果在事务里做会持有连接和锁一个分页请求就可能耗尽连接池。分页查询应该走只读连接必要的是时候用快照隔离级别而不是在业务代码里显式开事务。第四个细节监控分页接口的 P99 延迟。很多团队只看着平均延迟结果平均响应 200ms但最慢的 1% 请求需要 8 秒用户感知到的就是“这个系统很卡”。建议给分页接口单独建监控面板观察 P95/P99 延迟和深翻页请求的命中率。4.3 给团队定分页规范模版经过两次大事故后我给团队定了一份分页实现规范核心就几条分享给你直接参考一是业务系统默认用游标分页禁止使用 OFFSET 深翻页OFFSET超过 10000 时直接报错提示。二是所有分页查询必须显式指定排序字段且该字段必须是唯一且非空的组合。三是严禁在循环中调用分页接口拉全量数据导出必须走批量流式接口。四是所有分页接口必须支持结果缓存缓存策略由业务场景配置。五是大表查询必须带分区裁剪条件禁止无限制全表扫描。这套规范落地后分页问题在团队里几乎绝迹。不是因为我们写了多精妙的代码而是把容易踩的坑在流程层面提前拦住了。5. 最后再分享几个亲测有效的技巧聊点更实战的小技巧这些大多没有写在任何官方文档里全是我自己踩出来的经验。第一个技巧游标除了可以用数值 ID也可以用时间戳加 ID 的复合 base64 编码。前端传参时不用暴露内部排序字段后端解析时再拆出来。加一层编码既安全又方便后续扩展。我现在做分页接口时游标都是统一格式{timestamp}_{id}前端就是一个不透明字符串后端拿到再解析。这样以后即使换排序字段前端也完全不用改。第二个技巧ClickHouse 的LIMIT N BY特别适合分组 TopN 分页。比如你想看每个城市最新 20 条订单传统做法是先窗口函数算排名再过滤数据量大时效率堪忧。用LIMIT 20 BY city_id一个 SQL 搞定性能能提升一个数量级。很多场景下的“分页”其实是分组 TopN用对语法很重要。第三个技巧Doris 和 StarRocks 的物化视图可以自动做查询改写。你建好物化视图后写分页 SQL 查询时引擎会自动命中物化数据不用改业务代码。这个能力很香但有个前提你的排序字段和过滤条件必须和物化视图的维度对齐否则改写不上。建物化视图时把分页排序的字段也加进去宁可多占点存储也别丢失分页能力。第四个技巧如果你用 Spark分页最好在写出阶段完成。把查询结果写成一个有序的中间结果表加上全局自增 ID 作为排序键分页时直接按 ID 查询。这相当于把“昂贵的排序计算”一次性完成后面的分页就变成了主键点查性能最稳定。这个方法特别适合离线 T1 报表的分页因为数据本身是静态的完全可以提前算出全量排序。一点点个人的体会分页这件事看着小但它直接决定了一个数据分析产品是否“好用”。用户不会关心你的引擎多厉害他只知道“翻到第 20 页卡了 10 秒”就是烂体验。与其等出了问题再优化不如在设计阶段就把分页需求和引擎特性一起想清楚。该用游标用游标该加缓存加缓存该物化就物化组合拳打下来大数据 OLAP 的分页其实没有那么可怕。希望这篇把思路和实操都讲透了你在自己的项目里能少踩几个坑。