Spring Boot 3 + Vue 3 高校选修课系统:用 Redis + Lua 彻底解决选课超卖
开源教务系统实践 · 选课高并发 · 时间冲突判定 · 缓存一致性
一、为什么又做了一个选课系统
选课系统是典型的「简单业务 + 极端负载」:业务规则不难,难的是开放选课那一秒。
传统做法通常是两条路:一是数据库行锁 / 乐观锁硬扛,二是靠消息队列把请求堆起来异步处理。前者在热点课程上会把连接池打满,后者要额外维护 MQ,对小体量部署并不划算。
这个项目想验证的是第三条路:用 Redis 的原子性把「校验 + 扣减」压成一步,让绝大多数请求在 Redis 层就被裁决掉,只有真正拿到名额的请求才落库。顺带把教务场景里几个容易被忽略的细节补齐——时间冲突的四维判定、缓存与数据库的最终一致、成绩与评价的联动。
技术栈选的是 Java 21 + Spring Boot 3.3 + Vue 3 + Redis 7 + MySQL 8,前后端分离,另附一个原生微信小程序作为学生端。
二、技术栈
| 层 | 选型 |
|---|---|
| 后端 | Java 21 · Spring Boot 3.3.5 · Spring Security 6 · MyBatis-Plus 3.5.7 |
| 存储 | MySQL 8.0 · Redis 7.x(原子操作 / 缓存 / 令牌白名单) |
| 前端 | Vue 3.5 · Vite 5.4 · Element Plus 2.8 · Pinia · ECharts 5.5 |
| 小程序 | 原生微信小程序(WXML / WXSS / JS,无构建依赖) |
| 部署 | Docker Compose · Nginx |
三、系统展示
3.1 交互动画
选课中心 —— 课程列表、余量与选课窗口实时校验:

选课 / 退课全流程
课表查看 —— 周课表渲染,点击课程卡片查看上课详情:

课表与课程详情
管理员数据概览 —— 统计图表与实时数据:

数据概览
3.2 页面截图

登录页

管理员 · 数据概览

管理员 · 学期与选课窗口设置

管理员 · 课程管理

学生 · 选课中心

学生 · 课表

学生 · 成绩与绩点

教师 · 我的授课

学生 · 教学评价

教师 · 评价分析

管理员 · 评价审核与统计

