1. 选课系统为什么值得用Spring Boot从头写一遍每年到了选课季教务系统的崩溃几乎成了固定节目。几千人同时点“选课”按钮页面转圈转到天荒地老刷新之后要么课程满了要么系统直接报错。这不是学校服务器不行而是选课这个场景天然就是高并发写入的典型战场——热门课程的名额可能只有几十个但盯着它的人有几百上千。我拿这个项目练手最初的想法很简单把选课系统当成一个高并发场景的试验田。它不像电商秒杀那么极端但麻雀虽小五脏俱全涉及用户认证、课程管理、名额扣减、并发控制、数据一致性这些核心问题。用Spring Boot来做是因为它的生态足够成熟JPA处理数据建模很顺手配合MySQL能快速搭出一个可运行的原型然后再逐步往里面加并发控制的逻辑。这篇文章适合谁看如果你已经写过几个Spring Boot的增删改查接口想找一个有实际业务复杂度的项目来练手选课系统是个很好的选择。如果你正在准备Java相关的面试高并发防超卖这个话题几乎是必问的但很多人只背了八股文没真正动过手。我会把整个项目的设计思路、数据建模、并发方案选型、代码实现和踩坑经验都摊开来讲你可以直接照着复现。技术栈方面我选的是Spring Boot 3.x Spring Data JPA MySQL 8.0前端用最朴素的Thymeleaf模板不引入前后端分离的复杂度。为什么用JPA而不是MyBatis-Plus后面会专门聊这个选型问题。整个项目的核心难点不在CRUD而在于如何保证在并发请求下课程名额不会被超卖同时系统还能保持可接受的吞吐量。2. 数据建模选课系统的骨架怎么搭2.1 核心实体识别与关系梳理选课系统的数据模型看起来简单但真动手设计的时候有几个地方很容易踩坑。最核心的实体有三个学生Student、课程Course、选课记录Enrollment。学生和课程之间是多对多关系选课记录就是这张关系表的实体化。为什么要把选课记录单独建一个实体而不是直接用JPA的ManyToMany注解因为选课记录本身有业务属性——选课时间、成绩、状态已选/已退/已完成。用ManyToMany的话中间表是自动生成的你没法往里面加字段。所以老老实实建一个Enrollment实体用两个ManyToOne分别指向Student和Course这是更可控的做法。课程表里有一个关键字段remaining_capacity剩余名额。这个字段是防超卖的核心。每次选课成功这个值减一退课时加一。听起来简单但并发场景下多个线程同时读到同一个值然后各自减一写回去就会出问题。这就是典型的“读-改-写”竞态条件。还有一个容易被忽略的字段course_status。课程有“未开放”“开放选课”“已满”“已结束”几种状态。不要只靠remaining_capacity来判断课程是否可选因为有些课程可能因为其他原因被管理员手动关闭。状态字段和名额字段要配合使用。2.2 表结构设计与索引策略先看学生表。除了基本的id、name、student_no学号、password之外我加了一个version字段用于乐观锁。这个后面讲并发控制的时候会详细说。student_no上建唯一索引这是登录凭证不能重复。课程表的设计要点比较多。course_code建唯一索引course_name建普通索引方便搜索。remaining_capacity和total_capacity都是int类型不要用varchar存数字这是基本素养。teacher_name字段冗余存储教师姓名而不是关联教师表因为在这个项目里教师信息不需要独立管理冗余可以减少一次join。选课记录表是重点。student_id和course_id分别建索引同时这两个字段组合起来建一个唯一索引。这个唯一索引的作用是防止同一个学生对同一门课重复选课。注意这里说的是“防止重复选课”不是“防止超卖”两者是不同的层面。唯一索引是数据库层面的最后一道防线但你不能完全依赖它来处理并发因为唯一索引冲突会抛异常在高并发下大量异常会影响性能。选课时间字段用datetime类型默认值设为CURRENT_TIMESTAMP。这里有个小细节MySQL 8.0之前datetime的默认值不支持函数只能用timestamp。如果你用的是MySQL 5.7记得把字段类型改成timestamp或者升级到8.0。关于索引还有一个经验不要过度索引。选课记录表上除了主键索引、唯一索引和两个外键索引之外不要再加其他索引了。因为选课记录是写入密集型的表每多一个索引写入时就多一次索引维护的开销。我见过有人在选课记录表上给选课时间建索引结果写入性能直接掉了一半。2.3 JPA实体映射的细节处理用JPA做实体映射的时候有几个地方需要特别注意。首先是Version注解加在Course实体的version字段上这是JPA乐观锁的标准做法。每次更新Course记录时JPA会自动在where条件里带上version值如果version不匹配就抛OptimisticLockException。然后是Column的nullable和length属性。不要偷懒不写虽然JPA有默认值但显式声明能让代码更清晰也方便后续用Schema生成工具。比如course_name字段我设了length 100nullable false。remaining_capacity设了nullable false并且用columnDefinition int default 0指定默认值。还有一个坑JPA的懒加载。Student和Course在Enrollment里都是ManyToOne默认的fetch策略是EAGER。这意味着每次查Enrollment都会顺带把Student和Course查出来。在选课列表页面这会导致N1查询问题。我的做法是改成LAZY然后在需要的地方用JOIN FETCH手动加载。比如查某个学生的选课记录时用Query(select e from Enrollment e join fetch e.course where e.student.id :studentId)这样一条SQL就能把课程信息带出来。关于spring data jpa和mybatis-plus的区别这里多说一句。JPA的优势在于实体关系映射和自动化的CRUD适合领域模型比较清晰的项目。MyBatis-Plus的优势在于SQL可控性强适合复杂查询多的场景。选课系统里写操作选课、退课是核心读操作相对简单所以JPA更合适。而且JPA的乐观锁支持是开箱即用的MyBatis-Plus需要自己实现。3. 高并发防超卖从乐观锁到Redis预扣减3.1 超卖问题的本质与场景分析先把这个问题的本质说清楚。假设课程A剩余名额是1现在有两个学生同时点选课。两个请求几乎同时到达服务器各自开启一个事务。事务1读到remaining_capacity 1事务2也读到remaining_capacity 1。然后事务1执行update把remaining_capacity改成0提交。事务2也执行update把remaining_capacity改成0提交。结果就是两个人都选上了但名额只有1个超卖了。这个问题的根源在于“读”和“写”之间没有原子性。在数据库层面默认的事务隔离级别是REPEATABLE READMySQL默认但即使在这个级别下普通的select语句读到的仍然是快照数据不会阻止其他事务的修改。要解决这个问题有几种思路。第一种是悲观锁用select ... for update把行锁住。事务1锁住课程行之后事务2的select会被阻塞直到事务1提交。这样确实能防止超卖但问题是并发性能差。如果热门课程有几百人同时抢所有人都排队等锁响应时间会急剧上升甚至导致大量请求超时。第二种是乐观锁用version字段或者直接比较remaining_capacity的值。update的时候带上条件update course set remaining_capacity remaining_capacity - 1 where id ? and remaining_capacity 0。这样即使两个事务同时读到1只有一个update能成功另一个update影响行数为0就知道名额已经被抢了。乐观锁的并发性能比悲观锁好很多因为不需要排队等锁失败的请求直接返回“名额已满”就行。第三种是在应用层用Redis做预扣减。所有选课请求先到Redis用decrement命令原子性地减名额。如果减完之后的值小于0说明名额不够直接返回失败。如果减成功再异步写入数据库。这种方式性能最好但引入了一致性问题——Redis和MySQL的数据可能不一致需要额外的补偿机制。3.2 乐观锁方案的具体实现我最终选的是乐观锁方案因为它在实现复杂度和性能之间取得了比较好的平衡。具体做法是在Course实体上加Version字段然后在Service层捕获OptimisticLockException。但这里有个细节JPA的Version乐观锁是在实体更新时自动生效的它比较的是version字段而不是remaining_capacity。这意味着如果两个事务同时读到version 5事务1更新成功version变成6事务2更新时发现version不匹配抛异常。这个机制是有效的但它有一个副作用任何对Course实体的更新都会触发版本检查包括修改课程名称、教师信息这些和名额无关的操作。在高并发选课期间如果管理员同时修改课程信息就会产生不必要的冲突。所以我用了另一种更直接的方式在Repository层自定义一个update方法用Modifying注解写原生SQL。Modifying Query(update Course c set c.remainingCapacity c.remainingCapacity - 1 where c.id :courseId and c.remainingCapacity 0) int decrementCapacity(Param(courseId) Long courseId);这个update语句在数据库层面是原子的。where条件里的remaining_capacity 0保证了不会减到负数。返回值是受影响的行数如果返回0说明名额已经没了直接抛业务异常。然后在Service层选课的逻辑是这样的Transactional public Enrollment enroll(Long studentId, Long courseId) { // 先检查是否已经选过 if (enrollmentRepository.existsByStudentIdAndCourseId(studentId, courseId)) { throw new BusinessException(你已经选过这门课了); } // 原子扣减名额 int affected courseRepository.decrementCapacity(courseId); if (affected 0) { throw new BusinessException(名额已满); } // 创建选课记录 Enrollment enrollment new Enrollment(); enrollment.setStudent(studentRepository.getReferenceById(studentId)); enrollment.setCourse(courseRepository.getReferenceById(courseId)); enrollment.setEnrollTime(LocalDateTime.now()); enrollment.setStatus(EnrollmentStatus.ENROLLED); return enrollmentRepository.save(enrollment); }这个方案的关键点在于扣减名额和创建选课记录在同一个事务里。如果创建选课记录失败事务回滚名额扣减也会回滚。这保证了数据的一致性。但这里还有一个隐藏的问题唯一索引冲突。如果两个请求同时通过了“是否已经选过”的检查然后都去扣减名额一个成功一个失败这没问题。但如果两个请求都扣减成功了比如课程名额充足然后都去创建选课记录唯一索引会阻止第二条记录插入抛DataIntegrityViolationException。这个异常需要捕获并转换成友好的提示。3.3 性能优化与限流策略乐观锁方案在名额充足的时候表现很好因为大部分请求都能成功。但在名额很少的时候大量请求会失败这些失败的请求也会消耗数据库连接和CPU。所以还需要在应用层做限流。我用了两种限流手段。第一种是令牌桶算法用Guava的RateLimiter。在选课接口的入口处每个用户每秒最多允许发起2次选课请求。超过这个频率的请求直接返回“操作过于频繁”。这个限流是按用户维度做的防止有人写脚本刷接口。第二种是信号量用Java的Semaphore控制同时进入选课逻辑的请求数量。我设的是50意味着最多同时有50个请求在竞争数据库资源。超出的请求会在队列里等待等待超过一定时间就返回“系统繁忙请稍后重试”。这个限流是全局的保护数据库不被压垮。还有一个优化点把课程列表的查询结果缓存到Redis里。课程列表是读多写少的数据每次选课页面刷新都要查一遍数据库没必要。用Spring Cache Redis设置5分钟的过期时间。当课程名额发生变化时主动清除对应的缓存。这样大部分读请求都走Redis数据库只需要处理写请求。关于nginx高并发如果这个系统部署到生产环境Nginx层面也可以做限流。用limit_req_zone指令按IP限制请求速率。但Nginx限流是粗粒度的它不知道哪个请求是选课请求哪个是查课程列表的请求。所以应用层的限流更精准两者可以配合使用。4. 完整实操流程从零搭建选课系统4.1 环境准备与项目初始化先列一下我用的环境版本JDK 21、Spring Boot 3.5.0、MySQL 8.0.36、Maven 3.9.6。为什么用JDK 21因为Spring Boot 3.x最低要求JDK 17而JDK 21是LTS版本支持虚拟线程。虚拟线程在处理高并发IO时很有优势虽然这个项目里IO不是瓶颈但提前用上没坏处。MySQL的安装配置这里不展开网上教程很多。重点提一下字符集建库的时候一定要指定utf8mb4否则学生姓名里的生僻字会乱码。还有时区问题MySQL默认用的是系统时区JPA连接的时候可能会差8小时。在JDBC URL里加上serverTimezoneAsia/Shanghai就能解决。项目初始化用Spring Initializr勾选这几个依赖Spring Web、Spring Data JPA、MySQL Driver、Thymeleaf、Spring Boot DevTools、Validation。Lombok可选我用了因为实体类的getter/setter写起来太啰嗦。application.yml的配置spring: datasource: url: jdbc:mysql://localhost:3306/course_selection?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai username: root password: your_password hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true连接池用HikariCPmaximum-pool-size设20。这个值不是越大越好MySQL默认的最大连接数是151但实际能承受的并发连接数受限于服务器配置。20个连接对于这个项目足够了设太大反而会导致数据库上下文切换开销增加。4.2 核心接口开发与参数校验选课接口的Controller层RestController RequestMapping(/api/enrollment) public class EnrollmentController { Autowired private EnrollmentService enrollmentService; PostMapping(/enroll) public ResponseEntity? enroll(RequestBody Valid EnrollmentRequest request) { try { Enrollment enrollment enrollmentService.enroll( request.getStudentId(), request.getCourseId()); return ResponseEntity.ok(new ApiResponse(true, 选课成功, enrollment.getId())); } catch (BusinessException e) { return ResponseEntity.badRequest() .body(new ApiResponse(false, e.getMessage(), null)); } } }EnrollmentRequest用Jakarta Validation注解做参数校验public class EnrollmentRequest { NotNull(message 学生ID不能为空) private Long studentId; NotNull(message 课程ID不能为空) private Long courseId; }参数校验看起来是小事但能挡住很多无效请求。比如studentId为null的请求如果不校验到了Service层会抛NullPointerException日志里一堆异常堆栈排查起来很烦。用Valid注解自动校验校验失败直接返回400干净利落。退课接口的逻辑和选课相反先删除选课记录然后增加课程名额。这里要注意顺序先删记录再加名额。如果反过来先加名额再删记录万一删记录失败名额就多出来了。虽然在一个事务里最终会回滚但先删后加的逻辑更符合直觉。课程查询接口用分页Spring Data JPA的Pageable用起来很方便GetMapping(/courses) public PageCourseVO listCourses( RequestParam(defaultValue 0) int page, RequestParam(defaultValue 10) int size, RequestParam(required false) String keyword) { Pageable pageable PageRequest.of(page, size, Sort.by(courseCode).ascending()); return courseService.findCourses(keyword, pageable); }这里返回的是CourseVO而不是Course实体因为实体里有version字段和一些内部字段不需要暴露给前端。VO里只包含课程编号、名称、教师、剩余名额、总名额这些展示需要的字段。4.3 并发测试与结果验证写完代码之后必须做并发测试。我用的是JMeter也可以用Apache Benchab。测试场景100个线程同时请求选课接口课程名额设为10。JMeter的配置线程数100Ramp-Up时间0秒瞬间并发循环次数1。HTTP请求指向选课接口Body Data里传studentId和courseId。注意每个线程的studentId要不一样否则会被“重复选课”的逻辑挡住。可以用JMeter的CSV Data Set Config从文件读取学生ID。测试结果100个请求中10个返回“选课成功”90个返回“名额已满”。数据库里查选课记录正好10条。课程表的remaining_capacity是0。没有超卖。然后测试退课场景10个学生同时退课课程名额从0变回10。数据库里选课记录清空remaining_capacity恢复为10。再测一个边界场景课程名额为1两个请求同时到达。结果应该是一个成功一个失败。这个场景用JMeter不好模拟因为两个请求的到达时间很难精确控制。我写了一个简单的Java测试类用CountDownLatch让两个线程同时发起请求Test public void testConcurrentEnroll() throws Exception { int threads 2; CountDownLatch latch new CountDownLatch(threads); ExecutorService executor Executors.newFixedThreadPool(threads); AtomicInteger successCount new AtomicInteger(0); AtomicInteger failCount new AtomicInteger(0); for (int i 0; i threads; i) { final Long studentId (long) (i 1); executor.submit(() - { try { latch.countDown(); latch.await(); enrollmentService.enroll(studentId, 1L); successCount.incrementAndGet(); } catch (BusinessException e) { failCount.incrementAndGet(); } }); } executor.shutdown(); executor.awaitTermination(10, TimeUnit.SECONDS); assertEquals(1, successCount.get()); assertEquals(1, failCount.get()); }这个测试跑了很多次结果都是1成功1失败没有出现过2个都成功的情况。说明乐观锁方案是有效的。5. 踩坑记录与常见问题排查5.1 事务失效与异常捕获的坑第一个大坑Transactional注解失效。我一开始把选课逻辑写在一个private方法里然后从同一个类的public方法调用它。结果发现事务根本没生效扣减名额之后如果创建选课记录失败名额没有回滚。原因很简单Spring的Transactional是基于AOP代理的同一个类内部的方法调用不会走代理所以注解不生效。解决办法是把选课逻辑抽到单独的Service类里或者用AopContext.currentProxy()获取代理对象。我选了前者把EnrollmentService拆成两个类一个负责事务逻辑一个负责业务编排。第二个坑异常被吞掉。在Service层捕获了DataIntegrityViolationException之后如果没有重新抛出事务不会回滚。因为Spring默认只对RuntimeException回滚如果你catch了异常然后没抛出去Spring以为方法正常执行完了就提交事务。所以catch块里要么重新抛一个RuntimeException要么手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。第三个坑乐观锁异常的处理。JPA抛出的OptimisticLockException是RuntimeException会被Spring的事务管理器捕获并回滚。但如果你在Service层catch了这个异常然后返回了一个错误信息事务同样不会回滚。正确的做法是让异常抛到Controller层在Controller层用ExceptionHandler统一处理。5.2 数据库连接池与超时配置HikariCP的connection-timeout默认是30秒这个值太长了。在高并发场景下如果连接池满了请求会等30秒才返回超时用户体验极差。我改成了3秒超过3秒拿不到连接就直接返回“系统繁忙”。还有一个参数maximum-pool-size。我一开始设了50结果MySQL那边经常报“Too many connections”。后来查了一下MySQL的max_connections默认是151但实际能承受的并发连接数受限于内存和CPU。每个连接大约占用256KB到几MB的内存50个连接就是几十MB。对于开发机来说20个连接足够了。另外JPA的hibernate.jdbc.batch_size参数也值得关注。默认是0意味着每条SQL单独发送。如果批量插入选课记录可以设成50这样Hibernate会把多条insert语句合并成一条批量插入。不过选课场景下每次只插入一条记录这个参数意义不大。5.3 常见问题速查表问题现象可能原因排查方法解决方案选课成功但名额没减事务未生效检查Transactional是否在public方法上是否被同类内部调用抽到独立Service类或使用AopContext名额减了但选课记录没创建异常被吞掉检查catch块是否重新抛出异常重新抛出RuntimeException或手动回滚并发测试出现超卖扣减语句不是原子的检查update语句的where条件确保where里有remaining_capacity 0重复选课唯一索引未生效检查数据库表的唯一索引是否存在在student_id和course_id上建联合唯一索引接口响应慢连接池太小或SQL慢查看HikariCP的活跃连接数和慢SQL日志调整连接池大小优化SQL加索引乐观锁冲突频繁版本号更新太频繁查看OptimisticLockException的日志频率考虑改用Redis预扣减方案5.4 独家避坑技巧第一个技巧在选课接口上加一个“幂等令牌”。前端每次打开选课页面时先请求一个token选课的时候带上这个token。后端用Redis的setnx命令检查token是否已经使用过如果已经使用过就拒绝请求。这能防止用户重复点击按钮导致的重复选课。第二个技巧课程名额的扣减和选课记录的创建不要放在同一个事务里。听起来反直觉但这样做有好处。如果放在同一个事务里扣减名额的update语句会持有行锁直到事务提交这段时间内其他请求的update会被阻塞。把扣减名额单独放在一个短事务里提交之后再创建选课记录能减少锁的持有时间。当然这样做需要处理“扣减成功但创建记录失败”的情况可以用一个补偿任务来恢复名额。第三个技巧监控选课接口的P99响应时间。如果P99超过500ms说明系统已经接近瓶颈了。这时候可以考虑加缓存、加限流、或者升级数据库配置。不要等到系统崩了才去查问题。第四个技巧在课程表里加一个enroll_start_time和enroll_end_time字段控制选课的时间窗口。不在时间窗口内的请求直接拒绝不进入扣减逻辑。这能挡住很多无效请求减轻系统压力。6. 从单机到集群这个项目还能怎么扩展单机版的选课系统跑通了但真实场景下肯定是多台服务器组成的集群。集群环境下乐观锁方案会遇到新的问题多个JVM实例同时更新同一行数据数据库层面的行锁会成为瓶颈。一个可行的扩展方向是引入Redis做分布式锁。用Redisson的RLock在扣减名额之前先获取锁锁的key是课程ID。拿到锁的实例才能执行扣减操作其他实例等待。但分布式锁的性能不如数据库行锁因为多了一次网络往返。所以更好的方案还是Redis预扣减所有实例都去Redis里decrementRedis是单线程的天然原子。减成功之后再异步写入数据库。另一个扩展方向是读写分离。课程列表查询走从库选课写操作走主库。Spring Boot里可以用AbstractRoutingDataSource实现动态数据源切换。但读写分离会带来主从延迟问题刚选完课的学生可能查不到自己的选课记录。解决办法是选课成功之后强制走主库查询一次或者在前端做乐观更新。还有一个方向是引入消息队列。选课请求先写入Kafka然后由消费者异步处理。这样接口的响应时间可以做到毫秒级用户体验很好。但问题是用户不知道选课是否成功需要轮询或者WebSocket推送结果。而且消息队列的引入增加了系统的复杂度对于课程设计级别的项目来说有点过度设计。我个人觉得如果只是做课程设计或者面试项目单机版的乐观锁方案已经足够展示你对高并发问题的理解。如果要在简历里写“高并发选课系统”面试官大概率会问你的QPS是多少怎么测的瓶颈在哪里怎么优化这些问题需要你真正动手跑过测试才能回答。关于Java面试题里常问的“高并发场景下如何保证数据一致性”这个项目就是一个很好的例子。你可以从数据库事务、乐观锁、Redis原子操作、分布式锁这几个层面去回答每个层面都有具体的代码和测试数据支撑比干巴巴地背八股文有说服力得多。最后分享一个我在调试并发问题时常用的方法在扣减名额的update语句后面加一行日志打印受影响的行数。如果发现某个请求的受影响行数是0但返回给用户的是“选课成功”那就说明逻辑有问题。这个简单的日志帮我定位过好几次bug比看堆栈信息直观多了。
阅读完成 · 觉得有帮助?