想象一下,这是周一早晨八点半,距离早八下课还有十分钟,全校两千多名学生同时涌入教务系统提交期末作业,或者教务处后台正在为下周的选课进行最后的排课数据修正。

这一刻,如果你的系统还在用简单的 if 判断来处理数据,那场面会非常尴尬:数据库报错、作业提交失败、成绩覆盖,甚至老师发现学生人数突然变多了两个。

这就是高校信息化管理中典型的高并发与数据一致性地狱。今天,我们不讲枯燥的教科书定义,而是把镜头拉近,看看在传统的 JSP + Servlet + JDBC 架构下,老师们和后端开发人员是如何一边骂娘,一边把这些坑填平的。

一、 并发冲突:当“抢课”变成“抢命”

先说最让人头秃的排课场景。

假设某门核心课《高等数学B》只剩 50 个名额,而想选这门课的学生有 200 人。系统逻辑看似很简单:

  1. 学生点击“选课”。
  2. 检查数据库里剩余名额是否 > 0。
  3. 如果大于0,名额减1,插入选课记录。
  4. 返回成功。

但在并发情况下,这四个步骤如果缺乏保护,就会出现“超卖”现象。

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. 剩余名额是 1。
  2. 学生 A 进来,执行第1步,拿到 remaining = 1。
  3. 学生 B 同时也进来,执行第1步,也拿到 remaining = 1(因为 A 还没提交,数据库没变)。
  4. 学生 A 判断 1 > 0 成立,执行第3步插入记录,执行第4步把名额改成 0。
  5. 学生 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,服务器接收保存,老师下载批改。

但这里隐藏着三个致命风险:

  1. 文件类型伪造:学生上传了一个 .exe 病毒,服务器保存后,老师点击下载,电脑中病毒。
  2. 越权访问:学生 A 通过修改 URL 参数(如 ?id=1001),下载了学生 B 的隐私作业,或者篡改了其他人的成绩。
  3. 敏感信息泄露:作业内容或学生个人信息(姓名、学号)在传输过程中被中间人截获。

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)中,它仍然是主力。

解决并发冲突和数据安全,并不是靠某一行神奇的代码,而是靠一套严谨的工程习惯:

  1. 对于并发:认清“检查-执行”之间的时间窗口,要么用悲观锁(For Update)强硬隔离,要么用乐观锁(版本号/CAS)允许冲突后重试。在选课这种场景,乐观锁 + 前端轮询/提示通常是更好的用户体验平衡点。
  2. 对于文件安全:永远不要信任客户端的文件名和类型。执行白名单校验和随机重命名。
  3. 对于权限安全:遵循最小权限原则,数据访问必须通过服务端 Session 中的身份进行校验,严禁直接使用 URL 参数作为数据归属的依据。
  4. 对于传输安全:HTTPS 是底线,不是选项。

高校管理后台面对的不仅仅是数据,更是两千多名学生和老师对教育公平与隐私的信任。每一个 if 判断的背后,都是一次对系统稳定性的考验。希望这些来自“老后端”的经验,能帮你避开那些曾经让我们掉坑里的坑。