从学校教务系统卡顿到选课高峰期崩溃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% |
十、给学校信息化中心的建议
- 提前规划:选课系统优化不是一蹴而就的,要提前3-6个月开始准备
- 数据备份:定期备份数据库,防止误操作
- 文档沉淀:把每次遇到的问题、解决方案都记录下来
- 跨部门协作:教务、信息化、网络中心要密切配合
- 学生教育:引导学生错峰选课,提前了解选课策略
- 应急预案:准备好降级方案,宁可部分功能不可用,也不能全部崩溃
最后想说,技术优化是手段,让学生顺利选上课才是目的。每次选课季结束,看到老师们在论坛上点赞我们的系统稳定,那种成就感真的很难用言语形容。希望这篇指南能对正在为选课系统头疼的同行们有所帮助。如果有具体问题,欢迎在评论区交流讨论。
