最近在看JPA相关内容时,看到 getAll 实现时看到两种分页方式便对其了解了一下,如有错误还请各位佬纠正
offset分页
offset分页主要以limit与offset为核心,通常来讲就是数条数:实现拿取数据的开始条数与结束条数为核心。 在mysql或oracle中则以下面的例子为实现:
-- MySQL/PostgreSQL
SELECT * FROM users OFFSET 99950 LIMIT 50;
-- Oracle
SELECT * FROM users
OFFSET 99950 ROWS
FETCH NEXT 50 ROWS ONLY;
其本质是拿到所有的数据然后做嵌套的过滤,即
select * from(
select t.*,rownum as rn from (你的查询内容)t where rownum > :start
) where rn < :end;
这种分页实现是简单的,但是会有以下的问题
性能问题
每次查询会拿到所有的end条数之前的所有内容,导致效率低下,且越是页数靠后,性能越是差。 假设页大小为50
| 页数 | 请求的条数 |
|---|---|
| 第5 | 250 |
| 第500 | 25000 |
数据问题
如果存在翻页之前插入数据的形式则会导致翻页数据问题,依旧以50页举例:
graph TD
A查询第一页 --> B插入数据25条 --> A翻页到第二页
在此流程下,则会导致A实际上翻到第二页是其认为的1.5页而非第二页,可能产生数据误解
keyset分页
而keyset分页则与offset有所不同,它主要是通过类似游标的方式进行,每次的翻页都会记录下相应主键的值,以此为界限进行翻页,keyset方式尽管实现会更加复杂,但是性能会很稳定
-- MySQL/PostgreSQL
SELECT * FROM users where id>:key LIMIT 50;
-- Oracle
SELECT * FROM users where id>:key and rownum <= 50
这样就避免了offset分页导致的查询无效数据的性能问题,而且这种方式查到的相当于一个当前时间的快照,按照id查询不会查到新添加的数据,只有在新的查询中重新记录游标才会拿到新的数据。需要注意的是,在翻页时需要判断是否可以继续翻页。
但是本质上还是需要处理上下翻页的问题:一般有如下两种方式:
双游标形式
这种方式会记录下本页数据第一条与最后一条的id(下记为sc与ec):上翻页与下翻页便可以以下方式实现
-- 下一页
select * from table where id > :ec and rownum < :pagesize;
-- 上一页
select * from table where id < :sc and rownum < :pagesize;
实现也还是简单便捷的,只是需要记录一个游标即可
对称查询
这种方式只需要记录一个游标c(举例为最后一条)即可:
-- 下一页
select * from table where id > :c order by id asc;
-- 上一页
select * from (select * from table where id > :c order by id desc)order by id asc;
这种方式需要注意的就是,翻上一页的时候是使用倒序但是如果要保持顺序展示还是需要再转一下的。
业务思考
一般的分页场景:
- 服务端传统UI:存在pageno,pagesize,total:依靠页数,页大小,总条数计算总页数。可以使用offset或者rowkey的双游标,省去正序、倒序的步骤,但是依旧是两个场景:直接的页数的场景(需要pageno,pagesize,total:意味着如果keyset需要额外取total?且如果页数跨度大的话需要多次取key?或增大limit再进行筛选?)、上一页下一页场景(不需要pageno,pagesize,total但如何判断是否可以上一页、下一页比较好?提前查询的话存在新增数据的话可能第一页的前方存在不完整的一页,且性能较差,相当于查两页)--offset更加适合存在明确页数的场景,keyset适合做上一页下一页的场景,如果两种按钮均存在可以分别触发不同逻辑的分页查询-- 有新数据可能会更加混乱
- 客户端无限滚动:使用对称查询会比较合适,基本是单向的场景
- 浅分页:offset分页也是足够使用的
- 深度分页:需要使用keyset分页确保性能
结合业务认为offset适合实际页数场景,keyset适合上一页下一页、无限滚动的场景。-- 体现offset优势:随机访问
提出问题keyset如何在实际页数的场景下进行使用,是否有价值使用,是否有好的方式实现?
| 业务场景 | 推荐策略 | 关键原因 | 典型应用 |
|---|---|---|---|
| 后台管理系统分页 | ✅ Offset为主 | 需要页数显示、跳页、统计总数 | CRM、ERP、数据看板 |
| 无限滚动/瀑布流 | ✅ Keyset为主 | 深度分页性能、连续体验 | 社交媒体、新闻feed、电商列表 |
| 搜索列表结果 | ⚠️ 混合策略 | 前几页Offset,深度Keyset | 电商搜索、内容搜索 |
| 实时数据流 | ✅ Keyset | 数据一致性、性能稳定 | 聊天记录、操作日志、时间线 |
| 报表导出 | ✅ Offset | 需要完整数据集、批处理 | 数据导出、报表生成 |
如果我就要实现在传统分页下的keyset分页,就只能去取或者估算key值
public class KeysetWithPageEstimation {
// 方案1:预计算游标映射
public class CursorPageMapper {
private Map<Integer, String> pageToCursor = new ConcurrentHashMap<>();
private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
public CursorPageMapper() {
// 定期预计算前N页的游标
scheduler.scheduleAtFixedRate(this::precomputeCursors, 0, 5, TimeUnit.MINUTES);
}
private void precomputeCursors() {
int maxPages = 100; // 预计算前100页
String cursor = null;
for (int page = 1; page <= maxPages; page++) {
List<User> users = userRepository.findByCursor(cursor, 50);
if (!users.isEmpty()) {
pageToCursor.put(page, buildCursor(users.get(users.size() - 1)));
cursor = buildCursor(users.get(users.size() - 1));
}
}
}
public String getCursorForPage(int page) {
return pageToCursor.get(page);
}
}
// 方案2:页面估算(不精确但可用)
public EstimatedPageInfo estimatePageInfo(String currentCursor, int pageSize) {
// 估算逻辑:
// 1. 统计比当前游标新的记录数
long newerCount = userRepository.countNewerThan(currentCursor);
// 2. 估算页码(假设均匀分布)
long estimatedPage = (newerCount / pageSize) + 1;
long estimatedTotal = newerCount + countOlderThan(currentCursor);
long estimatedTotalPages = (estimatedTotal + pageSize - 1) / pageSize;
return new EstimatedPageInfo(estimatedPage, estimatedTotalPages, estimatedTotal);
}
}
当然,我们应该去思考如何通过keyset分页与offset的分页来优化体验:
-
混合分页策略:例如前五十页使用offset,后面更改UI为上一页下一页,并使用keyset分页:注意边界处理:在第50页的时候需要记录或计算游标,在keyset拿到的下一页
-
预加载 + 虚拟分页:采用只展示特定数量的页数的形式:例如只能在当前页数前两页、后两页的范围内跳转,那么我们直接keyset加载这五页,然后在内存内分页,实现一定范围内的跳转与深度分页的性能稳定,问题就是key的更新会比较复杂,——如每次的key均为5页内第一条key,页大小为前端展示大小的5倍——,且会占用更大的内存
总结
1、offset分页适合浅分页,深度分页性能差
2、keyset分页适合深度分页,性能稳定
3、二者还存在是否为时间切面值、场景适用性的区别
4、思考利用keyset分页优化传统分页