那一刻,服务器炸了

想象一下这个场景:周五晚上八点,选课系统开放。三万名学生同时点击“提交”按钮。屏幕转圈、报错、再转圈……然后一片白屏。教务处老师看着后台日志里疯狂刷新的 NullPointerException 和数据库连接超时错误,心态彻底崩了。

这不是电影情节,这是每年开学季无数高校教务系统的真实写照。

很多学校用的还是十年前的老系统:JSP页面里混杂着Java代码、业务逻辑散落在各个Servlet里、数据库连接没复用、缓存完全靠内存硬扛……这种架构在几百人规模时还能凑合,一旦并发量上来,直接雪崩。

但你知道吗?即使是JSP+Servlet这种“古老”的技术栈,只要设计得当,照样能撑起万级并发的稳定系统。今天我们就来拆解,如何用传统但稳健的JSP+Servlet架构,构建一个真正稳定、高效、可维护的校园教务管理平台。


一、为什么选课系统总是崩?先搞清楚病因

在动手写代码之前,我们必须先理解那些导致系统崩溃的根本原因。这不是为了抱怨,而是为了对症下药。

1.1 并发控制的缺失

选课系统最核心的矛盾是:资源有限,需求无限

一门课只有100个名额,但可能有500人想选。如果系统没有做好并发控制,就会出现这种离谱的情况:

  • 学生A和学生B同时看到还剩2个名额
  • 两人同时提交选课请求
  • 数据库把名额扣成0,但录入了2条记录
  • 结果:实际选了200人,但系统显示只选了198人,或者更糟——超卖了

这种问题在没有锁机制的系统里几乎是必然发生的。

1.2 数据库连接的浪费

很多早期的JSP系统,每个请求都新建一个数据库连接,处理完就关闭。听起来很合理对吧?但问题在于:

  • 创建和销毁数据库连接非常耗时(可能几百毫秒)
  • 三万人同时选课,意味着同时有三万个连接请求涌向数据库
  • 数据库的连接池瞬间被撑爆,新请求直接拒绝

正确的做法是使用连接池,让连接被复用,而不是每次都新建。

1.3 代码结构的混乱

如果你打开一个传统的教务系统JSP文件,可能会看到这样的代码:

<%
    // 获取参数
    String courseId = request.getParameter("courseId");
    
    // 连接数据库
    Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/school", "root", "123456");
    
    // 执行查询
    Statement stmt = conn.createStatement();
    ResultSet rs = stmt.executeQuery("SELECT * FROM course WHERE id='" + courseId + "'");
    
    // 处理结果
    while(rs.next()){
        out.println("<tr>");
        out.println("<td>" + rs.getString("name") + "</td>");
        out.println("<td>" + rs.getInt("capacity") + "</td>");
        out.println("</tr>");
    }
    
    // 关闭资源
    rs.close();
    stmt.close();
    conn.close();
%>

这段代码有至少五个问题:

  1. SQL注入风险courseId 直接拼接到SQL语句中,攻击者可以轻松注入恶意代码
  2. 连接每次新建:没有使用连接池
  3. 资源管理不当:如果中间抛出异常,close() 可能永远不会执行
  4. 逻辑与表现混杂:业务逻辑写在JSP里,违反了MVC原则
  5. 硬编码配置:数据库地址、用户名、密码直接写在代码里

这样的系统,不崩才怪。

1.4 没有缓存机制

很多教务系统在查询课程信息时,每次都去数据库查。但课程信息是相对静态的——一学期内不会频繁变化。如果三万人同时查询同一门课的信息,数据库会被三万次查询淹没。

这就是缓存的用武之地。


二、架构设计:MVC模式的正确打开方式

要解决上述问题,首先需要在架构层面做出改变。MVC(Model-View-Controller)不是新概念,但很多学校的教务系统其实并没有真正遵循它。

2.1 什么是真正的MVC

在JSP+Servlet架构中,MVC的分工应该是:

  • Model(模型):负责业务逻辑和数据处理,通常是JavaBean或POJO类
  • View(视图):负责展示,即JSP页面,只包含HTML和少量的JSTL标签
  • Controller(控制器):负责接收请求、调用Model、选择View,即Servlet

