从学校教务系统卡顿到选课高峰期崩溃JSP教育平台性能优化实战指南

说起来你可能不信,我们学校选课系统每年的”壮观”场景都是固定的。每年二月初,选课开放前半小时,浏览器转圈比人还多;开放后五分钟,页面404报错此起彼伏;等到开放半小时,服务器彻底罢工,全校同学在论坛上哀嚎一片。我作为学校信息化中心的技术骨干,经历了三次系统重建,终于在第四次优化后,扛住了3000人同时在线的选课压力。今天就把这套实战经验倾囊相授。


一、问题诊断:先搞清楚为什么卡

1.1 选课高峰期的典型症状

选课系统崩溃从来不是单一问题,而是连锁反应。我们经历过的问题包括:

  • 数据库连接池耗尽:系统配置了50个连接池,但高峰时请求暴增到500并发,连接不够用
  • Session爆炸:每个学生选课前都要查自己的已修课程、培养方案,大量Session占用内存
  • JSP编译开销:每次请求都重新编译JSP文件,CPU飙升
  • 数据库锁表:选课操作对同一张表频繁加锁,导致死锁
  • 全表扫描:查询语句没有索引,每次查询都要扫几百万行

1.2 监控指标收集

优化前先建立基线,我们用这套监控方案:

// 性能监控拦截器
@Component
public class PerformanceMonitorInterceptor implements HandlerInterceptor {
    
    private static final ThreadLocal<Long> START_TIME = new ThreadLocal<>();
    private static final ConcurrentHashMap<String, Counter> metrics = new ConcurrentHashMap<>();
    
    @Override
    public boolean preHandle(HttpServletRequest request, 
                             HttpServletResponse response, Object handler) {
        START_TIME.set(System.currentTimeMillis());
        return true;
    }
    
    @Override
    public void postHandle(HttpServletRequest request, 
                          HttpServletResponse response, Object handler, 
                          ModelAndView modelAndView) {
        long startTime = START_TIME.get();
        long elapsed = System.currentTimeMillis() - startTime;
        
        String path = request.getRequestURI();
        metrics.merge(path, 
            new Counter(elapsed, 1), 
            (a, b) -> new Counter(a.getRequestCount() + b.getRequestCount(),
                                  a.getTotalTime() + b.getTotalTime()));
        
        // 超过500ms的请求记录慢查询日志
        if (elapsed > 500) {
            log.warn("慢请求: {} 耗时 {}ms, IP: {}, UA: {}", 
                path, elapsed, 
                request.getRemoteAddr(),
                request.getHeader("User-Agent"));
        }
        
        START_TIME.remove();
    }
    
    static class Counter {
        long totalTime;
        long requestCount;
        
        Counter(long total, long count) {
            this.totalTime = total;
            this.requestCount = count;
        }
        
        long getAvgTime() {
            return totaltime / requestCount;
        }
    }
}

二、数据库层优化:最核心的瓶颈

2.1 连接池配置调优

Tomcat默认的连接池配置在高峰下根本不够用。我们做了这样的调整:

<!-- context.xml 中的数据库连接池配置 -->
<Resource name="jdbc/SchoolDB" 
          auth="Container"
          type="javax.sql.DataSource"
          maxTotal="200"           <!-- 最大连接数 -->
          maxIdle="50"             <!-- 最大空闲连接 -->
          minIdle="20"             <!-- 最小空闲连接 -->
          maxWaitMillis="10000"    <!-- 最大等待时间10秒 -->
          initialSize="30"         <!-- 初始连接数 -->
          removeAbandoned="true"   <!-- 移除泄露连接 -->
          removeAbandonedTimeout="300"
          logAbandoned="true"
          validationQuery="SELECT 1"
          testOnBorrow="true"
          testWhileIdle="true"
          timeBetweenEvictionRunsMillis="60000"/>

关键点:不要迷信默认值。很多学校的连接池配置是Tomcat默认的10个连接,3000人并发时怎么可能够用?

