分页是指一系列相互连接且包含相似内容的页面顺序。这是一种将包含大量记录的列表拆分为多个子列表的技术。例如,当你在 Google 上使用关键词搜索时,会收到数以万计的结果。值得注意的是,即使页面某部分的内容被分割到了不同的页面上,我们仍然将其定义为分页。
Java 中的分页实现
为了将大量记录分成多个部分,我们可以使用分页技术。这允许用户仅显示一部分记录。如果在单个页面上加载所有记录,可能会耗费较长时间,因此始终建议创建分页功能。在 Java 中,我们可以轻松开发分页示例。在本示例中,我们将使用 MySQL 数据库来获取记录。但在深入代码之前,让我们先理解为什么这在 2026 年依然重要——尽管我们现在拥有了 React、Vue 和 Serverless,但数据分页的核心逻辑依然是后端工程的基础。
实战示例:构建核心逻辑
第 1 步:在 MySQL 中创建一张表
create table employee(empid int(11),empname varchar(20),empsalary int(11),empdept varchar(20));
第 2 步:创建一个 JavaBean 类,用于向数据库设置值以及从数据库获取值。
在现代开发中,我们通常称这种简单的 POJO 为“实体”或“DTO”。虽然 Lombok 等工具可以自动生成样板代码,但理解底层的 Getter 和 Setter 对于掌握 Java 内存模型至关重要。
public class Employee {
int employeeId;
String employeeName;
int salary;
String deptName;
// 标准的 Getter 方法
// 我们在这里封装数据,防止外部直接访问内部状态
public String getDeptName() { return deptName; }
public void setDeptName(String deptName) {
this.deptName = deptName;
}
public int getEmployeeId() { return employeeId; }
public void setEmployeeId(int employeeId) {
this.employeeId = employeeId;
}
public String getEmployeeName() { return employeeName; }
public void setEmployeeName(String employeeName) {
this.employeeName = employeeName;
}
public int getSalary() { return salary; }
public void setSalary(int salary) {
this.salary = salary;
}
}
第 3 步:创建一个工厂类,用于从数据库获取连接。
注意:在下面的代码中,我们使用了单例模式。在 2026 年,我们通常依赖连接池(如 HikariCP)和 Spring Boot 的自动配置来管理这些。但作为一个“硬核”的 Java 工程师,理解如何手动建立 JDBC 连接是你调试底层连接泄漏问题的关键。
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class ConnectionFactory {
// 指向自身的静态引用
// 这是一个经典的“饿汉式”单例实现,线程安全但缺乏懒加载
private static ConnectionFactory instance = new ConnectionFactory();
// 在生产环境中,这些配置应当外部化到配置中心(如 Nacos 或 Consul)
String url = "jdbc:mysql://localhost:3306/ashok";
String user = "root";
String password = "";
// 注意:com.mysql.jdbc.Driver 在 MySQL 8.0+ 中已被废弃,建议使用 com.mysql.cj.jdbc.Driver
String driverClass = "com.mysql.cj.jdbc.Driver";
// 私有构造函数防止外部 new 对象
private ConnectionFactory() {
try {
Class.forName(driverClass);
} catch (ClassNotFoundException e) {
e.printStackTrace();
// 在生产环境中,这里应该记录到日志系统(如 SLF4J)而不是仅打印堆栈
}
}
public static ConnectionFactory getInstance() {
return instance;
}
// 获取连接
public Connection getConnection() throws SQLException, ClassNotFoundException {
Connection connection = DriverManager.getConnection(url, user, password);
return connection;
}
}
第 4 步:创建一个 Dao 类,用于创建工厂类对象并在方法中调用。同时创建查询语句,将其结果设置到 JavaBean 对象中,并添加到 ArrayList 对象里。
这里我们引入了 SQL_CALC_FOUND_ROWS。这是一个很有用的技巧,它允许我们在一次数据库交互中获取数据和总数。然而,在现代高并发场景下,我们可能会思考:这真的是最高效的方式吗?让我们先看看代码,稍后我会进行深度剖析。
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
public class EmployeeDAO {
Connection connection;
Statement stmt;
// 用于保存总记录数,以便前端计算页码
private int noOfRecords;
public EmployeeDAO() {}
private static Connection getConnection() throws SQLException, ClassNotFoundException {
Connection con = ConnectionFactory.getInstance().getConnection();
return con;
}
// 核心分页逻辑
// offset: 起始位置 (例如第2页,每页5条,offset就是5)
// noOfRecords: 每页显示的记录数
public List viewAllEmployees(int offset, int noOfRecords) {
// 使用 SQL_CALC_FOUND_ROWS 是一种优化,避免发送两次 Count 查询
// 但在某些 MySQL 版本中,这可能会引发性能锁竞争
String query = "select SQL_CALC_FOUND_ROWS * from employee limit " + offset + ", " + noOfRecords;
List list = new ArrayList();
Employee employee = null;
try {
connection = getConnection();
stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(query);
// 遍历结果集,将数据库行映射为 Java 对象 (ORM 的原始形态)
while (rs.next()) {
employee = new Employee();
employee.setEmployeeId(rs.getInt(1));
employee.setEmployeeName(rs.getString(2));
employee.setSalary(rs.getInt(3));
employee.setDeptName(rs.getString(4));
list.add(employee);
}
rs.close();
// 这是一个 MySQL 特有的函数,用于获取上一条查询不考虑 Limit 时的总行数
rs = stmt.executeQuery("SELECT FOUND_ROWS()");
if (rs.next())
this.noOfRecords = rs.getInt(1);
} catch (SQLException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
} finally {
// 资源释放是防止内存泄漏的关键
try {
if (stmt != null) stmt.close();
if (connection != null) connection.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
return list;
}
// 提供给 Servlet 获取总记录数的方法
public int getNoOfRecords() { return noOfRecords; }
}
深入剖析:2026 年视角的分页技术重构
作为一名经历过多个技术周期迭代的开发者,我们深知上述代码虽然能够工作,但在 2026 年的云原生与 AI 时代,我们需要用更先进的工程思维来审视它。在我们的最近的一个企业级项目中,我们将这种传统的 Servlet 分页逻辑重构为了更符合现代微服务架构的模式。让我们探讨几个关键点。
#### 1. 从 Statement 到 PreparedStatement:不仅是语法糖,更是安全防线
在之前的示例中,为了代码简洁,我们使用了 INLINECODEa4c1c3e8 并拼接 SQL 字符串。这在 2026 年是绝对不被允许的,也是我们作为工程师必须第一时间识别出的安全漏洞。SQL 注入至今仍是 OWASP Top 10 的常客。如果你现在使用 Cursor 或 GitHub Copilot 等辅助编程工具,它们可能会建议你直接使用 INLINECODE758ae527,这正是 AI 辅助我们在基础安全层面不犯错的一个例子。
改进后的 DAO 片段 (使用 PreparedStatement):
public List viewAllEmployeesSafe(int offset, int limit) {
String query = "SELECT * FROM employee LIMIT ?, ?";
List list = new ArrayList();
try (Connection con = ConnectionFactory.getInstance().getConnection();
PreparedStatement pstmt = con.prepareStatement(query)) {
// 参数化查询,数据库驱动会自动处理转义,彻底杜绝 SQL 注入
pstmt.setInt(1, offset);
pstmt.setInt(2, limit);
try (ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
Employee emp = new Employee();
// ... 映射逻辑 ...
list.add(emp);
}
}
// 注意:这里我们不再使用 SQL_CALC_FOUND_ROWS,因为某些现代云数据库(如 Aurora)
// 推荐使用 COUNT(*) OVER() 或单独的 Count 查询来优化资源利用。
} catch (SQLException | ClassNotFoundException e) {
throw new RuntimeException("分页查询失败", e); // 异常链保留
}
return list;
}
#### 2. 性能与“Keyset Pagination” (游标分页)
传统的 INLINECODE8f18635a 在数据量达到百万级时,性能会断崖式下跌。为什么?因为数据库必须扫描并跳过 INLINECODE6467d677 之前的所有行。在我们的生产实践中,当数据量激增时,我们发现第 10000 页的加载时间远高于第 1 页。
为了解决这个问题,我们引入了 Keyset Pagination (也称为 Cursor-based Pagination 或 Seek Method)。这种方法不使用偏移量,而是记录上一页最后一条数据的 ID(或唯一索引)。
场景对比:
- 传统分页:
SELECT * FROM user LIMIT 10000, 10;(数据库需读取 10010 行) - Keyset 分页:
SELECT * FROM user WHERE id > last_seen_id ORDER BY id LIMIT 10;(数据库直接通过索引定位,极快)
这种“无状态”的分页方式在 2026 年的移动应用和无限滚动场景中已成为标准。
#### 3. 缓存策略:Redis 与本地缓存的艺术
想象一下,如果首页被每秒数万次访问,每次都去击打 MySQL 数据库是不明智的。我们可以在 Servlet 层引入多级缓存。
- L1 本地缓存: 存放元数据或极高热点的数据,但由于 Servlet 容器可能分布式部署,本地缓存容易不一致,需谨慎使用。
- L2 分布式缓存: 这里的王者无疑是 Redis。
在 2026 年,我们不仅缓存数据,更缓存“计算”。例如,我们可以直接缓存 INLINECODEe7248888 的序列化 JSON 结果,甚至可以使用 Redis 的 INLINECODE976277a0 结构来存储每一页的数据,设置合理的过期时间(TTL)。
// 伪代码:带有缓存逻辑的 Service 层
public List getEmployeesByPage(int page) {
String cacheKey = "employee_page_" + page;
// 1. 尝试从 Redis 获取
String cachedData = redis.get(cacheKey);
if (cachedData != null) {
return deserialize(cachedData); // 命中缓存,直接返回
}
// 2. 缓存未命中,查询数据库
List result = employeeDAO.viewAllEmployees(...);
// 3. 写入缓存,设置 5 分钟过期
redis.setex(cacheKey, 300, serialize(result));
return result;
}
现代前端与 Servlet 的交互:AJAX 还是 Server-Side Rendering?
在 GeeksforGeeks 的传统示例中,我们通常使用 JSP 进行服务端渲染。但在 2026 年,前后端分离已成主流。我们不再返回 HTML 视图,而是返回 JSON。
这意味着我们的 Servlet 代码会有所变化,我们将使用 INLINECODEec2e3f9b 来输出 JSON 字符串,而不是 INLINECODE8751b71a。
JSON 响应示例:
// 在 Servlet 中
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
int page = Integer.parseInt(request.getParameter("page"));
int limit = 10;
List employees = dao.viewAllEmployees((page-1)*limit, limit);
int totalRecords = dao.getNoOfRecords();
// 构建一个简单的 Map 并转为 JSON (生产环境使用 Jackson 或 Gson)
Map data = new HashMap();
data.put("employees", employees);
data.put("currentPage", page);
data.put("totalPages", (int) Math.ceil((double) totalRecords / limit));
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(new Gson().toJson(data));
}
总结:从代码编写者的视角
回顾这篇文章,我们从最基本的 JDBC 分页开始,一步步深入到安全性、性能优化以及现代化的缓存策略。我们不仅复制了代码,更重要的是,我们一起探讨了“为什么”和“怎么做”。在 2026 年,虽然框架会变得越来越智能(也许 AI 可以直接帮我们写出这些 DAO),但理解底层的原理——无论是 SQL 执行计划、网络开销,还是缓存一致性——依然是区分“码农”和“架构师”的试金石。希望你在实际项目中,不仅能让代码运行起来,更能让它优雅、安全且高效地运行。