本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:分页查询是处理大规模数据展示的核心技术,广泛应用于各类Web系统以提升性能与用户体验。本文深入讲解分页查询的基本概念、SQL实现方式及在Spring、Struts、Hibernate三大主流Java Web框架中的具体应用。内容涵盖MySQL、Oracle、SQL Server等数据库的分页语法,Spring Data JPA与MyBatis的分页集成,Hibernate的Criteria与HQL分页方法,Struts2结合前端标签库的分页展示,以及jQuery等前端工具的配合使用。通过Spring+Struts+Hibernate整合案例,全面呈现从后端数据获取到前端渲染的完整分页流程,助力开发者构建高效的数据展示系统。

分页查询的深度解析与工程实践:从数据库到前端的全链路优化

在现代Web应用中,你有没有遇到过这样的场景?——用户打开一个商品列表页,页面卡顿了三秒才渲染出数据;或者当你翻到第1000页时,系统直接报错“请求超时”。这些问题背后,往往都指向同一个核心问题: 分页查询设计不合理

我们每天都在写 LIMIT 10 OFFSET 20 ,调用 Pageable 接口,使用 <el-pagination> 组件……但你知道吗?看似简单的“翻页”功能,其实是连接数据库性能、服务稳定性与用户体验的关键枢纽。一个低效的分页实现,轻则拖慢响应速度,重则击穿整个系统。

今天,我们就来彻底拆解这个“小功能”背后的“大世界”,带你从底层机制到上层交互,走完一次完整的分页之旅 🚀。


🔍 什么是真正的“分页”?

很多人以为分页就是把数据切成一页一页返回,其实不然。真正高效的分页是一种 按需加载 + 精准定位 的策略。

它的数学本质很简单:

offset = (page - 1) * size
  • page :当前页码(比如第3页)
  • size :每页条数(比如10条)
  • offset :跳过多少条记录

所以第3页的数据,就需要跳过 (3-1)*10 = 20 条记录,然后取接下来的10条。

这看起来很直观,但在实际执行中,不同的数据库、ORM框架甚至前后端协作方式,都会让这个过程变得复杂而微妙。

来看一个典型的RESTful分页响应结构:

{
  "content": [...],
  "total": 1000,
  "page": 1,
  "size": 10,
  "hasNext": true
}

这些字段不仅仅是展示用途,它们构成了前后端协同工作的“契约”。例如:
- total 告诉前端总共有多少页;
- hasNext 决定了是否显示“下一页”按钮;
- page size 可用于构建下一次请求的URL。

💡 小贴士:如果你正在设计API,建议统一采用这种标准格式。它已被 Spring Data REST、Swagger UI 等广泛支持,几乎成了行业事实标准。


🛠️ 数据库层:别再盲目用 OFFSET!

让我们先聚焦最底层——数据库。毕竟,90%的分页性能问题,根源都在这里。

MySQL 的 LIMIT/OFFSET 到底慢在哪?

假设你在MySQL里写了这么一句SQL:

SELECT * FROM orders 
ORDER BY created_at DESC 
LIMIT 10 OFFSET 50000;

你想获取第5001页的数据(每页10条),结果发现查询花了800ms!为什么?

因为 OFFSET 并不是跳跃式访问,而是顺序扫描跳过 。即使你为 created_at 加了索引,MySQL也必须:
1. 扫描前50010条记录;
2. 跳过前50000条;
3. 返回最后10条。

这就像是你要找一本书的第100章,却得先把前面99章全部读一遍 😅。

分页深度 查询耗时(ms) 是否命中索引
第1页 5
第100页 15
第1000页 80
第10000页 650

可以看到,随着偏移量增大,耗时呈非线性增长。这就是所谓的“深度分页陷阱”。

如何破局?试试游标分页(Cursor-based Pagination)

与其记住“第几页”,不如记住“上次看到哪一条”。我们可以基于某个有序字段(如时间戳或主键)进行续读。

-- 上一页最后一条记录的时间是 '2023-04-01 10:20:30'
SELECT * FROM orders 
WHERE created_at < '2023-04-01 10:20:30' 
ORDER BY created_at DESC 
LIMIT 10;

这种方式的优势非常明显:
- ✅ 利用索引直接定位起点;
- ✅ 时间复杂度接近 O(log n),不受分页深度影响;
- ✅ 特别适合“无限滚动”类场景(如朋友圈、信息流);