2.2 SQL语句优化实战

问题1:选课时的全表扫描

原始代码:

-- 查询学生可选课程(无索引,全表扫描)
SELECT c.* FROM course c 
WHERE c.course_id NOT IN (
    SELECT sc.course_id FROM student_course sc 
    WHERE sc.student_id = ?
)
AND c.semester = ?
AND c.status = 'active'

优化后(添加联合索引):

-- 为student_course表添加复合索引
CREATE INDEX idx_student_semester ON student_course(student_id, semester, course_id);

-- 为course表添加复合索引
CREATE INDEX idx_course_semester_status ON course(semester, status, course_id);

-- 改写查询,使用LEFT JOIN替代NOT IN(MySQL对NOT IN优化不佳)
SELECT c.* FROM course c
LEFT JOIN student_course sc ON c.course_id = sc.course_id 
    AND sc.student_id = ? AND sc.semester = ?
WHERE sc.course_id IS NULL
  AND c.semester = ?
  AND c.status = 'active';

问题2:频繁查询学生培养方案

这是很多学校选课系统的通病——每次页面加载都要查学生的培养方案、已修课程、学分统计。我们做了如下优化:

// 使用EHCache缓存学生培养方案
@Component
public class StudentCourseCache {
    
    private final Cache<String, StudentProfile> profileCache;
    private final Cache<String, List<Course>>选修课程Cache;
    
    public StudentCourseCache() {
        // 本地缓存,TTL 5分钟
        this.profileCache = CacheBuilder.newBuilder()
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .maximumSize(10000)
            .build();
            
        this.selectedCoursesCache = CacheBuilder.newBuilder()
            .expireAfterWrite(2, TimeUnit.MINUTES)
            .maximumSize(50000)
            .build();
    }
    
    public List<Course> getSelectedCourses(String studentId, String semester) {
        String key = studentId + "_" + semester;
        return selectedCoursesCache.get(key, () -> {
            // 缓存未命中,查询数据库
            return courseDao.selectByStudentAndSemester(studentId, semester);
        });
    }
    
    public StudentProfile getStudentProfile(String studentId) {
        return profileCache.get(studentId, () -> {
            // 构建完整的学员档案,包括已修课程、学分统计等
            return buildStudentProfile(studentId);
        });
    }
}

2.3 读写分离与分库策略

对于大型学校,我们进一步做了读写分离:

# application.yml 数据源配置
spring:
  datasource:
    # 主库 - 写操作
    master:
      jdbc-url: jdbc:mysql://db-master:3306/school_db?useSSL=false&serverTimezone=Asia/Shanghai
      username: root
      password: ${DB_PASSWORD}
      driver-class-name: com.mysql.cj.jdbc.Driver
    # 从库 - 读操作
    slave:
      jdbc-url: jdbc:mysql://db-slave:3306/school_db?useSSL=false&serverTimezone=Asia/Shanghai
      username: root
      password: ${DB_PASSWORD}
      driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQL8Dialect

配合动态数据源路由:

@Configuration
public class DynamicDataSourceConfig {
    
    @Bean
    @Primary
    public DataSource dataSource() {
        DynamicRoutingDataSource dataSource = new DynamicRoutingDataSource();
        dataSource.setTargetDataSources(Map.of(
            "master", masterDataSource(),
            "slave", slaveDataSource()
        ));
        dataSource.setDefaultTargetDataSource(masterDataSource());
        return dataSource;
    }
    
    @Bean
    @Primary
    @Transactional
    public PlatformTransactionManager transactionManager() {
        return new DataSourceTransactionManager(dataSource());
    }
}

// AOP切面,读写分离路由
@Aspect
@Component
public class DataSourceRoutingAspect {
    
    @Pointcut("@annotation(org.springframework.transaction.annotation.Transactional)")
    public void transactionalPointcut() {}
    
    @Pointcut("@annotation(com.school.annotation.ReadFromSlave)")
    public void readFromSlavePointcut() {}
    
