简介:基于Java开发的教务查询系统,是一个面向SSM初学者的完整练手项目,适合正在学习Java后端课程设计或准备毕业设计的人群。项目采用Spring+SpringMVC+MyBatis整合架构,配合Shiro安全框架、C3P0连接池、Log4j日志与Bootstrap前端,实现课程、学生、教师、管理员、选课等教务模块,覆盖后台管理系统的典型业务场景。压缩包共271个文件,大小35.32MB,其中55个java文件提供业务源码,76个class为编译产物,42个xml包含Spring/MyBatis配置及Mapper映射,31个jar为项目依赖库,28个jsp为前端视图,另有SQL脚本、Bootstrap样式、JavaScript文件与少量文档资料,目录按Controller、Service、Dao分层,导入IDE后即可运行。已有411人浏览学习。通过研读该项目,可以学习MyBatis Generator生成的Example查询条件类如何组装动态SQL,理解Shiro过滤器链如何完成登录与权限判断,掌握SpringMVC中Controller与JSP页面的数据流转方式;也可借助附带SQL脚本反向梳理选课业务表关系,为自行扩展功能或二次开发提供参考。
1. 基于java开发的教务系统,真正要处理的是这三件麻烦事
很多第一次接触“基于java开发的教务系统”这个题目的人,第一反应都是:学生表、教师表、课程表、成绩表,四个表拼起来,增删改查一写,就完事了。实际上真到上线时你会发现,教务系统是所有管理类系统里最不像CRUD的那一类。它同时要处理三件事:不同身份的人操作同一批数据但权限完全不同、选课时几百人同时抢一门课名额不能超卖、以及成绩一旦发布就不能随意改动的状态边界。
这个标题在南边高校的课程设计和毕业设计里出现频率极高,也是很多Java工程师面试时会拿来当项目经历讲的题目。面试官爱问它的原因很简单:事务、并发、行级权限、状态机,全都能在这一个小项目里问透。这篇文章我会直接按照一线开发的做法,把技术栈、表结构、核心代码和踩坑点拆开讲,默认你用的是Spring Boot + MyBatis-Plus + MySQL这套Java生态下最稳妥的组合,适合想快速跑通、又要能扛住答辩追问的读者。
2. 教务系统的技术选型:为什么Spring Boot + MyBatis-Plus是默认答案
2.1 教务系统不是“大号增删改查”:状态、并发、权限三条线
先说清楚教务系统的复杂度到底在哪里。普通的管理系统,比如图书借阅,核心是单条记录的增删改查,最多加一个状态字段。教务系统不一样,它有一个“学期”的概念在头顶上罩着。同一门课,春季学期和秋季学期是两条独立的开课记录;同一个学生,上学期选了课,下学期选了同一门课,也是两条独立的选课记录。所有业务判断都要先带上学期维度,这个维度一旦没设计好,后面做跨学期查询时一定翻车。
第二是并发。选课期间的请求量集中在同一个时间段,一门热门课容量只有80人,但可能有几百人在同一秒点击选课。这时候如果只在Java代码里做“先查人数,再判断是否满员”,那查到的数量一定是过期的,最后实际录入的人数会远超容量。这类问题在Java面试里会被直接问“你怎么保证数据一致性”,这不是面试题里虚拟的场景,教务系统里天天都在发生。
第三是行级权限。学生只能看自己的选课记录和成绩,教师只能录入和修改自己名下课程的分数,管理员可以看全部。这种权限不是说“给不同角色返回不同菜单”就完了,而是在数据层就要过滤行。教师端查询成绩列表时,SQL里就得带上teacher_id = 当前登录人这个条件,而不是查出全部再在Java里筛,否则接口被人伪造一个ID就能看到别人课上的成绩。
2.2 最小可运行的Maven依赖与项目骨架
技术栈上,我一般直接用Spring Boot,持久层用MyBatis-Plus,数据库用MySQL。不用JSP,不是因为JSP不能用,而是Spring Boot打成jar包之后JSP视图解析很别扭,普通管理后台用Thymeleaf“能跑通、好调试”,前后端要分离就再加Vue。下面是一份能直接启动的最小pom依赖:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>注意几个选型理由:MyBatis-Plus自带BaseMapper和分页插件,单表CRUD完全不用手写XML,开发速度对课程设计体量来说刚刚好。mysql-connector-j是8.x驱动,JDBC URL里必须加时区参数,否则连数据库会直接报错。Spring Security不是所有教务系统都必须上,但既然这个项目大概率会被拿去面试,建议还是用,因为后面教师行级权限要做方法级校验时,Spring Security的注解比自写拦截器省事得多。
2.3 权限模型:Spring Security做认证,方法级注解做授权
教务系统的用户只有三种:管理员、教师、学生。不需要复杂的RBAC角色树,直接在用户表里用一个user_type字段区分就可以。Spring Security的职责分成两层:登录认证,以及接口授权。
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth -> auth .antMatchers("/login", "/css/**", "/js/**").permitAll() .antMatchers("/api/student/**").hasRole("STUDENT") .antMatchers("/api/teacher/**").hasRole("TEACHER") .antMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/index")) .csrf(csrf -> csrf.disable()); return http.build(); } }角色前缀ROLE_是Spring Security的默认要求,在UserDetailsService里把数据库查出的user_type拼成“ROLE_STUDENT”这样的字符串返回即可。真正关键的是方法级权限,比如教师录成绩的Service方法上标注@PreAuthorize("hasRole('TEACHER')"),这只是第一道门,第二道门才是查询自己课程的行级过滤,这个在第4章会给出具体写法。不要把权限逻辑全堆在Controller里,面向对象编程的做法是把“权限校验”放到Service入口,Controller只负责接收参数和返回结果。
3. 数据模型设计:从账号表到选课关系,一张图之外还要看边界
3.1 账号统一存sys_user,学生档案再单独扩展
很多初学者喜欢建三张表:student表、teacher表、admin表。这在业务上是个坏味道:登录时要查三张表才能确认用户名和密码,教师兼行政、教师又当管理员这种场景直接不知道怎么处理。教务系统的正确做法是统一建一张sys_user账号表,再用type字段区分角色,学生的学籍扩展信息单独建一张student_profile表,教师的职称、学院扩展信息单独建一张teacher_profile表。
CREATE TABLE sys_user ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(32) NOT NULL COMMENT '登录账号,学生用学号,教师用工号', password VARCHAR(100) NOT NULL COMMENT 'BCrypt密文,不存明文', user_type TINYINT NOT NULL DEFAULT 2 COMMENT '0=管理员 1=教师 2=学生', real_name VARCHAR(32) NOT NULL COMMENT '真实姓名', email VARCHAR(64) DEFAULT NULL COMMENT '选课通知邮箱', status TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=禁用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='统一账号表';密码字段一定要用BCrypt加密,Spring Security里BCryptPasswordEncoder可以直接用,存进去的密文长度是60位,所以字段长度给100是留了余量。status字段用于禁用已毕业学生或离职教师的账号,比直接删记录稳妥。username用学号或工号天然唯一,所以不需要再额外加一个user_code字段。
3.2 课程表与容量控制:selected_count放这里,扣减要用条件更新
课程表在设计时要区分两个概念:课程基础信息和开课计划。如果你只有一门“Java程序设计”,春季和秋季各开一个班,那么course表里存的是课程名称、学分、学时这些不变的基础信息;而开课计划表里存的是某学期、某老师、在哪个教室、容量多少、选课开始时间。简单教务系统可以把这两个概念合并到一张course表里,加一个semester_id字段区分学期,但容量和任课教师一定要跟着开课记录走,不能放基础信息里,否则换老师后旧学期的数据就乱了。
CREATE TABLE course ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, course_code VARCHAR(20) NOT NULL COMMENT '课程编号,如CS101', course_name VARCHAR(64) NOT NULL COMMENT '课程名称', credit DECIMAL(3,1) NOT NULL COMMENT '学分,如2.5', total_hours INT NOT NULL DEFAULT 32 COMMENT '总学时', semester_id BIGINT NOT NULL COMMENT '开课学期ID', teacher_id BIGINT NOT NULL COMMENT '任课教师,存sys_user.id', capacity INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '容量,0表示不限', selected_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已选人数', course_status TINYINT NOT NULL DEFAULT 0 COMMENT '0=未开放 1=选课中 2=选课结束', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_sem_course (semester_id, course_code) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='开课计划表';capacity和selected_count的类型用INT UNSIGNED,避免有人把selected_count改成负数。course_status是个状态字段,选课开始前由管理员从后台改为1,选课结束改为2,只有状态为1的课程学生才能提交选课,这是最简单也最不容易出错的开关方式。注意唯一索引是(semester_id, course_code),意思是同一个学期内同一门课只有一个班次,如果想一个学期开多个平行班,必须再加一个class_no字段进入唯一索引。
3.3 选课关系表:score、score_status、exam_type怎么放
选课关系表是数据一致性最关键的战场。成绩字段我建议直接放在选课关系表里,不要单独建成绩表。单独建成绩表的好处是能保留历次修改记录,代价是选课记录和成绩记录一旦分离,会出现学生选了课但成绩表里没有记录、或者成绩表里有记录但选课记录被删了的脏数据。在课程设计这个体量下,一个字段一张表,收益很低成本很高。
CREATE TABLE student_course ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, student_id BIGINT NOT NULL COMMENT '学生sys_user.id', course_id BIGINT NOT NULL COMMENT '开课计划course.id', semester_id BIGINT NOT NULL COMMENT '学期ID,冗余存储方便按学期查', score DECIMAL(5,1) DEFAULT NULL COMMENT '成绩,NULL表示未录入', score_status TINYINT NOT NULL DEFAULT 0 COMMENT '0=未录 1=已录未发布 2=已发布', exam_type TINYINT NOT NULL DEFAULT 0 COMMENT '0=正常考试 1=补考 2=重修', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_stu_course_sem (student_id, course_id, semester_id, exam_type), KEY idx_course (course_id), KEY idx_semester (semester_id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='选课与成绩关系表';这里的UNIQUE KEY是防重复选课的最后一道防线:即使Java代码里漏了判断,数据库也会因为唯一索引冲突而拒绝第二条重复记录。score_status不是多余设计,它解决的是“成绩录错了能不能改”的问题。第一次录入后状态是1,教务员审核无误后置为2,发布之后成绩不可再改,真要修改就必须走单独的成绩变更流程。exam_type进唯一索引是为了支持补考和重修:学生某学期正常考挂了,补考是另外一条记录,而不是把原来的成绩覆盖掉,这样成绩单和绩点计算才准确。
学期表(semester)也不能省,最少要有semester_name、start_date、end_date、select_start_time、select_end_time、status六个字段,所有业务都靠它判断当前处于哪个阶段。没有学期表的教务系统,代码里一定到处是硬编码的“2024-2025-1”这种字符串,后期查数据必炸。
4. 选课、成绩、学期切换:三段核心Java代码与数据一致性做法
4.1 选课并发:先扣容量再插记录,事务与唯一索引一起兜底
选课接口是数据一致性要求最高的地方。先说一个新手最容易犯的错误:先select count查一下已选人数,小于容量再insert。这在低并发下没问题,一旦两个请求同时查出“还剩1个名额”,两个都执行insert,最终选进去2个人。Java层面解决不了这个竞态,必须把判断逻辑放到数据库的原子操作里。
正确做法是“先执行条件更新扣减容量,更新影响行数为0说明名额已经没了,再插入选课关系”。
@Service public class CourseSelectService { @Resource private CourseMapper courseMapper; @Resource private StudentCourseMapper studentCourseMapper; @Resource private SemesterMapper semesterMapper; @Transactional(rollbackFor = Exception.class) public void selectCourse(Long studentId, Long courseId, Long semesterId) { Semester semester = semesterMapper.selectById(semesterId); if (semester == null || semester.getStatus() != 1) { throw new BizException("当前不在选课时间内"); } int updated = courseMapper.reduceCapacity(courseId); if (updated == 0) { throw new BizException("该课程名额已满"); } StudentCourse sc = new StudentCourse(); sc.setStudentId(studentId); sc.setCourseId(courseId); sc.setSemesterId(semesterId); sc.setExamType(0); try { studentCourseMapper.insert(sc); } catch (DuplicateKeyException e) { throw new BizException("你已选过这门课,请勿重复提交"); } } }对应的Mapper XML如下:
<update id="reduceCapacity"> UPDATE course SET selected_count = selected_count + 1 WHERE id = #{courseId} AND selected_count < capacity AND course_status = 1 </update>这段逻辑有三个关键点。第一,MySQL执行UPDATE时会对命中的行加行级锁,第二个请求必须等第一个请求提交或回滚后才能执行这条UPDATE,此时selected_count已经加过了,等于把“判断容量”和“扣减容量”合并成了一个原子操作。第二,插入选课记录时如果触发唯一索引冲突,DuplicateKeyException会把整个事务标记为rollback-only,所以catch里一定要抛出业务异常让事务回滚,否则可能出现“选课记录没插入成功、但容量已经被扣掉”的中间状态。第三,这里不能用MyBatis-Plus自带的updateById先查出实体再更新,因为“查出来再改”永远不会是原子操作。
4.2 成绩录入:教师行级权限 + 状态位控制“录一次还是改一次”
成绩录入接口的核心诉求是:教师只能操作自己任课的课程,成绩一旦发布就不能随便改。行级权限不能用“传一个teacherId参数进来”实现,因为参数是可以伪造的,必须从登录上下文里拿当前登录人。
@Service public class ScoreService { @Resource private CourseMapper courseMapper; @Resource private StudentCourseMapper studentCourseMapper; @PreAuthorize("hasRole('TEACHER')") public void recordScore(Long courseId, Long studentCourseId, BigDecimal score) { Long currentTeacherId = getCurrentUserId(); Course course = courseMapper.selectById(courseId); if (course == null || !currentTeacherId.equals(course.getTeacherId())) { throw new BizException("你不是该课程任课教师,不能录入成绩"); } StudentCourse sc = studentCourseMapper.selectById(studentCourseId); if (sc == null || !courseId.equals(sc.getCourseId())) { throw new BizException("选课记录不存在或不属于该课程"); } if (sc.getScoreStatus() == 2) { throw new BizException("成绩已发布,不能直接修改"); } int updated = studentCourseMapper.updateScoreWithStatus(studentCourseId, score); if (updated == 0) { throw new BizException("成绩更新失败,请刷新后重试"); } } private Long getCurrentUserId() { Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); // 登录时把userId放进了UserDetails对象里,这里直接取自定义属性 return ((LoginUser) principal).getUserId(); } }Mapper XML里的小心机:
<update id="updateScoreWithStatus"> UPDATE student_course SET score = #{score}, score_status = 1 WHERE id = #{studentCourseId} AND score_status IN (0, 1) </update>WHERE条件里带score_status IN (0, 1),意思是只有未发布和未审核的成绩允许修改。两个教师同时在同一秒修改同一个学生的成绩,第二个人执行UPDATE时影响行数会是0,代码通过返回的updated判断出来,避免了“后写覆盖先写”的丢失更新问题。这里没有必要上悲观锁,一个状态条件就能解决。
4.3 学期边界:开学两周后不能让任何人选课,一个状态字段解决
教务系统里还有一种经常翻车的场景:学生选课时间已经截止,但老师手动往选课记录表插了一条数据,或者学生直接访问选课接口居然还能成功。原因是代码里只判断了课程状态,没有判断学期状态。
我的做法是学期状态跟开课状态分开:semester表里的status控制整个学期当前处于“报名前、选课中、行课中、已结课”哪个阶段,course表里的course_status控制具体某门课能不能选。选课接口必须两个条件同时满足。
public void selectCourse(Long studentId, Long courseId, Long semesterId) { Semester semester = semesterMapper.selectById(semesterId); if (semester == null || semester.getStatus() == null || semester.getStatus() < 0 || !isInSelectTime(semester)) { throw new BizException("当前不是选课时间"); } // 其余选课逻辑保持不变 } private boolean isInSelectTime(Semester semester) { LocalDateTime now = LocalDateTime.now(); boolean afterStart = now.isAfter(semester.getSelectStartTime()); boolean beforeEnd = now.isBefore(semester.getSelectEndTime()); return afterStart && beforeEnd; }更严格一点的做法是连“补录”都要走管理员后台接口,学生端没有直接插入选课记录的入口。这个设计本身不复杂,难的是很多人在第一次开发时把时间判断逻辑散落在Controller、Service、前端三个地方,结果改一处漏一处。把“当前时间是否在选课窗口内”封装成Semester实体的一个方法,所有入口统一调它,这是面向对象思路在业务规则上的正确用法。
5. 避坑指南:教务系统上线前后最容易翻车的五个真实问题
5.1 选课超卖:100人选80个名额,结果录进去120人
现象:选课开启的第一分钟,热门课显示已选人数超过容量,甚至出现同一个学生有两条选课记录。
原因:代码是“先查剩余名额,再执行插入”,没有原子扣减操作,高并发下两个请求同时读到了剩余名额1,都通过了判断。
解决:把容量扣减写成UPDATE course SET selected_count = selected_count + 1 WHERE selected_count < capacity,判断影响行数等于0就拒绝。同时给student_course表加上联唯一索引,双保险。资深的做法是再配合Redis预扣名额,这个在第6章会讲。
5.2 主键Long传给前端后,最后四位全变成了0
现象:前端页面上显示的学生ID是1208925819614629170,数据库里存的明明是1208925819614629171。
原因:JavaScript的Number类型只能安全表示到2的53次方,MyBatis-Plus默认的雪花算法生成19位Long型主键,传给JSON后精度丢失。
解决:全局配置把Long类型序列化为字符串,在Jackson配置里加一个自定义ObjectMapper,也是比较推荐的Java基础做法。单个字段上可以加@JsonSerialize(using = ToStringSerializer.class),但全局配置更省事:
@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() { return builder -> builder .serializerByType(Long.class, ToStringSerializer.instance) .serializerByType(Long.TYPE, ToStringSerializer.instance); } }5.3 同一个Service类里调用另一个方法,事务悄悄失效
现象:选课Service里selectCourse方法标注了@Transactional,内部又把“扣容量”和“插记录”拆到了两个private方法里,结果扣容量成功、插记录失败,容量没有回滚。
原因:Spring的事务是通过AOP代理实现的,只有外部调用代理对象的方法时事务注解才会生效。同类里的this.selectCourse()调用this.reduceCapacity(),实际上是直接调了原始对象的方法,根本没走代理,所以reduceCapacity上的@Transactional注解被忽略了。
解决:把“扣容量”和“插选课记录”拆到两个独立的Service类里,由Controller层调用外层Service;或者在类里注入自身代理。前者更干净,也便于单元测试。
5.4 换老师之后,旧学期的成绩单归属错了人
现象:上学期教“Java程序设计”的张老师离职了,这学期换成了李老师,管理员直接改了course表的teacher_id。结果学生查上学期的成绩单时,任课教师显示成了李老师,老师端也看到了不是自己教的历史成绩。
原因:course表里teacher_id这一列对于历史学期来说应该是快照数据,不应该随当前人事变更而更新。直接把teacher_id当作“当前任课老师”含义来更新,破坏了历史数据。
解决:开课计划表的teacher_id从创建后就不允许修改,如果这学期需要换老师,新建一条开课记录,旧记录保持原样。给管理员改教师的功能只作用于“未开始选课”的记录,选课开始后该字段置为只读。
5.5 建表当天没报错,两周后报表统计全乱了
现象:成绩报表查出来选课人数是错的,或者某条SQL在测试环境没问题,生产环境时间差8小时。
原因:两类问题最常见。一是表名或字段名撞了MySQL关键字,比如课程表里有个字段叫condition,或者表名用order;二是JDBC连接串没配serverTimezone,导致LocalDateTime在入库时被数据库时区偏移。
解决:建表时避开desc、condition、group这些保留字,字段命名全部用course_name、selected_count这种带业务前缀的方式。JDBC连接串固定写成jdbc:mysql://localhost:3306/edu?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8。实体字段类型统一用LocalDateTime,不要用java.util.Date。
6. 从“能跑通”到“敢验收”:选课并发验证、Redis加分项与我的习惯
6.1 先跑一遍可复现的选课闭环脚本
验收一个教务系统,不是把页面点一遍就算完。我建议至少做三个层面的验证。第一层是数据一致性:写一个JUnit测试,模拟50个线程同时选同一门容量30的课程,跑完断言选课记录总数不超过30、course表的selected_count正好等于30。第二层是行级权限:用一个教师的账号调成绩录入接口,传不属于自己课程的courseId,期望返回业务异常。第三层是状态流转:跳过选课时间后再次调选课接口,看是否被拒绝。
@Test @Transactional public void testConcurrentSelect() throws InterruptedException { Long courseId = 1L; int threadCount = 50; ExecutorService pool = Executors.newFixedThreadPool(16); CountDownLatch start = new CountDownLatch(1); CountDownLatch end = new CountDownLatch(threadCount); for (int i = 1; i <= threadCount; i++) { Long studentId = 1000L + i; CompletableFuture.runAsync(() -> { try { start.await(); courseSelectService.selectCourse(studentId, courseId, 1L); } catch (Exception ignored) { } finally { end.countDown(); } }, pool); } start.countDown(); end.await(); StudentCourse count = studentCourseMapper.selectCount(courseId); Course course = courseMapper.selectById(courseId); assertTrue(count <= 30); assertEquals(count, course.getSelectedCount()); }这个脚本跑通了,比答辩现场演示十遍页面都有说服力。
6.2 Redis预扣名额是个加分项,但要和数据库回滚对齐
如果真实选课场景下有1000人同时抢课,数据库行锁虽然能保证一致性,但会让接口响应时间变长。常见做法是选课开始时把课程容量预热到Redis,用DECR命令原子扣减,扣减成功后把请求放进MQ异步写数据库,由消费者最终落库。Redis只负责挡住超额流量,数据库兜底保证最终一致。实现时注意:消费者落库失败要把Redis名额回补,否则会出现Redis说有名额但数据库没记录、学生“选上”了却查不到的问题。
6.3 一个让我少踩很多坑的习惯
我现在的习惯是每个学期开始前,用一段初始化脚本把semester表状态、course表容量和selected_count全部重置一遍,然后跑6.1里的那个并发测试,再开始正式选课。这样数据一致性验证不是上线前一天临时抱佛脚,而是每次发版前固定动作。教务系统这个项目看起来简单,但它把事务、锁、权限、数据快照这几个后端的硬骨头都集中在了同一个业务场景里,值得把每一行代码写到经得起追问,希望这些内容能帮你在自己的课程设计或项目里少走一点弯路。
本文还有配套的精品资源,点击获取