管理员 · 操作日志审计
四、核心难点一:选课超卖怎么彻底杜绝
问题拆开看是两件不同的事,很多人把它们混为一谈:
- 超卖:两个人抢最后一个名额,结果都成功。
- 被打垮:上千请求同时进入「校验 → 落库 → 同步计数」,连接池打满。
前者是原子性问题,后者是吞吐问题,必须分别解决。
4.1 用 Lua 把四步压成一个原子操作
先看脚本(lua/select_course.lua):
local capacity = redis.call('GET', KEYS[1])
if capacity == false then return 3 end -- 缓存未就绪
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return 1 -- 已选过
end
if tonumber(capacity) <= 0 then return 2 end -- 余量不足
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
-- 两个 Key 同步续期,保持生命周期一致(ARGV[2] 为 TTL 秒数)
local ttl = tonumber(ARGV[2])
if ttl and ttl > 0 then
redis.call('EXPIRE', KEYS[1], ttl)
redis.call('EXPIRE', KEYS[2], ttl)
end
return 0 -- 预占成功
「查重 → 余量校验 → 扣减 → 记录已选」四步合并。借助 Redis 单线程执行脚本的特性,这四步之间不可能被其他请求插入,超卖在语义层面就不存在了,而不是靠事后补偿。
配合 course_selection 表上 (student_id, course_id, semester_id) 唯一索引作为数据库级最后一道闸。
4.2 落库失败必须归还名额
Redis 预占成功后还要写库,写库可能因任何原因失败(外键、超时、宕机)。此时名额已经被扣掉——如果不归还,这门课的余量会永久少一个。
完整流程因此设计成「预占 + 失败回滚」:
① 校验选课开关与时间窗口
② 校验课程状态(是否下架 / 结课)
③ 校验学分上限
④ 时间冲突预检(与已选课程逐条比对)
⑤ Lua 脚本原子预占(Redis)
⑥ 预占成功 → 写入选课记录(数据库事务)
⑦ 同步数据库 selected_count
任一步失败 → 回滚 Redis 预占(归还余量 + 移除已选标记)
4.3 削峰:用时间换算叫号,而不是定时任务
如果确实需要在极高峰期限制进入核心流程的并发,可以打开可选的排队开关。这里有个小设计:叫号进度不是由定时任务推进的,而是用时间算出来的。
入队: seq = INCR(queue_seq)
叫号: currentSeq = (now - qstartAt) / 1000 × queuePerSecond
放行: mySeq <= currentSeq
好处是任何实例在任意时刻算出的进度都一致,天然支持多实例,不需要分布式协调。
另一个刻意的取舍:排队令牌是软约束。无令牌时仍然放行(超卖已由 Lua 兜底),只有「有令牌但还没叫到」才拒绝。这样 Redis 抖动不会导致整个选课功能不可用。
五、核心难点二:时间冲突的四维判定
教务场景的「时间冲突」不是简单比较两个时间区间。一门课可能周一 1-2 节(1-16 周单周)在 A101,周三 3-4 节(1-8 周)在 B203。两条排课记录构成冲突,必须同时满足四个条件:
1. 同一天:day_of_week 相同
2. 节次重叠:startA ≤ endB 且 endA ≥ startB
3. 周次重叠:[startWeekA, endWeekA] 与 [startWeekB, endWeekB] 有交集
4. 周类型兼容:一方为「每周」则冲突;
一方/双方为「单双周」时,需存在同为奇偶的公共周次
第 4 条是最容易漏的:1-16 周单周的课,和 9-16 周单周的课,虽然周次有交集但都是单周,冲突;和 10-16 周双周的课,公共周次全是偶数 vs 全是奇数,不冲突。
判定用在两处:选课前预检(给用户即时反馈,避免白跑一遍流程)和落库前复检(防止预检与实际写入之间被并发插入)。
六、核心难点三:Redis 与数据库的最终一致
引入缓存做名额控制后,数据就分成了三份:数据库的 selected_count、Redis 的容量键、Redis 的已选集合。三者一定会漂移,问题只是漂移多久。
分三层兜:
| 层次 | 做法 |
|---|---|
| 预热 | 开放选课前管理员一键把余量与已选集合写入 Redis,避免开场时集中回源 |
| 回源 | 缓存缺失时按「短锁 + 自旋等待 + 双重检查」重建,防止击穿 |
| 兜底 | 定时任务每 5 分钟对「DB 计数 / Redis 余量 / Redis 已选集合」三方比对并校正 |
七、数据层:约束优先于代码校验
应用层校验总有被绕过的可能(并发、直连、脚本),所以关键业务规则一律在数据库再落一遍:
- 唯一索引:
course_selection上的三元组唯一键是防重复选课的最后一道闸;course_grade.selection_id、course_review.selection_id同理。 - 乐观锁:
course.version处理并发改课。 - 逻辑删除:除日志表外统一用
deleted字段,由 MyBatis-Plus 自动处理。
有一处例外值得单独说:course_review 不能用逻辑删除。它的唯一键是 selection_id(一次选课只能有一条评价),而逻辑删除只是给应用层看的「墓碑标记」——行还在,唯一键也还被占着。
冲突链路是这样的:
① 学生评价选课 #1001
INSERT course_review (selection_id=1001, deleted=0) 唯一键被占用
② 删除评价(逻辑删除)
UPDATE course_review SET deleted=1 WHERE selection_id=1001 行还在,1001 仍被占用
③ 重新评价(业务判断「我没评过」)
应用层查询:WHERE selection_id=1001 AND deleted=0 → 查不到 → 走 INSERT
INSERT course_review (selection_id=1001, deleted=0)
❌ Duplicate entry '1001' for key 'uk_selection_id'
矛盾点在第 ③ 步:应用层查的是「未删除视图」,数据库校验的是「物理全量视图」——一个说"没有",一个说"有"。全局异常处理器虽然会兜住 DuplicateKeyException 并转成 code = 5002「数据已存在」 的提示(HTTP 仍是 200),但语义已经错了:用户点的是「提交评价」,得到的是「数据已存在」——一条本应能成功的业务操作,被一个数据库约束拒绝,而前端无从解释这个提示从何而来。
三种修法的取舍:
| 方案 | 代价 |
|---|---|
唯一键加删除标记(selection_id, deleted) |
deleted 只有 0/1,第二次删除仍会撞键;同一 selection_id 最多只能存在 2 行 |
| 复用记录 | 重新评价时 UPDATE SET deleted=0 并覆盖内容。可行,但业务代码要区分「新建 / 复活」两条路径,易漏;且丢失历史留痕 |
| 物理删除(采用) | 语义即「一选课一评价,删掉就是没有」,无需额外设计 |
根本原因是 MySQL 不支持带 WHERE 条件的部分索引(PostgreSQL 的 partial index 可以),所以做不到「仅在 deleted=0 时唯一」。凡是有唯一约束的表,逻辑删除都要额外设计。
选物理删除的判断依据是业务语义:评价属于「可重建、无审计刚性要求」的内容(留痕由 sys_log 承担),删了就该真的没有。
反面参照更能说明问题:course_selection 有同样的三元组唯一键,却保留记录不删除——退课只把 status 置 0 并记录 drop_time(不动 deleted 字段),重新选课时复用同一行。因为要保留「曾选过」的审计事实。
status 与 deleted 的区别别混:前者是业务状态(学生能看见"已退选"),后者是行级墓碑(对业务不可见)。同是「唯一键 + 删除」,业务语义不同,策略就不同。
八、学生端小程序
miniprogram/ 是原生微信小程序(无构建依赖,微信开发者工具直接导入),复用后端既有接口,不需要额外部署服务。覆盖教学评价(提交 / 修改 / 撤回 / 汇总 / 趋势)、课表、成绩、公告与个人中心。
评价趋势的雷达图与折线图是 Canvas 手绘的,没引第三方图表库——小程序端为了这点图表引入整个 ECharts 包不划算。
九、踩坑与一处取舍
坑 1:Set 没有过期时间。 最初只给容量键设了 TTL,已选集合是 SADD 自动创建、永久有效。结果容量键过期回源重建后,残留的集合里还有早已退课的学生,SISMEMBER 命中,误判成「你已选过这门课」。修法是脚本里对两个键同步续期,保持生命周期一致。
坑 2:逻辑删除撞唯一键。 course_review 一开始也用了逻辑删除,结果用户「删除评价后重新评价」时收到 code 5002「数据已存在」——墓碑行还占着唯一键。这类表的删除语义必须是物理的。
取舍 3:业务异常统一返回 HTTP 200。 这套选型有意放弃 REST 的状态码语义:所有失败都是 HTTP 200,结果由 Result{code, message, success} 承载;未登录与无权限也分别落 code 401/403(RestAuthenticationEntryPoint 与 RestAccessDeniedHandler 手动置 200),而不是 HTTP 状态码。
好处是前端只需判断 code 一个分支,业务码还能域段化区分「已选过 / 学分超限 / 不在选课窗口」——HTTP 状态码只有 409、400 两个格子,给不了这么细。
代价也要认:APM 与 Nginx 日志里错误率恒为 0,基于状态码的监控报警失效;第三方与 curl 用 status < 400 判成功会误判。所以它只适合「内部系统 + 自家前端」,对外 API 不该这么用。
坑 4:缓存预热必须在开放前。 少了这一步,开放瞬间大量请求同时回源,短锁会排队,「缓存未就绪」的报错率飙升。预热不是优化项,是功能前置条件。
十、快速开始
先克隆仓库(两端代码一致,任选其一):
# Gitee
git clone https://gitee.com/huangrenhui/CollegeElectiveSystem.git
# GitHub
git clone https://github.com/HuangRenhui/CollegeElectiveSystem.git
然后二选一启动:
# 方式一:Docker Compose 一键启动
docker compose up -d --build
# 方式二:本地开发
mysql -uroot -p < scripts/init-db.sql # 基础数据 + 演示数据
cd backend && mvn spring-boot:run
cd frontend && npm install && npm run dev
访问 http://localhost 进入前端,http://localhost:8080/api/doc.html 是 Knife4j 接口文档。内置演示数据(20 名学生、8 名教师、20 门课程、261 条选课记录),登录即可看到完整效果。
演示账号:admin / T2026001 / 2026010101,密码均为 123456(生产环境务必改掉,并通过环境变量覆盖 JWT_SECRET)。
十一、小结
这个项目最值得说的不是「用了什么框架」,而是三处设计取舍:
- 把「超卖」和「被打垮」当成两个问题分别解决,而不是指望一个方案通吃;
- 缓存一致性不追求强一致,而是用「预热 + 回源 + 定时校正」把漂移控制在可接受范围;
- 关键规则落到数据库约束上,应用层校验只负责体验,不负责正确性。
完整源码、部署脚本与文档(架构设计、数据库设计、接口文档、功能实现说明)都在仓库里:
- Gitee(主仓库):https://gitee.com/huangrenhui/CollegeElectiveSystem
- GitHub(镜像):https://github.com/HuangRenhui/CollegeElectiveSystem
欢迎交流与指正,如果这篇对你有帮助,点个赞或 star 都是鼓励。
技术栈:Java 21 · Spring Boot 3.3.5 · Vue 3.5 · Redis 7 · MySQL 8 · Element Plus · 原生微信小程序
许可证:MIT
如果,您希望更容易地发现我的新博客,不妨点击一下左下角的【关注我】。
如果,您对我的博客所讲述的内容有兴趣,请继续关注我的后续博客,我是【码猿手】。

浙公网安备 33010602011771号