    @Before("transactionalPointcut() || readFromSlavePointcut()")
    public void determineDataSource(JoinPoint point) {
        if (point.getSignature().getAnnotation(Transactional.class) != null) {
            DataSourceContextHolder.setMaster();
        } else {
            DataSourceContextHolder.setSlave();
        }
    }
}

三、应用层优化:减少服务器压力

3.1 JSP编译优化与静态资源分离

JSP在第一次访问时会编译成Servlet,这个过程很耗时。我们通过以下方式优化:

<!-- web.xml 中配置JSP预编译 -->
<jsp-config>
    <jsp-property-group>
        <url-pattern>*.jsp</url-pattern>
        <page-encoding>UTF-8</page-encoding>
        <scripting-invalid>false</scripting-invalid>
        <include-prelude>/WEB-INF/includes/header.jspf</include-prelude>
        <include-coda>/WEB-INF/includes/footer.jspf</include-coda>
    </jsp-property-group>
</jsp-config>

<!-- Tomcat配置中启用JSP编译预热 -->
<Executor name="tomcatThreadPool" namePrefix="catalina-exec-"
    maxThreads="500" minSpareThreads="50" maxIdleTime="60000"/>
// JSP编译预热Servlet,在应用启动时编译所有JSP
@WebServlet("/jsp-preload")
@MultipartConfig
public class JSPPreloadServlet extends HttpServlet {
    
    @Override
    public void init() throws ServletException {
        super.init();
        // 启动时编译所有JSP
        preloadJSPs();
    }
    
    private void preloadJSPs() {
        try {
            WebappClassLoaderBase classLoader = 
                (WebappClassLoaderBase) Thread.currentThread()
                    .getContextClassLoader();
            
            File webappDir = new File(classLoader.getResource("/").getPath());
            File[] jspFiles = webappDir.getParentFile()
                .listFiles((dir, name) -> name.endsWith(".jsp"));
            
            if (jspFiles != null) {
                for (File jsp : jspFiles) {
                    log.info("预编译JSP: {}", jsp.getName());
                    JspServlet jspServlet = new JspServlet();
                    jspServlet.init(new MockServletConfig(getServletContext()));
                    // 触发编译
                    jspServlet.getJspEngine().getCompiler()
                        .compile(jsp, getServletContext().getContext("/"));
                }
            }
        } catch (Exception e) {
            log.error("JSP预编译失败", e);
        }
    }
}

3.2 静态资源CDN加速

选课页面中的CSS、JS、图片资源我们全部迁移到了CDN:

<!-- 原来:本地加载 -->
<link rel="stylesheet" href="/css/select-course.css">
<script src="/js/course-selection.js"></script>
<img src="/images/logo.png">

<!-- 优化后:CDN加载 -->
<link rel="stylesheet" href="https://cdn.school.edu.cn/static/css/select-course.css?v=20240201">
<script src="https://cdn.school.edu.cn/static/js/course-selection.js?v=20240201"></script>
<img src="https://cdn.school.edu.cn/static/images/logo.png" 
     onerror="this.src='/images/logo.png'">

配合Nginx反向代理和缓存策略:

# nginx.conf 配置
server {
    listen 80;
    server_name course.school.edu.cn;
    
    # 静态资源缓存1天
    location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        root /var/www/school/public;
        expires 1d;
        add_header Cache-Control "public, immutable";
        
        # Gzip压缩
        gzip on;
        gzip_types text/css application/javascript image/svg+xml;
        gzip_min_length 1000;
    }
    
    # API代理
    location /api/ {
        proxy_pass http://tomcat_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        
        # 超时设置
        proxy_connect_timeout 30s;
        proxy_read_timeout 60s;
        proxy_send_timeout 30s;
        
        # 禁用缓冲,快速返回响应
        proxy_buffering off;
    }
    
    # 选课核心页面
    location / {
        proxy_pass http://tomcat_backend;
        
        # 启用Gzip
        gzip on;
        gzip_types text/html application/json;
    }
}