当然也有缺点:不能随机跳转到任意页码。但对于大多数连续浏览场景来说,这是完全可以接受的折衷。

更严谨的做法是加上主键兜底,防止时间重复导致漏数据:

SELECT id, title, created_at 
FROM posts 
WHERE (created_at < ? OR (created_at = ? AND id < ?)) 
ORDER BY created_at DESC, id DESC 
LIMIT 10;

前端只需保存并传递上一页最后一个 created_at id 即可。

flowchart TD
    A[客户端发起分页请求] --> B{是否首次请求?}
    B -- 是 --> C[执行标准LIMIT OFFSET查询]
    B -- 否 --> D[携带上一页末尾游标值]
    D --> E[构造WHERE条件过滤已读数据]
    E --> F[使用索引快速定位起始点]
    F --> G[返回下一批数据]

图:基于游标的分页流程图

Oracle 怎么做分页?ROWNUM 的坑你踩过吗?

Oracle没有 LIMIT 关键字,而是通过伪列 ROWNUM 实现分页。但它的行为有点反直觉:

-- ❌ 错误示范
SELECT * FROM (
    SELECT * FROM employees ORDER BY salary DESC
) WHERE ROWNUM > 10 AND ROWNUM <= 20; -- 永远无结果!

为什么会这样?因为 ROWNUM 是在结果生成过程中动态分配的,且从1开始递增 。第一条记录得到 ROWNUM=1 ,不满足 >10 ,被过滤;后续所有行都无法进入结果集。

正确做法是嵌套两层子查询:

SELECT * FROM (
    SELECT e.*, ROWNUM rn FROM (
        SELECT * FROM employees ORDER BY salary DESC
    ) e WHERE ROWNUM <= 20
) WHERE rn > 10;
  • 内层排序并限制最大返回20行;
  • 中间层添加行号别名 rn
  • 外层筛选出第11~20条。

为了提升性能,还可以加上优化器提示:

/*+ FIRST_ROWS(10) */

告诉CBO(成本基优化器)优先返回前N行,减少整体延迟。

如果你追求极致性能,还可以尝试基于 ROWID 的游标分页:

SELECT * FROM (
    SELECT e.*, ROWID rid FROM employees e
    WHERE ROWID > ?
    ORDER BY salary DESC
    FETCH FIRST 10 ROWS ONLY
);

ROWID 是Oracle中指向物理地址的唯一标识,可以实现近乎常量时间的翻页 ⚡️。

SQL Server 的窗口函数真香警告!

SQL Server 提供了强大的 ROW_NUMBER() 函数,结合 OVER() 子句,能写出非常清晰的分页逻辑:

WITH PagedEmployees AS (
    SELECT 
        id, name, salary,
        ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn
    FROM employees
)
SELECT id, name, salary FROM PagedEmployees 
WHERE rn BETWEEN 11 AND 20;

语义明确,维护性强。而且从 SQL Server 2012 开始,还支持标准语法:

SELECT * FROM employees 
ORDER BY salary DESC 
OFFSET 10 ROWS 
FETCH NEXT 10 ROWS ONLY;

推荐新项目优先使用后者,不仅简洁,还能获得更好的执行计划优化。

下面是三种常见方案的执行成本对比:

查询方式 执行步骤 成本估算
OFFSET-FETCH Sort → Offset → Fetch
ROW_NUMBER()+CTE Index Scan → Window Function → Filter 中等
TOP + CTE Top N Scan → Window → Filter

聪明的做法是先用 TOP 限制扫描范围,避免全表计算行号。

graph LR
    A[原始数据] --> B[排序操作]
    B --> C{是否使用索引排序?}
    C -- 是 --> D[索引扫描]
    C -- 否 --> E[内存排序(filesort)]
    D --> F[应用OFFSET/FETCH]
    E --> F
    F --> G[返回结果]

图:SQL Server分页执行路径


🧱 持久层框架:别让ORM成为你的黑盒

现在回到Java世界。我们在开发中经常依赖Spring Data JPA、MyBatis这类框架,但如果不了解它们如何生成SQL,很容易掉进性能陷阱。

Spring Data JPA:方便背后的代价

Spring Data JPA 提供了极其便捷的分页接口:

public interface UserRepository extends PagingAndSortingRepository<User, Long> {}

@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;

    public Page<User> getUsers(int page, int size, String sortBy) {
        Pageable pageable = PageRequest.of(page, size, Sort.by(sortBy));
        return userRepository.findAll(pageable);
    }
}

