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 = 100,nullable = false。remaining_capacity设了nullable = false,并且用columnDefinition = "int default 0"指定默认值。
还有一个坑:JPA的懒加载。Student和Course在Enrollment里都是@ManyToOne,默认的fetch策略是EAGER。这意味着每次查Enrollment,都会顺带把Student和Course查出来。在选课列表页面,这会导致N+1查询问题。我的做法是改成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 READ(MySQL默认),但即使在这个级别下,普通的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里加上serverTimezone=Asia/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?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/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连接池用HikariCP,maximum-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 Page<CourseVO> 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 Bench(ab)。测试场景:100个线程同时请求选课接口,课程名额设为10。
JMeter的配置:线程数100,Ramp-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里decrement,Redis是单线程的,天然原子。减成功之后再异步写入数据库。
另一个扩展方向是读写分离。课程列表查询走从库,选课写操作走主库。Spring Boot里可以用AbstractRoutingDataSource实现动态数据源切换。但读写分离会带来主从延迟问题,刚选完课的学生可能查不到自己的选课记录。解决办法是选课成功之后,强制走主库查询一次,或者在前端做乐观更新。
还有一个方向是引入消息队列。选课请求先写入Kafka,然后由消费者异步处理。这样接口的响应时间可以做到毫秒级,用户体验很好。但问题是用户不知道选课是否成功,需要轮询或者WebSocket推送结果。而且消息队列的引入增加了系统的复杂度,对于课程设计级别的项目来说有点过度设计。
我个人觉得,如果只是做课程设计或者面试项目,单机版的乐观锁方案已经足够展示你对高并发问题的理解。如果要在简历里写“高并发选课系统”,面试官大概率会问:你的QPS是多少?怎么测的?瓶颈在哪里?怎么优化?这些问题需要你真正动手跑过测试才能回答。
关于Java面试题里常问的“高并发场景下如何保证数据一致性”,这个项目就是一个很好的例子。你可以从数据库事务、乐观锁、Redis原子操作、分布式锁这几个层面去回答,每个层面都有具体的代码和测试数据支撑,比干巴巴地背八股文有说服力得多。
最后分享一个我在调试并发问题时常用的方法:在扣减名额的update语句后面,加一行日志打印受影响的行数。如果发现某个请求的受影响行数是0,但返回给用户的是“选课成功”,那就说明逻辑有问题。这个简单的日志帮我定位过好几次bug,比看堆栈信息直观多了。