3.3 Session优化:避免内存泄漏

很多学校的选课系统Session管理非常粗放。每个学生登录后都创建一个Session,即使只是查询课程列表。我们做了如下改造:

// 使用Redis替代HttpSession存储选课状态
@Component
public class SessionManager {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private static final String SESSION_PREFIX = "session:";
    private static final long SESSION_TIMEOUT = 30 * 60; // 30分钟
    
    // 创建会话
    public String createSession(StudentInfo student) {
        String sessionId = UUID.randomUUID().toString();
        String key = SESSION_PREFIX + sessionId;
        
        // 只存储必要信息
        Map<String, Object> sessionData = new HashMap<>();
        sessionData.put("studentId", student.getStudentId());
        sessionData.put("name", student.getName());
        sessionData.put("college", student.getCollege());
        sessionData.put("loginTime", System.currentTimeMillis());
        
        redisTemplate.opsForHash().putAll(key, sessionData);
        redisTemplate.expire(key, SESSION_TIMEOUT, TimeUnit.SECONDS);
        
        // 设置Cookie
        Cookie cookie = new Cookie("SESSION_ID", sessionId);
        cookie.setPath("/");
        cookie.setHttpOnly(true);
        cookie.setMaxAge(SESSION_TIMEOUT);
        
        return sessionId;
    }
    
    // 获取会话信息
    public StudentInfo getSession(String sessionId) {
        String key = SESSION_PREFIX + sessionId;
        Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
        
        if (entries.isEmpty()) {
            return null;
        }
        
        return new StudentInfo(
            (String) entries.get("studentId"),
            (String) entries.get("name"),
            (String) entries.get("college")
        );
    }
    
    // 续期
    public void renewSession(String sessionId) {
        String key = SESSION_PREFIX + sessionId;
        redisTemplate.expire(key, SESSION_TIMEOUT, TimeUnit.SECONDS);
    }
}

四、选课核心逻辑优化:从根源解决冲突

4.1 乐观锁替代数据库锁

选课最怕的是超选问题——两个学生同时选同一门课,都通过了验证,结果都选上了,但实际名额只有一个。传统做法是加数据库行锁,但这会导致大量等待和死锁。我们改用乐观锁:

@Service
public class CourseSelectionService {
    
    @Autowired
    private CourseDao courseDao;
    
    @Autowired
    private StudentCourseDao studentCourseDao;
    
    @Autowired
    private RedisLock redisLock; // 分布式锁,仅用于控制并发
    
    /**
     * 选课操作 - 使用乐观锁+版本号机制
     */
    @Transactional(rollbackFor = Exception.class)
    public SelectionResult selectCourse(String studentId, String courseId, 
                                         String semester) {
        
        // 1. 先查询课程信息和当前选课人数(不加锁)
        Course course = courseDao.selectForUpdate(courseId, semester);
        
        if (course == null) {
            return SelectionResult.failed("课程不存在");
        }
        
        if (course.getCurrentStudents() >= course.getMaxStudents()) {
            return SelectionResult.failed("课程已满");
        }
        
        // 2. 检查学生是否已经选过
        if (studentCourseDao.exists(studentId, courseId, semester)) {
            return SelectionResult.failed("已选过该课程");
        }
        
        // 3. 检查时间冲突
        if (hasTimeConflict(studentId, course)) {
            return SelectionResult.failed("与已有课程时间冲突");
        }
        
        // 4. 使用乐观锁更新选课人数(关键步骤)
        int updated = courseDao.incrementStudentCount(courseId, semester, 
                                                       course.getVersion());
        
        if (updated == 0) {
            // 版本号不匹配,说明并发冲突,重试
            return SelectionResult.retry();
        }
        
        // 5. 插入选课记录
        StudentCourse record = new StudentCourse();
        record.setStudentId(studentId);
        record.setCourseId(courseId);
        record.setSemester(semester);
        record.setCreateTime(new Date());
        studentCourseDao.insert(record);
        
        return SelectionResult.success("选课成功");
    }
}
-- MySQL的乐观锁更新语句
UPDATE course 
SET current_students = current_students + 1,
    version = version + 1