短短几行代码就实现了分页功能,是不是很爽?但别忘了,框架背后做了什么:

-- 先执行 COUNT 查询
SELECT COUNT(*) FROM user;

-- 再执行分页查询
SELECT * FROM user ORDER BY createdDate DESC LIMIT 10 OFFSET 10;

看到了吗?每次分页都会执行两次查询!对于大表而言, COUNT(*) 本身就可能很慢,尤其是在有复杂联表或动态条件的情况下。

💡 建议
- 对于总数不变的场景(如配置表),可以接受;
- 对于高频访问的大数据列表,考虑关闭总数统计:

Pageable pageable = PageRequest.of(page, size, sort);
Slice<User> slice = userRepository.findAll(pageable); // 使用 Slice 而非 Page

Slice 不查总数,只判断是否有下一页,效率更高。

MyBatis:灵活但需要手动控制

相比JPA的“自动化”,MyBatis 更贴近SQL,给了开发者更多掌控权。

基础分页写法如下:

<select id="selectUsers" resultType="User">
    SELECT id, name, email, create_time
    FROM users
    <where>
        <if test="status != null">
            AND status = #{status}
        </if>
    </where>
    ORDER BY create_time DESC
    LIMIT #{limit} OFFSET #{offset}
</select>

对应的Mapper接口:

List<User> selectUsers(@Param("offset") int offset,
                       @Param("limit") int limit,
                       @Param("status") String status);

注意几个关键点:
- offset = page * size (页码从0开始);
- 必须手动校验参数合法性;
- 动态排序字段要用 ${} 而非 #{} ,但要注意SQL注入风险!

参数名 类型 安全建议
#{offset} int 应做非负校验
#{limit} int 建议限制最大值(如100)
${sortField} string 白名单校验,避免SQL注入
${sortOrder} string 仅允许ASC/DESC

当面对百万级数据导出任务时,传统分页容易OOM。这时可以用 ResultHandler 实现流式处理:

sqlSessionTemplate.select("selectAllUsers", new ResultHandler<User>() {
    @Override
    public void handleResult(ResultContext<? extends User> context) {
        User user = (User) context.getResultObject();
        processUser(user); // 逐条处理,不占内存
    }
});

数据通过JDBC ResultSet 流式传输,极大降低内存压力,特别适合批量导出、ETL等场景。

Hibernate 原生API:老派但精准

虽然大多数人用JPA封装,但在一些高性能模块中,仍会直接使用Hibernate原生API:

Session session = sessionFactory.openSession();
String hql = "FROM User u WHERE u.status = :status ORDER BY u.createTime DESC";
Query<User> query = session.createQuery(hql, User.class);

query.setParameter("status", "ACTIVE");
query.setFirstResult(10);   // 相当于 OFFSET
query.setMaxResults(10);    // 相当于 LIMIT

List<User> results = query.list();

简单直接,完全可控。配合Criteria API还能实现类型安全的动态查询:

CriteriaBuilder cb = session.getCriteriaBuilder();
CriteriaQuery<User> cq = cb.createQuery(User.class);
Root<User> root = cq.from(User.class);

cq.select(root)
  .where(cb.equal(root.get("status"), "ACTIVE"))
  .orderBy(cb.desc(root.get("createTime")));

TypedQuery<User> query = session.createQuery(cq);
query.setFirstResult(10);
query.setMaxResults(10);

编译期检查字段名,避免拼写错误,适合复杂业务逻辑组合。

graph LR
    A[HQL/Criteria Query] --> B{设置 setFirstResult/setMaxResults}
    B --> C[生成 SQL 带 LIMIT/OFFSET]
    C --> D[执行数据库查询]
    D --> E[获取 ResultSet]
    E --> F[映射为实体对象]
    F --> G[返回 List 或 Page]

⚙️ 服务层协同:打造健壮的分页流水线

有了底层支撑还不够,服务层才是串联整个分页流程的核心。

参数校验:第一道防线

任何来自前端的输入都是潜在威胁。恶意用户可能发送:

?page=-999999&size=99999

这会导致严重的性能问题甚至DoS攻击。因此必须建立防御机制。

定义一个标准DTO:

public class PageRequestDTO {
    @Min(value = 1, message = "页码最小值为1")
    @Max(value = Integer.MAX_VALUE, message = "页码过大")
    private Integer page = 1;

