1. 项目背景与核心需求
在高校教务管理场景中,选课系统是最核心的基础设施之一。传统的手工排课和纸质选课方式存在诸多痛点:教务老师需要手动处理大量冲突检查,学生排队填写选课表效率低下,选课结果统计耗时且容易出错。我曾参与过某高校的选课系统改造项目,亲眼目睹过教务处的老师为了处理5000多名学生的选课数据,连续加班一周手工核对表格的窘境。
基于SpringBoot+Vue+MySQL的选课系统主要解决以下核心问题:
- 高并发访问:选课开放时段通常会出现短时间内大量学生同时操作的情况,系统需要支持每秒数百次的选课请求
- 实时冲突检测:需要即时检查课程时间冲突、学分上限、先修课程等约束条件
- 数据一致性:避免超选、重复选课等数据异常情况
- 权限精细控制:区分学生、教师、教务管理员等不同角色的操作权限
2. 技术架构设计解析
2.1 整体架构设计
系统采用经典的前后端分离架构:
code复制[前端] Vue 3.x + Element Plus + Axios
↑
HTTP/HTTPS
↓
[后端] Spring Boot 2.7 + Spring Security + MyBatis
↑
JDBC/连接池
↓
[数据层] MySQL 8.0 + Redis 6.x
这种架构的优势在于:
- 前后端可以并行开发,通过API文档定义接口规范
- 前端组件化开发便于功能扩展和维护
- 后端微服务架构易于应对高并发场景
2.2 关键技术选型考量
SpringBoot选型原因:
- 自动配置特性大幅减少XML配置
- 内嵌Tomcat简化部署流程
- 丰富的Starter依赖(如spring-boot-starter-data-redis)
- 完善的监控端点(/actuator)
Vue.js的优势:
- 响应式数据绑定简化DOM操作
- 组件化开发模式提升代码复用率
- Vue Router实现前端路由控制
- 与Element UI组件库完美集成
MySQL设计要点:
- 使用InnoDB引擎保证事务完整性
- 对核心表建立合适索引(如学生ID、课程ID)
- 设置外键约束保证数据一致性
- 采用UTF8MB4字符集支持完整Unicode
3. 数据库设计与优化
3.1 核心表结构设计
sql复制-- 学生表
CREATE TABLE `student` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`student_no` VARCHAR(20) NOT NULL COMMENT '学号',
`name` VARCHAR(50) NOT NULL,
`gender` CHAR(1) NOT NULL,
`department` VARCHAR(100) NOT NULL COMMENT '院系',
`credit_limit` INT DEFAULT 30 COMMENT '学分上限',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_student_no` (`student_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 课程表
CREATE TABLE `course` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`course_code` VARCHAR(20) NOT NULL COMMENT '课程代码',
`name` VARCHAR(100) NOT NULL,
`teacher_id` BIGINT NOT NULL,
`credit` INT NOT NULL COMMENT '学分',
`schedule` VARCHAR(100) NOT NULL COMMENT '时间安排',
`location` VARCHAR(50) NOT NULL,
`capacity` INT NOT NULL COMMENT '容量',
`selected` INT DEFAULT 0 COMMENT '已选人数',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_course_code` (`course_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 选课记录表
CREATE TABLE `selection` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`student_id` BIGINT NOT NULL,
`course_id` BIGINT NOT NULL,
`select_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-有效 0-已退选',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_student_course` (`student_id`, `course_id`),
KEY `idx_course` (`course_id`),
CONSTRAINT `fk_selection_student` FOREIGN KEY (`student_id`) REFERENCES `student` (`id`),
CONSTRAINT `fk_selection_course` FOREIGN KEY (`course_id`) REFERENCES `course` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3.2 性能优化实践
- 读写分离:配置MySQL主从复制,将统计报表类查询路由到从库
- 缓存策略:
- 使用Redis缓存热门课程信息
- 实现选课结果的本地缓存(Caffeine)
- 连接池配置:
yaml复制spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
4. 核心功能实现细节
4.1 选课业务流程
java复制@Transactional
public SelectionResult selectCourse(Long studentId, Long courseId) {
// 1. 校验学生状态
Student student = studentRepository.findById(studentId)
.orElseThrow(() -> new BizException("学生不存在"));
if (!student.isActive()) {
throw new BizException("学生账户已禁用");
}
// 2. 校验课程状态
Course course = courseRepository.findById(courseId)
.orElseThrow(() -> new BizException("课程不存在"));
if (course.getSelected() >= course.getCapacity()) {
throw new BizException("课程已满");
}
// 3. 检查时间冲突
List<Course> selectedCourses = selectionRepository.findSelectedCourses(studentId);
if (hasScheduleConflict(course, selectedCourses)) {
throw new BizException("课程时间冲突");
}
// 4. 检查学分上限
int totalCredits = selectedCourses.stream().mapToInt(Course::getCredit).sum();
if (totalCredits + course.getCredit() > student.getCreditLimit()) {
throw new BizException("超过学分上限");
}
// 5. 创建选课记录
Selection selection = new Selection();
selection.setStudentId(studentId);
selection.setCourseId(courseId);
selectionRepository.save(selection);
// 6. 更新已选人数
courseRepository.incrementSelected(courseId);
return SelectionResult.success(course);
}
4.2 高并发处理方案
- 乐观锁控制:
java复制@Update("UPDATE course SET selected = selected + 1 WHERE id = #{id} AND selected < capacity")
int incrementSelected(@Param("id") Long courseId);
- Redis分布式锁:
java复制public boolean trySelectWithLock(Long studentId, Long courseId) {
String lockKey = "select:lock:" + courseId;
String requestId = UUID.randomUUID().toString();
try {
// 尝试获取锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
return selectCourse(studentId, courseId).isSuccess();
}
return false;
} finally {
// 释放锁
if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) {
redisTemplate.delete(lockKey);
}
}
}
- 消息队列削峰:
java复制@RabbitListener(queues = "course.selection.queue")
public void processSelection(SelectionMessage message) {
trySelectWithLock(message.getStudentId(), message.getCourseId());
}
5. 前端关键实现
5.1 课程列表组件
vue复制<template>
<el-table :data="courses" v-loading="loading">
<el-table-column prop="courseCode" label="课程代码" width="120"/>
<el-table-column prop="name" label="课程名称"/>
<el-table-column prop="teacherName" label="授课教师" width="150"/>
<el-table-column prop="schedule" label="时间安排" width="180"/>
<el-table-column prop="location" label="地点" width="120"/>
<el-table-column label="人数" width="120">
<template #default="{row}">
{{ row.selected }}/{{ row.capacity }}
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{row}">
<el-button
size="small"
:disabled="row.selected >= row.capacity"
@click="handleSelect(row.id)">
选课
</el-button>
</template>
</el-table-column>
</el-table>
</template>
<script>
export default {
data() {
return {
courses: [],
loading: false
}
},
methods: {
async loadCourses() {
this.loading = true;
try {
const res = await api.get('/api/courses');
this.courses = res.data;
} finally {
this.loading = false;
}
},
async handleSelect(courseId) {
try {
await api.post('/api/selections', { courseId });
this.$message.success('选课成功');
this.loadCourses();
} catch (err) {
this.$message.error(err.response?.data?.message || '选课失败');
}
}
},
mounted() {
this.loadCourses();
}
}
</script>
5.2 可视化仪表盘
vue复制<template>
<div class="dashboard">
<el-row :gutter="20">
<el-col :span="8">
<el-card>
<div slot="header">选课统计</div>
<ve-pie :data="selectionChartData"/>
</el-card>
</el-col>
<el-col :span="16">
<el-card>
<div slot="header">热门课程TOP10</div>
<ve-bar :data="popularCoursesData"/>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script>
import { VePie, VeBar } from 'v-charts';
export default {
components: { VePie, VeBar },
data() {
return {
selectionChartData: {
columns: ['status', 'count'],
rows: [
{ status: '已选', count: 0 },
{ status: '剩余', count: 0 }
]
},
popularCoursesData: {
columns: ['course', 'selections'],
rows: []
}
}
},
async mounted() {
const res = await api.get('/api/statistics');
this.selectionChartData.rows = [
{ status: '已选', count: res.data.selectedCount },
{ status: '剩余', count: res.data.remainingCount }
];
this.popularCoursesData.rows = res.data.popularCourses;
}
}
</script>
6. 系统部署方案
6.1 生产环境部署架构
code复制 [CDN]
|
[负载均衡] → [Nginx] → [SpringBoot集群]
| |
[Vue静态资源] [Redis哨兵]
|
[MySQL主从]
6.2 Docker部署示例
后端Dockerfile:
dockerfile复制FROM openjdk:11-jre
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
前端Dockerfile:
dockerfile复制FROM nginx:alpine
COPY dist/ /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
docker-compose.yml:
yaml复制version: '3'
services:
backend:
build: ./backend
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod
- DB_URL=jdbc:mysql://mysql:3306/course_selection
- REDIS_HOST=redis
depends_on:
- mysql
- redis
frontend:
build: ./frontend
ports:
- "80:80"
depends_on:
- backend
mysql:
image: mysql:8.0
environment:
- MYSQL_ROOT_PASSWORD=root
- MYSQL_DATABASE=course_selection
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:6-alpine
command: redis-server --appendonly yes
volumes:
- redis_data:/data
volumes:
mysql_data:
redis_data:
7. 项目开发经验分享
在实际开发过程中,有几个关键点需要特别注意:
-
事务边界控制:选课操作涉及多个数据表的更新,必须确保在一个事务中完成。我们曾经遇到过因为事务配置不当导致选课人数计数不一致的问题。正确的做法是在Service层方法添加@Transactional注解,并设置合适的隔离级别和传播行为。
-
缓存一致性:使用Redis缓存课程信息时,需要处理好缓存更新策略。我们采用的方案是:
- 课程变更时主动清除缓存
- 设置合理的过期时间(如5分钟)
- 使用双重检查锁定防止缓存击穿
-
前端性能优化:
- 对课程列表实现虚拟滚动,避免渲染大量DOM节点
- 使用HTTP/2协议提升资源加载效率
- 对API响应进行Gzip压缩
- 配置合适的缓存策略(Cache-Control)
-
压力测试要点:
- 使用JMeter模拟高并发选课场景
- 重点关注数据库连接池使用情况
- 监控JVM内存和GC情况
- 测试Redis集群的吞吐量极限
这个项目最让我印象深刻的是处理选课高峰期的性能问题。最初版本在模拟500并发时就会出现大量超时,通过引入Redis分布式锁、消息队列削峰、数据库连接池优化等一系列措施后,最终可以稳定支持2000+的并发选课请求。这让我深刻理解了高并发系统设计的核心要点:减少锁竞争、缩短事务时间、合理利用缓存。
