ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Servlet+JSP+MySQL实战:用户列表分页展示与中文乱码解决

Servlet+JSP+MySQL实战:用户列表分页展示与中文乱码解决

1. 项目缘起与整体设计思路

1.1 这个项目到底在做什么

先把话说清楚:这个项目的本质,是用最经典的 Java Web 三件套——Servlet、JSP、MySQL——搭一个用户信息列表展示的完整闭环。听起来像是教科书里的练习题,但真动手做一遍,你会发现里面藏着大量实际开发中绕不开的坑:数据库连接怎么管、前后端数据怎么传、页面怎么渲染、分页怎么做、中文乱码怎么处理。

我做这个项目的出发点很简单:很多刚入行的朋友学完 Servlet 和 JSP 之后,知识点是散的,知道doGet和doPost的区别,也知道 JDBC 怎么连数据库,但把这些东西串成一个能跑起来的完整应用,中间总卡壳。这个项目就是把这些散点串成一条线,让你看到一个请求从浏览器发出,经过 Servlet 处理,查完 MySQL,再把数据交给 JSP 渲染成页面的完整链路。

适合谁来参考:有 Java 基础、了解 HTML 和基本 SQL 的朋友。如果你已经能写简单的 Servlet,但不知道怎么和数据库配合做一个真实的功能模块,这个项目正好补上这一环。零基础也能看,但建议先把 Java 语法和 JDBC 基础过一遍,不然会有点吃力。

1.2 为什么选 Servlet + JSP + MySQL 这套组合

现在做 Web 开发,Spring Boot 几乎是默认选项,那为什么还要用 Servlet + JSP?我的理由有三条。

第一,理解底层。Spring Boot 帮你封装了 DispatcherServlet、视图解析器、数据源管理,用起来爽,但出了问题你根本不知道从哪查。用原生 Servlet 写一遍,你会真正理解一个 HTTP 请求是怎么被容器接收、怎么路由到具体处理逻辑、怎么把结果写回响应的。这些认知在你后面用框架时,会变成排查问题的直觉。

第二,依赖极简。这个项目只需要一个 Servlet 容器(比如 Tomcat)、一个 MySQL 数据库、一个 JDBC 驱动包,没有复杂的依赖树,没有版本冲突,新手不会被 Maven 的依赖管理搞晕。你可以把全部精力放在业务逻辑上。

第三,JSP 的模板能力。虽然现在前后端分离是大趋势,但 JSP 作为服务端渲染的模板技术,在展示类页面上依然高效。你不需要写一堆 AJAX 请求,不需要处理 JSON 序列化,Servlet 把数据塞进 request 域,JSP 直接遍历渲染,简单直接。

注意:这里说的“前后端分离”是指逻辑上的分层——Servlet 负责控制和数据处理,JSP 负责展示,各司其职。不是指现在流行的 REST API + 前端框架那种物理分离。这个概念要区分清楚,不然容易混淆。

1.3 整体架构分层设计

我把整个项目分成四层,每层职责明确,不互相越界。

表现层(JSP):只负责把数据渲染成 HTML,不写任何 Java 业务逻辑。JSP 里可以有 JSTL 标签做循环和判断,但不应该有数据库操作代码。

控制层(Servlet):接收请求、调用服务层、把结果存入 request 域、转发到 JSP。Servlet 是请求的入口和出口,但它不应该直接写 SQL。

服务层(Service):处理业务逻辑,比如分页计算、数据校验。这一层看起来简单,但它是解耦的关键。如果以后要加缓存或者换数据源,只改这一层就行。

数据访问层(DAO):封装所有 JDBC 操作,对外提供findAll、findByPage、count这样的方法。上层不需要知道 SQL 怎么写,只需要调用方法拿数据。

这种分层的好处是:改数据库不影响业务逻辑,改页面不影响数据处理,每层可以独立测试。虽然项目小,但养成这个习惯,后面做大了不会乱。

2. 核心细节解析与实操要点

2.1 数据库表设计与建表语句

用户信息列表,核心就是一张用户表。我设计的字段如下:

字段名类型说明
idINT AUTO_INCREMENT主键,自增
usernameVARCHAR(50)用户名,非空
emailVARCHAR(100)邮箱
phoneVARCHAR(20)手机号
create_timeDATETIME创建时间,默认当前时间