    @Min(value = 1, message = "每页数量最小值为1")
    @Max(value = 1000, message = "每页数量不得超过1000")
    private Integer size = 20;

    // getters and setters
}

控制器中自动校验:

@GetMapping
public ResponseEntity<Page<User>> getUsers(@Valid PageRequestDTO dto, 
                                          BindingResult result) {
    if (result.hasErrors()) {
        throw new IllegalArgumentException(
            result.getAllErrors().stream()
                  .map(ObjectError::getDefaultMessage)
                  .collect(Collectors.joining(", "))
        );
    }

    Pageable pageable = PageRequest.of(dto.getPage() - 1, dto.getSize());
    Page<User> users = userService.findAll(pageable);
    return ResponseEntity.ok(users);
}

遵循“失败快”原则,尽早拒绝非法请求。

还可以引入动态限流机制,根据不同用户等级设置不同上限:

@Service
public class PaginationConfigService {
    public int getMaxPageSize(String apiKey) {
        return switch (getUserTier(apiKey)) {
            case "premium" -> 5000;
            case "enterprise" -> 10000;
            default -> 1000;
        };
    }
}

适用于SaaS平台或多租户系统。

flowchart TD
    A[HTTP请求到达] --> B{参数是否合法?}
    B -- 否 --> C[返回400 Bad Request]
    B -- 是 --> D{size > 最大允许值?}
    D -- 是 --> E[截断为maxSize]
    D -- 否 --> F[保留原值]
    E --> G[构造Pageable]
    F --> G
    G --> H[调用Service层]
    H --> I[返回分页结果]

自动化拦截器:告别重复代码

每个Controller都写一遍参数校验太麻烦?可以用AOP或拦截器统一处理。

方案一:自定义注解 + AOP切面
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EnablePaging {
    int defaultSize() default 20;
    int maxSize() default 1000;
}

切面逻辑:

@Aspect
@Component
public class PagingAspect {
    @Around("@annotation(enablePaging)")
    public Object injectPageable(ProceedingJoinPoint pjp, EnablePaging enablePaging) 
            throws Throwable {
        HttpServletRequest request = getCurrentRequest();
        int page = parseInt(request.getParameter("page"), 1);
        int size = parseInt(request.getParameter("size"), enablePaging.defaultSize());
        size = Math.min(size, enablePaging.maxSize());

        for (int i = 0; i < pjp.getArgs().length; i++) {
            if (pjp.getArgs()[i] instanceof PageableHolder) {
                pjp.getArgs()[i] = new PageableHolder(page, size);
            }
        }

        return pjp.proceed();
    }
}
方案二:Spring MVC拦截器
public class PagingInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        request.setAttribute("resolvedPage", Math.max(1, parseOr(request.getParameter("page"), 1)));
        request.setAttribute("resolvedSize", Math.min(parseOr(request.getParameter("size"), 20), 1000));
        return true;
    }
}

注册到配置中即可全局生效。

两种方式各有优势:AOP更灵活,适合复杂场景;Interceptor更适合统一治理。


🖼️ 前端展示:不只是UI,更是体验

最后来到用户看得见的部分。一个好的分页组件,不仅要能用,还要“好用”。

jQuery Pagination 插件还能用吗?

虽然现在主流是Vue/React,但仍有大量遗留系统在用jQuery。经典的 jquery.pagination.js 依然可用:

<div id="pagination-container"></div>

<script>
$('#pagination-container').pagination({
    pages: 128,
    currentPage: 1,
    displayedPages: 5,
    onPageClick: function(pageNumber) {
      loadPageData(pageNumber, 10);
    }
});
</script>

只要后端返回 totalPages 字段,就能自动渲染页码按钮。适合传统多页面架构。

Vue + Element Plus:现代化组件典范

<template>
  <el-table :data="tableData" v-loading="loading">
    <!-- 表格列 -->
  </el-table>

  <el-pagination
    @size-change="handleSizeChange"
    @current-change="handleCurrentChange"
    :current-page="currentPage"
    :page-sizes="[10, 20, 50, 100]"
    :page-size="pageSize"
    layout="total, sizes, prev, pager, next, jumper"
    :total="totalItems"
  />
</template>

