说实话,第一次接到“帮我做个教务系统”的需求时,我内心是拒绝的。不是技术难,而是太懂了——那种看着学生在网页上转圈圈、然后弹出“查询超时”或者一堆方块字的绝望,我见过太多了。
今天这篇不聊高大上的架构,就聊聊那些实际开发中坑掉人的细节。如果你正在用JSP+Java做类似的项目,或者正在维护这种老旧系统,下面这些案例可能能救你一命。
一、数据库连接超时:那个消失的“正在加载”
记得有一次,教务处张老师急匆匆跑来找我,说系统崩了。学生一点“成绩查询”,页面就白屏,过几秒报ConnectionTimeout。
我们排查了很久,以为是数据库服务器挂了,结果重启DB后依然如此。最后发现是连接池配置的锅。
1.1 为什么JSP里Connection会超时?
很多初学者写的代码是这样的:
// 典型的错误示范:每次查询都new一个连接
Connection conn = DriverManager.getConnection(url, user, password);
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT * FROM grades WHERE student_id = '" + studentId + "'");
// 用完没关!或者关晚了
问题出在哪里?每次请求都创建一个数据库连接,而连接是有开销的。当并发稍微上来,连接池就耗尽,新请求只能排队等待,最终超时。
1.2 正确的做法:使用连接池
我推荐用HikariCP(轻量、高性能)或者Tomcat自带的JNDI数据源。
配置方式(以Tomcat JNDI为例):
在context.xml中定义数据源:
<Resource name="jdbc/SchoolDB"
auth="Container"
type="javax.sql.DataSource"
maxTotal="50" <!-- 最大连接数 -->
maxIdle="20" <!-- 空闲时保留的最小连接数 -->
maxWaitMillis="10000" <!-- 获取连接最大等待时间 -->
username="root"
password="your_password"
driverClassName="com.mysql.cj.jdbc.Driver"
url="jdbc:mysql://localhost:3306/school_db?useSSL=false&serverTimezone=UTC"
validationQuery="SELECT 1"/>
然后在JSP里通过JNDI查找:
<%@ page import="javax.sql.DataSource, javax.naming.Context, javax.naming.InitialContext" %>
<%
Context initCtx = new InitialContext();
Context envCtx = (Context) initCtx.lookup("java:comp/env");
DataSource ds = (DataSource) envCtx.lookup("jdbc/SchoolDB");
Connection conn = ds.getConnection(); // 从池中取连接,不是新建
try {
PreparedStatement pstmt = conn.prepareStatement("SELECT * FROM grades WHERE student_id = ?");
pstmt.setString(1, request.getParameter("studentId"));
ResultSet rs = pstmt.executeQuery();
// ... 处理结果
} finally {
conn.close(); // 返回池中,不是关闭
}
%>
1.3 还有一个隐藏坑:防火墙和超时设置
有时连接超时不是代码问题,是网络层的问题。如果数据库和Web服务器不在同一台机器,中间的防火墙可能会在TCP连接空闲一段时间后静默断开,而你的应用还以为连接活着。
解决方案:在连接池配置中开启testWhileIdle和validationQuery,这样每次从池中取连接前都会先ping一下,确保连接还活着。
二、报表乱码:那些看不懂的方块
另一个高频投诉是:成绩报表导出来,中文全是???或者乱码。
2.1 乱码的根源:编码不一致
乱码的本质是:发送方用一种编码,接收方用另一种编码解码。
常见的坑点有四处:
| 层级 | 常见错误 |
|---|---|
| 浏览器 | 没有声明charset,或声明错误 |
| Servlet/JSP | 没有设置response编码 |
| JDBC连接 | URL中没有指定characterEncoding |
| 数据库 | 表字段字符集与连接字符集不一致 |
2.2 完整解决方案:从数据库到页面一站式编码
第一步:数据库层面
确保你的数据库、表、字段都使用utf8mb4(注意:不是utf8,MySQL的utf8最多支持3字节,utf8mb4才支持完整Unicode):
-- 查看当前数据库字符集
SHOW CREATE DATABASE school_db;
-- 如果不是utf8mb4,修改数据库
ALTER DATABASE school_db CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
-- 修改表
ALTER TABLE grades CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
第二步:JDBC连接参数
在数据库URL中加入字符集声明:
jdbc:mysql://localhost:3306/school_db?useUnicode=true&characterEncoding=utf8mb4&connectionCollation=utf8mb4_unicode_ci
⚠️ 注意:MySQL Connector/J 5.1+ 版本中,
useUnicode=true和characterEncoding=utf8是默认行为,但显式声明更安全。而且如果用的是MySQL 8.0+驱动,建议用utf8mb4。
第三步:JSP页面编码
每个JSP文件顶部加上:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%-- 确保JSP本身以UTF-8保存,IDE里检查一下文件编码 --%>
第四步:Servlet/Filter统一处理
写一个过滤器,对所有请求和响应强制设置编码:
@WebFilter("/*")
public class EncodingFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html;charset=UTF-8");
chain.doFilter(request, response);
}
}
第五步:导出Excel时的编码处理
如果报表是导出Excel,要注意:Excel的CSV导出和真正的.xlsx格式编码处理方式不同。
CSV导出时,必须在文件头加BOM(Byte Order Mark),否则Excel用系统默认编码(中文Windows是GBK)打开就会乱码:
response.setHeader("Content-Disposition", "attachment;filename="
+ new String("成绩报表.csv".getBytes("UTF-8"), "ISO-8859-1"));
response.setContentType("text/csv;charset=UTF-8");
// 写入BOM
OutputStreamWriter osw = new OutputStreamWriter(response.getOutputStream(), "UTF-8");
osw.write('\uFEFF'); // UTF-8 BOM
osw.write("学号,姓名,成绩\n");
// ... 写入数据
三、为什么我总说“用PreparedStatement”?
回到成绩加载失败的问题,除了连接超时,还有一个常见原因:SQL注入导致的性能问题,甚至查询错误。
想象一下这个场景:
// 危险代码!
String sql = "SELECT * FROM grades WHERE student_id = '" + request.getParameter("id") + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
如果学生输入的ID是' OR '1'='1,会发生什么?整个表的数据都会查出来,然后页面渲染大量数据,直接内存溢出或超时。
正确写法:
String sql = "SELECT s.student_name, g.score, g.exam_name " +
"FROM grades g " +
"JOIN students s ON g.student_id = s.student_id " +
"WHERE g.student_id = ? AND g.exam_name = ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setString(1, studentId);
pstmt.setString(2, examName);
ResultSet rs = pstmt.executeQuery();
PreparedStatement的好处不只是防注入:
- 性能:预编译语句可以被数据库缓存重复执行
- 安全:参数化查询,不会拼字符串
- 可读:SQL逻辑清晰,不容易出错
四、分页查询:别再一次性查所有成绩了
还有一个让我头疼的问题:有些老师为了“简化”,直接写:
SELECT * FROM grades
然后JSP里循环打印。当学生数量上千、考试场次几十次,这个查询结果集可能达到几十万行。内存爆、渲染慢、浏览器卡死——学生以为是系统故障,其实是查询设计问题。
正确的分页查询:
int pageSize = 20;
int currentPage = 1;
if (request.getParameter("page") != null) {
currentPage = Integer.parseInt(request.getParameter("page"));
}
int offset = (currentPage - 1) * pageSize;
String sql = "SELECT s.student_name, g.score, g.exam_name " +
"FROM grades g JOIN students s ON g.student_id = s.student_id " +
"WHERE g.student_id = ? " +
"ORDER BY g.exam_date DESC " +
"LIMIT ? OFFSET ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setString(1, studentId);
pstmt.setInt(2, pageSize);
pstmt.setInt(3, offset);
ResultSet rs = pstmt.executeQuery();
同时,你需要一个总数查询来计算总页数:
String countSql = "SELECT COUNT(*) FROM grades WHERE student_id = ?";
PreparedStatement countStmt = conn.prepareStatement(countSql);
countStmt.setString(1, studentId);
ResultSet countRs = countStmt.executeQuery();
int totalRecords = 0;
if (countRs.next()) {
totalRecords = countRs.getInt(1);
}
int totalPages = (totalRecords + pageSize - 1) / pageSize;
五、一个完整的JSP成绩查询页面示例
下面是一个相对健壮的示例,包含了前面提到的所有最佳实践:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="javax.sql.DataSource" %>
<%@ page import="javax.naming.Context" %>
<%@ page import="javax.naming.InitialContext" %>
<%@ page import="java.sql.Connection" %>
<%@ page import="java.sql.PreparedStatement" %>
<%@ page import="java.sql.ResultSet" %>
<%@ page import="java.sql.SQLException" %>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>成绩查询</title>
<style>
body { font-family: "Microsoft YaHei", sans-serif; margin: 40px; }
table { border-collapse: collapse; width: 80%; }
th, td { border: 1px solid #ddd; padding: 10px; text-align: center; }
th { background-color: #4CAF50; color: white; }
.error { color: red; font-weight: bold; }
.pagination { margin-top: 20px; text-align: center; }
.pagination a { margin: 0 5px; padding: 5px 10px; border: 1px solid #ddd; }
</style>
</head>
<body>
<h2>学生成绩查询系统</h2>
<%
// 获取学号参数
String studentId = request.getParameter("studentId");
int currentPage = 1;
if (request.getParameter("page") != null) {
try {
currentPage = Integer.parseInt(request.getParameter("page"));
if (currentPage < 1) currentPage = 1;
} catch (NumberFormatException e) {
currentPage = 1;
}
}
int pageSize = 15;
int totalRecords = 0;
int totalPages = 0;
%>
<form method="get" action="">
请输入学号:<input type="text" name="studentId" value="<%= studentId != null ? studentId : "" %>">
<input type="submit" value="查询">
</form>
<%
if (studentId != null && !studentId.trim().isEmpty()) {
DataSource ds = null;
Connection conn = null;
PreparedStatement pstmt = null;
PreparedStatement countStmt = null;
try {
// 获取连接
Context initCtx = new InitialContext();
Context envCtx = (Context) initCtx.lookup("java:comp/env");
ds = (DataSource) envCtx.lookup("jdbc/SchoolDB");
conn = ds.getConnection();
// 查询数据(分页)
String sql = "SELECT s.student_name, g.score, g.exam_name " +
"FROM grades g " +
"JOIN students s ON g.student_id = s.student_id " +
"WHERE g.student_id = ? " +
"ORDER BY g.exam_date DESC " +
"LIMIT ? OFFSET ?";
pstmt = conn.prepareStatement(sql);
pstmt.setString(1, studentId);
pstmt.setInt(2, pageSize);
pstmt.setInt(3, (currentPage - 1) * pageSize);
ResultSet rs = pstmt.executeQuery();
// 查询总数
String countSql = "SELECT COUNT(*) FROM grades WHERE student_id = ?";
countStmt = conn.prepareStatement(countSql);
countStmt.setString(1, studentId);
ResultSet countRs = countStmt.executeQuery();
if (countRs.next()) {
totalRecords = countRs.getInt(1);
}
totalPages = (totalRecords + pageSize - 1) / pageSize;
// 显示结果
if (totalRecords == 0) {
out.println("<p class='error'>未找到该学生的成绩记录。</p>");
} else {
out.println("<table>");
out.println("<tr><th>姓名</th><th>考试科目</th><th>成绩</th></tr>");
while (rs.next()) {
out.println("<tr>");
out.println("<td>" + rs.getString("student_name") + "</td>");
out.println("<td>" + rs.getString("exam_name") + "</td>");
out.println("<td>" + rs.getDouble("score") + "</td>");
out.println("</tr>");
}
out.println("</table>");
// 分页导航
out.println("<div class='pagination'>");
if (currentPage > 1) {
out.println("<a href='?studentId=" + studentId + "&page=" + (currentPage - 1) + "'>上一页</a>");
}
out.println("<span>第 " + currentPage + " / " + totalPages + " 页 (共 " + totalRecords + " 条)</span>");
if (currentPage < totalPages) {
out.println("<a href='?studentId=" + studentId + "&page=" + (currentPage + 1) + "'>下一页</a>");
}
out.println("</div>");
}
} catch (Exception e) {
out.println("<p class='error'>查询失败:" + e.getMessage() + "</p>");
e.printStackTrace(out);
} finally {
// 关闭资源
if (pstmt != null) try { pstmt.close(); } catch (SQLException e) { e.printStackTrace(); }
if (countStmt != null) try { countStmt.close(); } catch (SQLException e) { e.printStackTrace(); }
if (conn != null) try { conn.close(); } catch (SQLException e) { e.printStackTrace(); }
}
}
%>
</body>
</html>
六、调试技巧:如何快速定位问题
当你遇到成绩加载失败时,按以下步骤排查:
- 看错误信息:不要只看页面显示,查看服务器日志(Tomcat的
catalina.out或localhost.log) - 检查数据库连接:用数据库客户端工具直接连一下,确认账号密码和网络正常
- 打印SQL:在查询前打印出完整的SQL语句,直接在数据库客户端执行,看是否报错
- 检查编码:如果看到乱码,先确认浏览器、JSP、Servlet、JDBC、数据库五层编码是否一致
- 测试并发:用多个浏览器窗口同时查询,看是否出现连接超时
七、给老师的建议:为什么不建议继续用JSP?
最后说句实话:JSP+Java的技术栈在2024年已经比较陈旧了。如果你正在维护老旧系统,上面这些坑能帮你渡劫。但如果你是新项目,我建议考虑更现代的架构:
- 前端:Vue/React + Axios
- 后端:Spring Boot + MyBatis/JPA
- 数据库:MySQL/PostgreSQL
- 部署:Docker容器化
这些技术栈有更完善的社区支持、更清晰的文档、更好的性能,而且不需要你手写JDBC连接管理,框架会帮你处理连接池、事务、编码等细节。
但如果你只是要临时应付一下,或者维护一个老旧系统,希望这篇文章能帮你避开那些让我熬夜踩过的坑。
总结一下:成绩加载失败,90%的概率是以下三个问题之一:
- 数据库连接池配置不当或连接泄漏
- 编码不一致导致乱码或查询失败
- 没有分页,一次性加载大量数据
把这三点解决好,你的教务系统稳定性至少提升一大截。希望老师们都能少加一点班,早点回家陪孩子。
