尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

选课系统并发控制实战:从超卖问题到行锁与条件更新

发布时间:2026/9/26 18:50:06

资讯中心
01
ARTICLE

选课系统并发控制实战:从超卖问题到行锁与条件更新

选课系统并发控制实战:从超卖问题到行锁与条件更新
简介面向高校计算机相关专业学生的《学生选课管理信息系统》课程设计报告覆盖学生入校注册、基本信息维护、课程信息管理、教师主讲课程限制、学生选课入库、考试成绩补录等选课业务核心环节。报告围绕SCDB简化数据库展开设计了学生、课程、教师、选课记录、考试成绩等主要数据表并提出可根据实际业务适当追加数据表的扩展思路同时提供可运行的exe实现便于读者直接查看课程设计成果。资源包为rar格式整体大小约19.03MB核心为exe可执行程序与课程设计报告说明文件总数标注为0文件类型以exe为主适合用于数据库课程设计参考、系统功能演示或毕业设计基础。目前已有1509人学习/下载。下载后可获得完整的选课管理业务方案、数据库表结构设计思路以及可运行演示程序能够帮助理解从需求分析、数据建模到功能落地的完整过程也可在答辩展示中直接运行操作。1. 学生选课管理信息系统课程设计里最容易被低估的并发问题第一次看到这个题目的人多半觉得它就是个普通的增删改查练习——建几张表、写几个页面、跑通选课退课报告里放几张截图就算交差。真做完你会发现选课系统最难的从来不是 CRUD而是“锁”。一场 30 人同时抢 10 个名额的模拟选课就能让没做并发控制的系统当场翻车两个学生同时看到“还剩 1 个名额”同时点选课最后数据库里出现两行选课记录。这个瞬间直接决定你的课程设计是停留在“能用”还是能站到并发控制的高度上。下面这套方案围绕学生选课管理信息系统的数据模型、接口链路、并发处理和报告取材展开适合正在做课设的学生也适合需要带课设的助教快速判断一个实现到底靠不靠谱。2. 从需求到核心表结构选课系统的 ER 设计与表落地2.1 先想清楚这三张表学生、课程、选课记录常见做法是设计三张核心表学生表、课程表、选课记录表。很多第一次做的人会把“已选课程”直接做成学生表里的一个字段比如用逗号拼一串课程 ID这种设计在演示时看着简单实际写起来全是坑——你没法查“一门课被哪些人选了”没法加唯一约束更没法在事务里可靠地扣减名额。标准做法是单独建一张选课记录表每一行代表“某个学生选了某门课”。课程表里要有总容量字段还要有一个冗余的已选人数字段。我见过不少课设只存容量不存已选数每次判断名额就去 count 选课记录表小数据量没问题但并发上来以后这种做法的扫描开销和锁粒度都不好看。存冗余字段虽然要自己维护一致性但在选课这种小业务里配合事务扣减非常直观。三张表的字段设计大概是这样的表名关键字段说明studentid, student_no, name, passwordstudent_no 加唯一索引courseid, course_no, name, capacity, selected_count, versionversion 留给乐观锁用select_recordid, student_id, course_id, select_timestudent_id course_id 建唯一索引课程表的 version 字段是可选的但我建议从一开始就加上。后面写并发控制时你会感谢这个字段。2.2 用 SQLAlchemy 把表和唯一约束落下来用 Flask SQLAlchemy 写模型时选课记录表的唯一约束要在模型里显式声明这是防止同一个学生重复选同一门课的数据库底线。光靠应用层查一遍再判断安全性是不够的数据库约束才是最后的防线。from datetime import datetime from sqlalchemy import UniqueConstraint, CheckConstraint from extensions import db class Course(db.Model): __tablename__ course id db.Column(db.Integer, primary_keyTrue) course_no db.Column(db.String(20), uniqueTrue, nullableFalse) name db.Column(db.String(100), nullableFalse) capacity db.Column(db.Integer, nullableFalse) # 总容量 selected_count db.Column(db.Integer, default0) # 已选人数 version db.Column(db.Integer, default0) # 乐观锁版本号 class SelectRecord(db.Model): __tablename__ select_record id db.Column(db.Integer, primary_keyTrue) student_id db.Column(db.Integer, db.ForeignKey(student.id), nullableFalse) course_id db.Column(db.Integer, db.ForeignKey(course.id), nullableFalse) select_time db.Column(db.DateTime, defaultdatetime.now) __table_args__ ( UniqueConstraint(student_id, course_id, nameuniq_student_course), )选课记录表加上唯一约束以后重复插入会在数据库层面抛异常这个约束在并发场景下比任何 if 判断都可靠。课程表的 selected_count 字段默认 0每次选课成功就加 1退课就减 1这个字段必须和选课记录的写入放在同一个事务里不能分两步走。很多实现翻车就翻在这里——先插记录再更新人数中间进程崩了记录有了人数没加上。2.3 补两张边界情况的处理退课和时间冲突退课在课设里通常直接用 DELETE 删掉选课记录同时把 selected_count 减回。有人会问为什么不做“已退课”状态标记我的经验是选课记录表里保留历史倒也不是不行但名额回收、判重、统计都会跟着复杂课设阶段用物理删除最简单报告里也好解释。时间冲突检测属于选课业务里很有价值的边界功能。课程表加 weekday、start_time、end_time 三个字段选课前在应用层查一下这个学生已有的选课记录有重叠就拒绝。这个逻辑不加也能跑但加了以后报告里能多一段业务规则描述答辩时老师也爱问属于性价比很高的功能。3. 用 Flask 跑通选课主流程从请求到名额扣减3.1 搭一个带事务的最小后端骨架选课主流程的接口按常规方式拆成三层路由层只收参数、调 serviceservice 层做业务判断和事务控制model 层只负责数据访问。这么做的好处是事务边界放在 service 层里看代码的人一眼就能看出“这个事务到底包含哪些操作”。先看一个不含并发控制的朴素版本这个版本单用户跑没问题但有两个并发请求同时打进来就会出问题后面第 4 章专门处理超卖。from flask import Blueprint, jsonify, request from extensions import db from models import Course, SelectRecord course_bp Blueprint(course, __name__) course_bp.route(/api/select, methods[POST]) def select_course(): data request.get_json() student_id data.get(student_id) course_id data.get(course_id) course db.session.query(Course).filter(Course.id course_id).first() if not course: return jsonify({code: 1, msg: 课程不存在}), 404 # 朴素写法先查剩余名额再插入单用户下没问题 if course.selected_count course.capacity: return jsonify({code: 2, msg: 已满员}), 400 record SelectRecord(student_idstudent_id, course_idcourse_id) db.session.add(record) course.selected_count 1 db.session.commit() return jsonify({code: 0, msg: 选课成功})这段代码逻辑很直白查出课程判断名额插入记录更新人数提交事务。注意 commit 在最后一步保证了记录插入和人数更新要么都成功要么都失败。现在的问题是两个请求同时进到这个函数里都查出了 selected_count 9、capacity 10然后都走完了整个流程最后数据库里就会出现两条选课记录名额变成 11。这就是先查再改的竞态条件。3.2 用 curl 模拟并发请求先把问题打出来写后端代码不能只在浏览器里点来点去地测至少要会用一个工具从命令行同时发几个请求。后端先跑起来监听 5000 端口然后开两个终端窗口同时执行下面的命令# 终端 A curl -X POST http://127.0.0.1:5000/api/select \ -H Content-Type: application/json \ -d {student_id: 1, course_id: 2} # 终端 B curl -X POST http://127.0.0.1:5000/api/select \ -H Content-Type: application/json \ -d {student_id: 2, course_id: 2}如果课程 2 剩余名额只有 1 个这两个请求在朴素版本下极大概率都返回“选课成功”。这个现象在开发调试模式里特别容易复现因为 Flask 的调试服务器默认开了两个线程处理请求。看到两个成功响应恭喜你你已经拿到了报告里最有说服力的“问题截图”第 4 章就是用代码解决这个问题的过程。3.3 前端按钮怎么接这个接口前端部分用一个简单的 Vue 页面配合演示即可。页面加载时拿到全部课程列表和当前学生已选列表点击“选课”按钮之后发请求用后端返回的结果刷新剩余名额这里关键是不要让前端自己做“剩余名额减一”的乐观更新——并发场景下前端算的数是错的一切以服务端返回的最新数据为准。// 选课按钮点击事件 async function onSelect(courseId) { const resp await fetch(/api/select, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ student_id: currentStudentId, course_id: courseId }) }) const data await resp.json() if (data.code 0) { // 成功后重新拉一遍课程数据剩余名额以服务端返回为准 await loadCourseList() } else { alert(data.msg) } }loadCourseList 会重新请求课程列表接口把 selected_count 重新渲染出来。有人图省事会在点击后直接对界面上的数字减一一旦请求失败或者名额判断被后端拒绝界面状态就和数据库对不上了。课程设计展示现场很容易被老师看出这种问题随手切个页面刷新一下就露馅。4. 选课高峰的并发控制行锁、条件更新与事务边界的取舍4.1 先搞清楚超卖到底是怎么发生的超卖不是 MySQL 的 bug而是你的代码逻辑存在“检查与执行之间的空档”。两个事务并发执行时事务 A 查到了剩余名额 1事务 B 也查到了剩余名额 1然后各自往下走谁都没拦住谁。解决思路无非两种方向让检查与扣减变成原子操作或者在检查时把资源锁住不让别人读。先说第一个方向把“判断有没有名额”和“扣减名额”合并成一条 UPDATE 语句利用 UPDATE 自身的行锁特性条件不满足就不更新。这种写法叫条件更新不需要显式加锁代码最短。from sqlalchemy import update course_bp.route(/api/select, methods[POST]) def select_course_safe(): data request.get_json() student_id data.get(student_id) course_id data.get(course_id) # 用条件更新扣减名额affected_rows 为 0 说明名额已满或课程不存在 result db.session.execute( update(Course) .where(Course.id course_id) .where(Course.selected_count Course.capacity) .values(selected_countCourse.selected_count 1) ) affected_rows result.rowcount if affected_rows 0: db.session.rollback() return jsonify({code: 2, msg: 已满员}), 400 # 名额扣减成功后再插入选课记录保持同一事务 db.session.add(SelectRecord(student_idstudent_id, course_idcourse_id)) db.session.commit() return jsonify({code: 0, msg: 选课成功})这里 UPDATE 语句里的 where 条件同时包含了课程 ID 和名额判断数据库在执行时会对命中的行加锁只有锁成功并且条件满足才更新行数因此两个并发请求只有一个能拿到 rowcount 1另一个拿到 rowcount 0 后直接回滚。注意这里的 db.session.rollback() 不是可省动作如果你不回滚事务里后面再查数据时会读到脏状态。第二种做法是显式行锁用 SELECT ... FOR UPDATE先锁住课程行再检查名额再插入记录再更新人数。这条链路更长但对业务的表达更直白报告里和答辩时更容易讲清楚所以我个人更推荐在课程设计报告里写这一版。from sqlalchemy import select course_bp.route(/api/select, methods[POST]) def select_course_lock(): data request.get_json() student_id data.get(student_id) course_id data.get(course_id) # 显式行锁锁住 course 这一行防止并发读取同一份名额数据 course db.session.execute( select(Course) .where(Course.id course_id) .with_for_update() ).scalar_one_or_none() if not course: return jsonify({code: 1, msg: 课程不存在}), 404 if course.selected_count course.capacity: db.session.rollback() return jsonify({code: 2, msg: 已满员}), 400 db.session.add(SelectRecord(student_idstudent_id, course_idcourse_id)) course.selected_count 1 db.session.commit() return jsonify({code: 0, msg: 选课成功})with_for_update() 是这里的核心调用它让这条 SELECT 语句锁定命中的行直到当前事务结束。事务结束的时机是 commit 或 rollback锁的释放就发生在这两者之一。如果两个并发请求同时到达事务 B 的 SELECT ... FOR UPDATE 会一直等待事务 A 提交后才能读到数据而事务 A 提交后的数据里名额已经减少事务 B 的检查自然失败。这个方案虽然慢一点但逻辑最清晰面答时一句话就能说清。为什么不直接用 SELECT 然后 UPDATE 两步走SELECT 不加锁的时候事务 B 读到的还是旧数据。加了 FOR UPDATE 后事务 B 的读会被阻塞从根源上避免了竞态。这就是显式行锁和朴素写法的本质区别。4.2 三种方案放一起怎么选方案并发安全实现成本答辩表现朴素 CHECK INSERT不安全最低容易被问倒条件 UPDATE安全低代码短但需要解释 WHERE 里做判断的原理SELECT FOR UPDATE安全中链路完整锁、事务、回滚都能讲乐观锁version 字段安全中适合展示“不用锁也能防并发”的思路乐观锁的写法也值得在报告里提一句每次更新时带上 version 条件UPDATE 影响行数为 0 就重试或者返回失败。它适合并发冲突概率低的场景因为不阻塞读操作。选课高峰期冲突其实不小但作为方案对比写进报告里能体现你的知识面。4.3 事务边界选课记录和名额必须同生共死不管是条件更新还是行锁事务边界都要收住选课记录的 INSERT 和 selected_count 的 UPDATE 必须在同一个事务里。很多新手把这两步写在两个函数里先调一个接口插入记录再调另一个接口更新人数中间任何一个环节崩溃数据就永久不一致。这就是事务边界问题。正确习惯是service 函数里写 try-except捕获异常统一滚回成功统一提交。开发时可以把日志级别调到 INFO在回滚的地方打一条日志排查问题会节省大量时间。调试模式里看到的“名额扣了但记录没插上”或者反过来基本都是事务边界没守住。5. 学生选课管理信息系统最常见的 5 个踩坑点与排查方法5.1 重启服务后名额自动复原现象选课系统跑得好好的一重启服务端程序所有课程的名额又变回最初数量刚才选过课的数据像是丢了一次。原因有人偷懒把 selected_count 存在了 Python 的全局字典里每次选课只是改了内存里的数字根本没写进数据库。服务一重启内存清空一切回到初始状态。解决全局变量只适合当缓存不能当存储。把所有名额数据全部落库从数据库读写。检查方式是搜一遍代码里有没有{}字典或者全局变量在存储业务数据出现就重构。5.2 两个并发请求同时选课成功名额超卖现象剩余名额只剩 1 个开两个浏览器窗口同时点选课两个都提示成功。原因代码走了“先查剩余名额、再插入记录”的朴素逻辑两个请求同时读到同样的名额数据然后先后完成插入。解决按第 4 章的方案处理。最低要求是把 UPDATE 改成条件更新朴素写法必须淘汰。调试时可以用curl并发测试看到只有一个成功才算数。5.3 退课后剩余名额对不上课程列表显示异常现象学生退了一门课课程列表里的已选人数没变或者变了但再次选课时提示满员。原因退课接口只执行了 DELETE 选课记录没更新 selected_count或者更新这个动作不在同一个事务里执行到一半被异常中断。解决退课必须同时做两件事删除选课记录和把课程表的 selected_count 减一并且在一个事务里提交。写成代码时不要分开两个接口调用这个操作本身就是一个事务单元。5.4 封版测试时发现选课接口偶尔卡住不动现象并发测试打了几十个请求后后面的一部分请求长时间不返回像整个服务被卡死。原因SELECT ... FOR UPDATE 的行锁在等另一个事务释放锁而那个事务因为异常没有 commit 也没有 rollback锁一直被占着。常见于调试模式下的异常断言或者事务里提前 return 忘了回滚。解决给事务包裹层的 except 里统一写db.session.rollback()并把超时时间调出来。用innodb_lock_wait_timeout配置让锁等待超过一定秒数就报错不要无限等下去。排查时先看 MySQL 的SHOW PROCESSLIST找到 State 为 Locked 的连接。5.5 数据流图里的外部实体画错报告被扣分现象报告里的数据流图把“课程”画成外部实体把“选课”画成数据存储答辩时老师问了一句就答不上来。原因报告是赶出来的图是从网上套模板改的没有对照真实代码结构。解决画图前先把自己的系统拆成三层——外部实体只有学生和管理员数据存储就是你的表学生表、课程表、选课记录表“选课”是加工处理不是实体。把建表语句摆在旁边画图不要凭空编。6. 把实现过程写进课程设计报告数据流图与验证数据的取材技巧代码写完课程设计报告不要从头编。我的习惯是边写代码边做三件事最后写报告只是整理素材而不是凭空想象。第一件打开 SQLAlchemy 的echoTrue配置让控制台打印所有 SQL 语句。这些 SQL 日志就是数据流图的第一手素材INSERT 选课记录之前先 UPDATE 课程名额说明这两个操作在同一事务内出现FOR UPDATE说明这里用了行锁。把这些关键日志复制下来配合截图贴进报告数据流和时序根本不用编。第二件把并发测试的结果存档。用两条 curl 同时请求同一条只剩 1 个名额的课程一次成功一次失败这是整个报告最亮的验证数据。记住要把失败响应的 JSON 也截进去不能只截成功的。第三件画时序图时用“学生、控制器、服务层、数据库”四个泳道把选课主流程走一遍。从请求进入控制器开始画到数据库返回提交成功为止。画完对照日志核一遍这种图答辩时不用背照图说就行因为每一步都是真实跑出来的。最后跟你说一个我踩过的教训当年做类似的课设我把全部精力放在把页面做得好看报告里的核心实现草草带过结果老师当场打开两个浏览器演示并发系统直接表演超卖整个答辩氛围瞬间凝固。从那以后凡是涉及名额、库存、余额这类的题目我第一件事就是先写并发测试再补页面。这次选课系统的课设建议你也按这个顺序来——先把并发问题解决掉再往回补前端样式都来得及。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。