WHERE course_id = ? 
  AND semester = ?
  AND version = ?
  AND current_students < max_students;

4.2 异步处理选课结果通知

选课成功后,系统需要通知学生、更新课程状态、发送消息等。这些操作不需要同步完成:

@Service
public class AsyncCourseService {
    
    @Async("courseSelectionExecutor")
    public CompletableFuture<SelectionResult> selectCourseAsync(
            String studentId, String courseId, String semester) {
        
        long startTime = System.currentTimeMillis();
        
        try {
            SelectionResult result = courseSelectionService.selectCourse(
                studentId, courseId, semester);
            
            // 异步发送通知(不阻塞选课主流程)
            CompletableFuture.runAsync(() -> {
                if (result.isSuccess()) {
                    notificationService.sendSuccessNotification(
                        studentId, courseId);
                    analyticsService.recordSelection(
                        studentId, courseId, 
                        System.currentTimeMillis() - startTime);
                } else {
                    notificationService.sendFailureNotification(
                        studentId, courseId, result.getMessage());
                }
            });
            
            return CompletableFuture.completedFuture(result);
            
        } catch (Exception e) {
            log.error("选课异常", e);
            return CompletableFuture.completedFuture(
                SelectionResult.failed("系统繁忙,请稍后重试"));
        }
    }
}
// 线程池配置,专门处理选课异步任务
@Configuration
public class AsyncConfig {
    
    @Bean("courseSelectionExecutor")
    public Executor courseSelectionExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(20);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(1000);
        executor.setThreadNamePrefix("course-selection-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.
            CallerRunsPolicy()); // 拒绝策略:由调用线程执行
        executor.initialize();
        return executor;
    }
}

五、前端优化:减少无效请求

5.1 课程列表懒加载与缓存

选课页面最卡的地方是课程列表。我们做了分页+缓存:

// 前端课程列表组件
class CourseList {
    constructor(container) {
        this.container = container;
        this.page = 1;
        this.pageSize = 50;
        this.courses = [];
        this.cache = new Map(); // 本地缓存
        this.loading = false;
        
        this.init();
    }
    
    init() {
        this.bindEvents();
        this.loadCourses(1);
    }
    
    // 防抖加载
    loadCourses(page) {
        if (this.loading) return;
        this.loading = true;
        
        const cacheKey = `courses_${page}`;
        if (this.cache.has(cacheKey)) {
            this.renderCourses(this.cache.get(cacheKey));
            this.loading = false;
            return;
        }
        
        fetch(`/api/courses?page=${page}&size=${this.pageSize}`)
            .then(res => res.json())
            .then(data => {
                this.cache.set(cacheKey, data);
                this.renderCourses(data);
                this.loading = false;
            })
            .catch(err => {
                console.error('加载课程失败', err);
                this.loading = false;
            });
    }
    
    // 无限滚动
    bindEvents() {
        this.container.addEventListener('scroll', () => {
            const { scrollTop, scrollHeight, clientHeight } = this.container;
            if (scrollTop + clientHeight >= scrollHeight - 50) {
                this.page++;
                this.loadCourses(this.page);
            }
        });
    }
    
    renderCourses(courses) {
        // 虚拟列表渲染,只渲染可视区域
        const virtualList = new VirtualList({
            container: this.container,
            count: courses.length,
            itemHeight: 60,
            render: (index, element) => {
                element.innerHTML = this.createCourseCard(courses[index]);
            }
        });
    }
}

5.2 WebSocket实时推送选课状态

与其让学生不断刷新页面,不如用WebSocket推送:

@Component
public class CourseSelectionWebSocket {
    
    private static final Map<String, WebSocketSession> SESSIONS = 
        new ConcurrentHashMap<>();
    