建表语句:

CREATE TABLE user_info ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, email VARCHAR(100), phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有几个细节值得说。字符集用utf8mb4而不是utf8,因为utf8在 MySQL 里是三个字节的,存不了 emoji 和一些特殊字符,utf8mb4才是真正的四字节 UTF-8。引擎用 InnoDB,支持事务和行级锁,虽然这个项目用不上事务,但养成习惯没坏处。

create_time用DEFAULT CURRENT_TIMESTAMP,插入数据时不用手动传时间,数据库自动填。这个设计在实际开发中很常见,省事且不容易出错。

实操心得:建表时一定要显式指定字符集。我踩过的坑是,数据库默认字符集是 latin1,插入中文直接变问号,排查半天才发现是建表时没指定。后来养成习惯,每张表都写DEFAULT CHARSET=utf8mb4。

2.2 JDBC 工具类的封装

直接在每个 DAO 方法里写DriverManager.getConnection是灾难,代码重复不说,连接管理也容易出问题。我封装了一个DBUtil类,核心做三件事:加载驱动、获取连接、关闭资源。

public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/test_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "your_password"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL驱动加载失败", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement stmt, ResultSet rs) { // 依次关闭,每个都判空,异常吞掉但不影响其他关闭 } }

连接 URL 里有三个参数必须注意。useSSL=false关掉 SSL 警告,本地开发用不上。serverTimezone=Asia/Shanghai指定时区,不指定的话 MySQL 8 会报时区错误。characterEncoding=utf8保证中文不乱码。

驱动类名是com.mysql.cj.jdbc.Driver,注意是cj不是jdbc。MySQL 5 的驱动类是com.mysql.jdbc.Driver,MySQL 8 换成了cj包。用错的话会报ClassNotFoundException。

注意:这个工具类每次调用getConnection都新建连接,没有用连接池。项目小、并发低的时候没问题,但如果要做生产级应用,必须换成连接池(比如 HikariCP 或 Druid)。连接池的核心价值是复用连接,避免频繁建立和销毁连接的开销。一个数据库连接的建立涉及 TCP 握手、认证等过程,耗时在几十毫秒级别,高并发下这是致命的。

2.3 Servlet 的请求处理流程

Servlet 是整个项目的控制中枢。我以用户列表展示为例,梳理一下完整的处理流程。

浏览器访问/userList?page=1,容器根据web.xml或注解找到对应的 Servlet。我用的注解方式:

@WebServlet("/userList") public class UserListServlet extends HttpServlet { private UserService userService = new UserServiceImpl(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 获取分页参数 String pageStr = req.getParameter("page"); int page = (pageStr == null || pageStr.isEmpty()) ? 1 : Integer.parseInt(pageStr); int pageSize = 10; // 2. 调用服务层获取数据 PageResult<User> pageResult = userService.getUserByPage(page, pageSize); // 3. 数据存入 request 域 req.setAttribute("pageResult", pageResult); // 4. 转发到 JSP req.getRequestDispatcher("/WEB-INF/views/userList.jsp").forward(req, resp); } }

这里有几个关键决策。第一,分页参数从请求里取,默认第一页,避免用户直接访问/userList时报空指针。第二,pageSize写死为 10,实际项目中可以做成可配置的。第三,数据用setAttribute存入 request 域,而不是 session 域,因为列表数据只对当前请求有效,放 session 里会浪费内存且可能造成数据不一致。第四,JSP 放在WEB-INF下,这样浏览器不能直接访问,必须经过 Servlet 转发,保证了控制层的入口唯一性。

2.4 分页逻辑的完整实现

分页是这个项目的核心功能点,也是最容易出错的地方。我把它拆成三个部分:计算总页数、查询当前页数据、封装分页结果。

先看总页数的计算。假设总记录数是totalCount,每页显示pageSize条,那么总页数totalPage = (totalCount + pageSize - 1) / pageSize。这个公式用的是整数除法,加pageSize - 1是为了向上取整。比如 21 条记录,每页 10 条,(21 + 9) / 10 = 3页,正确。如果直接21 / 10 = 2,就少了一页。

查询当前页数据的 SQL 用LIMIT子句:

SELECT * FROM user_info ORDER BY id DESC LIMIT ?, ?

第一个问号是偏移量offset = (page - 1) * pageSize,第二个问号是pageSize。比如第 2 页,偏移量是 10,查第 11 到 20 条。

分页结果我封装成一个PageResult泛型类,包含四个字段:当前页数据列表、当前页码、总页数、总记录数。这样 JSP 页面拿到一个对象就能渲染所有分页信息,不需要单独传多个属性。

public class PageResult<T> { private List<T> list; private int currentPage; private int totalPage; private int totalCount; // 构造方法和 getter/setter 省略 }

实操心得:分页查询一定要加ORDER BY。不加排序的话,MySQL 返回的记录顺序是不确定的,翻页时可能出现同一条记录在两页都出现,或者某条记录被跳过。我一般用id DESC,最新的数据排前面,符合大多数列表展示的需求。

3. 实操过程与核心环节实现

3.1 项目目录结构与配置

先把目录结构定下来,后面写代码不会乱。

src/ main/ java/ com/example/ dao/ UserDao.java impl/UserDaoImpl.java service/ UserService.java impl/UserServiceImpl.java servlet/ UserListServlet.java entity/ User.java util/ DBUtil.java vo/ PageResult.java webapp/ WEB-INF/ views/ userList.jsp web.xml index.jsp

entity放实体类,和数据库表一一对应。vo放视图对象,比如PageResult。dao和service各有一个接口和一个实现类,面向接口编程,方便替换实现。

web.xml如果用 Servlet 3.0 以上的注解方式,可以只保留版本声明,不用配置 Servlet 映射。但如果你用的容器版本较老,还是需要在web.xml里配置:

<servlet> <servlet-name>UserListServlet</servlet-name> <servlet-class>com.example.servlet.UserListServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>UserListServlet</servlet-name> <url-pattern>/userList</url-pattern> </servlet-mapping>

3.2 DAO 层的完整实现

DAO 层负责所有数据库操作。我定义两个方法:countAll查总数,findByPage查当前页数据。

public class UserDaoImpl implements UserDao { @Override public int countAll() { String sql = "SELECT COUNT(*) FROM user_info"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { if (rs.next()) { return rs.getInt(1); } } catch (SQLException e) { throw new RuntimeException("查询用户总数失败", e); } return 0; } @Override public List<User> findByPage(int offset, int pageSize) { String sql = "SELECT * FROM user_info ORDER BY id DESC LIMIT ?, ?"; List<User> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, offset); ps.setInt(2, pageSize); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { User user = new User(); user.setId(rs.getInt("id")); user.setUsername(rs.getString("username")); user.setEmail(rs.getString("email")); user.setPhone(rs.getString("phone")); user.setCreateTime(rs.getTimestamp("create_time")); list.add(user); } } } catch (SQLException e) { throw new RuntimeException("分页查询用户失败", e); } return list; } }

这里用了 try-with-resources 语法,Connection、PreparedStatement、ResultSet都会自动关闭,不用手动写finally块。这是 Java 7 引入的特性,比传统写法简洁很多,而且不会漏关资源。

用PreparedStatement而不是Statement,有两个原因。第一,防止 SQL 注入,参数用?占位,用户输入不会被拼接到 SQL 里。第二,预编译的 SQL 执行效率更高,数据库可以缓存执行计划。

3.3 Service 层的分页组装

Service 层把 DAO 的查询结果组装成PageResult。

