想象一下,这是周一早晨八点半,距离早八下课还有十分钟,全校两千多名学生同时涌入教务系统提交期末作业,或者教务处后台正在为下周的选课进行最后的排课数据修正。
这一刻,如果你的系统还在用简单的 if 判断来处理数据,那场面会非常尴尬:数据库报错、作业提交失败、成绩覆盖,甚至老师发现学生人数突然变多了两个。
这就是高校信息化管理中典型的高并发与数据一致性地狱。今天,我们不讲枯燥的教科书定义,而是把镜头拉近,看看在传统的 JSP + Servlet + JDBC 架构下,老师们和后端开发人员是如何一边骂娘,一边把这些坑填平的。
一、 并发冲突:当“抢课”变成“抢命”
先说最让人头秃的排课场景。
假设某门核心课《高等数学B》只剩 50 个名额,而想选这门课的学生有 200 人。系统逻辑看似很简单:
- 学生点击“选课”。
- 检查数据库里剩余名额是否 > 0。
- 如果大于0,名额减1,插入选课记录。
- 返回成功。
但在并发情况下,这四个步骤如果缺乏保护,就会出现“超卖”现象。
1.1 经典陷阱:检查与更新的间隙
我们用一段伪代码来看看问题出在哪。假设两个学生 A 和 B 同时点击提交:
// 伪代码:看似正确的逻辑
public void selectCourse(int studentId, int courseId) {
// 1. 查询剩余名额
int remaining = courseDAO.getRemainingSeats(courseId);
// 2. 判断
if (remaining > 0) {
// 3. 插入选课记录
enrollDAO.save(studentId, courseId);
// 4. 更新剩余名额
courseDAO.updateRemainingSeats(courseId, remaining - 1);
} else {
throw new RuntimeException("名额已满");
}
}
灾难现场还原:
- 剩余名额是 1。
- 学生 A 进来,执行第1步,拿到
remaining = 1。 - 学生 B 同时也进来,执行第1步,也拿到
remaining = 1(因为 A 还没提交,数据库没变)。 - 学生 A 判断
1 > 0成立,执行第3步插入记录,执行第4步把名额改成 0。 - 学生 B 判断
1 > 0也成立,执行第3步插入记录,执行第4步把名额改成 0(注意:他基于的是他自己拿到的1-1=0,而不是 A 改完后的结果)。
结果: 选了两个人,名额却显示为0。如果并发更高,名额可能变成负数,或者数据库插入报错导致事务回滚,学生投诉电话打爆教务处。
1.2 JSP/Servlet 层的解决方案:悲观锁与乐观锁
在高校后台开发中,我们通常不会在 JSP 页面里写这些逻辑(那是大忌),而是在 Servlet 或 Service 层处理。针对这个问题,主要有两种解法。
方案一:数据库悲观锁(Pessimistic Locking)
这是最直接的方法。既然担心别人改数据,那就锁住这行数据,直到我处理完。
在 MySQL 中,我们可以利用 SELECT ... FOR UPDATE。
// 使用 PreparedStatement 进行带锁查询
String sql = "SELECT remaining_seats FROM course WHERE course_id = ? FOR UPDATE";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setInt(1, courseId);
ResultSet rs = pstmt.executeQuery();
if (rs.next()) {
int remaining = rs.getInt("remaining_seats");
if (remaining > 0) {
// 开始事务
conn.setAutoCommit(false);
try {
// 插入选课记录
String insertSql = "INSERT INTO enrollment (student_id, course_id) VALUES (?, ?)";
pstmt = conn.prepareStatement(insertSql);
pstmt.setInt(1, studentId);
pstmt.setInt(2, courseId);
pstmt.executeUpdate();
// 更新名额
String updateSql = "UPDATE course SET remaining_seats = remaining_seats - 1 WHERE course_id = ?";
pstmt = conn.prepareStatement(updateSql);
pstmt.setInt(1, courseId);
pstmt.executeUpdate();
conn.commit();
} catch (SQLException e) {
conn.rollback(); // 出错了就回滚
throw e;
} finally {
conn.setAutoCommit(true);
// 关闭资源...
}
}
}
原理: FOR UPDATE 会在数据库层面给这行记录加排他锁。当学生 A 查询时,数据库锁住了那行课程记录。学生 B 再来查,必须等待 A 提交事务(解锁)或者超时。这样,B 要么排队等 A 选完后自己还能选(如果还有名额),要么发现名额为0。
缺点: 在高并发下,等待锁会导致系统响应变慢,用户体验极差。比如选课高峰期,点一下按钮转圈转了十秒。
方案二:乐观锁(Optimistic Locking)+ 版本号
为了不让系统卡死,我们更倾向于用“乐观”的方式。也就是不锁表,但在更新时检查一下数据有没有被别人改过。
我们在 course 表里加一个字段 version,每次更新名额时,版本号 +1。
// 1. 先查出当前状态和版本号
String checkSql = "SELECT remaining_seats, version FROM course WHERE course_id = ?";
// ... 执行查询,得到 remaining 和 version
// 2. 业务逻辑判断:remaining > 0 ?
// 3. 更新时带上版本号条件(CAS 思想)
String updateSql = "UPDATE course SET remaining_seats = remaining_seats - 1, version = version + 1 " +
"WHERE course_id = ? AND remaining_seats > 0 AND version = ?";
PreparedStatement pstmt = conn.prepareStatement(updateSql);
pstmt.setInt(1, courseId);
pstmt.setInt(2, currentVersion); // 使用刚才查出来的版本号
int rowsAffected = pstmt.executeUpdate();
if (rowsAffected == 0) {
throw new RuntimeException("选课失败,可能名额已被抢占,请刷新后重试");
}
原理: 如果在你判断 remaining > 0 之后、执行 UPDATE 之前,别人把名额改成了0,那么 WHERE remaining_seats > 0 这个条件就不满足了,rowsAffected 为 0。这时我们就知道冲突发生了,可以友好地提示用户“稍后再试”,而不是默默覆盖数据。
为什么这在高校后台很流行? 因为选课通常是“读多写少”(大部分时间是浏览课表,只有瞬间点击提交),乐观锁的性能远高于悲观锁。
二、 数据安全:作业提交时的“数据泄露”危机
解决了并发,我们再来看看另一个让教务员 nightmares 的问题:作业提交的数据安全。
在线作业系统看似简单:学生上传一个 .java 或 .pdf,服务器接收保存,老师下载批改。
但这里隐藏着三个致命风险:
- 文件类型伪造:学生上传了一个
.exe病毒,服务器保存后,老师点击下载,电脑中病毒。 - 越权访问:学生 A 通过修改 URL 参数(如
?id=1001),下载了学生 B 的隐私作业,或者篡改了其他人的成绩。 - 敏感信息泄露:作业内容或学生个人信息(姓名、学号)在传输过程中被中间人截获。
2.1 文件上传的安全清洗
在 JSP 开发中,我们常用 HttpServletRequest 的 getPart() 或 getInputStream() 来处理上传。很多新手代码是这样的:
// 危险代码示例!不要这样写
Part filePart = request.getPart("homeworkFile");
String fileName = filePart.getSubmittedFileName();
// 直接保存,文件名可能是 "shell.exe" 或 "../web.xml"
filePart.write(uploadPath + "/" + fileName);
这段代码有几个问题:
- 白名单校验缺失:没有检查扩展名。
- 路径遍历漏洞:
fileName可能包含../../,将文件写到 Web 目录外或覆盖系统文件。 - 文件名冲突:两个学生都叫
作业.java,后面的覆盖前面的。
专家级解决方案:
我们需要一个严格的文件名规范化和内容校验机制。
import org.apache.commons.io.FilenameUtils;
import java.util.UUID;
import java.util.Arrays;
import java.util.Set;
public String secureSaveFile(Part filePart, String uploadDir) {
// 1. 获取原始文件名
String originalFileName = filePart.getSubmittedFileName();
// 2. 获取扩展名,强制转为小写
String extension = FilenameUtils.getExtension(originalFileName).toLowerCase();
// 3. 白名单校验:只允许 .java, .pdf, .doc, .docx, .txt
Set<String> allowedExtensions = Set.of("java", "pdf", "doc", "docx", "txt");
if (!allowedExtensions.contains(extension)) {
throw new SecurityException("不支持的文件类型: " + extension);
}
// 4. 生成唯一的内部文件名,防止覆盖和路径遍历
// 格式: 学号_时间戳_随机UUID.扩展名
String secureFileName = UUID.randomUUID().toString() + "_" + System.currentTimeMillis() + "." + extension;
// 5. 确保上传目录存在
File dir = new File(uploadDir);
if (!dir.exists()) {
dir.mkdirs();
}
// 6. 安全写入
// 注意:不要在路径中拼接用户输入的任何内容
String savePath = dir.getAbsolutePath() + File.separator + secureFileName;
try (InputStream is = filePart.getInputStream()) {
Files.copy(is, Paths.get(savePath), StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new RuntimeException("文件保存失败", e);
}
// 7. 返回安全文件名存入数据库,而不是原始文件名
return secureFileName;
}
关键点解析:
- UUID 重命名:即使攻击者上传了
virus.exe,在服务器上也变成了a1b2c3d4-..._1715666666.exe(如果允许 exe 的话,但我们禁止了)。更重要的是,即使允许.jpg,攻击者也难以预测文件名来构造下载链接。 - 白名单:拒绝一切不在列表中的文件。黑名单(如“禁止 exe”)是不安全的,因为可能有
php,jsp,html,sh等危险类型,白名单才能彻底堵死。
2.2 越权访问:身份验证与数据隔离
在高校系统中,最可怕的不是黑客攻击,而是内部越权。
比如,成绩查询接口:GET /api/student/score?studentId=2021001。
如果后端代码只做了“用户是否登录”的判断,而没有做“当前登录用户是否有权查看这个 studentId 的数据”,那么:
- 学生 A 登录。
- 学生 A 修改请求参数为
studentId=2021002(同学 B 的学号)。 - 服务器直接查出 B 的成绩返回给 A。
B 的成绩隐私泄露了。
解决方案:基于 Session 的上下文校验
在 Servlet 或 Spring MVC 的 Controller 中,永远不要信任前端传来的 ID。
// 假设使用标准的 HttpSession
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 1. 从 Session 中获取当前登录学生的信息
Student currentUser = (Student) request.getSession().getAttribute("currentUser");
if (currentUser == null) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "请先登录");
return;
}
// 2. 获取请求中的目标学号(来自前端参数)
String targetStudentId = request.getParameter("studentId");
// 3. 【关键步骤】权限校验:当前用户只能查看自己的数据
// 除非角色是管理员,否则 targetStudentId 必须等于 currentUser.getId()
if (!currentUser.getId().equals(targetStudentId) && !isAdmin(currentUser.getRole())) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权查看他人数据");
return;
}
// 4. 查询数据库,使用预编译语句防止 SQL 注入
String sql = "SELECT * FROM scores WHERE student_id = ?";
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, targetStudentId); // 绑定参数,而不是拼接字符串
// ... 执行查询并返回
}
}
原则: 所有的数据查询,WHERE 条件中的身份标识,必须优先使用服务端 Session 中的信息,而不是客户端传过来的参数。客户端传来的参数只用于展示或次要过滤,绝不能作为主要权限依据。
2.3 传输层安全:HTTPS 的强制启用
最后,关于数据在网络上“裸奔”的问题。
很多高校内网系统只用了 HTTP。这意味着,学生在图书馆连校园 Wi-Fi 提交作业时,他的账号、密码、作业内容,在局域网内是以明文传输的。任何连在同一个 Wi-Fi 下的人,用个抓包工具(如 Wireshark 或浏览器 F12 的 Network 面板),就能截获他的所有数据。
这是不可接受的安全事故。
在 JSP 项目部署时,必须在 Nginx 或 Web 服务器(Tomcat)层面强制启用 HTTPS。
Nginx 配置示例:
server {
listen 80;
server_name jwxy.example.edu.cn;
# 强制跳转到 HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name jwxy.example.edu.cn;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# 启用 HSTS,防止降级攻击
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://localhost:8080; # 转发给后端的 JSP/Tomcat 服务
}
}
同时,JSP 页面中涉及敏感提交(如密码修改、成绩确认)的表单,必须确保 action 是 https:// 开头,或者使用相对路径(由 Nginx 统一处理)。
三、 总结:从代码细节看系统稳健性
回到标题,从教务排课到在线作业提交,JSP 作为经典的 Java Web 技术,虽然在现代微服务架构中逐渐被前后端分离方案(Vue/React + Spring Boot)取代,但在大量高校遗留系统(Legacy Systems)中,它仍然是主力。
解决并发冲突和数据安全,并不是靠某一行神奇的代码,而是靠一套严谨的工程习惯:
- 对于并发:认清“检查-执行”之间的时间窗口,要么用悲观锁(For Update)强硬隔离,要么用乐观锁(版本号/CAS)允许冲突后重试。在选课这种场景,乐观锁 + 前端轮询/提示通常是更好的用户体验平衡点。
- 对于文件安全:永远不要信任客户端的文件名和类型。执行白名单校验和随机重命名。
- 对于权限安全:遵循最小权限原则,数据访问必须通过服务端 Session 中的身份进行校验,严禁直接使用 URL 参数作为数据归属的依据。
- 对于传输安全:HTTPS 是底线,不是选项。
高校管理后台面对的不仅仅是数据,更是两千多名学生和老师对教育公平与隐私的信任。每一个 if 判断的背后,都是一次对系统稳定性的考验。希望这些来自“老后端”的经验,能帮你避开那些曾经让我们掉坑里的坑。