<script>
export default {
  data() {
    return {
      tableData: [],
      currentPage: 1,
      pageSize: 10,
      totalItems: 0,
      loading: false
    };
  },
  methods: {
    async fetchData() {
      this.loading = true;
      const res = await fetch(`/api/users?page=${this.currentPage - 1}&size=${this.pageSize}`);
      const data = await res.json();

      this.tableData = data.content;
      this.totalItems = data.totalElements;
      this.loading = false;
    },
    handleSizeChange(val) {
      this.pageSize = val;
      this.fetchData();
    },
    handleCurrentChange(val) {
      this.currentPage = val;
      this.fetchData();
    }
  },
  mounted() {
    this.fetchData();
  }
};
</script>

组件自动处理各种交互逻辑,配合 v-loading 提供流畅反馈。

“无限滚动”怎么搞?Intersection Observer 来帮忙

移动端常用“上拉加载更多”,可以通过监听元素可见性实现:

const observer = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting && !loading && hasMore) {
    loadNextPage();
  }
});

observer.observe(document.querySelector('#infinite-trigger'));

function loadNextPage() {
  loading = true;
  currentPage++;
  fetch(`/api/feed?page=${currentPage}`)
    .then(r => r.json())
    .then(data => {
      feedList.push(...data.content);
      hasMore = data.hasNextPage;
    })
    .finally(() => loading = false);
}

无需翻页按钮,浏览更连贯。

graph TD
    A[用户进入页面] --> B{是否首次加载?}
    B -->|是| C[请求第一页数据]
    B -->|否| D[监听滚动位置]
    D --> E[触底检测]
    E --> F{是否有下一页?}
    F -->|是| G[发起下一页请求]
    F -->|否| H[提示已到底部]
    G --> I[追加数据至列表]
    I --> J[更新分页状态]
    J --> D

🌟 用户体验优化:那些让你眼前一亮的小细节

智能预加载:让用户感觉“更快”

利用空闲时间提前拉取下一页数据:

let prefetchController = new AbortController();

function schedulePrefetch(nextPage) {
  requestIdleCallback(async () => {
    if (navigator.onLine && !cache.has(nextPage)) {
      try {
        const res = await fetch(`/api/data?page=${nextPage}`, { signal: prefetchController.signal });
        const data = await res.json();
        cache.set(nextPage, data);
      } catch (e) {}
    }
  });
}

当用户点击“下一页”时,优先从缓存读取,瞬间完成切换。

记住浏览位置:刷新也不丢进度

使用 sessionStorage 保存当前页和筛选条件:

// 保存
function saveViewPosition(key, page, filters) {
  sessionStorage.setItem(key, JSON.stringify({ page, filters }));
}

// 恢复
function restoreViewPosition(key) {
  const saved = sessionStorage.getItem(key);
  return saved ? JSON.parse(saved) : null;
}

// 初始化
const pos = restoreViewPosition('user-list');
if (pos) {
  setCurrentPage(pos.page);
  applyFilters(pos.filters);
}

这种细节虽小,却能让产品显得更加专业和贴心 ❤️。


✅ 总结:构建高效分页系统的五大原则

  1. 避免深度OFFSET :超过1万条的表慎用 LIMIT OFFSET ,改用游标分页;
  2. 合理使用索引 :确保排序字段有索引,最好覆盖查询所需字段;
  3. 控制单页数量 :服务端强制限制 size 上限,防DoS攻击;
  4. 减少COUNT查询 :大数据列表可用 Slice 替代 Page
  5. 前后端协同设计 :统一元数据结构,支持缓存、预加载等高级特性。

分页看似平凡,实则是系统架构能力的试金石。一个小小的 LIMIT 背后,藏着数据库原理、网络传输、内存管理、用户体验等多个维度的权衡。

下次当你写下 page=1&size=10 的时候,不妨多想一步:这条请求真的高效吗?它会不会在未来某天成为压垮系统的最后一根稻草?

毕竟,真正的工程师,从不在细节上妥协 🛠️✨。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:分页查询是处理大规模数据展示的核心技术,广泛应用于各类Web系统以提升性能与用户体验。本文深入讲解分页查询的基本概念、SQL实现方式及在Spring、Struts、Hibernate三大主流Java Web框架中的具体应用。内容涵盖MySQL、Oracle、SQL Server等数据库的分页语法,Spring Data JPA与MyBatis的分页集成,Hibernate的Criteria与HQL分页方法,Struts2结合前端标签库的分页展示,以及jQuery等前端工具的配合使用。通过Spring+Struts+Hibernate整合案例,全面呈现从后端数据获取到前端渲染的完整分页流程,助力开发者构建高效的数据展示系统。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