从选课季的系统里爬出来后,我一直想找机会把这段“基于SpringBoot的选课调查系统”的开发经历完整沉淀下来。原因很简单:这套系统看起来就是常见的CRUD加几个统计接口,真正落地时却踩了一连串和SpringBoot配置、数据库并发、事务边界有关的坑。整个过程让我重新理解了什么叫“小系统不小”——选课、问卷、统计这三条主线,每一条单独拆出来都不难,但组合到一起,再叠加高峰期的并发压力,就能暴露出一堆平时注意不到的工程细节。
这篇内容会把我当时的设计思路、表结构、核心接口代码、并发控制方案、部署配置以及最终排查过的几个真实问题,尽量完整地分享出来。适合正在做类似课设或后台管理系统的同学,也适合刚入行SpringBoot、想了解一个真实项目完整链路的开发者。我会把每个关键选择背后“为什么这么做”也讲清楚,而不是只给一段能跑通的代码。
1. 先想清楚:选课调查系统到底要解决什么问题
很多人在动手做这类系统时,第一反应就是列功能菜单——学生管理、课程管理、选课记录、问卷调查、统计报表,然后就开始写实体类。我在早期版本也是这么干的,但做到一半就会发现功能之间是割裂的,选课是选课,问卷是问卷,统计数据要从好几张表里硬查。第二次重构时我换了个思路,先回答一个问题:这套系统的价值到底在哪?
1.1 选课不只是一张课表:系统要解决的三类问题
从实际使用场景倒推,选课调查系统其实要解决三类问题。
第一类是课程容量管理。每门课有固定容量,学生选课的先决条件是“还有余量”。这个看似简单的逻辑,在并发下会变成一道经典的超卖问题,如果直接用“先查容量再插入记录”的方式,两个学生同时提交就会超出容量。
第二类是选课意愿调查。很多学校在正式选课前,需要通过问卷了解学生的课程偏好,或者让已经选课的学生对课程内容、教师授课方式做反馈。这部分需要解决的是匿名性和唯一性——一个学生只能提交一次问卷,但管理员在统计结果时不能再暴露学生身份,导致学生不敢说真话。
第三类是决策支持。系统收集到的数据要给教务或老师使用,所以统计口径必须清晰:哪些课程最受欢迎、哪些时间段的课程热度高、问卷选项的分布比例如何。这决定了统计接口不能只是简单的count聚合,还要考虑维度筛选。
1.2 我最终划定的功能边界
基于上面的分析,我给这套系统划定了边界:面向学生端提供课程浏览、选课、退课、参与问卷、查看本人选课结果这几个功能;面向管理员端提供课程管理、学生管理、选课记录查询、问卷配置和统计看板这几个功能。认证和权限用Spring Security加JWT来做,没有单独引入更重的权限框架。
这个范围是经过取舍的。选课调查系统不需要在线支付、不需要音视频、不需要消息推送,这些功能一步到位反而会把系统复杂度推高,让你分不清哪些模块是核心。真实项目中,先守住核心链路,再谈扩展,比一开始就堆技术栈要靠谱得多。SpringBoot在这类系统里的价值,恰恰是把“约定优于配置”贯彻到每个模块里,让开发者把精力集中在业务逻辑而非框架整合上。
2. 数据库设计:先想清楚有哪些数据,再动手写代码
数据库设计这一步,决定了后续所有代码的复杂度。我的经验是,不要急着建表,先在纸上列出系统中会出现哪些“名词”:学生、课程、选课记录、问卷、问卷选项、问卷答案。名词之间是什么关系,是一对一还是一对多,多对多需不需要中间表。理清这些之后,再考虑字段类型和索引。
2.1 核心表结构与字段设计
我最终设计了七张表,其中四张是业务核心,三张是辅助支撑。
学生表(student):主键id、学号student_no、姓名name、院系department、年级grade、账号状态status。学号要加唯一索引,因为它是学生登录系统时的账号体系。
课程表(course):主键id、课程编号course_code、课程名称course_name、授课教师teacher_name、学分credit、课程类型course_type、容量capacity、已选人数selected_count、问卷状态survey_status、问卷开始时间survey_start_time、问卷结束时间survey_end_time。
选课记录表(enrollment):主键id、学生id student_id、课程id course_id、选课时间enroll_time、状态status。这张表上加了一个联合唯一索引uk_student_course(student_id, course_id),用来保证同一个学生不能重复选同一门课。
问卷表(survey):主键id、课程id course_id、标题title、描述description、开始时间start_time、结束时间end_time、状态status。问卷和课程是一对一关系,因为每个课程在特定选课周期内只有一份问卷。
问卷选项表(survey_option):主键id、问卷id survey_id、选项内容option_text、排序option_order。这是单选题的经典设计方式,把选项从问卷表中拆出来,方便动态配置。
问卷答案表(survey_answer):主键id、问卷id survey_id、学生id student_id、问题id question_id、选项id option_id、备注content、提交时间created_time。这个表上加了唯一约束uk_survey_student(survey_id, student_id, question_id),防止学生针对同一份问卷的同一道题重复提交。
用户表本来也是需要的,但后来结合Spring Security的用户体系,我直接用student表充当用户表,把密码字段password_hash加到了学生表里,用BCrypt加密。调查系统里的学生和用户是同一批人,没必要拆成两张表增加join复杂度。
2.2 索引与唯一约束的取舍
索引设计上,我的原则是“查询慢的字段建索引,写入频繁的字段慎建索引”。初期我在所有外键字段上都建了索引,结果发现选课高峰期写入性能不太理想。排查后发现selected_count这个字段频繁更新,但它上面的普通索引用处不大,反而拖慢了更新速度。后来我把这张表上的冗余索引清理了一遍,只保留最常用的查询路径。
真正起决定作用的是两个唯一约束:选课记录表上的uk_student_course,以及问卷答案表上的uk_survey_student。这两个约束不仅是数据正确性的最后一道防线,还能在并发操作时直接让数据库抛出唯一键冲突异常,应用层捕获到这个异常就知道是重复操作,省掉了额外的查询判断。这是我在反复试错后领悟到的:数据库层的约束比应用层的if判断可靠得多,因为应用层的check-then-act在多线程下天然存在原子性缺口。
3. 核心接口实现:选课、问卷、统计三条主线的开发细节
数据库设计完成后,就是接口开发。我没有按Controller、Service、Mapper的层级一个个机械地写,而是按照用户的操作流来组织代码。这次项目里花了最多精力的是三个接口:选课提交接口、问卷提交接口、统计报表查询接口。它们分别代表了并发控制、防重复提交和聚合查询三个典型问题。
3.1 选课接口与并发控制
选课接口的逻辑看着简单:校验课程是否可选,检查容量是否已满,插入选课记录,课程已选人数加一。但高并发场景下的实现方式完全不同。如果代码写成这样,一定会出问题:
// 错误示范:先查容量再插入 Course course = courseMapper.selectById(courseId); if (course.getSelectedCount() >= course.getCapacity()) { return Result.fail("课程已满"); } enrollmentMapper.insert(studentId, courseId); courseMapper.increaseSelectedCount(courseId);两个请求同时读到selectedCount=49,while capacity=50,都通过判断,然后都执行插入和更新,最终这门课的已选人数会变成51,超卖了。
我最终用的是“数据库悲观锁 + 唯一约束”双层方案。选课核心逻辑里,先通过SELECT ... FOR UPDATE锁住课程行:
@Transactional public Result enroll(Long studentId, Long courseId) { // 锁住课程记录,期间其他事务的更新会等待 Course course = courseMapper.selectByIdForUpdate(courseId); if (course == null || course.getStatus() != 1) { return Result.fail("课程不存在或不可选"); } if (course.getSelectedCount() >= course.getCapacity()) { return Result.fail("课程容量已满"); } try { enrollmentMapper.insert(studentId, courseId); } catch (DuplicateKeyException e) { return Result.fail("你已选择该课程,请勿重复提交"); } courseMapper.increaseSelectedCount(courseId); return Result.success(); }SELECT ... FOR UPDATE会让同一时刻只有一个事务能读到并更新这条课程记录,其他人必须等锁释放,从根源上解决了超卖。同时uk_student_course唯一约束兜底,防止同一个学生疯狂点击导致重复插入。这里有个前提是方法必须处于事务中,所以@Transactional注解不能少,否则数据库会自动提交,锁会立刻释放。
乐观锁方案我也考虑并测试过,就是在course表加version字段,更新时判断版本号。它的优点是不持锁、并发性能高,但实现起来要处理更新失败重试逻辑。在选课场景里,同一个课程行的更新频率非常高,悲观锁的等待时间远小于重试成本,所以我选择了悲观锁。如果你的系统是微服务多实例部署,那么JVM内部的synchronized不会起效,数据库锁或者Redis分布式锁才是正确的选择。
3.2 问卷提交接口与防重复设计
问卷提交接口的难点不在业务复杂度,而在识别“重复提交”。学生可能因为网络慢、手滑、或者想反复修改答案而多次请求。我的设计思路是把防重压力交给数据库唯一约束,而不是在应用层用状态判断。
前端提交的数据结构大致是:问卷id、问题id、选项id、备注文本。后端接收后用DTO来绑定参数:
@PostMapping("/submit") public Result submit(@RequestBody SurveyAnswerSubmitDTO dto) { Survey survey = surveyMapper.selectById(dto.getSurveyId()); if (survey == null || survey.getStatus() != 1) { return Result.fail("问卷不存在或已关闭"); } LocalDateTime now = LocalDateTime.now(); if (now.isBefore(survey.getStartTime()) || now.isAfter(survey.getEndTime())) { return Result.fail("不在问卷开放时间内"); } try { surveyAnswerMapper.insert(dto); } catch (DuplicateKeyException e) { return Result.fail("你已提交过该问卷,请勿重复提交"); } return Result.success(); }很多人忽略的细节是时间窗口校验。问卷的开放时间必须放在服务端校验,不能只靠前端按钮禁用。我见过一个同类系统只在前端判断时间,学生直接改系统时间或者用接口工具就能绕过限制,问卷结果被严重污染。除了时间窗口,匿名性的实现也需要注意:问卷答案表里存了student_id,但统计接口返回给管理员的只有选项分布,绝不能直接把答案明细暴露出去。为了避免管理员误拉明细,统计查询时根本不去关联student表。
3.3 统计报表接口
统计报表是管理员最看重的功能,也是代码最容易写成垃圾的地方。核心统计场景有两个:某门课每个选项有多少人选择;某个院系的学生选课偏好分布如何。
第一个场景直接对survey_option和survey_answer做左连接聚合即可:
@GetMapping("/statistics/option") public Result<List<OptionStatVO>> optionStatistics(Long surveyId) { return Result.success(surveyAnswerMapper.statisticsByOption(surveyId)); }对应的Mapper SQL:
SELECT so.id AS optionId, so.option_text AS optionText, COUNT(sa.id) AS answerCount FROM survey_option so LEFT JOIN survey_answer sa ON sa.option_id = so.id WHERE so.survey_id = #{surveyId} GROUP BY so.id, so.option_text ORDER BY so.option_order用LEFT JOIN而不是INNER JOIN,是为了避免某个选项一个学生都没选时不出现在结果里。统计接口最忌讳“结果集缺行”,管理员会认为数据不对。第二个场景需要多表关联,把student表的department字段和选课记录表关联起来,按院系分组统计选课数量。这类统计在数据量小时看不出问题,数据量大了就很吃索引。我给enrollment表的student_id、course_id都建了索引,同时在department字段上建了索引,让GROUP BY能走覆盖索引。这里的一个附加建议是,如果统计维度增多,比如按年级、按学号前缀、按课程类型筛选,条件组合会越来越多,SQL优化会变得很痛苦。后期如果真到这一步,可以考虑用定时任务把统计结果预聚合到一张独立的统计表里,查询直接查这张表,响应速度会快很多。我在这个项目里是先用了MySQL慢查询日志定位到几个低效SQL,再用EXPLAIN逐一优化,而不是一上来就引入重型OLAP组件。
4. 别急着上Flowable:工作流引擎在这个项目里为什么是多余的
这个话题得单独拎出来说,因为我做架构选型时,确实在调研阶段认真考虑过是否要在选课流程里引入Flowable。看了很多资料后我最终放弃了,而且是越做越觉得这个决定正确。SpringBoot集成Flowable确实不算难,加上flowable-spring-boot-starter后,自动部署、流程画布、历史任务这些能力都有,但它解决的是另一个量级的问题。
Flowable这类工作流引擎的核心价值是管理那些包含人工任务、网关判断、会签或签、并行分支的复杂流程。典型例子是OA里的请假审批:发起申请、部门主管审批、HR核对、总经理审批、财务备案,每个节点有不同受理人,有回退、有转办、有超时提醒。这种流程如果全部用状态字段自己写,状态机的复杂度会迅速失控。但选课调查系统里的流程是直线型的:发布课程、学生选课、开放问卷、回收问卷、生成统计。过程中没有多级审批,没有任务分配流转,没有驳回重报,用几个状态字段加日期校验就完全能覆盖。
引入Flowable的实际代价是巨大的。第一次启动它会自动创建几十张ACT_开头的表,这些表对业务是不可见的,但会显著增加数据库管理的复杂度。流程定义需要学习BPMN XML或者用在线设计器画图,团队成员都得理解流程引擎的概念。在这样一个以CRUD和统计为主的项目里,让所有开发人员额外学习一套工作流框架,收益几乎为零,成本却非常明确。
我的判断标准是:如果业务流程里有超过两个人工审批节点,或者节点之间存在条件分支和回跳,才值得考虑工作流引擎。选课系统里最接近“流程”的概念就是“选课—问卷—统计”这个时间线,但它完全可以由开课时间、选课截止时间、问卷开始时间、问卷结束时间这几个字段来控制。这里的核心设计思想是“用数据而非流程来描述业务状态”,系统里的时间字段越多、状态机越简单,代码就越容易排查。
当然,如果业务方明确说以后要加多级审核、要支持课程审批流程、要支持管理员多人协作审核,那Flowable就派得上用场了。但即便如此,也应该把它作为一个独立的模块演进,而不是在V1.0版本就铺开。
5. 配置和部署中的实际问题:从application.yml到Tomcat
SpringBoot项目里,很多人觉得配置就是写一个application.yml,数据库地址、用户名密码、端口号填上就行。实际上配置环节藏着不少坑,尤其是需要区分开发环境和生产环境时。我这次用的是多环境配置文件方案:application.yml放公共配置,application-dev.yml和application-prod.yml分别放各自的差异化配置。启动时通过spring.profiles.active来激活对应环境。
5.1 两份配置文件的差异细节
开发环境的配置重点在方便调试:日志级别设为DEBUG,可以看到SQL执行细节;连接池的空闲连接可以设小一些,避免本地数据库连接数被占满;MySQL的SQL日志打印用mybatis-plus的日志实现,或者直接在配置里开启logging.level的SQL包为DEBUG。
生产环境配置则要谨慎得多:日志级别调整到INFO,数据库连接池要限制最大连接数,避免高峰期把数据库打崩;项目打包后用java -jar启动时加上JVM参数限制堆内存;启用Spring Boot的Actuator监控,但要注意把敏感端点暴露权限关掉。我最开始在配置里开放了所有Actuator端点,结果被安全扫描工具提示存在信息泄露风险。这是真实教训,不是理论说教。
多环境配置还有一个隐蔽问题:密码等敏感信息直接写在application-prod.yml里是有隐患的。虽然选课系统只在校园网内部部署,风险相对可控,但如果有条件,还是建议用环境变量占位符代替明文密码,比如写成${DB_PASSWORD},然后在部署环境的系统变量里注入。这样至少保证配置文件即使泄露,也不会直接暴露数据库密码。
5.2 部署时的几个注意点
部署选课系统时我踩过一个印象深刻的坑:直接用默认的Spring Boot内嵌Tomcat对外提供服务,结果学生选课时并发一上来,偶尔会出现连接重置。排查后发现是Tomcat默认最大工作线程数是200,选课高峰期的大量请求把线程池占满,后续请求直接排队或超时。在本地测试时根本不会有这种压力,只有真实环境才能暴露。
解决办法是在配置里调整内嵌Tomcat的连接器参数:
server: port: 8080 tomcat: threads: max: 400 accept-count: 200 max-connections: 10000线程数不是越大越好,因为每个线程都占内存,开太多反而会增加GC压力。400这个值是结合服务器CPU核数(16核)和单个请求的平均处理时间(约50ms)计算的,算下来400个线程基本能满足高峰期需求。如果想更精细,还可以用并发压测工具跑一遍选课接口,观察TP99延迟和错误率来调整。
部署时还有一个容易忽略的问题是文件上传大小限制。虽然选课调查系统不太需要上传文件,但管理员可能在问卷里配置图片选项。Spring Boot默认的单文件上传限制是1MB,这个值在application.yml里如果不显式配置,一旦前端上传超过1MB的图片就会报错。我后来在配置里加了这个参数,并提醒管理员图片不要超过5MB,既满足需求又避免内存压力。
数据库连接池也值得一提。Spring Boot 2.x之后默认使用HikariCP,这个连接池本身性能非常好,但配置不当也可能出问题。我遇到过连接池最大连接数设置过小,导致高峰期从连接池获取连接超时。最终把maximum-pool-size设为50,minimum-idle设为10,同时设置了connection-timeout为30000毫秒,避免极端情况下无限等待。
6. 开发过程中踩过的坑与最终优化方案
这一节是我最想写的部分。选课调查系统本身的业务代码难度有限,真正折磨人的是那些“程序能跑,但数据不对”的隐蔽问题。我把整个过程里的关键排查链路分享出来,希望能帮你少走弯路。
6.1 数据库时区配置引发的数据错乱
系统上线后第一次回收问卷,管理员发现统计数据里的时间全部差了8个小时。比如学生实际提交问卷的时间是下午三点,数据库里存的时间却是晚上十一点,当天提交的问答被归到了前一天晚上。一开始我怀疑是后端代码问题,于是打印了Java里LocalDateTime.now()和系统时间,发现都没问题。后来才反应过来,问题出在JDBC连接串上。
MySQL 8.0的默认时区是UTC,如果JDBC URL里没有显式指定serverTimezone,驱动连接时就会使用数据库的默认时区。而我的后端服务器用的是中国标准时间(东八区),这样插入和读取时就会出现8小时的偏差。解决办法是在JDBC连接串上明确指定:
spring: datasource: url: jdbc:mysql://localhost:3306/course_survey?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai username: root password: ${DB_PASSWORD}这个问题很容易被忽略,因为本地开发时如果你的MySQL和代码在同一台机器上,并且系统时区都是北京时区,数据库连接时可能会自动适配,测试不出问题。换到云服务器或Docker容器环境后,时区不一致的情况非常常见。现在我做任何数据库项目,都会第一时间在连接串里显式指定时区。
6.2 事务边界过大的问题
选课接口优化完之后,我又犯了一个典型的Spring事务错误。最初我想在选课成功后顺便给学生的邮箱发一封选课确认邮件,于是把邮件发送逻辑直接写在了同一个事务方法里。结果有一次邮件服务不稳定,导致整个选课事务回滚,学生的选课记录没保存成功,系统还报了异常。
这个问题的本质是事务边界设计不合理。事务的职责是保证数据库操作的一致性,但邮件发送是外部IO,不应该纳入数据库事务的边界内。后来我把邮件发送从核心事务中移除了,改成事务提交成功后,把待发送消息写入一张task表,由单独的定时线程池异步消费发送邮件。这样即使邮件服务挂了,事务也已经提交,选课记录不会丢失。
Spring的@Transactional注解只在public方法上生效,这是很多面试题会考的点。我这次还发现,如果A方法调用同类B方法,而B方法加了@Transactional,这个事务是不生效的,因为Spring的事务是通过AOP代理实现的,同类内部调用走的是this对象,不是代理对象。这个坑在面试里常被问到,实际开发中也会遇到。如果确实需要保证内部方法的事务性,可以把B方法拆到另一个Service类里,或者通过注入自身代理来实现。
6.3 管理员查询接口慢的问题
第三个坑是管理员的选课明细查询接口。随着测试数据和真实数据积累,enrollment表已经接近十万行,管理员按课程、按院系筛选时,响应时间从最初的几百毫秒飙升到了三秒以上。用EXPLAIN分析后,发现查询走了全表扫描,因为没有命中我预期中的索引。
后来我调整了索引策略:在enrollment表的student_id和course_id之外,增加了status字段的索引,并优化了查询条件,把状态过滤下推到索引层面。同时给课程表的course_type和教师名称增加了组合索引,管理员按类型筛选时会走这个索引。优化之后,相同查询时间降到了200毫秒以内。
这里我的体会是,数据量小的阶段,什么查询都快,不用过度优化;数据量上来之后,通过慢查询日志找出真实的瓶颈,再做针对性优化。不要一开始就为了“性能”建一堆索引,索引过多反而会拖慢写操作。
6.4 登录认证与JWT实践
最后补一个认证方面的小坑。Spring Security加JWT的组合在SpringBoot项目里很常见,但配置顺序和过滤器链的顺序必须弄对。我第一版把JWT过滤器加在了UsernamePasswordAuthenticationFilter前面,结果导致所有请求都先走JWT解析,也没有放行登录接口和静态资源,前端页面直接白屏。
排查后发现是SecurityConfig里http.authorizeRequests()的放行规则配置顺序有问题。Spring Security的匹配规则是按顺序执行的,我应该把/login接口和静态资源放行放在前面,再配置其他接口需要认证。调整之后登录、浏览课程、提交问卷这些流程就正常了。JWT的过期时间我也做了区分:登录token有效期设为两小时,refresh token设置为七天,避免学生一节课还没上完就过期。
做选课调查系统这段时间,最大的收获其实是理解了“框架解决什么、业务解决什么、数据库兜底什么”这三者的边界。SpringBoot提供了快速构建和自动配置能力,让团队不用处理繁琐的XML配置;业务代码要解决的是真实的业务规则和用户操作流;而数据库层以约束和事务机制兜住并发和异常场景。这套系统虽然规模不大,但把这三层关系理清了,再往后的项目里我能少走很多弯路。
最后分享一个实用小技巧:在选课高峰期前,我会提前把课程信息加载进本地缓存,并定时刷新,减少查询课程列表时的数据库压力。具体的实现不复杂,用Spring的@Cacheable加Redis或者本地Caffeine都能做。如果只是短时间峰值,可以用Caffeine做本地缓存,部署简单,也不需要考虑Redis运维,实测对选课列表接口的响应速度提升非常明显。这个小改动逻辑简单,但收益很直接。