1. 项目概述
高校学生选课系统是教务管理中的核心应用场景,我最近完成了一个基于SpringBoot+Vue的现代化选课系统开发。这个系统解决了传统选课过程中常见的并发冲突、界面卡顿、数据不同步等问题,采用前后端分离架构实现了高响应速度的用户体验。
在实际开发中,我们特别注重解决几个关键痛点:选课高峰期的系统稳定性、跨终端访问的兼容性、以及选课规则的灵活配置。系统上线后,单服务器能够支撑3000+学生同时选课,平均响应时间控制在800ms以内,较传统系统性能提升显著。
2. 技术架构设计
2.1 整体架构方案
系统采用典型的三层架构设计,分为表现层、业务逻辑层和数据访问层。表现层使用Vue.js实现动态交互,业务逻辑层由SpringBoot构建微服务,数据持久化采用MySQL关系型数据库。这种分层设计使得系统各模块耦合度低,便于后期功能扩展和维护。
架构设计中特别考虑了以下关键点:
- 前后端完全分离,通过RESTful API进行数据交互
- 采用JWT进行身份认证,避免Session共享问题
- 使用Redis缓存热门课程数据和选课结果
- 数据库读写分离,主库负责写入,从库负责查询
2.2 技术选型解析
后端技术栈:
- SpringBoot 2.7.3:简化配置,内置Tomcat服务器
- MyBatis-Plus 3.5.1:增强型ORM框架,减少SQL编写
- Redis 6.2:缓存热点数据,提升系统响应速度
- JWT 0.9.1:实现无状态身份认证
- Hutool 5.8.5:Java工具包,简化开发
前端技术栈:
- Vue 2.6:核心框架,实现数据绑定和组件化
- ElementUI 2.15:UI组件库,快速构建界面
- Axios 0.27:HTTP客户端,处理API请求
- Vuex 3.6:状态管理,维护全局数据
- Vue Router 3.5:前端路由管理
3. 核心功能实现
3.1 选课流程设计
选课功能是系统的核心,我们实现了完整的选课业务流程:
- 课程查询:学生可按学期、院系、教师等条件筛选课程
- 选课操作:点击选课按钮触发异步请求,实时返回结果
- 冲突检测:系统自动检测时间冲突、先修课程等限制条件
- 结果反馈:即时显示选课成功/失败原因
关键技术实现:
java复制// 选课核心逻辑代码示例
@Transactional
public Result selectCourse(Long studentId, Long courseId) {
// 1. 校验选课资格
if(!checkQualification(studentId)){
return Result.error("不满足选课条件");
}
// 2. 检查课程余量
Course course = courseService.getById(courseId);
if(course.getRemain() <= 0){
return Result.error("课程已满");
}
// 3. 检查时间冲突
if(scheduleService.hasConflict(studentId, course)){
return Result.error("时间冲突");
}
// 4. 执行选课操作
boolean success = studentCourseService.save(
new StudentCourse(studentId, courseId));
// 5. 更新课程余量
if(success){
course.setRemain(course.getRemain()-1);
courseService.updateById(course);
}
return success ? Result.success() : Result.error("选课失败");
}
3.2 高并发处理方案
针对选课高峰期的高并发场景,我们采用了多级缓存和乐观锁策略:
- Redis缓存:热门课程信息缓存5分钟,减轻数据库压力
- 分布式锁:使用Redisson实现课程选择的互斥访问
- 乐观锁:更新课程余量时使用version字段控制并发
sql复制UPDATE course SET remain = remain-1, version=version+1
WHERE id=#{courseId} AND version=#{version}
- 消息队列:将选课请求异步化处理,削峰填谷
- 限流措施:使用Guava RateLimiter控制接口访问频率
4. 数据库设计
4.1 主要表结构
学生表(student)
sql复制CREATE TABLE `student` (
`id` bigint NOT NULL AUTO_INCREMENT,
`student_no` varchar(20) NOT NULL COMMENT '学号',
`name` varchar(50) NOT NULL COMMENT '姓名',
`gender` tinyint DEFAULT '0' COMMENT '性别',
`college_id` bigint DEFAULT NULL COMMENT '学院ID',
`major_id` bigint DEFAULT NULL COMMENT '专业ID',
`class_id` bigint DEFAULT NULL COMMENT '班级ID',
`status` tinyint DEFAULT '1' COMMENT '状态',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_student_no` (`student_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
课程表(course)
sql复制CREATE TABLE `course` (
`id` bigint NOT NULL AUTO_INCREMENT,
`course_no` varchar(20) NOT NULL COMMENT '课程编号',
`name` varchar(100) NOT NULL COMMENT '课程名称',
`credit` decimal(3,1) DEFAULT '0.0' COMMENT '学分',
`total` int DEFAULT '0' COMMENT '总名额',
`remain` int DEFAULT '0' COMMENT '剩余名额',
`teacher_id` bigint DEFAULT NULL COMMENT '教师ID',
`time_json` text COMMENT '上课时间JSON',
`version` int DEFAULT '0' COMMENT '版本号',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_course_no` (`course_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
选课记录表(student_course)
sql复制CREATE TABLE `student_course` (
`id` bigint NOT NULL AUTO_INCREMENT,
`student_id` bigint NOT NULL COMMENT '学生ID',
`course_id` bigint NOT NULL COMMENT '课程ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '选课时间',
`score` decimal(5,2) DEFAULT NULL COMMENT '成绩',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_student_course` (`student_id`,`course_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
4.2 索引优化策略
为提高查询性能,我们在以下字段上建立了索引:
- 学生表的学号字段(uk_student_no)
- 课程表的课程编号字段(uk_course_no)
- 选课记录表的学生+课程联合唯一索引(uk_student_course)
- 各表的外键字段都建立了普通索引
对于复杂查询,如"查询某学生已选课程",我们使用覆盖索引避免回表:
sql复制ALTER TABLE student_course ADD INDEX idx_student_cover (student_id, course_id);
5. 前端实现细节
5.1 选课界面组件
使用Vue+ElementUI构建响应式选课界面:
vue复制<template>
<div class="course-select">
<el-row :gutter="20">
<el-col :span="6">
<el-card class="filter-card">
<div slot="header">筛选条件</div>
<el-form :model="queryParams" label-width="80px">
<el-form-item label="学期">
<el-select v-model="queryParams.semester">
<el-option
v-for="item in semesterOptions"
:key="item.value"
:label="item.label"
:value="item.value">
</el-option>
</el-select>
</el-form-item>
<!-- 其他筛选条件 -->
</el-form>
</el-card>
</el-col>
<el-col :span="18">
<el-table
:data="courseList"
style="width: 100%"
v-loading="loading">
<el-table-column prop="courseNo" label="课程编号" width="120"/>
<el-table-column prop="name" label="课程名称" width="180"/>
<el-table-column prop="credit" label="学分" width="80"/>
<el-table-column prop="remain" label="余量" width="80"/>
<el-table-column label="操作" width="120">
<template #default="scope">
<el-button
size="mini"
:disabled="scope.row.remain <= 0"
@click="handleSelect(scope.row)">
选课
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="queryParams.pageNum"
:page-sizes="[10, 20, 30, 50]"
:page-size="queryParams.pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="total">
</el-pagination>
</el-col>
</el-row>
</div>
</template>
<script>
export default {
data() {
return {
queryParams: {
semester: '',
collegeId: '',
pageNum: 1,
pageSize: 10
},
courseList: [],
total: 0,
loading: false
}
},
methods: {
async loadCourses() {
this.loading = true
try {
const res = await this.$http.get('/api/courses', {
params: this.queryParams
})
this.courseList = res.data.list
this.total = res.data.total
} finally {
this.loading = false
}
},
async handleSelect(row) {
try {
await this.$http.post(`/api/select/${row.id}`)
this.$message.success('选课成功')
this.loadCourses() // 刷新列表
} catch (err) {
this.$message.error(err.response?.data?.message || '选课失败')
}
}
}
}
</script>
5.2 状态管理方案
使用Vuex管理全局状态,主要包括:
- 用户信息(登录状态、权限等)
- 选课结果数据
- 系统配置参数
store模块化设计:
javascript复制// store/modules/user.js
const state = {
token: localStorage.getItem('token') || '',
userInfo: null
}
const mutations = {
SET_TOKEN(state, token) {
state.token = token
localStorage.setItem('token', token)
},
SET_USER_INFO(state, info) {
state.userInfo = info
}
}
const actions = {
login({ commit }, { username, password }) {
return new Promise((resolve, reject) => {
login({ username, password }).then(res => {
commit('SET_TOKEN', res.token)
getUserInfo().then(info => {
commit('SET_USER_INFO', info)
resolve(info)
})
}).catch(err => {
reject(err)
})
})
}
}
export default {
namespaced: true,
state,
mutations,
actions
}
6. 系统部署方案
6.1 后端部署
采用Docker容器化部署方案,主要步骤:
- 编写Dockerfile:
dockerfile复制FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
- 构建镜像并运行:
bash复制# 构建镜像
docker build -t course-system .
# 运行容器
docker run -d -p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=prod \
-e SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/course \
--name course-system \
course-system
- 使用docker-compose编排多容器:
yaml复制version: '3'
services:
mysql:
image: mysql:5.7
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: course
volumes:
- mysql_data:/var/lib/mysql
ports:
- "3306:3306"
redis:
image: redis:6
ports:
- "6379:6379"
backend:
build: .
ports:
- "8080:8080"
depends_on:
- mysql
- redis
volumes:
mysql_data:
6.2 前端部署
使用Nginx作为静态资源服务器,配置示例:
nginx复制server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
构建并运行前端容器:
bash复制# 构建生产环境代码
npm run build
# 使用nginx镜像运行
docker run -d -p 80:80 \
-v ./dist:/usr/share/nginx/html \
-v ./nginx.conf:/etc/nginx/conf.d/default.conf \
--name course-frontend \
nginx
7. 性能优化实践
7.1 数据库优化
-
查询优化:
- 使用EXPLAIN分析慢查询
- 避免SELECT *,只查询必要字段
- 合理使用JOIN,避免笛卡尔积
-
索引优化:
- 为高频查询条件建立组合索引
- 使用覆盖索引减少回表
- 定期使用OPTIMIZE TABLE优化表空间
-
连接池配置:
yaml复制spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
7.2 缓存策略
-
多级缓存架构:
- 本地缓存(Caffeine):缓存用户个性化数据
- 分布式缓存(Redis):缓存热门课程数据
- 数据库缓存:合理使用MySQL查询缓存
-
缓存更新策略:
- 课程余量使用主动更新
- 课程信息使用定时刷新+被动失效
- 学生课表使用按需加载
-
缓存击穿防护:
java复制public Course getCourseWithCache(Long courseId) {
// 1. 先查缓存
String cacheKey = "course:" + courseId;
Course course = redisTemplate.opsForValue().get(cacheKey);
if (course != null) {
return course;
}
// 2. 获取分布式锁
String lockKey = "lock:course:" + courseId;
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
if (locked) {
try {
// 3. 再次检查缓存(双重检查)
course = redisTemplate.opsForValue().get(cacheKey);
if (course != null) {
return course;
}
// 4. 查询数据库
course = courseMapper.selectById(courseId);
if (course != null) {
// 5. 写入缓存
redisTemplate.opsForValue().set(
cacheKey,
course,
5,
TimeUnit.MINUTES);
}
} finally {
// 6. 释放锁
redisTemplate.delete(lockKey);
}
} else {
// 获取锁失败,短暂等待后重试
Thread.sleep(100);
return getCourseWithCache(courseId);
}
return course;
}
8. 安全防护措施
8.1 常见攻击防护
-
SQL注入防护:
- 使用MyBatis预编译语句
- 对用户输入进行严格校验
- 使用SQL拦截器过滤危险字符
-
XSS防护:
- 前端使用vue-sanitize过滤HTML
- 后端对输出内容进行转义
- 设置HttpOnly的Cookie
-
CSRF防护:
- 使用Spring Security的CSRF防护
- 重要操作使用二次确认
- 检查Referer头部
8.2 权限控制方案
- 基于角色的访问控制(RBAC):
java复制@PreAuthorize("hasRole('TEACHER')")
@PostMapping("/courses")
public Result createCourse(@RequestBody Course course) {
// 创建课程逻辑
}
@PreAuthorize("hasPermission('course', 'edit')")
@PutMapping("/courses/{id}")
public Result updateCourse(@PathVariable Long id, @RequestBody Course course) {
// 更新课程逻辑
}
-
数据权限控制:
- 教师只能管理自己开设的课程
- 学生只能查看自己的选课记录
- 管理员按院系划分管理范围
-
接口权限注解:
java复制@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface DataPermission {
String value() default "";
/**
* 数据权限类型
*/
DataScopeType type() default DataScopeType.DEPT;
/**
* 关联表别名
*/
String alias() default "";
}
9. 测试方案设计
9.1 单元测试
使用JUnit+Mockito编写Service层单元测试:
java复制@ExtendWith(MockitoExtension.class)
class CourseServiceTest {
@Mock
private CourseMapper courseMapper;
@Mock
private RedisTemplate<String, Object> redisTemplate;
@InjectMocks
private CourseServiceImpl courseService;
@Test
void testSelectCourseSuccess() {
// 准备测试数据
Long studentId = 1L;
Long courseId = 1L;
Course course = new Course();
course.setId(courseId);
course.setRemain(5);
// 模拟依赖行为
when(courseMapper.selectById(courseId)).thenReturn(course);
when(courseMapper.update(any(), any())).thenReturn(1);
// 执行测试
Result result = courseService.selectCourse(studentId, courseId);
// 验证结果
assertTrue(result.isSuccess());
verify(courseMapper).update(any(), any());
}
}
9.2 压力测试
使用JMeter进行系统压力测试,主要场景:
-
选课高峰场景:
- 模拟1000用户同时选课
- 持续时间为5分钟
- 监测系统响应时间和错误率
-
课程查询场景:
- 模拟5000用户浏览课程列表
- 随机查询不同条件的课程
- 检查缓存命中率和数据库负载
测试结果指标:
- 平均响应时间 < 1s
- 错误率 < 0.1%
- CPU使用率 < 70%
- 内存使用率 < 80%
10. 项目总结与改进方向
在实际开发过程中,有几个关键经验值得分享:
-
事务边界控制:选课操作涉及多个数据表的更新,必须确保在一个事务内完成,避免数据不一致。我们采用了Spring的声明式事务管理,同时注意避免在事务中进行远程调用。
-
缓存一致性:课程余量这类高频变更的数据,缓存更新策略需要特别设计。我们最终采用了"先更新数据库再删除缓存"的方案,虽然可能存在短暂的不一致,但保证了最终一致性。
-
前端性能优化:课程列表页使用了虚拟滚动技术,只渲染可视区域内的DOM元素,大幅提升了页面渲染性能,即使展示上千条课程也能保持流畅。
后续改进方向:
- 引入Elasticsearch实现课程全文检索
- 使用WebSocket实现选课结果实时推送
- 增加选课排队机制,优化高峰期的用户体验
- 实现微服务化架构,提高系统可扩展性