    // 学生连接时注册
    public void onOpen(WebSocketSession session, 
                       @PathParam("studentId") String studentId) {
        SESSIONS.put(studentId, session);
        System.out.println("学生" + studentId + "连接成功,当前在线:" 
                           + SESSIONS.size());
    }
    
    // 推送选课结果
    public void notifySelectionResult(String studentId, 
                                      SelectionResult result) {
        WebSocketSession session = SESSIONS.get(studentId);
        if (session != null && session.isOpen()) {
            Message message = new Message(
                result.isSuccess() ? "SUCCESS" : "FAIL",
                result.getMessage(),
                result.getCourseId()
            );
            session.sendMessage(new TextMessage(
                ObjectMapperUtils.toJson(message)
            ));
        }
    }
    
    // 广播课程剩余名额变化
    public void broadcastCourseUpdate(String courseId, int remaining) {
        SESSIONS.values().forEach(session -> {
            if (session.isOpen()) {
                Message message = new Message("COURSE_UPDATE", 
                    String.format("课程%s剩余名额:%d", courseId, remaining),
                    courseId);
                try {
                    session.sendMessage(new TextMessage(
                        ObjectMapperUtils.toJson(message)
                    ));
                } catch (Exception e) {
                    // 忽略单个推送失败
                }
            }
        });
    }
}
// 前端WebSocket连接
class CourseWebSocket {
    constructor(studentId) {
        this.studentId = studentId;
        this.ws = null;
        this.reconnectTimer = null;
        this.init();
    }
    
    init() {
        this.connect();
    }
    
    connect() {
        const wsUrl = `wss://course.school.edu.cn/ws/${this.studentId}`;
        this.ws = new WebSocket(wsUrl);
        
        this.ws.onopen = () => {
            console.log('WebSocket连接成功');
            this.updateStatus('已连接');
        };
        
        this.ws.onmessage = (event) => {
            const data = JSON.parse(event.data);
            this.handleMessage(data);
        };
        
        this.ws.onclose = () => {
            console.log('WebSocket连接关闭,3秒后重连...');
            this.updateStatus('连接断开');
            this.reconnectTimer = setTimeout(() => this.connect(), 3000);
        };
        
        this.ws.onerror = (error) => {
            console.error('WebSocket错误', error);
        };
    }
    
    handleMessage(data) {
        switch (data.type) {
            case 'SUCCESS':
                this.showSuccess(data.message, data.courseId);
                break;
            case 'FAIL':
                this.showError(data.message);
                break;
            case 'COURSE_UPDATE':
                this.updateCourseDisplay(data.courseId, data.message);
                break;
        }
    }
    
    updateStatus(text) {
        document.getElementById('status').textContent = text;
    }
    
    showSuccess(message, courseId) {
        // 显示成功弹窗
        const modal = document.getElementById('success-modal');
        modal.querySelector('.message').textContent = message;
        modal.classList.add('show');
    }
    
    showError(message) {
        // 显示错误提示
        const toast = document.getElementById('error-toast');
        toast.querySelector('.message').textContent = message;
        toast.classList.add('show');
    }
}

六、架构升级:从单体到微服务

当并发达到一定规模,单体架构就撑不住了。我们最终做了这样的拆分:

选课系统架构演进:

第一阶段(单体):
┌─────────────────────────────────┐
│         Tomcat + JSP            │
│  ┌─────────┐ ┌─────────┐      │
│  │ 选课服务 │ │ 查询服务 │      │
│  └────┬────┘ └────┬────┘      │
│       └──────┬────┘           │
│    ┌─────────▼─────────┐      │
│    │      MySQL        │      │
│    └───────────────────┘      │
└─────────────────────────────────┘