public class UserServiceImpl implements UserService { private UserDao userDao = new UserDaoImpl(); @Override public PageResult<User> getUserByPage(int page, int pageSize) { // 1. 查总数 int totalCount = userDao.countAll(); // 2. 计算总页数 int totalPage = (totalCount + pageSize - 1) / pageSize; // 3. 校正页码,防止越界 if (page < 1) page = 1; if (page > totalPage && totalPage > 0) page = totalPage; // 4. 计算偏移量 int offset = (page - 1) * pageSize; // 5. 查当前页数据 List<User> list = userDao.findByPage(offset, pageSize); // 6. 封装结果 PageResult<User> result = new PageResult<>(); result.setList(list); result.setCurrentPage(page); result.setTotalPage(totalPage); result.setTotalCount(totalCount); return result; } }

页码校正这一步很关键。如果用户手动把 URL 里的page改成 999,不校正的话会查到空数据,页面显示空白。校正后自动跳到最后一页,体验更好。另外totalPage为 0 的情况也要考虑,空表时page保持 1,不会出现负数偏移量。

3.4 JSP 页面的渲染实现

JSP 页面负责把PageResult渲染成表格和分页条。我用 JSTL 标签库来做循环和判断,避免在 JSP 里写 Java 代码。

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <html> <head> <title>用户列表</title> <style> table { border-collapse: collapse; width: 80%; margin: 20px auto; } th, td { border: 1px solid #ddd; padding: 8px; text-align: center; } th { background-color: #f2f2f2; } .pagination { text-align: center; margin: 20px; } .pagination a { margin: 0 5px; text-decoration: none; } </style> </head> <body> <table> <tr> <th>ID</th> <th>用户名</th> <th>邮箱</th> <th>手机号</th> <th>创建时间</th> </tr> <c:forEach items="${pageResult.list}" var="user"> <tr> <td>${user.id}</td> <td>${user.username}</td> <td>${user.email}</td> <td>${user.phone}</td> <td><fmt:formatDate value="${user.createTime}" pattern="yyyy-MM-dd HH:mm:ss"/></td> </tr> </c:forEach> </table> <div class="pagination"> <c:if test="${pageResult.currentPage > 1}"> <a href="userList?page=${pageResult.currentPage - 1}">上一页</a> </c:if> <span>第 ${pageResult.currentPage} / ${pageResult.totalPage} 页,共 ${pageResult.totalCount} 条</span> <c:if test="${pageResult.currentPage < pageResult.totalPage}"> <a href="userList?page=${pageResult.currentPage + 1}">下一页</a> </c:if> </div> </body> </html>

<c:forEach>遍历用户列表,<fmt:formatDate>格式化时间。分页条用<c:if>判断是否显示上一页和下一页,第一页不显示上一页,最后一页不显示下一页。

注意:JSP 文件顶部必须声明contentType="text/html;charset=UTF-8",否则中文会乱码。这个声明告诉容器用 UTF-8 编码输出 HTML。另外pageEncoding="UTF-8"也建议加上,它控制 JSP 文件本身的编码。

3.5 中文乱码的完整解决方案

中文乱码是 Java Web 开发中最常见的问题,没有之一。乱码可能出现在三个环节:请求参数、响应输出、数据库存储。我逐个说解决方案。

请求参数乱码:GET 请求的参数在 URL 里,Tomcat 8 以后默认用 UTF-8 解码,一般没问题。POST 请求的参数在请求体里,默认用 ISO-8859-1 解码,需要在读取参数之前设置req.setCharacterEncoding("UTF-8")。注意这行代码必须放在getParameter之前,否则不生效。

响应输出乱码:设置resp.setContentType("text/html;charset=UTF-8"),告诉浏览器用 UTF-8 解析响应内容。

数据库存储乱码:三个地方要统一。数据库和表的字符集设为utf8mb4,连接 URL 加characterEncoding=utf8,JSP 页面声明 UTF-8。三处一致,中文就不会乱。

我一般写一个EncodingFilter,统一处理所有请求的编码:

@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); chain.doFilter(req, resp); } }

这样每个 Servlet 里不用重复写编码设置,过滤器统一搞定。

4. 常见问题与排查技巧实录

4.1 数据库连接失败排查表

报错信息可能原因解决方法
ClassNotFoundException: com.mysql.cj.jdbc.Driver驱动包没导入把 mysql-connector-java 的 jar 放到 WEB-INF/lib 下
Access denied for user用户名或密码错误检查 DBUtil 里的 USER 和 PASSWORD
Unknown database数据库不存在先执行 CREATE DATABASE
The server time zone value is unrecognized时区未指定URL 加 serverTimezone=Asia/Shanghai
Communications link failureMySQL 服务没启动启动 MySQL 服务,检查端口 3306

这张表基本覆盖了 90% 的连接问题。我遇到最多的是驱动包没导入和时区问题。驱动包要放在WEB-INF/lib目录下,不是项目的src下,也不是系统的 CLASSPATH 里。Tomcat 只认WEB-INF/lib下的 jar。

4.2 页面显示 404 的排查思路

404 表示资源找不到,可能的原因有几种。第一,Servlet 的 URL 映射写错了,检查@WebServlet的值和浏览器访问的路径是否一致。第二,JSP 文件路径写错了,getRequestDispatcher的路径要以/开头,相对于 Web 应用的根目录。第三,项目没有正确部署,检查 Tomcat 的webapps目录下有没有你的项目文件夹。

