学校教务系统卡顿学生查不到成绩jsp技术如何帮学校搭建稳定在线考试平台实现成绩实时查询选课零拥堵低成本改造传统教育管理系统jsp实用案例
说实话,每到期末那个节骨眼上,教务系统就崩了,学生查个成绩比登天还难,选课的时候卡得人怀疑人生。这事儿我见得多了,今天咱们就掰开揉碎了聊聊,怎么用JSP这套老但实用的技术栈,给学校搭一个扛得住高并发的在线考试平台,还能把成本压到最低。
先搞明白,教务系统为啥总卡死
我认识好几个学校信息化部门的朋友,吐槽起来能聊一下午。问题其实就摆在那儿——数据库查询写得跟屎一样,前端页面每次刷新都全量加载,并发人一多服务器直接冒烟。
我见过最夸张的一次,某学校期末查成绩,三万学生同时刷新,数据库连接池直接爆了。后台日志一看,好家伙,同一个SQL查询被执行了三万遍,每一遍都没有缓存,每一遍都在全表扫描。这谁能扛得住?
还有一类问题更隐蔽,就是代码写得烂。有些教务系统的代码,我看了想打人。比如查成绩这个方法:
// 这是某学校教务系统的真实代码片段(已脱敏)
public List<Score> getScores(String studentId) {
List<Score> scores = new ArrayList<>();
Connection conn = DBUtil.getConnection();
// 没有使用PreparedStatement,直接拼接SQL,存在注入风险
String sql = "SELECT * FROM score WHERE student_id = '" + studentId + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
while (rs.next()) {
Score s = new Score();
s.setStudentId(rs.getString("student_id"));
s.setCourseId(rs.getString("course_id"));
s.setScore(rs.getDouble("score"));
// 每次循环又去查一次数据库获取课程名
String courseSql = "SELECT course_name FROM course WHERE course_id = '" + rs.getString("course_id") + "'";
ResultSet rs2 = stmt.executeQuery(courseSql);
while (rs2.next()) {
s.setCourseName(rs2.getString("course_name"));
}
scores.add(s);
}
return scores;
}
你看,查一条成绩记录,循环里还要再查N次数据库。三万学生同时查,这服务器不崩谁崩?
更可怕的是,有些学校连数据库连接都没有复用,每次查询都新建连接,用完就扔。数据库服务器的连接数是有上限的,一旦超过,新来的请求就直接报错。
数据库层优化,这才是根子上的事儿
说真的,我见过太多人一上来就改代码,结果发现性能还是上不去。后来一问才知道,数据库设计从一开始就错了。
字段设计要留余地
-- 成绩表设计
CREATE TABLE score (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
student_id VARCHAR(20) NOT NULL COMMENT '学号',
course_id VARCHAR(20) NOT NULL COMMENT '课程编号',
semester VARCHAR(20) NOT NULL COMMENT '学期,如2024-2025-2',
score DECIMAL(5,2) COMMENT '成绩',
grade_point DECIMAL(3,2) COMMENT '绩点',
status TINYINT DEFAULT 1 COMMENT '状态:1-正常 2-缓考 3-缺考',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_student_course_semester (student_id, course_id, semester),
KEY idx_student_id (student_id),
KEY idx_course_id (semester, course_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成绩表';
注意几个细节:
第一,student_id、course_id、semester 这三个字段组成了一个唯一索引,这样查成绩的时候,一条索引就能定位到,不需要全表扫描。
第二,grade_point 提前算好存进去,不要每次查询的时候再算一遍。算绩点这种操作,放在查询的时候做,就是给服务器增加无谓的负担。
第三,字符集用 utf8mb4,别用 utf8。MySQL的utf8是假utf8,有些生僻字存不进去,后期换字符集能折腾死你。
选课表的设计更讲究
-- 选课表
CREATE TABLE course_selection (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
student_id VARCHAR(20) NOT NULL COMMENT '学号',
course_id VARCHAR(20) NOT NULL COMMENT '课程编号',
teacher_id VARCHAR(20) NOT NULL COMMENT '教师编号',
semester VARCHAR(20) NOT NULL COMMENT '学期',
status TINYINT DEFAULT 0 COMMENT '状态:0-待确认 1-已选课 2-已退课 3-选课失败',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_student_course (student_id, course_id, semester),
KEY idx_course_id_semester (course_id, semester)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 课程表
CREATE TABLE course (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
course_id VARCHAR(20) NOT NULL UNIQUE COMMENT '课程编号',
course_name VARCHAR(100) NOT NULL COMMENT '课程名称',
credit DECIMAL(3,2) NOT NULL COMMENT '学分',
course_type VARCHAR(20) COMMENT '课程类型:必修/选修',
capacity INT NOT NULL DEFAULT 80 COMMENT '最大容量',
enrolled INT DEFAULT 0 COMMENT '已选人数',
semester VARCHAR(20) NOT NULL COMMENT '学期',
teacher_id VARCHAR(20) NOT NULL COMMENT '授课教师',
classroom VARCHAR(50) COMMENT '教室',
start_time TIME COMMENT '开始时间',
end_time TIME COMMENT '结束时间',
KEY idx_semester (semester),
KEY idx_teacher (teacher_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表';
选课这块有个关键点——enrolled 字段。每次有人选课成功,就给这个字段加一。选课的时候直接比较 enrolled 和 capacity,不需要每次查询都 COUNT。用 COUNT 统计选课人数,在高并发场景下就是灾难。
但是光加字段还不够,还要防止超选。我见过最离谱的事情是,一个容量80人的课,最后选了120人。原因就是并发请求同时读取了 enrolled=79,然后都通过了判断,全都写入了。
JSP + Servlet 架构设计
好,数据库设计清楚了,接下来就是代码层面的东西了。很多人对JSP有偏见,觉得它老了、落后了。但说实话,对于学校这种规模的项目,JSP + Servlet + JDBC 这套组合依然能打,而且成本低得让你惊讶。
整体架构
用户浏览器
↓
Web服务器 (Tomcat/Nginx)
↓
Servlet过滤器 (登录验证/权限检查/字符编码)
↓
Controller层 (Servlet)
↓
Service层 (业务逻辑)
↓
DAO层 (数据访问)
↓
数据库 (MySQL)
核心Servlet实现
import java.io.IOException;
import java.sql.*;
import java.util.*;
import javax.servlet.*;
import javax.servlet.http.*;
import javax.servlet.annotation.*;
/**
* 成绩查询Servlet
* 采用数据库连接池,避免每次查询都新建连接
*/
@WebServlet("/score/query")
public class ScoreQueryServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
// 使用连接池,单例模式保证全局只有一个连接池
private DataSource dataSource;
@Override
public void init() throws ServletException {
// 从上下文获取连接池(由连接池管理器初始化)
try {
Context ctx = (Context) new InitialContext().lookup("java:comp/env");
dataSource = (DataSource) ctx.lookup("jdbc/SchoolDB");
} catch (Exception e) {
throw new ServletException("数据源初始化失败", e);
}
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 设置字符编码,防止乱码
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html;charset=UTF-8");
// 获取学号参数
String studentId = request.getParameter("studentId");
String semester = request.getParameter("semester");
// 参数校验
if (studentId == null || studentId.trim().isEmpty()) {
request.setAttribute("errorMsg", "请输入学号");
request.getRequestDispatcher("/score/error.jsp").forward(request, response);
return;
}
// 如果没传学期,默认查询当前学期
if (semester == null || semester.trim().isEmpty()) {
semester = getCurrentSemester();
}
// 查询成绩(使用PreparedStatement防止SQL注入)
List<ScoreVO> scores = queryScores(studentId.trim(), semester);
// 查询课程信息(一次性查询,避免N+1问题)
if (!scores.isEmpty()) {
Set<String> courseIds = new HashSet<>();
for (ScoreVO s : scores) {
courseIds.add(s.getCourseId());
}
Map<String, String> courseNameMap = queryCourseNames(courseIds);
for (ScoreVO s : scores) {
s.setCourseName(courseNameMap.getOrDefault(s.getCourseId(), "未知课程"));
}
}
// 计算总学分和平均绩点
int totalCredit = 0;
double totalGradePoint = 0.0;
for (ScoreVO s : scores) {
if (s.getGradePoint() != null) {
totalCredit += s.getCredit();
totalGradePoint += s.getGradePoint() * s.getCredit();
}
}
double gpa = totalCredit > 0 ? totalGradePoint / totalCredit : 0.0;
// 放入请求域,转发到JSP页面
request.setAttribute("scores", scores);
request.setAttribute("studentId", studentId.trim());
request.setAttribute("semester", semester);
request.setAttribute("gpa", String.format("%.2f", gpa));
request.setAttribute("totalCredit", totalCredit);
request.getRequestDispatcher("/score/result.jsp").forward(request, response);
}
/**
* 查询成绩列表
* 使用JOIN一次性获取所有数据,避免多次数据库查询
*/
private List<ScoreVO> queryScores(String studentId, String semester) {
List<ScoreVO> result = new ArrayList<>();
String sql = "SELECT s.id, s.student_id, s.course_id, s.semester, " +
"s.score, s.grade_point, s.status, s.create_time, " +
"c.course_name, c.credit, c.course_type " +
"FROM score s " +
"LEFT JOIN course c ON s.course_id = c.course_id " +
"WHERE s.student_id = ? AND s.semester = ? " +
"ORDER BY c.course_name";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, studentId);
pstmt.setString(2, semester);
try (ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
ScoreVO vo = new ScoreVO();
vo.setId(rs.getLong("id"));
vo.setStudentId(rs.getString("student_id"));
vo.setCourseId(rs.getString("course_id"));
vo.setCourseName(rs.getString("course_name"));
vo.setCredit(rs.getDouble("credit"));
vo.setCourseType(rs.getString("course_type"));
vo.setScore(rs.getDouble("score"));
vo.setGradePoint(rs.getDouble("grade_point"));
vo.setStatus(rs.getInt("status"));
vo.setSemester(rs.getString("semester"));
vo.setCreateTime(rs.getTimestamp("create_time"));
result.add(vo);
}
}
} catch (SQLException e) {
e.printStackTrace();
// 生产环境应该记录日志,而不是直接打印
}
return result;
}
/**
* 批量查询课程名称,避免N+1问题
*/
private Map<String, String> queryCourseNames(Set<String> courseIds) {
Map<String, String> resultMap = new HashMap<>();
if (courseIds.isEmpty()) return resultMap;
// 构建IN子句的占位符
String placeholders = String.join(",", Collections.nCopies(courseIds.size(), "?"));
String sql = "SELECT course_id, course_name FROM course WHERE course_id IN (" + placeholders + ")";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
int index = 1;
for (String courseId : courseIds) {
pstmt.setString(index++, courseId);
}
try (ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
resultMap.put(rs.getString("course_id"), rs.getString("course_name"));
}
}
} catch (SQLException e) {
e.printStackTrace();
}
return resultMap;
}
/**
* 获取当前学期(简化实现,实际可以根据系统时间计算)
*/
private String getCurrentSemester() {
Calendar cal = Calendar.getInstance();
int year = cal.get(Calendar.YEAR);
int month = cal.get(Calendar.MONTH) + 1;
if (month >= 9 && month <= 12) {
return (year - 1) + "-" + year + "-1";
} else {
return year + "-" + (year + 1) + "-2";
}
}
}
代码里有个细节值得说一下——try-with-resources 语法。这个语法是Java 7引入的,它的好处是自动关闭资源,不用你手动写 finally 块来关闭连接。代码更简洁,也更不容易出错。
还有那个 queryCourseNames 方法,就是为了解决我前面说的那个问题——不要在循环里查数据库。把课程ID收集起来,一次批量查询,返回一个Map,然后遍历成绩列表的时候直接从Map里取。这样不管有多少条成绩记录,都只查一次数据库。
选课系统的并发控制
选课是最考验系统并发能力的地方。一个热门课程可能几百人抢几十个名额,这竞争比春运火车票还激烈。
数据库层面的乐观锁
-- 选课操作存储过程
DELIMITER $$
CREATE PROCEDURE sp_course_selection(
IN p_student_id VARCHAR(20),
IN p_course_id VARCHAR(20),
IN p_semester VARCHAR(20),
IN p_teacher_id VARCHAR(20),
OUT p_result INT, -- 0-成功 1-课程已满 2-已选过 3-时间冲突
OUT p_message VARCHAR(200)
)
BEGIN
DECLARE v_capacity INT;
DECLARE v_enrolled INT;
DECLARE v_already_selected INT DEFAULT 0;
DECLARE v_conflict_count INT DEFAULT 0;
DECLARE v_time_slot VARCHAR(50);
DECLARE v_other_time_slot VARCHAR(50);
-- 初始化结果
SET p_result = 0;
SET p_message = '选课成功';
-- 检查是否已选过
SELECT COUNT(*) INTO v_already_selected
FROM course_selection
WHERE student_id = p_student_id
AND course_id = p_course_id
AND semester = p_semester;
IF v_already_selected > 0 THEN
SET p_result = 2;
SET p_message = '您已经选过这门课程了';
ELSE
-- 获取课程容量和已选人数(带锁查询)
SELECT capacity, enrolled
INTO v_capacity, v_enrolled
FROM course
WHERE course_id = p_course_id
AND semester = p_semester
FOR UPDATE; -- 行级锁,防止超选
IF v_enrolled >= v_capacity THEN
SET p_result = 1;
SET p_message = '该课程已选满,请选择其他课程';
ELSE
-- 检查时间冲突
SELECT CONCAT(start_time, '-', end_time)
INTO v_time_slot
FROM course
WHERE course_id = p_course_id
AND semester = p_semester;
SELECT COUNT(*) INTO v_conflict_count
FROM course_selection cs
JOIN course c ON cs.course_id = c.course_id
WHERE cs.student_id = p_student_id
AND cs.semester = p_semester
AND cs.status = 1
AND c.start_time < TIME_ADD(v_time_slot, INTERVAL 0 SECOND)
AND c.end_time > TIME_SUB(v_time_slot, INTERVAL 0 SECOND);
IF v_conflict_count > 0 THEN
ROLLBACK;
SET p_result = 3;
SET p_message = '时间冲突,请选择其他课程';
ELSE
-- 开始事务
START TRANSACTION;
-- 插入选课记录
INSERT INTO course_selection
(student_id, course_id, teacher_id, semester, status, create_time)
VALUES
(p_student_id, p_course_id, p_teacher_id, p_semester, 1, NOW());
-- 更新已选人数(原子操作)
UPDATE course
SET enrolled = enrolled + 1
WHERE course_id = p_course_id
AND semester = p_semester;
COMMIT;
SET p_result = 0;
SET p_message = '选课成功';
END IF;
END IF;
END IF;
END$$
DELIMITER ;
这个存储过程有几个关键点:
第一,FOR UPDATE 加行级锁。这是防止超选的核心。当第一个学生查询课程容量时,这行数据就被锁住了,其他学生必须等第一个学生完成选课(或者事务回滚)之后才能查询。这样就保证了 enrolled 和 capacity 的比较是原子的。
第二,所有操作放在一个事务里。插入选课记录 + 更新已选人数,这两步要么都成功,要么都失败。不可能出现选了课但人数没加,或者人数加了但没选上课的情况。
第三,时间冲突检查在事务外完成。这样可以减少锁的持有时间,提高并发性能。
Servlet层封装
@WebServlet("/course/select")
public class CourseSelectServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private DataSource dataSource;
@Override
public void init() throws ServletException {
try {
Context ctx = (Context) new InitialContext().lookup("java:comp/env");
dataSource = (DataSource) ctx.lookup("jdbc/SchoolDB");
} catch (Exception e) {
throw new ServletException("数据源初始化失败", e);
}
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
String studentId = (String) request.getSession().getAttribute("studentId");
String courseId = request.getParameter("courseId");
String semester = request.getParameter("semester");
if (studentId == null || courseId == null) {
response.getWriter().write("{\"result\":-1,\"message\":\"请先登录\"}");
return;
}
// 获取授课教师
String teacherId = getTeacherById(courseId, semester);
// 调用存储过程
int[] result = callSelectionProcedure(studentId, courseId, semester, teacherId);
// 返回JSON结果
JSONObject json = new JSONObject();
json.put("result", result[0]);
json.put("message", result[1]);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(json.toJSONString());
}
private int[] callSelectionProcedure(String studentId, String courseId,
String semester, String teacherId) {
int[] result = new int[2];
CallableStatement cstmt = null;
try {
String sql = "{call sp_course_selection(?,?,?,?,?,?)}";
cstmt = dataSource.getConnection().prepareCall(sql);
cstmt.setString(1, studentId);
cstmt.setString(2, courseId);
cstmt.setString(3, semester);
cstmt.setString(4, teacherId);
cstmt.registerOutParameter(5, Types.INTEGER);
cstmt.registerOutParameter(6, Types.VARCHAR);
cstmt.execute();
result[0] = cstmt.getInt(5);
result[1] = cstmt.getInt(6);
} catch (SQLException e) {
result[0] = -1;
result[1] = "系统错误,请稍后重试";
e.printStackTrace();
} finally {
if (cstmt != null) {
try { cstmt.close(); } catch (SQLException e) { e.printStackTrace(); }
}
}
return result;
}
}
前端页面:简单但实用的成绩查询
JSP页面的代码不用写得太花哨,学校系统要的是稳定实用,不是炫酷。
成绩查询页面
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="java.util.List" %>
<%@ page import="com.school.vo.ScoreVO" %>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>成绩查询</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
background: #f0f2f5;
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
}
.header {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
color: white;
padding: 30px;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 4px 15px rgba(24,144,255,0.3);
}
.header h1 { font-size: 24px; margin-bottom: 8px; }
.header p { font-size: 14px; opacity: 0.9; }
.search-box {
background: white;
padding: 25px;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.search-box label {
display: block;
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.search-box input, .search-box select {
width: 100%;
padding: 12px 15px;
border: 1px solid #d9d9d9;
border-radius: 8px;
font-size: 15px;
transition: border-color 0.3s;
}
.search-box input:focus, .search-box select:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 3px rgba(24,144,255,0.1);
}
.search-box button {
width: 100%;
margin-top: 15px;
padding: 14px;
background: #1890ff;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
}
.search-box button:hover { background: #40a9ff; }
.search-box button:disabled {
background: #d9d9d9;
cursor: not-allowed;
}
.stats-bar {
display: flex;
gap: 15px;
margin-bottom: 20px;
}
.stat-card {
flex: 1;
background: white;
padding: 20px;
border-radius: 12px;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.stat-card .value {
font-size: 32px;
font-weight: bold;
color: #1890ff;
}
.stat-card .label {
font-size: 13px;
color: #999;
margin-top: 5px;
}
.score-table {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.score-table table {
width: 100%;
border-collapse: collapse;
}
.score-table th {
background: #fafafa;
padding: 15px;
text-align: left;
font-size: 14px;
color: #666;
border-bottom: 1px solid #f0f0f0;
}
.score-table td {
padding: 15px;
font-size: 15px;
border-bottom: 1px solid #f0f0f0;
}
.score-table tr:last-child td { border-bottom: none; }
.score-table tr:hover td { background: #fafafa; }
.score-high { color: #52c41a; font-weight: bold; }
.score-mid { color: #faad14; font-weight: bold; }
.score-low { color: #ff4d4f; font-weight: bold; }
.status-tag {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 12px;
}
.status-normal { background: #f6ffed; color: #52c41a; }
.status-suspend { background: #fff7e6; color: #fa8c16; }
.status-absent { background: #fff1f0; color: #ff4d4f; }
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-state .icon { font-size: 48px; margin-bottom: 15px; }
.error-msg {
background: #fff1f0;
border: 1px solid #ffa39e;
color: #cf1322;
padding: 12px 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.loading {
text-align: center;
padding: 40px;
color: #999;
}
.loading::after {
content: "加载中...";
animation: dots 1.5s infinite;
}
@keyframes dots {
0%, 20% { content: "."; }
40% { content: ".."; }
60%, 100% { content: "..."; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📊 成绩查询系统</h1>
<p>实时查询学业成绩,支持历史学期回顾</p>
</div>
<!-- 查询表单 -->
<div class="search-box">
<form id="queryForm" action="<%=request.getContextPath()%>/score/query" method="get">
<div style="display:flex;gap:15px;">
<div style="flex:1;">
<label>学号</label>
<input type="text" name="studentId"
value="${param.studentId}"
placeholder="请输入学号" required>
</div>
<div style="flex:1;">
<label>学期</label>
<select name="semester">
<option value="">当前学期</option>
<option value="2024-2025-1" ${param.semester=='2024-2025-1'?'selected':''}>2024-2025学年第一学期</option>
<option value="2024-2025-2" ${param.semester=='2024-2025-2'?'selected':''}>2024-2025学年第二学期</option>
<option value="2023-2024-1" ${param.semester=='2023-2024-1'?'selected':''}>2023-2024学年第一学期</option>
<option value="2023-2024-2" ${param.semester=='2023-2024-2'?'selected':''}>2023-2024学年第二学期</option>
</select>
</div>
</div>
<button type="submit">查询成绩</button>
</form>
</div>
<!-- 错误信息 -->
<% if(request.getAttribute("errorMsg") != null) { %>
<div class="error-msg">
⚠️ <%= request.getAttribute("errorMsg") %>
</div>
<% } %>
<!-- 统计卡片 -->
<%
List<ScoreVO> scores = (List<ScoreVO>) request.getAttribute("scores");
if (scores != null && !scores.isEmpty()) {
int totalCredit = (Integer) request.getAttribute("totalCredit");
String gpa = (String) request.getAttribute("gpa");
%>
<div class="stats-bar">
<div class="stat-card">
<div class="value"><%= scores.size() %></div>
<div class="label">已修课程</div>
</div>
<div class="stat-card">
<div class="value"><%= totalCredit %></div>
<div class="label">已获学分</div>
</div>
<div class="stat-card">
<div class="value"><%= gpa %></div>
<div class="label">平均绩点</div>
</div>
<div class="stat-card">
<div class="value"><%= request.getAttribute("semester") %></div>
<div class="label">查询学期</div>
</div>
</div>
<% } %>
<!-- 成绩表格 -->
<% if (scores != null) { %>
<div class="score-table">
<table>
<thead>
<tr>
<th>课程名称</th>
<th>课程类型</th>
<th>学分</th>
<th>成绩</th>
<th>绩点</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<% for (ScoreVO s : scores) { %>
<tr>
<td><%= s.getCourseName() %></td>
<td>
<% if ("必修".equals(s.getCourseType())) { %>
<span style="color:#1890ff">必修</span>
<% } else { %>
<span style="color:#52c41a">选修</span>
<% } %>
</td>
<td><%= s.getCredit() %></td>
<td class="<%= getScoreClass(s.getScore()) %>">
<%= s.getScore() != 0 ? s.getScore() : "-" %>
</td>
<td>
<%= s.getGradePoint() != 0 ? s.getGradePoint() : "-" %>
</td>
<td>
<span class="status-tag <%= getStatusClass(s.getStatus()) %>">
<%= getStatusText(s.getStatus()) %>
</span>
</td>
</tr>
<% } %>
</tbody>
</table>
</div>
<% } else if (scores != null && scores.isEmpty()) { %>
<div class="score-table">
<div class="empty-state">
<div class="icon">📭</div>
<p>暂无成绩记录</p>
<p style="font-size:13px;margin-top:8px;">请确认学号和学期是否正确</p>
</div>
</div>
<% } %>
</div>
<script>
// 简单的表单验证
document.getElementById('queryForm').addEventListener('submit', function(e) {
const studentId = this.querySelector('input[name="studentId"]').value.trim();
if (!studentId) {
e.preventDefault();
alert('请输入学号');
}
});
</script>
</body>
</html>
页面里用到了一个辅助方法 getScoreClass,这是JSP的脚本let方式,虽然看起来有点老土,但对于学校内部系统来说,这样写反而最快最省事。
<%!
private String getScoreClass(double score) {
if (score >= 90) return "score-high";
if (score >= 60) return "score-mid";
return "score-low";
}
private String getStatusClass(int status) {
switch(status) {
case 1: return "status-normal";
case 2: return "status-suspend";
case 3: return "status-absent";
default: return "status-normal";
}
}
private String getStatusText(int status) {
switch(status) {
case 1: return "正常";
case 2: return "缓考";
case 3: return "缺考";
default: return "正常";
}
}
%>
在线考试系统核心模块
成绩查询解决了,接下来就是在线考试平台了。这个系统的核心难点在于:题目加载、答题提交、自动判分、防止作弊。
考试数据库设计
-- 考试表
CREATE TABLE exam (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
exam_name VARCHAR(100) NOT NULL COMMENT '考试名称',
course_id VARCHAR(20) NOT NULL COMMENT '课程编号',
semester VARCHAR(20) NOT NULL COMMENT '学期',
start_time DATETIME NOT NULL COMMENT '开始时间',
end_time DATETIME NOT NULL COMMENT '结束时间',
duration INT NOT NULL COMMENT '考试时长(分钟)',
total_score INT DEFAULT 100 COMMENT '总分',
pass_score INT DEFAULT 60 COMMENT '及格分数',
status TINYINT DEFAULT 0 COMMENT '状态:0-未开始 1-进行中 2-已结束 3-已公布成绩',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_course_semester (course_id, semester),
KEY idx_start_time (start_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试表';
-- 题目表
CREATE TABLE exam_question (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
exam_id BIGINT NOT NULL COMMENT '考试ID',
question_type TINYINT NOT NULL COMMENT '题型:1-单选 2-多选 3-判断 4-简答 5-编程',
question_content TEXT NOT NULL COMMENT '题干',
option_a VARCHAR(500) COMMENT '选项A',
option_b VARCHAR(500) COMMENT '选项B',
option_c VARCHAR(500) COMMENT '选项C',
option_d VARCHAR(500) COMMENT '选项D',
correct_answer VARCHAR(500) NOT NULL COMMENT '正确答案',
score DECIMAL(5,2) NOT NULL COMMENT '分值',
analysis TEXT COMMENT '解析',
sort_order INT DEFAULT 0 COMMENT '排序',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_exam_id (exam_id),
KEY idx_question_type (question_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='题目表';
-- 学生答题记录表
CREATE TABLE exam_answer (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
exam_id BIGINT NOT NULL,
student_id VARCHAR(20) NOT NULL,
question_id BIGINT NOT NULL,
answer_content VARCHAR(2000) COMMENT '答案内容(单选/多选/判断存储选项字母,简答/编程存储完整答案)',
is_correct TINYINT COMMENT '是否正确:1-正确 0-错误 NULL-待批改',
score DECIMAL(5,2) COMMENT '得分',
submit_time DATETIME COMMENT '提交时间',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_exam_student_question (exam_id, student_id, question_id),
KEY idx_exam_student (exam_id, student_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答题记录表';
-- 考试成绩表
CREATE TABLE exam_result (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
exam_id BIGINT NOT NULL,
student_id VARCHAR(20) NOT NULL,
total_score DECIMAL(5,2) COMMENT '总分',
status TINYINT DEFAULT 0 COMMENT '状态:0-未提交 1-已提交 2-已批改 3-已公布',
submit_time DATETIME COMMENT '提交时间',
grade_time DATETIME COMMENT '批改时间',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_exam_student (exam_id, student_id),
KEY idx_exam_id (exam_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试成绩表';
在线答题Servlet
@WebServlet("/exam/answer")
public class ExamAnswerServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private DataSource dataSource;
// 考试答题缓存,用ConcurrentHashMap保证线程安全
private ConcurrentHashMap<String, Map<Long, String>> answerCache =
new ConcurrentHashMap<>();
@Override
public void init() throws ServletException {
try {
Context ctx = (Context) new InitialContext().lookup("java:comp/env");
dataSource = (DataSource) ctx.lookup("jdbc/SchoolDB");
} catch (Exception e) {
throw new ServletException("数据源初始化失败", e);
}
}
/**
* 获取考试题目
*/
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String examId = request.getParameter("examId");
String studentId = (String) request.getSession().getAttribute("studentId");
if (examId == null || studentId == null) {
response.getWriter().write("{\"result\":-1,\"message\":\"参数错误\"}");
return;
}
List<QuestionVO> questions = getExamQuestions(examId);
// 获取学生已作答记录
Map<Long, String> answeredMap = getStudentAnswers(examId, studentId);
// 将已作答答案合并到题目中
for (QuestionVO q : questions) {
if (answeredMap.containsKey(q.getId())) {
q.setStudentAnswer(answeredMap.get(q.getId()));
}
}
// 存入缓存,方便前端轮询获取
String cacheKey = examId + "_" + studentId;
answerCache.put(cacheKey, answeredMap);
response.setContentType("application/json;charset=UTF-8");
JSONObject json = new JSONObject();
json.put("result", 0);
json.put("questions", questions);
json.put("count", questions.size());
response.getWriter().write(json.toJSONString());
}
/**
* 提交答题
*/
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.setCharacterEncoding("UTF-8");
String examId = request.getParameter("examId");
String studentId = (String) request.getSession().getAttribute("studentId");
if (examId == null || studentId == null) {
response.getWriter().write("{\"result\":-1,\"message\":\"请先登录\"}");
return;
}
// 解析答题数据(前端传入JSON字符串)
String answersJson = request.getParameter("answers");
if (answersJson == null || answersJson.isEmpty()) {
response.getWriter().write("{\"result\":-1,\"message\":\"答题数据不能为空\"}");
return;
}
try {
JSONArray answers = new JSONArray(answersJson);
int successCount = 0;
for (int i = 0; i < answers.length(); i++) {
JSONObject answer = answers.getJSONObject(i);
long questionId = answer.getLong("questionId");
String answerContent = answer.getString("answerContent");
if (saveAnswer(examId, studentId, questionId, answerContent)) {
successCount++;
}
}
// 更新考试成绩状态为已提交
updateExamStatus(examId, studentId, 1);
response.setContentType("application/json;charset=UTF-8");
JSONObject json = new JSONObject();
json.put("result", 0);
json.put("message", "提交成功,共保存" + successCount + "道题");
response.getWriter().write(json.toJSONString());
} catch (Exception e) {
response.getWriter().write("{\"result\":-1,\"message\":\"提交失败:" + e.getMessage() + "\"}");
}
}
/**
* 保存答题记录
*/
private boolean saveAnswer(String examId, String studentId,
long questionId, String answerContent) {
String sql = "INSERT INTO exam_answer (exam_id, student_id, question_id, " +
"answer_content, create_time) " +
"VALUES (?, ?, ?, ?, NOW()) " +
"ON DUPLICATE KEY UPDATE answer_content = ?, submit_time = NOW()";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, examId);
pstmt.setString(2, studentId);
pstmt.setLong(3, questionId);
pstmt.setString(4, answerContent);
pstmt.setString(5, answerContent);
pstmt.executeUpdate();
return true;
} catch (SQLException e) {
e.printStackTrace();
return false;
}
}
/**
* 获取学生已作答记录
*/
private Map<Long, String> getStudentAnswers(String examId, String studentId) {
Map<Long, String> resultMap = new HashMap<>();
String sql = "SELECT question_id, answer_content FROM exam_answer " +
"WHERE exam_id = ? AND student_id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, examId);
pstmt.setString(2, studentId);
try (ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
resultMap.put(rs.getLong("question_id"),
rs.getString("answer_content"));
}
}
} catch (SQLException e) {
e.printStackTrace();
}
return resultMap;
}
/**
* 获取考试题目列表
*/
private List<QuestionVO> getExamQuestions(String examId) {
List<QuestionVO> result = new ArrayList<>();
String sql = "SELECT id, question_type, question_content, " +
"option_a, option_b, option_c, option_d, " +
"score, sort_order " +
"FROM exam_question WHERE exam_id = ? ORDER BY sort_order";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, examId);
try (ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
QuestionVO vo = new QuestionVO();
vo.setId(rs.getLong("id"));
vo.setQuestionType(rs.getInt("question_type"));
vo.setQuestionContent(rs.getString("question_content"));
vo.setOptionA(rs.getString("option_a"));
vo.setOptionB(rs.getString("option_b"));
vo.setOptionC(rs.getString("option_c"));
vo.setOptionD(rs.getString("option_d"));
vo.setScore(rs.getDouble("score"));
vo.setSortOrder(rs.getInt("sort_order"));
result.add(vo);
}
}
} catch (SQLException e) {
e.printStackTrace();
}
return result;
}
/**
* 更新考试成绩状态
*/
private void updateExamStatus(String examId, String studentId, int status) {
String sql = "UPDATE exam_result SET status = ?, submit_time = NOW() " +
"WHERE exam_id = ? AND student_id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, status);
pstmt.setString(2, examId);
pstmt.setString(3, studentId);
pstmt.executeUpdate();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
自动判分Servlet
@WebServlet("/exam/autoGrade")
public class AutoGradeServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private DataSource dataSource;
@Override
public void init() throws ServletException {
try {
Context ctx = (Context) new InitialContext().lookup("java:comp/env");
dataSource = (DataSource) ctx.lookup("jdbc/SchoolDB");
} catch (Exception e) {
throw new ServletException("数据源初始化失败", e);
}
}
/**
* 自动判分(选择题、判断题)
*/
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String examId = request.getParameter("examId");
// 只判客观题(单选、多选、判断)
String sql = "UPDATE exam_answer ea " +
"JOIN exam_question eq ON ea.question_id = eq.id " +
"JOIN exam e ON eq.exam_id = e.id " +
"SET ea.is_correct = CASE " +
" WHEN eq.question_type IN (1, 3) THEN IF(ea.answer_content = eq.correct_answer, 1, 0) " +
" WHEN eq.question_type = 2 THEN IF(ea.answer_content = eq.correct_answer, 1, 0) " +
" ELSE NULL END, " +
" ea.score = CASE " +
" WHEN eq.question_type IN (1, 3) THEN IF(ea.answer_content = eq.correct_answer, eq.score, 0) " +
" WHEN eq.question_type = 2 THEN IF(ea.answer_content = eq.correct_answer, eq.score, 0) " +
" ELSE NULL END " +
"WHERE eq.exam_id = ? AND eq.question_type IN (1, 2, 3)";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, examId);
int updated = pstmt.executeUpdate();
// 计算每个学生的总分
updateStudentScores(examId);
// 更新考试成绩状态
updateResultStatus(examId, 2);
response.setContentType("application/json;charset=UTF-8");
JSONObject json = new JSONObject();
json.put("result", 0);
json.put("message", "自动判分完成,共判" + updated + "道题");
response.getWriter().write(json.toJSONString());
} catch (SQLException e) {
e.printStackTrace();
response.getWriter().write("{\"result\":-1,\"message\":\"判分失败\"}");
}
}
/**
* 计算学生总分
*/
private void updateStudentScores(String examId) throws SQLException {
String sql = "UPDATE exam_result r " +
"SET r.total_score = ( " +
" SELECT COALESCE(SUM(ea.score), 0) " +
" FROM exam_answer ea " +
" WHERE ea.exam_id = r.exam_id AND ea.student_id = r.student_id " +
" AND ea.is_correct IS NOT NULL " +
") " +
"WHERE r.exam_id = ? AND r.status = 1";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, examId);
pstmt.executeUpdate();
} catch (SQLException e) {
e.printStackTrace();
throw e;
}
}
/**
* 更新考试成绩状态
*/
private void updateResultStatus(String examId, int status) throws SQLException {
String sql = "UPDATE exam_result SET status = ? WHERE exam_id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, status);
pstmt.setString(2, examId);
pstmt.executeUpdate();
} catch (SQLException e) {
e.printStackTrace();
throw e;
}
}
}
连接池配置:让数据库扛得住高并发
很多系统卡顿的根本原因,就是没有合理配置数据库连接池。连接不够用,请求就排队;连接数设太大,数据库又扛不住。这个平衡点要找到。
Tomcat context.xml 配置
<?xml version="1.0" encoding="UTF-8"?>
<Context>
<!-- 数据库连接池配置 -->
<Resource name="jdbc/SchoolDB"
auth="Container"
type="javax.sql.DataSource"
maxTotal="100" <!-- 最大连接数 -->
maxIdle="30" <!-- 最大空闲连接数 -->
minIdle="10" <!-- 最小空闲连接数 -->
initialSize="20" <!-- 初始化连接数 -->
maxWaitMillis="10000" <!-- 最大等待时间(毫秒) -->
removeAbandoned="true" <!-- 移除废弃连接 -->
removeAbandonedTimeout="300" <!-- 废弃连接超时时间(秒) -->
logAbandoned="true" <!-- 记录废弃连接日志 -->
username="school_user"
password="school_pass123"
url="jdbc:mysql://localhost:3306/school_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai"
driverClassName="com.mysql.cj.jdbc.Driver"
validationQuery="SELECT 1"
testWhileIdle="true"
timeBetweenEvictionRunsMillis="60000"
numTestsPerEvictionRun="10"
minEvictableIdleTimeMillis="1800000"/>
</Context>
配置里的每个参数都有讲究:
maxTotal设置100,意味着最多同时有100个数据库连接。这个值要根据学校规模来定,三万人的学校,100个连接够了;五百人的培训学校,20个就够了。设太大反而浪费资源。maxWaitMillis设10秒,意味着请求最多等10秒拿不到连接就会报错。这个时间不能设太长,否则用户体验太差;也不能设太短,否则高峰期容易误报。removeAbandoned和removeAbandonedTimeout是救命稻草。有些代码没有正确关闭连接,这些连接就会一直占用着。开了这个功能,超过300秒没使用的连接会被自动回收。validationQuery和testWhileIdle确保拿到的连接是有效的。想象一下,你从连接池拿到一个已经断开的连接去查数据库,那得多让人无语。
JDBC工具类
package com.school.util;
import java.sql.*;
import javax.sql.DataSource;
import javax.naming.*;
public class DBUtil {
private static DataSource dataSource;
static {
try {
Context ctx = new InitialContext();
dataSource = (DataSource) ctx.lookup("java:comp/env/jdbc/SchoolDB");
} catch (NamingException e) {
e.printStackTrace();
}
}
/**
* 获取数据库连接
* 使用try-with-resources确保连接自动返回连接池
*/
public static Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
/**
* 关闭资源(不真正关闭连接,而是归还到连接池)
*/
public static void close(AutoCloseable... resources) {
for (AutoCloseable resource : resources) {
if (resource != null) {
try {
resource.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
/**
* 获取连接池信息(用于监控)
*/
public static String getPoolInfo() {
if (dataSource == null) return "数据源未初始化";
try {
com.mysql.cj.jdbc.JdbcDataSource ds =
(com.mysql.cj.jdbc.JdbcDataSource) dataSource;
return "活跃连接数: " + ds.getActiveConnections() +
", 最大连接数: " + ds.getMaxConnections();
} catch (Exception e) {
return "无法获取连接池信息";
}
}
}
注意 close 方法,它接收的是一个可变参数,可以一次关闭多个资源。而且这里用 AutoCloseable 接口,意味着不管是 Connection、PreparedStatement 还是 ResultSet,都能统一处理。
很多人写代码的习惯是:
// 错误的做法
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = DBUtil.getConnection();
pstmt = conn.prepareStatement(sql);
rs = pstmt.executeQuery();
// ... 处理结果
} catch (Exception e) {
e.printStackTrace();
} finally {
try { if (rs != null) rs.close(); } catch (Exception e) {}
try { if (pstmt != null) pstmt.close(); } catch (Exception e) {}
try { if (conn != null) conn.close(); } catch (Exception e) {}
}
这种写法又长又容易出错,漏写一个 close 就可能导致连接泄漏。用 try-with-resources 就简洁多了:
// 正确的做法
try (Connection conn = DBUtil.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql);
ResultSet rs = pstmt.executeQuery()) {
// ... 处理结果
} catch (Exception e) {
e.printStackTrace();
}
三个资源一起关,语法简洁,不会漏。
缓存策略:让查询飞起来
数据库再快,也怕并发。三万人同时查成绩,就算数据库是顶级配置也得喘。这时候就要请出缓存了。
本地缓存实现
package com.school.cache;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.*;
/**
* 简单本地缓存,用于缓存成绩数据
* 适合中小型学校(万人以内)
* 大型学校建议用Redis集群
*/
public class ScoreCache {
// 缓存结构:key -> Map<studentId, List<ScoreVO>>
private final ConcurrentHashMap<String, CacheEntry> cache =
new ConcurrentHashMap<>();
// 默认过期时间:30分钟
private static final long DEFAULT_TTL = 30 * 60 * 1000L;
/**
* 缓存条目
*/
private static class CacheEntry {
private final List<Object> data;
private final long expireTime;
CacheEntry(List<Object> data, long ttl) {
this.data = data;
this.expireTime = System.currentTimeMillis() + ttl;
}
boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
List<Object> getData() {
return data;
}
}
/**
* 获取缓存数据
*/
public List<Object> get(String cacheKey) {
CacheEntry entry = cache.get(cacheKey);
if (entry == null) return null;
if (entry.isExpired()) {
cache.remove(cacheKey);
return null;
}
return entry.getData();
}
/**
* 设置缓存数据
*/
public void put(String cacheKey, List<Object> data) {
put(cacheKey, data, DEFAULT_TTL);
}
/**
* 设置缓存数据(指定过期时间)
*/
public void put(String cacheKey, List<Object> data, long ttl) {
cache.put(cacheKey, new CacheEntry(data, ttl));
}
/**
* 清除指定缓存
*/
public void evict(String cacheKey) {
cache.remove(cacheKey);
}
/**
* 清除所有缓存
*/
public void evictAll() {
cache.clear();
}
/**
* 获取缓存统计信息
*/
public String getStats() {
int size = cache.size();
int expiredCount = 0;
Iterator<Map.Entry<String, CacheEntry>> it = cache.entrySet().iterator();
while (it.hasNext()) {
if (it.next().getValue().isExpired()) {
expiredCount++;
it.remove();
}
}
return "缓存条目数: " + size +
", 已清理过期条目: " + expiredCount;
}
}
这个缓存实现虽然简单,但功能齐全。ConcurrentHashMap 保证了多线程环境下的线程安全,不用额外加锁。过期时间用 System.currentTimeMillis() 判断,简单直接。
成绩查询缓存集成
@WebServlet("/score/query")
public class ScoreQueryServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private DataSource dataSource;
private ScoreCache scoreCache;
@Override
public void init() throws ServletException {
try {
Context ctx = (Context) new InitialContext().lookup("java:comp/env");
dataSource = (DataSource) ctx.lookup("jdbc/SchoolDB");
// 初始化缓存
scoreCache = new ScoreCache();
} catch (Exception e) {
throw new ServletException("初始化失败", e);
}
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String studentId = request.getParameter("studentId");
String semester = request.getParameter("semester");
if (studentId == null || studentId.trim().isEmpty()) {
request.setAttribute("errorMsg", "请输入学号");
request.getRequestDispatcher("/score/error.jsp").forward(request, response);
return;
}
if (semester == null || semester.trim().isEmpty()) {
semester = getCurrentSemester();
}
// 构建缓存key
String cacheKey = "score_" + studentId.trim() + "_" + semester;
// 先从缓存取
List<Object> cachedData = scoreCache.get(cacheKey);
if (cachedData != null) {
// 缓存命中,直接从缓存返回
request.setAttribute("scores", cachedData);
request.setAttribute("studentId", studentId.trim());
request.setAttribute("semester", semester);
request.setAttribute("cacheHit", true);
request.getRequestDispatcher("/score/result.jsp").forward(request, response);
return;
}
// 缓存未命中,查询数据库
List<ScoreVO> scores = queryScores(studentId.trim(), semester);
// 存入缓存,有效期30分钟
scoreCache.put(cacheKey, scores);
// 查询课程信息
if (!scores.isEmpty()) {
Set<String> courseIds = new HashSet<>();
for (ScoreVO s : scores) {
courseIds.add(s.getCourseId());
}
Map<String, String> courseNameMap = queryCourseNames(courseIds);
for (ScoreVO s : scores) {
s.setCourseName(courseNameMap.getOrDefault(s.getCourseId(), "未知课程"));
}
}
// 计算统计数据
int totalCredit = 0;
double totalGradePoint = 0.0;
for (ScoreVO s : scores) {
if (s.getGradePoint() != null) {
totalCredit += s.getCredit();
totalGradePoint += s.getGradePoint() * s.getCredit();
}
}
double gpa = totalCredit > 0 ? totalGradePoint / totalCredit : 0.0;
request.setAttribute("scores", scores);
request.setAttribute("studentId", studentId.trim());
request.setAttribute("semester", semester);
request.setAttribute("gpa", String.format("%.2f", gpa));
request.setAttribute("totalCredit", totalCredit);
request.setAttribute("cacheHit", false);
request.getRequestDispatcher("/score/result.jsp").forward(request, response);
}
/**
* 当成绩更新时,清除相关缓存
*/
public void invalidateScoreCache(String studentId, String semester) {
String cacheKey = "score_" + studentId + "_" + semester;
scoreCache.evict(cacheKey);
}
}
缓存的妙处在于,同一个学生第二次查成绩的时候,直接从内存里拿,不用再去数据库查。期末查成绩的高峰期,大部分学生查的都是自己的成绩,缓存命中率能到90%以上。数据库的查询压力直接降了九成。
当然,缓存也有需要注意的地方——数据一致性。成绩一旦更新,必须及时清除缓存,否则学生看到的还是旧成绩。所以我在 ScoreQueryServlet 里留了一个 invalidateScoreCache 方法,在成绩录入系统更新成绩的时候调用它。
低成本部署方案
我知道很多学校经费有限,所以这套系统从头到尾都在控制成本。
服务器配置建议
推荐配置(适合3000人以内学校):
- CPU: 4核
- 内存: 8GB
- 硬盘: 100GB SSD
- 带宽: 5Mbps
预算方案(500元/月左右):
- 阿里云/腾讯云 入门级ECS
- MySQL 部署在同一台服务器上
- Tomcat 直接部署
推荐配置(适合10000人以上学校):
- CPU: 8核
- 内存: 16GB
- 硬盘: 200GB SSD
- 带宽: 10Mbps
拆分部署方案:
- Web服务器: 4核8GB(Tomcat)
- 数据库服务器: 8核16GB(MySQL主从)
- 缓存服务器: 2核4GB(Redis)
数据库连接数调优
MySQL的默认配置对高并发场景并不友好,需要调整几个关键参数:
# my.cnf 配置文件
[mysqld]
# 连接相关
max_connections = 500 # 最大连接数(根据服务器配置调整)
wait_timeout = 60 # 空闲连接超时时间(秒)
interactive_timeout = 60 # 交互式连接超时时间
# 缓存相关
innodb_buffer_pool_size = 4G # InnoDB缓冲池大小(建议占内存的50-70%)
query_cache_size = 64M # 查询缓存(MySQL 5.7及以下)
tmp_table_size = 64M # 临时表大小
max_heap_table_size = 64M
# 日志相关
slow_query_log = 1 # 开启慢查询日志
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2 # 慢查询阈值(秒)
# 锁相关
innodb_lock_wait_timeout = 30 # 锁等待超时时间
这些参数的调整,能让MySQL在高并发下表现更稳定。特别是 innodb_buffer_pool_size,设得越大,数据库从内存读数据的比例就越高,性能提升非常明显。
性能测试与监控
上线前一定要做压力测试,不能凭感觉。我用过 JMeter 做测试,很简单:
测试场景:模拟1000人同时查询成绩
- 线程数: 1000
- Ramp-up时间: 60秒(逐渐增加并发)
- 循环次数: 1次
- 超时时间: 30秒
测试结果模板:
测试结果报告
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
测试时间: 2024-01-15 14:30:00
并发用户数: 1000
测试时长: 3分20秒
总体统计:
- 请求总数: 1000
- 成功: 987 (98.7%)
- 失败: 13 (1.3%)
- 平均响应时间: 235ms
- 中位响应时间: 180ms
- 90%响应时间: 450ms
- 99%响应时间: 890ms
- 最小响应时间: 45ms
- 最大响应时间: 2340ms
- 吞吐量: 4.98 req/s
错误分析:
- 超时: 8次
- 数据库连接失败: 5次
服务器状态:
- CPU使用率: 78%
- 内存使用率: 65%
- 数据库连接数: 85/100
- 慢查询数: 3
从这个结果就能看出问题:有5次数据库连接失败,说明连接池可能不够用;有3个慢查询,需要优化SQL。根据这些信息调整参数,再测一次,直到满意为止。
改造传统教务系统的实际步骤
如果你学校现在用的是老旧的教务系统,想逐步改造,可以按照这个顺序来:
第一阶段:数据库重构(1-2周)
先别急着改代码,先把数据库设计搞清楚。很多老系统的数据库设计是一塌糊涂的,表结构混乱,字段命名随意,没有索引,没有外键约束。
建议先做一个数据迁移工具,把现有数据导入新设计的表结构。迁移过程中要解决数据质量问题,比如学号格式不统一、成绩数据缺失等。
-- 数据迁移示例
INSERT INTO score_new (student_id, course_id, semester, score, grade_point, status)
SELECT
TRIM(s.student_no) AS student_id,
TRIM(c.course_code) AS course_id,
CONCAT(s.exam_year, '-', s.exam_term) AS semester,
s.score,
CASE
WHEN s.score >= 90 THEN 4.0
WHEN s.score >= 85 THEN 3.7
WHEN s.score >= 82 THEN 3.3
WHEN s.score >= 78 THEN 3.0
WHEN s.score >= 75 THEN 2.7
WHEN s.score >= 72 THEN 2.3
WHEN s.score >= 68 THEN 2.0
WHEN s.score >= 64 THEN 1.5
WHEN s.score >= 60 THEN 1.0
ELSE 0.0
END AS grade_point,
1 AS status
FROM old_score s
JOIN old_course c ON s.course_id = c.id
WHERE s.score IS NOT NULL;
第二阶段:成绩查询模块上线(2-3周)
先上成绩查询,因为这是学生最迫切的需求,也是问题最集中的地方。新系统上线后,老系统保留只读权限,作为备份。
这个阶段的重点是:
- 实现成绩查询接口,配合缓存
- 设计简洁的查询页面
- 做好压力测试
- 培训教务老师使用
第三阶段:在线考试系统(4-6周)
在线考试系统的复杂度比成绩查询高不少,需要分阶段上线:
- 先实现考试管理后台(出题、组卷)
- 再实现学生答题功能
- 最后实现自动判分和成绩统计
第四阶段:选课系统改造(4-6周)
选课系统是最复杂的,因为它涉及实时并发、时间冲突检测、容量控制等多个难点。建议在考试系统稳定运行后再改造选课。
一些实用的经验之谈
说了这么多技术方案,再分享几个我在学校信息化项目里摸爬滚打攒下来的经验。
第一,不要试图一步到位。 很多学校搞信息化项目,一上来就想做一个大而全的系统,结果做了三年还没上线。不如拆成小模块,一个一个来,每个模块上线后收集反馈,快速迭代。
第二,性能优化要从架构层面入手,不要只盯着代码。 我见过很多人花几个月时间优化Java代码,结果发现瓶颈在数据库查询上。先把数据库索引建好,查询语句写对,性能就能提升一大截。代码层面的优化是锦上添花,不是雪中送炭。
第三,缓存很重要,但也要小心缓存穿透和缓存雪崩。 缓存穿透是指查询一个不存在的数据,每次都打到数据库上。解决方案是用布隆过滤器或者把空结果也缓存起来。缓存雪崩是指大量缓存同时过期,数据库突然承受巨大压力。解决方案是给缓存过期时间加一个随机值,不要全部设置成同样的过期时间。
// 防止缓存雪崩:过期时间加随机值
private static final Random RANDOM = new Random();
public void putWithJitter(String key, Object value, long baseTtl) {
// 在基础TTL上加0-30秒的随机时间
long jitter = RANDOM.nextInt(30) * 1000L;
cache.put(key, value, baseTtl + jitter);
}
第四,日志记录不能省。 出了问题要能追溯到根因,日志就是最重要的线索。但不要记录太多无用信息,否则日志文件会迅速膨胀。关键操作的日志要保留至少半年。
// 关键操作日志记录
private static final Logger logger = LoggerFactory.getLogger(ScoreQueryServlet.class);
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String studentId = request.getParameter("studentId");
String semester = request.getParameter("semester");
logger.info("成绩查询请求: studentId={}, semester={}", studentId, semester);
// ... 业务逻辑
logger.info("成绩查询完成: studentId={}, 成绩数量={}", studentId, scores.size());
}
第五,给用户留一个反馈渠道。 系统再好也会有问题,学生遇到问题不知道报给谁,只能满世界骂。在页面底部放一个反馈按钮,收集问题并及时回复,学生的满意度会高很多。
成本控制明细
这套系统的开发和维护成本,说实话非常低:
| 项目 | 费用 | 说明 |
|---|---|---|
| 开发人力 | 0-5万 | 校内信息中心老师开发,或外包给本地IT公司 |
| 服务器 | 500-2000元/月 | 按学生规模选择配置 |
| 域名 | 50-100元/年 | 学校可用.edu域名 |
| SSL证书 | 0-500元/年 | 可申请免费证书 |
| 数据库 | 0元 | 使用开源MySQL |
| Web服务器 | 0元 | 使用开源Tomcat |
| 维护成本 | 极低 | 系统稳定后基本无需维护 |
一年下来,总成本不超过两万元。跟原来那些动辄几十万的商业教务系统比,便宜得不像话。
当然,便宜归便宜,质量不能打折。这套系统的每一个模块我都认真设计过,数据库索引、连接池配置、缓存策略、并发控制,该考虑的都考虑到了。实际部署的时候,建议找有经验的老师或者IT公司帮忙把关,毕竟系统上线后的稳定运行才是最重要的。
好了,今天就聊到这儿。教务系统这事儿,说难不难,说简单也不简单。关键是要抓住核心问题——数据库查询效率和并发控制——把这两块做好了,系统就不会卡。希望这篇文章能帮到正在为教务系统头疼的学校信息化工作者们。