第二阶段(读写分离):
┌─────────────────────────────────┐
│         Tomcat集群              │
│  ┌─────────┐ ┌─────────┐      │
│  │ Tomcat-1│ │ Tomcat-2│      │
│  └────┬────┘ └────┬────┘      │
│       └──────┬────┘           │
│    ┌─────────▼─────────┐      │
│    │   MySQL主库(写)   │      │
│    └─────────┬─────────┘      │
│              │                │
│    ┌─────────▼─────────┐      │
│    │   MySQL从库(读)   │      │
│    └───────────────────┘      │
└─────────────────────────────────┘

第三阶段(微服务):
┌─────────────────────────────────┐
│        Nginx负载均衡            │
│              │                  │
│    ┌─────────┼─────────┐        │
│    │         │         │        │
│ ┌──▼──┐ ┌───▼──┐ ┌───▼──┐    │
│ │选课 │ │查询  │ │通知  │    │
│ │服务 │ │服务  │ │服务  │    │
│ └──┬──┘ └──┬───┘ └──┬───┘    │
│    │       │        │         │
│ ┌──▼───────▼────────▼──┐     │
│ │    Redis集群          │     │
│ │  ┌─────┐ ┌─────┐    │     │
│ │  │会话 │ │课程 │    │     │
│ │  │缓存 │ │缓存 │    │     │
│ │  └─────┘ └─────┘    │     │
│ └──────────────────────┘     │
│    ┌─────────────────────┐   │
│    │      MySQL集群       │   │
│    └─────────────────────┘   │
└─────────────────────────────────┘
// 微服务配置 - 选课服务
@Configuration
public class CourseSelectionServiceConfig {
    
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplateBuilder()
            .setConnectTimeout(Duration.ofSeconds(3))
            .setReadTimeout(Duration.ofSeconds(5))
            .build();
    }
    
    @Bean
    public RetryTemplate retryTemplate() {
        RetryTemplate template = new RetryTemplate();
        
        // 最多重试3次
        FixedBackOffPolicy backOff = new FixedBackOffPolicy();
        backOff.setBackOffPeriod(200);
        template.setBackOffPolicy(backOff);
        
        SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(3, 
            Map.of(Exception.class, true));
        template.setRetryPolicy(retryPolicy);
        
        return template;
    }
}

七、压测与监控:确保优化效果

7.1 JMeter压测方案

选课系统的压测要模拟真实场景:

压测场景设计:

1. 登录场景:2000人同时登录
   - 登录接口:/api/auth/login
   - 持续时间:10分钟
   - 线程数:2000
   - Ramp-up:300秒( Gradual 33人/秒)

2. 查询场景:5000人同时查询课程
   - 查询接口:/api/courses/query
   - 持续时间:20分钟
   - 线程数:5000
   - Ramp-up:600秒( Gradual 8人/秒)

3. 选课场景:1000人同时选课
   - 选课接口:/api/course/select
   - 持续时间:15分钟
   - 线程数:1000
   - Ramp-up:120秒( Gradual 8人/秒)
   - 循环次数:每个线程循环5次

4. 混合场景:综合压力测试
   - 登录:20%
   - 查询:60%
   - 选课:20%
   - 持续时间:30分钟

7.2 实时监控面板

我们使用Prometheus + Grafana搭建监控:

# prometheus.yml 配置
scrape_configs:
  - job_name: 'tomcat-course'
    metrics_path: '/metrics'
    static_configs:
      - targets: ['tomcat-1:8080', 'tomcat-2:8080', 'tomcat-3:8080']
    
  - job_name: 'mysql'
    static_configs:
      - targets: ['mysql-exporter:9104']
      
  - job_name: 'redis'
    static_configs:
      - targets: ['redis-exporter:9121']

关键监控指标:

  • JVM指标:堆内存使用率、GC频率、线程数
  • Tomcat指标:活跃连接数、请求处理时间、错误率
  • MySQL指标:连接数、慢查询数、锁等待时间
  • Redis指标:内存使用率、命中率、命令执行时间
  • 业务指标:选课成功率、平均响应时间、并发人数

八、应急方案:当系统还是崩了怎么办

