学生选课系统App开发:高并发下的数据一致性与事务设计
简介面向高校教务管理人员、教师与学生及移动开发学习者的学生选课系统完整项目包。系统围绕管理员课程维护、教师成绩录入、学生选课退课与个人信息管理展开覆盖教育信息化的典型业务流程适合课程设计、毕业设计或Android开发实战参考。压缩包共1193个文件体积28.23MB其中包含734个class编译文件、13个dex与12个apk可直接安装体验另有35个java源码、129个xml布局配置、png图片、json数据及gradle工程配置等便于从源码层面理解选课系统前后端交互与界面实现。已有442人学习下载。资源提供多种版本的apk安装包、完整源码与工程目录部分文件如rawproto协议文件、vsdx架构图还可辅助分析数据协议与结构设计有助于快速搭建同类管理系统并掌握移动端教务功能开发要点。1. 学生选课系统App先让选课规则跑起来再谈界面做过校园信息化项目的人都知道选课系统最大的麻烦不在App界面而在“同时抢课”那一刻的服务端表现。学生选课系统App本质上是把教务的选课规则搬进手机课程查询、培养方案校验、时间冲突检测、学分上限、退课释放名额、成绩公布后的退课截止。这些规则全部集中在“选课”这一个动作上App只是动作的触发终端。如果后台没有把并发选课和冲突校验做稳App做得再顺滑一到选课高峰照样转圈、报错、丢课。这篇笔记面向两类人一类是要给学生做移动端选课的开发同学另一类是要评估“自己造还是买一个教务选课系统”的校方技术人员。我会把数据模型、接口设计、App端交互、高峰期处理、常见故障一次讲完偏重可以直接落地的写法不讨论大而全的汗牛充栋方案。先说结论学生选课系统的核心是“一件事只能成一次且不破坏任何一条选课约束”。把这个事务边界设计好高峰期即使全校进系统数据库也不至于被压垮用户也不至于反复提交。2. 选课系统的数据基础课程、教学班、学生选课结果2.1 三张核心表怎么设计冗余字段放到哪选课系统App背后至少要有三张基础表课程表、教学班表、选课结果表。课程表管“课程是什么”教学班表管“这个课这学期谁开、在哪儿上、容量多少”选课结果表才是学生真正抢占的数据。教学班表可以承载课程基本信息之外的开课属性比如教师、周次、节次、教室、容量、已选人数、限选条件。把“容量”和“已选人数”直接放在教学班表上能大幅简化查询但要注意并发更新。我一般的做法是教学班表用作选课入口的“票据”选课结果表记录谁拿到了这张票同时教学班表上维护已选人数作为冗余。选课结果单号不要用自增主键最好用业务号如学期号加教学班号加学号拼出来的唯一键天然防止重复选。表结构大致如下CREATE TABLE course_section ( section_id BIGINT PRIMARY KEY, course_id BIGINT NOT NULL, term_code VARCHAR(10) NOT NULL, teacher_name VARCHAR(50), capacity INT NOT NULL, selected_count INT NOT NULL DEFAULT 0, weekly VARCHAR(50), -- 1-16周 或 1-8,10-14周 week_day TINYINT, -- 1-7 周一至周日 start_period TINYINT, -- 起始节次 end_period TINYINT, -- 结束节次 credit DECIMAL(3,1), status TINYINT DEFAULT 1, -- 1可选 0停开 UNIQUE KEY uk_term_section (term_code, section_id) ); CREATE TABLE student_course_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, selection_no VARCHAR(40) NOT NULL, student_id VARCHAR(20) NOT NULL, section_id BIGINT NOT NULL, term_code VARCHAR(10) NOT NULL, status TINYINT DEFAULT 1, -- 1选中 0已退选 2被移除 selected_at DATETIME NOT NULL, UNIQUE KEY uk_student_section (student_id, section_id) );选课系统在与业务相关的表上只保留最基础的关联。比如上面这个选课结果表没有冗余课程名称查询时 JOIN 教学班表取课程信息就够了避免后续“课程改名”造成选课历史数据不一致。2.2 选课的唯一约束与事务边界学生选同一门课只能成功一次这一条通过uk_student_section唯一索引进库兜底而不是靠代码里先查再插。高并发下“先查后插”一定会出现重复因为两个请求同时读到没有记录同时插入唯一索引把后到的挡住事务报错提示“重复选课”。这比应用层判断可靠得多。事务边界要覆盖到校验、扣减、写入三步。START TRANSACTION; SELECT selected_count, capacity FROM course_section WHERE section_id ? AND term_code ? FOR UPDATE; -- 检查是否满足限额与冲突约束 UPDATE course_section SET selected_count selected_count 1 WHERE section_id ? INSERT INTO student_course_selection (selection_no, student_id, section_id, term_code, status, selected_at) VALUES (?, ?, ?, ?, 1, NOW()); COMMIT;重点解释FOR UPDATE。对教学班行加锁后同一时刻同一教学班只有一个事务能通过这一行后面的请求会排队等待避免两个学生同时占了最后一个名额。这样在容量很小比如 30 人的班级上可以严格保证不超员。这个写法不是唯一解却是最容易理解和排查的方案。选课高峰时数据库行锁会形成排队请求时间变长但选课业务本来就是一个短事务只要 QPS 没有到数据库扛不住的地步这个方案胜过引入分布式锁。2.3 选课冲突检测到底检测什么冲突检测通常包含四件事上课时间重叠、考试时间重叠、已修课程重复、超出培养方案学分上限。App 端能先做一层预检但后端必须重新检客户端不可信。时间冲突检测最容易漏的是“周次不重叠但节次相同”的课。比如 A 课在 1-8 周每周一第三节B 课在 10-16 周每周一第三节它们在周次上不重叠不能算冲突。这个逻辑要写死在服务端不能只比对星期和节次。-- 查找同一学生已选课程中星期和节次重叠的所有教学班 SELECT cs.section_id, cs.weekly, cs.start_period, cs.end_period FROM course_section cs JOIN student_course_selection scs ON cs.section_id scs.section_id WHERE scs.student_id ? AND scs.status 1 AND cs.term_code ? AND cs.week_day ? AND cs.start_period ? AND cs.end_period ?;这里传入的week_day、start_period、end_period是新选课程的时间段。查出所有重叠记录后再逐一判断周次序列是否真正相交。周次字符串可能包含逗号和分段不要试图用字符串包含判断应该转成“周次位图”存二进制列或者拆成多条周次区间存储。我见过不少翻车现场是在周次判断上偷懒只取“每周都有”的课程到学期中间才发现前后半段冲突。正确做法是有一个函数weeks_overlap(a, b)专门处理两个周次区间数组逐段比较。3. 选课系统App端怎么做从登录态到选课动作3.1 用户登录与凭证刷新别把密码存在 App 里选课系统App最要紧的安全问题是登录凭证。很多学校场景沿用 Web 端的 Session 机制App 却常常直接复用同一个接口隐患不少。移动端应当单独走 token 体系登录成功后服务端颁发短期令牌比如 2 小时有效App 把它放在内存或系统安全存储里不落普通文件。我一般这样设计认证接口POST /api/auth/login { studentId: 202408001, password: ****** }响应体{ accessToken: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..., expiresIn: 7200, refreshToken: 4f9d8a7c... }App 端用 refreshToken 在 accessToken 过期前静默刷新用户无感知。如果 refreshToken 也失效再引导重新登录。关键点每个接口的鉴权都在服务端校验 tokenApp 不做“是否已登录”的判断依据因为本地状态随时可能和服务端不一致。3.2 选课请求的最小可用代码防重复提交从客户端做起App 端点击“选课”按钮时请求体必须携带幂等键。所谓幂等键就是每次选课行为生成一个唯一的客户端请求号服务端缓存这个号同一请求重复到达时直接返回第一次的结果。FutureSelectCourseResult selectCourse({ required String sectionId, required String token, }) async { final requestId _generateRequestId(); final response await http.post( Uri.parse($baseUrl/api/course/select), headers: { Authorization: Bearer $token, Content-Type: application/json, }, body: jsonEncode({ sectionId: sectionId, requestId: requestId, }), ); final data jsonDecode(response.body); return SelectCourseResult.fromJson(data); }requestId由客户端生成UUID 或“学号时间戳随机数”都行。服务端收到后先去 Redis 查这个 id存在就直接返回旧结果不存在才执行选课流程执行完把结果写入 Redis设置 5 分钟过期。这个机制解决的是“用户多点了两次按钮”和“弱网重试”两个场景。没有幂等键时用户选课成功后客户端超时用户再点一次后端可能把同一门课选两次触发唯一索引报错用户看到的是含糊的“系统错误”。有了幂等键第二次点击拿到的是第一次成功的结果体验完全不同。App 端还要在按钮状态上做防抖提交后按钮置灰并显示“提交中”直到收到成功或失败结果再恢复。但这只是用户体验层的辅助真正的防重还是靠服务端幂等。3.3 课程列表与已选列表分页和缓存策略课程列表是最常见的接口。一个学期全校几百门课分页返回App 端不能一次拉全量要按学院、课程类别、上课时间筛选。我建议列表接口和详情接口分开。列表只返回课程名、授课教师、容量、剩余名额、上课时间摘要。详情接口再返回完整教学班信息。列表接口响应体里一定带一个version字段课程数据变化时递增App 端对比到版本不一致才刷新缓存。已选列表的查询要快。学生选课结果一般不超过几十条直接查关联表按学期过滤即可SELECT cs.course_id, cs.section_id, cs.teacher_name, cs.weekly, cs.week_day, cs.start_period, cs.end_period, cs.credit, cs.capacity, cs.selected_count FROM student_course_selection scs JOIN course_section cs ON scs.section_id cs.section_id WHERE scs.student_id ? AND scs.term_code ? AND scs.status 1 ORDER BY scs.selected_at;把student_id、term_code、status建联合索引查询基本都在毫秒级。这个查询同时承担了“课表展示”功能App 端拿到结果后按星期几分组渲染到周视图不需要单独再拉一次课表。4. 高峰期抢课有限名额怎么发都不超卖4.1 并发写入时的常见思路与天花板选课高并发和大家熟知的秒杀有不少相似之处有限的库存大量的并发请求需要保证不能超发。但选课和秒杀有一个根本区别每人能选多门课、选课有时间窗口、退课后名额再释放。所以不能直接把库存扣减做完就结束还要考虑撤销操作。最常见的实现是第 2 章的“事务内行锁”。它的优点是代码简单、事务性好缺点是单节教学班的选课请求完全串行化。假设全校最热门的课有 200 个名额同时有 3000 人点选数据库行锁会让后面 2800 个请求排队等待连接池、响应时间都会变得很难看。如果只想保证不超卖一个更快的方法是“条件更新”UPDATE course_section SET selected_count selected_count 1 WHERE section_id ? AND term_code ? AND selected_count capacity AND status 1;这个方案利用 MySQL 行锁的原子性命中行数等于 1 时才让学生选课成功。但不适合直接作为唯一判断依据因为selected_count可能因退课回滚边界情况要考虑。具体来说退课是把selected_count减一如果一个学生选课成功后立即退课再重新选课条件更新和事务校验的时序就会产生细微差别。我的选择是默认走“事务行锁”应对绝大多数学校的选课规模如果总并发确实高到数据库撑不住再加 Redis 预扣库存前置挡一层数据库只承载最终落单。4.2 用 Redis 预扣库存挡流量MySQL 落单用 Redis 做选课预扣技术方案比较成熟。开课前把每个教学班的容量写入 Redis选课请求先到 Redis 做原子扣减成功才放行到数据库执行落单失败直接返回“名额已满”。Redis 的扣减操作是原子的天然不会超扣。# 初始化教学班容量 SET course:10245:capacity 200 # 选课请求进来直接原子扣减 DECR course:10245:capacity这里不能只DECR然后判断返回值要和容量比较一起做。正确做法是先把容量读出来缓存到本地内存再循环执行或者用 Lua 脚本保证判断与扣减是原子操作local cap tonumber(redis.call(GET, KEYS[1])) if cap and cap 0 then redis.call(DECR, KEYS[1]) return 1 else return 0 end用 Lua 脚本调用EVAL传入容量键Redis 端保证了这个脚本执行过程中不会有其他客户端插入指令。注意这个方案必须配合一个“回滚”机制数据库落单失败时要把 Redis 中扣掉的数加回来同时记录失败原因。选课成功后 Redis 容量不用再改动因为名额要等退课时才恢复。预扣库存方案的好处是把大流量挡在数据库前面。一个学校的并发选课再怎么高数据库落单的 QPS 也远小于 Redis 处理的 QPS。代价是多了一层一致性风险——Redis 宕机丢失预扣数据或回滚脚本没执行成功导致名额少放。针对这些风险需要加定时校准任务比对 Redis 剩余容量和数据库实际已选人数。4.3 异步结果通知与状态轮询选课高峰时同步接口很难在几秒内返回。数据库行锁导致的等待时间可能长达数十秒App 端直接等响应不现实。常见做法是选课请求提交后立即返回“处理中”服务端异步完成App 轮询或等推送通知。轮询接口一般这样设计GET /api/course/select-status?requestIdxxxx{ status: PROCESSING, message: 正在排队处理 }处理完成后返回结果{ status: SUCCESS, sectionId: 10245, selectedAt: 2025-08-28T10:02:33 }App 端拿到 PROCESSING 后按固定间隔重试。间隔不宜太短我一般用 1.5 秒到 3 秒连续轮询超过 30 次仍未结束就提示“系统繁忙请稍后查看已选列表”。不要在轮询里无限循环否则高峰期服务端的查询压力反而成了另一个瓶颈。如果后端已有消息推送通道可以改为推送结果轮询只做兜底。但推送通道需要额外维护心跳和重连在校园网环境下稳定性不如轮询。实际操作中我倾向于优先轮询因为选课是一个低频状态变化推算下来这种模式足够。5. 选课系统App常见故障排查现象与根因我在这类项目里踩坑最多的五个问题有现成的代码问题也有部署层面的疏漏。按现象到根因再到解决的方式记下来。5.1 学生明明没选过课却提示“已选过该课”现象个别学生选某一门课时系统提示“已选过该课程”但学生在已选列表里查不到记录。原因这是student_course_selection表里的数据状态处于“已退选”但因为唯一键约束没有解除。我见过有的实现把退选做成物理删除删除后唯一键自然放开但也有实现用status0标记退选却没有给唯一键添加 status 条件导致同一个人退选后无法重新选同一门课。解决用状态字段表示退选时唯一键要改成(student_id, section_id, status)或者退选时物理删除旧记录并重新插入。更省事的方案是退选走UPDATE status0 WHERE student_id? AND section_id? AND status1重选时插入新记录但前提是旧记录不会挡路。也就是说唯一键不能包含 status 时物理删除更干净。选课系统这类场景没有必要保留退选历史物理删除是首选。5.2 选课成功后列表里没有这条课现象请求返回成功App 也弹了“选课成功”但回到已选列表这门课不出现。原因这个问题有三分之二是缓存没刷新。列表接口做了缓存学生选课后仍然读到旧的缓存数据。另一个原因是事务提交后列表查询走的是只读从库主从延迟让查询落后于写入。解决选课成功后 App 端拉取已选列表时带参数强制跳过本地缓存服务端对已选列表接口设置极短的缓存时间比如 5 秒或者选课成功事件直接触发列表缓存失效。从库延迟的问题要靠监控主从时间差延迟超过 1 秒就把列表查询切回主库。这种业务对“读最新”的诉求强于“读高性能”选课期间所有状态相关接口都建议主库读。5.3 高峰期数据库连接池被打满现象选课开始后第 10 分钟接口大面积超时数据库监控显示连接数占满。原因事务内行锁导致请求排队连接被长期占用不释放。连接池默认超时时间很长几十个连接全部卡在一个热门教学班的行锁上后续连接排队等待最终连接池耗尽。解决根据热门课程数量合理设置连接池上限同时把事务内的锁等待时间调短。MySQL 的innodb_lock_wait_timeout默认是 50 秒选课场景建议改成 5 秒或 10 秒拿不到锁就快速失败返回“当前选课人数较多请重试”。这个策略虽然会让部分请求失败但失败可以重试连接池被打满才是真正的灾难。5.4 App 端选课按钮灰色怎么点都没反应现象选课高峰期部分学生手机上的选课按钮置灰不可点或者点了没反应重启 App 后又恢复正常。原因App 端多处使用本地状态控制按钮。比较常见的是“防重复提交”的本地标记没有按时重置请求失败或超时后按钮仍然停留在“提交中”状态。另一个场景是首页接口失败后全局请求队列被清空后续动作全部失效。解决客户端加一个“请求超时自动恢复按钮可用”的兜底计时器。按钮置灰只能持续到请求超时而不是永久保持。我一般要求提交按钮在发出请求后 10 秒内没有响应就恢复为可点状态并允许重试。请求层要做到单个请求失败不级联影响全局队列。5.5 退课后名额没回来现象学生退掉一门课但剩余名额没有立即恢复其他学生仍看到“名额已满”。原因退课逻辑只更新了选课结果表没有同步调整教学班表的selected_count也没有操作 Redis 预扣库存的补偿。这在“MySQL 落单 Redis 预扣”的架构里特别常见回补库存的脚本遗漏了。解决退课操作必须和选课操作放在同一笔事务里START TRANSACTION; DELETE FROM student_course_selection WHERE student_id ? AND section_id ? AND status 1; UPDATE course_section SET selected_count selected_count - 1 WHERE section_id ? AND selected_count 0; COMMIT;如果部署了 Redis 预扣层事务提交成功后要执行INCR course:10245:capacity把预扣的量放回。两边的数据一致性要做到“退课失败则名额不放退课成功则两步必须都完成”。6. 从“能选”到“选得对”验证、监控与兜底技巧选课系统上线后最值得做的不是加更多功能而是建立一套“选课结果正确性”验证体系。我在每次选课活动开始前和结束后都会跑一个统计脚本把数据库里的选课结果数和教学班的已选人数做全量比对SELECT cs.section_id, cs.selected_count, COUNT(scs.id) AS actual_count FROM course_section cs LEFT JOIN student_course_selection scs ON cs.section_id scs.section_id AND scs.status 1 AND scs.term_code ? WHERE cs.term_code ? GROUP BY cs.section_id, cs.selected_count HAVING cs.selected_count ! actual_count;这个脚本只要亲自动手跑一遍就能发现各种数据不一致的问题根源。它同样适合在Press发布选课抢到的第二天跑一次出现问题可以及时修复。App 端还要加上一个“选课日历”的视角来验证用户感知。很多人看课表只看周几第几节但选课系统里还有单双周的概念。App 端课表在渲染周次时一定要把课程周次标出来否则学生看课表以为自己周周都有课实际后半学期已经结课。为了应付“选课系统查不到课程”的投诉可以做一个轻量级的服务端日志追踪。选课请求带上 requestId 后后端要把全链路的处理过程记到日志请求进入时间、Redis 扣减结果、SQL 事务结果、最终状态。出现纠纷时能快速定位“这个学生到底有没有选上、为什么选上后又消失”。最后提一个我从项目里学到的教训选课系统App上线前一定要做一次全校终端压力测试不要只测后端接口。Android 的碎片化、校园网弱网环境、老手机的渲染性能都会让真实并发和测试环境差很多。测试时用真实 Android 设备跑一批模拟选课观察哪些机型容易崩溃、哪些网络环境超时率偏高。这一点我当时没做后来第一个选课周就出了小范围问题后期用升级包加长超时时间才稳住。选课系统不复杂但它连接着“学生能不能选上课”这一桩大事。值得先把数据一致性做好再做花哨功能。希望这篇笔记能帮你少走一段弯路。本文还有配套的精品资源点击获取