关键点:JSP里不应该有任何Java业务逻辑代码,Servlet里不应该有任何HTML代码。

2.2 项目结构规划

一个规范的教务系统项目结构应该长这样:

WebContent/
├── WEB-INF/
│   ├── web.xml                    # 部署描述符
│   ├── jsp/                       # JSP页面(禁止外部直接访问)
│   │   ├── login/
│   │   │   └── login.jsp
│   │   ├── student/
│   │   │   ├── courseList.jsp
│   │   │   ├── courseDetail.jsp
│   │   │   └── myCourses.jsp
│   │   ├── teacher/
│   │   │   └── courseManage.jsp
│   │   └── admin/
│   │       └── dashboard.jsp
│   └── lib/                       # 依赖库
├── css/
│   └── style.css
├── js/
│   └── main.js
└── index.jsp

src/
└── com/sapiens/edu/
    ├── controller/                # Servlet控制器
    │   ├── LoginServlet.java
    │   ├── CourseServlet.java
    │   └── SelectionServlet.java
    ├── model/                     # 数据模型
    │   ├── Course.java
    │   ├── Student.java
    │   ├── CourseSelection.java
    │   └── User.java
    ├── dao/                       # 数据访问层
    │   ├── BaseDao.java
    │   ├── CourseDao.java
    │   ├── StudentDao.java
    │   └── SelectionDao.java
    ├── service/                   # 业务逻辑层
    │   ├── CourseService.java
    │   ├── SelectionService.java
    │   └── UserService.java
    ├── util/                      # 工具类
    │   ├── DBUtil.java            # 数据库连接池
    │   ├── CacheManager.java      # 缓存管理器
    │   └── SessionUtil.java       # 会话工具
    └── filter/                    # 过滤器
        ├── AuthFilter.java        # 认证过滤器
        └── EncodingFilter.java    # 编码过滤器

这个结构清晰地将关注点分离,每个层次各司其职。当你需要修改数据库查询逻辑时,只需改dao层;需要修改展示样式时,只需改jsp层;需要修改业务规则时,只需改service层。


三、数据库层:连接池是稳定的基石

前面提到,数据库连接的问题必须通过连接池来解决。我们使用Apache Commons DBCP或HikariCP来实现。

3.1 数据库连接池配置

首先,创建一个db.properties配置文件:

# 数据库连接配置
db.driver=com.mysql.cj.jdbc.Driver
db.url=jdbc:mysql://localhost:3306/school_edu?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF8
db.username=root
db.password=your_password_here

# 连接池配置
db.maxTotal=100           # 最大连接数
db.maxIdle=30             # 最大空闲连接数
db.minIdle=10             # 最小空闲连接数
db.initialSize=20         # 初始化连接数
db.maxWaitMillis=5000     # 最大等待时间(毫秒)
db.validationQuery=SELECT 1
db.testOnBorrow=true      # 借用连接时检测有效性

然后,创建连接池工具类DBUtil.java

package com.sapiens.edu.util;

import org.apache.commons.dbcp2.BasicDataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.ResourceBundle;

/**
 * 数据库连接池工具类
 * 使用单例模式确保全局只有一个连接池实例
 */
public class DBUtil {
    
    private static BasicDataSource dataSource;
    private static final String CONFIG_FILE = "db";
    