我踩过的一个坑是:JSP 放在WEB-INF/views/下,转发路径写成了views/userList.jsp,少了开头的/,结果 404。加上/变成/WEB-INF/views/userList.jsp就对了。这个/代表 Web 应用的根,不是文件系统的根,概念要分清。

4.3 分页数据错乱的排查

分页数据错乱通常有三种表现:数据重复、数据缺失、页码越界。

数据重复或缺失,大概率是 SQL 没加ORDER BY。MySQL 不保证不加排序时的返回顺序,翻页时可能乱。加上ORDER BY id DESC就稳定了。

页码越界,比如访问page=0或page=999,需要在 Service 层做校正。我前面代码里的校正逻辑就是干这个的。

还有一种隐蔽的情况:totalCount和findByPage之间数据发生了变化。比如查完总数后有人删了一条记录,再查当前页数据时总数对不上。这个在低并发项目里不用管,高并发下需要用事务或者接受短暂的不一致。

4.4 JSTL 标签不生效的解决

JSTL 标签不生效,页面直接把<c:forEach>当文本输出了。原因是缺少 JSTL 的 jar 包。需要导入两个 jar:jstl.jar和standard.jar。如果是 Maven 项目,加依赖:

<dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>

另外 JSP 顶部的taglib声明也要写对,uri是http://java.sun.com/jsp/jstl/core,前缀习惯用c。这两个都对,标签才能生效。

实操心得:JSTL 的 jar 包版本要和 Servlet 版本匹配。Servlet 3.0 以上用 JSTL 1.2,Servlet 2.5 用 JSTL 1.1。版本不匹配可能报NoClassDefFoundError。我一般直接用 1.2,兼容性最好。

5. 性能优化与扩展方向

5.1 连接池的引入

前面说过,DBUtil每次新建连接,性能差。引入 HikariCP 连接池后,连接复用,性能提升明显。配置大概是这样:

HikariConfig config = new HikariConfig(); config.setJdbcUrl(URL); config.setUsername(USER); config.setPassword(PASSWORD); config.setMaximumPoolSize(10); config.setMinimumIdle(2); HikariDataSource dataSource = new HikariDataSource(config);

maximumPoolSize设 10,表示最多 10 个连接。minimumIdle设 2,表示最少保持 2 个空闲连接。这两个参数要根据实际并发量调整。并发高就调大,但也不是越大越好,连接太多会拖垮数据库。

5.2 分页查询的索引优化

LIMIT分页在数据量大时有个经典问题:偏移量越大,查询越慢。因为 MySQL 要扫描前offset条记录再丢弃。比如LIMIT 100000, 10,要扫描 100010 条记录。

优化方案是用游标分页,记住上一页最后一条的 id,下一页查WHERE id < lastId ORDER BY id DESC LIMIT 10。这样每次只扫描 10 条,效率恒定。但游标分页不支持跳页,只能上一页下一页。具体用哪种,看业务需求。

5.3 从 JSP 到前后端分离的演进

如果要把这个项目改造成真正的前后端分离,步骤是这样的:Servlet 不再转发到 JSP,而是返回 JSON 数据;前端用 AJAX 请求数据,用 JavaScript 渲染页面。Servlet 里用 Jackson 或 Gson 把PageResult转成 JSON 字符串,设置resp.setContentType("application/json;charset=UTF-8"),然后resp.getWriter().write(json)。

这个改造的价值在于:前端和后端可以独立开发、独立部署,前端可以用 Vue 或 React 做更复杂的交互。但代价是复杂度上升,需要处理跨域、接口版本管理等问题。项目小的时候,JSP 服务端渲染更简单直接。

我个人在实际操作中的体会是,Servlet + JSP + MySQL 这套组合虽然老,但它是理解 Java Web 的最佳路径。你把这套东西吃透了,后面学 Spring MVC、Spring Boot 会非常顺,因为那些框架的核心思想——前端控制器、视图解析、依赖注入——都能在这套原生实现里找到影子。踩过几次坑之后,我对 HTTP 请求的生命周期、数据库连接的管理、页面渲染的时机都有了肌肉记忆,这些是看多少篇教程都换不来的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进