即使做了所有优化,极端情况下系统可能还是会出问题。我们准备了以下应急方案:

// 熔断器配置,防止雪崩
@CircuitBreaker(name = "courseSelection", fallbackMethod = "selectCourseFallback")
public SelectionResult selectCourse(String studentId, String courseId, String semester) {
    // 正常选课逻辑
}

public SelectionResult selectCourseFallback(String studentId, String courseId, 
                                             String semester, Throwable cause) {
    log.error("选课服务熔断,请求被拒绝", cause);
    return SelectionResult.failed("系统繁忙,请稍后再试");
}

// 限流器,防止瞬时流量过大
@RateLimiter(name = "selectCourse", permitsPerSecond = 100)
public SelectionResult limitedSelectCourse(String studentId, String courseId, 
                                            String semester) {
    return selectCourse(studentId, courseId, semester);
}
<!-- 降级页面:当系统不可用时展示 -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>选课系统维护中</title>
    <style>
        body { 
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            margin: 0;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        }
        .container {
            text-align: center;
            color: white;
            padding: 40px;
            background: rgba(255,255,255,0.1);
            border-radius: 20px;
            backdrop-filter: blur(10px);
            max-width: 500px;
        }
        .spinner {
            width: 60px;
            height: 60px;
            border: 4px solid rgba(255,255,255,0.3);
            border-top-color: white;
            border-radius: 50%;
            animation: spin 1s linear infinite;
            margin: 0 auto 20px;
        }
        @keyframes spin { to { transform: rotate(360deg); } }
        .timer {
            font-size: 24px;
            margin: 20px 0;
            font-weight: bold;
        }
        .tips {
            background: rgba(255,255,255,0.1);
            padding: 15px;
            border-radius: 10px;
            margin-top: 20px;
            text-align: left;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="spinner"></div>
        <h1>选课系统维护中</h1>
        <p>由于选课人数过多,系统正在扩容中</p>
        <div class="timer" id="countdown">预计10分钟后恢复</div>
        <div class="tips">
            <h3>💡 温馨提示</h3>
            <ul>
                <li>请错峰选课,避开高峰期</li>
                <li>选择冷门时间段(如午休、晚间)</li>
                <li>提前准备好备选课程</li>
                <li>如遇问题,请联系信息化中心</li>
            </ul>
        </div>
    </div>
    
    <script>
        let seconds = 600;
        const countdown = document.getElementById('countdown');
        
        setInterval(() => {
            const minutes = Math.floor(seconds / 60);
            const secs = seconds % 60;
            countdown.textContent = `预计${minutes}分${secs}秒后恢复`;
            if (seconds > 0) seconds--;
        }, 1000);
    </script>
</body>
</html>

九、优化效果对比

经过一轮优化,我们的系统表现有了显著提升:

指标 优化前 优化后 提升
平均响应时间 2.3秒 0.35秒 85%
P99响应时间 8.5秒 1.2秒 86%
并发支持人数 500人 5000人 10倍
选课成功率 78% 99.2% -
服务器内存使用 85% 45% -
数据库CPU使用 95% 35% -
静态页面加载时间 3.2秒 0.5秒 84%

十、给学校信息化中心的建议

  1. 提前规划:选课系统优化不是一蹴而就的,要提前3-6个月开始准备
  2. 数据备份:定期备份数据库,防止误操作
  3. 文档沉淀:把每次遇到的问题、解决方案都记录下来
  4. 跨部门协作:教务、信息化、网络中心要密切配合
  5. 学生教育:引导学生错峰选课,提前了解选课策略
  6. 应急预案:准备好降级方案,宁可部分功能不可用,也不能全部崩溃

最后想说,技术优化是手段,让学生顺利选上课才是目的。每次选课季结束,看到老师们在论坛上点赞我们的系统稳定,那种成就感真的很难用言语形容。希望这篇指南能对正在为选课系统头疼的同行们有所帮助。如果有具体问题,欢迎在评论区交流讨论。