尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SpringBoot选课调查系统实战:并发控制、事务边界与数据库设计全解析

SpringBoot选课调查系统实战:并发控制、事务边界与数据库设计全解析 从选课季的系统里爬出来后我一直想找机会把这段“基于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);两个请求同时读到selectedCount49while capacity50都通过判断然后都执行插入和更新最终这门课的已选人数会变成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 ResultListOptionStatVO optionStatistics(Long surveyId) { return Result.success(surveyAnswerMapper.statisticsByOption(surveyId)); }对应的Mapper SQLSELECT 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到TomcatSpringBoot项目里很多人觉得配置就是写一个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设为50minimum-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?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/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运维实测对选课列表接口的响应速度提升非常明显。这个小改动逻辑简单但收益很直接。
返回列表