    // 静态初始化块,确保连接池只创建一次
    static {
        try {
            // 从配置文件读取参数
            ResourceBundle rb = ResourceBundle.getBundle(CONFIG_FILE);
            
            dataSource = new BasicDataSource();
            dataSource.setDriverClassName(rb.getString("db.driver"));
            dataSource.setUrl(rb.getString("db.url"));
            dataSource.setUsername(rb.getString("db.username"));
            dataSource.setPassword(rb.getString("db.password"));
            
            // 连接池配置
            dataSource.setMaxTotal(Integer.parseInt(rb.getString("db.maxTotal")));
            dataSource.setMaxIdle(Integer.parseInt(rb.getString("db.maxIdle")));
            dataSource.setMinIdle(Integer.parseInt(rb.getString("db.minIdle")));
            dataSource.setInitialSize(Integer.parseInt(rb.getString("db.initialSize")));
            dataSource.setMaxWaitMillis(Long.parseLong(rb.getString("db.maxWaitMillis")));
            dataSource.setValidationQuery(rb.getString("db.validationQuery"));
            dataSource.setTestOnBorrow(Boolean.parseBoolean(rb.getString("db.testOnBorrow")));
            
            // 连接泄露检测(开发环境 useful)
            dataSource.setRemoveAbandonedOnBorrow(true);
            dataSource.setRemoveAbandonedTimeout(300);
            dataSource.setLogAbandoned(true);
            
        } catch (Exception e) {
            throw new RuntimeException("数据库连接池初始化失败", e);
        }
    }
    
    /**
     * 获取数据库连接
     */
    public static Connection getConnection() throws SQLException {
        return dataSource.getConnection();
    }
    
    /**
     * 关闭连接(归还到连接池)
     */
    public static void closeConnection(Connection conn) {
        if (conn != null) {
            try {
                conn.close(); // 实际上是归还到连接池,不是真正关闭
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
    }
    
    /**
     * 获取连接池实例(用于监控)
     */
    public static BasicDataSource getDataSource() {
        return dataSource;
    }
}

3.2 基础DAO模板

为了避免每个DAO都重复写相同的数据库操作代码,我们创建一个泛型基础DAO:

package com.sapiens.edu.dao;

import com.sapiens.edu.util.DBUtil;
import org.apache.commons.dbcp2.BasicDataSource;
import java.sql.*;
import java.util.*;

/**
 * 基础DAO类,提供通用的数据库操作方法
 * @param <T> 实体类型
 */
public abstract class BaseDao<T> {
    
    /**
     * 执行查询,返回单个对象
     */
    protected T queryForObject(Connection conn, String sql, RowMapper<T> rowMapper, Object... params) {
        PreparedStatement pstmt = null;
        ResultSet rs = null;
        try {
            pstmt = conn.prepareStatement(sql);
            for (int i = 0; i < params.length; i++) {
                pstmt.setObject(i + 1, params[i]);
            }
            rs = pstmt.executeQuery();
            if (rs.next()) {
                return rowMapper.mapRow(rs);
            }
        } catch (SQLException e) {
            throw new RuntimeException("查询失败: " + sql, e);
        } finally {
            closeResource(rs, pstmt);
        }
        return null;
    }
    
    /**
     * 执行查询,返回对象列表
     */
    protected List<T> queryForList(Connection conn, String sql, RowMapper<T> rowMapper, Object... params) {
        List<T> result = new ArrayList<>();
        PreparedStatement pstmt = null;
        ResultSet rs = null;
        try {
            pstmt = conn.prepareStatement(sql);
            for (int i = 0; i < params.length; i++) {
                pstmt.setObject(i + 1, params[i]);
            }
            rs = pstmt.executeQuery();
            while (rs.next()) {
                result.add(rowMapper.mapRow(rs));
            }
        } catch (SQLException e) {
            throw new RuntimeException("查询失败: " + sql, e);
        } finally {
            closeResource(rs, pstmt);
        }
        return result;
    }
    
    /**
     * 执行更新操作(插入、更新、删除)
     */
    protected int executeUpdate(Connection conn, String sql, Object... params) {
        PreparedStatement pstmt = null;
        try {
            pstmt = conn.prepareStatement(sql);
            for (int i = 0; i < params.length; i++) {
                pstmt.setObject(i + 1, params[i]);
            }
            return pstmt.executeUpdate();
        } catch (SQLException e) {
            throw new RuntimeException("执行失败: " + sql, e);
        } finally {
            closeResource(null, pstmt);
        }
    }
    
    /**
     * 执行批量更新操作
     */
    protected int[] executeBatch(Connection conn, String sql, List<Object[]> paramsList) {
        PreparedStatement pstmt = null;
        try {
            pstmt = conn.prepareStatement(sql);
            for (Object[] params : paramsList) {
                for (int i = 0; i < params.length; i++) {
                    pstmt.setObject(i + 1, params[i]);
                }
                pstmt.addBatch();
            }
            return pstmt.executeBatch();
        } catch (SQLException e) {
            throw new RuntimeException("批量执行失败: " + sql, e);
        } finally {
            closeResource(null, pstmt);
        }
    }
    
    /**
     * 关闭资源
     */
    private void closeResource(ResultSet rs, Statement stmt) {
        try {
            if (rs != null) rs.close();
        } catch (SQLException e) {
            e.printStackTrace();
        }
        try {
            if (stmt != null) stmt.close();
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
    
    /**
     * 行映射器接口,用于将ResultSet映射为对象
     */
    public interface RowMapper<T> {
        T mapRow(ResultSet rs) throws SQLException;
    }
}

这个基础DAO使用了RowMapper接口,让我们可以在查询结果时灵活地映射对象,而不是在DAO内部硬编码映射逻辑。

3.3 课程DAO实现

现在,我们基于基础DAO来实现课程数据访问:

”`java package com.sapiens.edu.dao;

import com.sapiens.edu.model.Course; import com.sapiens.edu.util.DBUtil;

import java.sql.Connection; import java.sql.SQLException; import java.util.List;

/**

  • 课程数据访问对象 */ public class CourseDao extends BaseDao {

    private static final String TABLE_NAME = “course”;

    /**

    • 根据ID查询课程 */ public Course findById(Integer id) { String sql = “SELECT * FROM ” + TABLE_NAME + “ WHERE id = ?”; return queryForObject(DBUtil.getConnection(), sql, rs -> { Course course = new Course(); course.setId(rs.getInt(“id”)); course.setName(rs.getString(“name”)); course.setTeacher(rs.getString(“teacher”)); course.setCapacity(rs.getInt(“capacity”)); course.setRegistered(rs.getInt(“registered”)); course.setCredits(rs.getInt(“credits”)); course.setDepartment(rs.getString(“department”)); course.setDescription(rs.getString(“description”)); course.setStatus(rs.getInt(“status”)); // 0-未开放 1-开放选课 2-选课结束 return course; }, id); }

    /**

    • 查询所有课程 */ public List findAll() { String sql = “SELECT * FROM ” + TABLE_NAME + “ ORDER BY id DESC”; return queryForList(DBUtil.getConnection(), sql, rs -> { Course course = new Course(); course.setId(rs.getInt(“id”)); course.setName(rs.getString(“name”)); course.setTeacher(rs.getString(“teacher”)); course.setCapacity(rs.getInt(“capacity”)); course.setRegistered(rs.getInt(“registered”)); course.setCredits(rs.getInt(“credits”)); course.setDepartment(rs.getString(“department”)); course.setDescription(rs.getString(“description”)); course.setStatus(rs.getInt(“status”)); return course; }); }

    /**

    • 根据状态查询课程(用于选课列表) */ public List findByStatus(Integer status) { String sql = “SELECT * FROM ” + TABLE_NAME + “ WHERE status = ? ORDER BY department, name”; return queryForList(DBUtil.getConnection(), sql, rs -> { Course course = new Course(); course.setId(rs.getInt(“id”)); course.setName(rs.getString(“name”)); course.setTeacher(rs.getString(“teacher”)); course.setCapacity(rs.getInt(“capacity”)); course.setRegistered(rs.getInt(“registered”)); course.setCredits(rs.getInt(“credits”)); course.setDepartment(rs.getString(“department”)); course.setDescription(rs.getString(“description”)); course.setStatus(rs.getInt(“status”)); return course; }, status); }

    /**

    • 更新课程已选人数 */ public boolean updateRegistered(Integer courseId, int newRegistered) { String sql = “UPDATE ” + TABLE_NAME + “ SET registered = ? WHERE id = ? AND registered < ?”; Connection conn = DBUtil.getConnection(); try { // 使用乐观锁,确保并发安全 int rows = executeUpdate(conn, sql, newRegistered, courseId, newRegistered + 1); return rows > 0; } finally { // 注意:这里不关闭连接,因为连接由调用者管理 } }