1. 为什么高校选课系统值得单独做一个优化专题
选课系统这个东西,功能清单摊开来看其实不复杂:学生查课、选课、退课,教务排课、控名额、导数据。任何一个练过手的人都能在一周内搭出一套能跑的版本。但每年一到选课开放的那几分钟,问题就会集中爆发——几百上千个请求同时砸向某几门热门课,数据库连接池瞬间打满,页面转圈,学生反复点提交,名额出现超卖,最后教务那边还要人工核对退课记录。
我做过两轮类似的系统,第一轮是实验室内部使用的课程分配工具,第二轮是给某高校教务部门做的一套正式选课平台,日活不算高,但峰值特别尖。这个峰值特征决定了它和普通的增删改查系统完全是两码事:它的请求分布极度不均,99%的课程无人问津,1%的课程在30秒内被抢空。你要优化的是那条最窄的通道,而不是平均吞吐。
所以这篇内容我想聊的不是"Django怎么建模型、怎么写视图"这种入门步骤,而是从架构设计开始,一步步讲清楚选课系统在高并发场景下的真实瓶颈在哪、我是怎么定位的、用了哪些手段、哪些手段看起来很美实际没用。适合已经能独立写Django项目、但没处理过集中式并发写入的中级开发者,也适合正在做毕设但希望做得像回事的同学。文中涉及的所有项目背景、数据规模都做过模糊处理,重点看思路和方法,不要纠结具体数字。
先说结论性的判断,方便你对照自己的项目:选课系统的性能问题,九成不在Django框架本身,而在数据库的写冲突和应用层的重复提交。框架层面你能做的优化很有限,真正的战场在事务边界、锁粒度、缓存策略和限流设计上。理解了这一点,后面所有方案的选择就都顺理成章了。
2. 系统整体设计与技术选型的取舍逻辑
2.1 需求拆解:哪些是伪需求,哪些是硬约束
动手之前我习惯先把需求分成三类:必须硬扛的、可以软化的、干脆不做的。选课系统的硬约束其实只有一条——同一门课的已选人数不能超过容量。这条约束在任何时刻、任何并发下都不能破。围绕这条,衍生出几个次级约束:一个学生不能重复选同一门课;退课后名额要能立刻释放;选课结果要能实时查询。
软化的部分就多了。比如"实时显示剩余名额",很多人第一反应是用数据库count去查,其实允许有1到2秒的延迟,完全可以用缓存来扛。再比如"选课成功的即时反馈",也可以先返回"排队中"再异步确认。这类需求一旦被识别为可软化,架构上的腾挪空间立刻大了一倍。
至于伪需求,我见过不少项目在列表页做全表模糊搜索加多条件组合筛选,还要求毫秒级响应。实际上一学期就几十到几百门课,前端一次性拉全量本地过滤完全够用,非要上搜索引擎纯属自我加戏。在资源有限的场景下,识别出可以砍掉的需求,比优化留下来的需求收益更大。
2.2 技术栈为什么这么定
后端我用的就是Django加DRF,数据库PostgreSQL,缓存Redis,部署用Gunicorn配Nginx。有人会问为什么不上Go或者Java,我的回答很直接:这套系统的瓶颈不在语言执行效率上,而在数据库的并发控制上。Django的ORM虽然有性能开销,但它在事务管理、连接复用上的能力足够,而且开发效率带来的迭代速度,在需求频繁调整的教务场景里价值更高。
数据库选PostgreSQL而不是MySQL,关键原因是它对行级锁和SELECT FOR UPDATE的语义更严谨,还支持ON CONFLICT这类原子操作,处理"重复选课"这种幂等场景时省掉很多判断。Redis的用途很明确:缓存课程余量、做分布式锁、承担限流计数,三件事各用不同的数据结构,不混用。
有一点要强调:Django自带的开发服务器绝对不能用来做并发测试,它是单线程的,测出来的数据毫无意义。我一开始就搭了Gunicorn加Nginx的环境,用多worker模式压测,这样得到的数字才能反映真实情况。压测工具用Locust,因为它能写脚本模拟"登录后循环抢同一门课"这种真实行为,比ab这种只打单一URL的工具贴近得多。
2.3 分层设计:把并发挡在数据库之外
整个系统的请求路径我从外到内分了四层,每一层都有明确的拦截职责:
| 层级 | 组件 | 主要职责 | 拦截目标 |
|---|---|---|---|
| 第一层 | Nginx | 连接数限制、静态资源 | 洪水式连接 |
| 第二层 | 应用限流 | 按用户/IP限频 | 脚本刷课 |
| 第三层 | Redis预检 | 余量判断、分布式锁 | 超量请求 |
| 第四层 | 数据库事务 | 行锁、唯一约束 | 最终一致性 |
这个设计的核心思想是逐层收敛请求。假设1000个人同时抢50个名额,如果全部打到数据库,就是1000次事务竞争;经过第三层预检,只有前50多个请求能进入数据库,其余在Redis层就被挡回去了。数据库承受的压力从1000降到50,这是数量级的差别。
注意:预检层不能作为正确性的最终保证。Redis里的余量是缓存值,可能因为异常退出、缓存击穿而失准。真正的正确性必须由数据库的唯一约束和行锁兜底,Redis只是性能手段。这个主次关系搞反了,就会出现"系统很快但数据错了"的灾难。
2.4 一个被低估的设计决策:把写操作做成幂等
幂等这块我踩过坑。最早的设计是"选课接口调用一次就插一条记录",结果学生网络慢、反复点提交,同一个人在同一门课上插了三条记录,虽然唯一约束挡住了大部分,但错误提示很难看,日志也乱。后来改成接口接收一个客户端生成的请求ID,服务端先查这个ID是否处理过,处理过就直接返回上次的结果。
这个改动看起来只是细节,但它让整个系统对重试变得友好。前端超时可以安全重试,网关重发也不会有副作用。高并发系统里,能安全重试的接口比不能重试的快接口更有价值,因为你减少的是用户的手动重复操作,那才是真正的压力来源。
3. 数据库层的核心细节与实操要点
3.1 表结构设计中的几个关键决定
模型设计上,我保留了四张核心表:课程表、课程班次表、选课记录表、学生表。这里有个容易做错的地方——很多人把"容量"和"已选人数"都放一张表里,每次选课去更新已选人数。这样做的问题在于,已选人数是一个高频被更新的字段,每次更新都会持有行锁,在热点课程上就是锁竞争的焦点。
我的做法是已选人数不落库为主,以选课记录表的count为准,Redis里维护一个实时计数。数据库只负责插入选课记录,靠唯一约束保证不重复。这样写操作变成纯insert,锁的范围小很多。查询余量时读Redis,Redis没命中再回源count一次并回填。
课程班次表上我建了两个关键索引:(course_id, semester)的普通索引用于列表查询,以及选课记录表上的(student_id, schedule_id)唯一索引。这个唯一索引是防重复选课的最后一道闸,必须建。建索引的时机也有讲究——先导数据再建唯一索引,比先建索引再导数据快得多,因为批量导入时逐行维护索引代价很高。
class EnrollRecord(models.Model): student = models.ForeignKey(Student, on_delete=models.CASCADE) schedule = models.ForeignKey(Schedule, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) request_id = models.CharField(max_length=64, db_index=True) class Meta: constraints = [ models.UniqueConstraint( fields=['student', 'schedule'], name='uniq_student_schedule' ) ] indexes = [ models.Index(fields=['schedule', 'created_at']), ]3.2 事务边界怎么划:短事务是铁律
事务边界的划定直接决定了锁持有时间。我见过把整个选课流程——校验学生状态、查余量、插记录、发通知、更新统计——全包在一个事务里的写法。这个事务里只要有一个环节慢(比如发通知走网络),锁就一直不释放,热点课上其他请求全部排队等锁,吞吐直接崩掉。
正确的做法是事务里只放必须原子化的写操作。具体到这个场景,事务内只做两件事:插入选课记录、扣减Redis计数(如果Redis操作失败则回滚)。通知、日志、统计更新全部扔到事务外,用消息队列或简单的后台任务异步处理。
from django.db import transaction from django.db import IntegrityError def enroll(student_id, schedule_id, request_id): try: with transaction.atomic(): EnrollRecord.objects.create( student_id=student_id, schedule_id=schedule_id, request_id=request_id ) except IntegrityError: return {"code": "ALREADY_ENROLLED", "msg": "你已选过这门课"} return {"code": "OK"}代码很短,但有几个点值得说。第一,没有在事务里做任何查询判断,重复选课交给唯一约束来判,避免了"先查后插"的竞态窗口。第二,异常处理只捕获IntegrityError,其他异常向上抛,不要吞掉。第三,整个事务的执行时间控制在毫秒级,锁持有时间极短。
3.3 悲观锁还是乐观锁:我为什么两个都用了
在必须校验余量的场景,我用了SELECT FOR UPDATE配合Redis预检的组合。流程是先读Redis余量,如果余量充足,进入事务,对课程班次行加锁,重新从数据库核对一次余量,确认后再插入记录。
有人会质疑:既然Redis已经判断过了,为什么事务里还要再查一次数据库?因为Redis的计数可能因为并发扣减和回滚不同步而失真,数据库那次查询是最终校验。这一步的成本是一次带锁的select,在有索引的情况下很快,可接受。
乐观锁则用在另一个场景:教务后台调整课程容量。这类操作频率极低,冲突概率几乎为零,用版本号乐观锁就够,不必占用悲观锁的资源。选择依据很简单——冲突概率高且冲突代价大用悲观锁,冲突概率低用乐观锁。选课是典型的高冲突场景,后台调容量是典型的低冲突场景,一刀切才是问题。
3.4 注意事项:这几点当初让我多熬了两个晚上
第一个坑是数据库连接池配置和Gunicorn worker数量不匹配。Gunicorn开了8个worker,每个worker默认可能有多个数据库连接,加起来远超数据库的最大连接数,高峰期直接报"too many connections"。解决办法是每个worker的CONN_MAX_AGE设一个合理值复用连接,总连接数控制在数据库上限的70%左右留余量。
第二个坑是事务里调用了外部HTTP接口。当时的逻辑是选课成功后同步调用一个通知服务,结果通知服务响应慢,事务被拖长,锁等待雪崩。改成异步投递后问题消失。这提醒我一个通用原则:任何跨网络调用都不该出现在数据库事务内。
第三个坑是批量退课的锁顺序。退多门课时如果不规定加锁顺序,两个请求可能交叉持锁形成死锁。我的做法是退课前对schedule_id排序,统一按升序加锁,死锁再没出现过。这个技巧在任何批量操作里都适用。
4. 缓存与限流的落地实现
4.1 Redis余量缓存的结构与一致性处理
余量缓存我用的是简单的字符串键cap:{schedule_id},值是剩余数量,配合DECR做原子扣减。为什么不用hash存全部课程的余量?因为单key的hash在热点课上会变成另一个竞争点,虽然Redis单线程保证了原子性,但把所有压力集中在一个key上,在这个key过期或重建时会有明显的抖动。分key存储,压力和重建都分散开了。
一致性的处理思路是缓存只减不增,到期重建。扣减时用DECR,如果扣完是负数,说明超了,立即INCR回补并拒绝请求。余量的准确值定期由数据库count重算后回填,周期设得短一些,比如30秒。这个过程中的短暂偏差可以接受,因为数据库层的约束会兜底,最多是用户看到"还有名额"但提交时提示已满,体验上损失不大,数据不会错。
import redis r = redis.Redis(decode_responses=True) def try_reserve(schedule_id, capacity): key = f"cap:{schedule_id}" if not r.exists(key): used = EnrollRecord.objects.filter(schedule_id=schedule_id).count() r.set(key, capacity - used, ex=60, nx=True) remain = r.decr(key) if remain < 0: r.incr(key) return False return True这段代码有个细节:set用了nx=True,防止多个请求同时初始化缓存时互相覆盖。这是个容易被忽略的并发细节,不加的话冷启动瞬间缓存值可能被写乱。
4.2 分布式锁的适用边界
分布式锁我用在了一个特定环节:同一学生对同一门课的并发请求串行化。锁的key是lock:{student_id}:{schedule_id},过期时间设5秒,拿到锁的请求才继续走后续逻辑。
但要明确一点,分布式锁不是用来保证名额正确性的。那个靠数据库唯一约束和行锁。锁在这里只是减少无效的数据库往返——如果同一用户同时发来5个请求,锁让其中一个进来,其余快速失败并返回友好提示,避免了5次必然失败的insert。这是性能优化,不是正确性保证。这个定位如果搞混,就容易过度依赖锁,最后锁本身的竞争成了新瓶颈。
4.3 分级限流的参数怎么定
限流我做了两级。用户级限流是每个用户每10秒最多5次选课请求,用Redis的计数器加过期时间实现,超限返回429。IP级限流更粗,每个IP每分钟60次,主要针对脚本批量请求。Nginx层还加了连接数限制,作为最外层的兜底。
参数怎么定?我的方法是根据真实峰值反推。观察到的极端情况是单个热门课在30秒内被点击约2000次,其中正常用户每人平均点3到4次(因为要看反馈),所以正常请求量大概在600到800次。限流阈值定在每人10秒5次,正常情况下不会误伤,脚本刷课的频率远高于此,能被有效拦截。
提醒:限流阈值不要一开始就设得很死,先开观察模式,记录被限流的请求量和来源分布,跑一两天确认没有误伤正常用户后再正式生效。我当初直接把阈值调到每10秒3次,结果选课当天有学生因为网速慢重试被拦,投诉了一轮。
4.4 页面层的减负:静态化和异步化
前端这块也值得说两句。课程列表页面的数据变化频率很低,我把列表接口的响应做了HTTP缓存,加ETag,浏览器命中就不用再请求。选课按钮防抖是基础操作,点击后按钮立即置灰并显示倒计时,防止用户连点。
另外我把"选课结果查询"从同步改成了轮询:提交后返回一个任务ID,前端每两秒查一次结果。这个改动让提交接口的响应时间从平均300毫秒降到几十毫秒,因为接口只负责把请求投进队列,不等待最终结果。用户感知上差别不大,但服务端的瞬时承载能力提升明显。
5. 完整实操过程与压测验证记录
5.1 环境搭建的关键配置
部署环境我用的是4核8G的单机,Nginx在前,Gunicorn起8个worker,PostgreSQL和Redis同机部署。这个配置不算高,但用来验证思路足够了。
Gunicorn的启动参数我调过好几轮,最终用的是--workers 8 --worker-class gthread --threads 4 --max-requests 2000。用gthread而不是默认的sync,是因为选课接口里有Redis和数据库的IO等待,线程模型能在等待时切换到别的请求,吞吐更高。max-requests设2000是为了防止内存泄漏累积,worker处理够一定请求数后平滑重启。
gunicorn config.wsgi:application \ --bind 0.0.0.0:8000 \ --workers 8 \ --worker-class gthread \ --threads 4 \ --max-requests 2000 \ --max-requests-jitter 200 \ --timeout 30 \ --access-logfile -worker数量的经验算法是CPU核数的2到4倍,但真正该看的是数据库连接数上限。8个worker乘4个线程,理论并发是32,数据库连接池按这个上限配,留出30%余量即可。参数不是拍脑袋定的,要跟下游资源对齐。
5.2 压测脚本与数据准备
数据准备这块,我造了5000个学生账号、200门课程、每门课2到3个班次,容量设置成30到80不等。重点是把其中5门课标记为"热门",让压测时所有虚拟用户都抢这5门,制造真实的集中竞争。
Locust脚本的核心是模拟"登录、进列表、点选课、查结果"这个完整链路,而不是只打一个接口。因为真实压力来自完整链路,单接口压测会高估系统能力。
from locust import HttpUser, task, between import random class EnrollUser(HttpUser): wait_time = between(1, 3) def on_start(self): self.client.post("/api/login", json={"username": "stu_demo", "password": "demo"}) self.hot_schedules = [101, 102, 103, 104, 105] @task(5) def enroll_hot(self): sid = random.choice(self.hot_schedules) self.client.post("/api/enroll", json={ "schedule_id": sid, "request_id": f"req-{random.randint(1, 10**9)}" })压测分三轮:第一轮不做任何优化,摸基线;第二轮加缓存和限流;第三轮加异步结果查询。每轮跑5分钟,记录QPS、P95延迟、错误率和数据库连接峰值。
5.3 三轮压测的数据对比与解读
| 指标 | 未优化 | 加缓存与限流 | 加异步结果 |
|---|---|---|---|
| 平均QPS | 42 | 310 | 380 |
| P95延迟 | 2800ms | 210ms | 120ms |
| 错误率 | 18% | 2.1% | 0.4% |
| 数据库连接峰值 | 100(打满) | 34 | 31 |
| 名额超卖次数 | 7 | 0 | 0 |
第一轮的数据很难看,100个连接打满,错误率18%,主要是连接超时和事务等待超时。而且出现了7次超卖,原因是当时的逻辑是"查余量—判断—插入"三步分离,没有加锁也没有唯一约束保护,并发下必然出错。
第二轮加上了Redis预检、唯一约束、限流之后,错误率降到2.1%,超卖归零,数据库连接峰值骤降到34。剩下的2.1%错误主要是限流拒绝,属于预期内。
第三轮把结果查询异步化并做了列表接口缓存,QPS再涨23%,P95延迟减半。这一轮的收益主要来自响应体变小和长连接占用减少,说明减少单请求的服务器占用时间,比单纯提高处理能力更有效。
5.4 一个必须做的验证:超卖与重复选课的穷举测试
压测数据好看不代表正确。我单独写了一个正确性验证脚本:用50个并发线程对同一门容量为10的课发起1000次选课请求,跑完后核对三件事——选课记录数是否恰好等于10、是否有学生重复记录、Redis余量是否为0。
第一次跑就发现了问题:记录数是10没错,但Redis余量是-3。原因是异常路径里有几次扣减没回补。定位到是try_reserve里exists和decr之间有窗口,缓存过期重建时出现了偏差。后来把逻辑改成先用DECR,结果小于0再INCR回补,并把重建逻辑放进事务检查里,问题解决。这个测试务必在正式上线前跑通,它比任何压测都重要。
6. 高频问题排查实录与经验速查
6.1 "页面转圈很久最后失败"的排查路径
这是收到最多的反馈。排查顺序我固定成四步:先看Nginx的upstream_response_time,如果这个时间很短但用户仍觉得慢,说明是前端或网络问题;如果这个时间很长,进第二步看应用日志里该请求的耗时分布;第三步看数据库的慢查询日志和锁等待;第四步看Redis的响应延迟。
实际遇到最多的情况是锁等待。某门课的选课事务持有行锁时间过长,后面的请求排队,超过Gunicorn的30秒超时后连接被掐断。根因往往是事务里混进了慢操作。我后来在事务前后加了计时日志,超过50毫秒就打印调用栈,一抓一个准。
6.2 常见问题速查表
| 现象 | 可能原因 | 排查手段 | 处理办法 |
|---|---|---|---|
| 高峰期大量连接超时 | 连接池小于并发需求或泄漏 | 看数据库连接数曲线与worker数 | 调连接池、加CONN_MAX_AGE、排查未关闭连接 |
| 余量显示与实际不符 | 缓存未回填或扣减未回补 | 对比Redis值与数据库count | 缩短回填周期、修正回补逻辑 |
| 同一学生出现重复记录 | 缺唯一约束或约束未生效 | 查表约束定义与索引状态 | 补唯一索引、接口做幂等 |
| 选课成功后列表不显示 | 缓存脏读或主从延迟 | 查缓存键与查询路径 | 写后失效缓存、关键查询走主库 |
| 部分用户持续429 | 限流阈值过严或误判代理IP | 看限流日志的来源分布 | 放宽阈值、按用户ID而非IP限流 |
| 死锁报错偶发 | 批量操作加锁顺序不一致 | 看数据库死锁日志 | 统一按主键排序加锁 |
| 压测时QPS上不去但CPU空闲 | IO等待或锁竞争 | 看连接池等待、慢查询 | 缩短事务、加索引、异步化 |
| worker内存持续上涨 | 全局变量累积或连接未释放 | 观察RSS曲线 | 设max-requests、排查缓存对象 |
6.3 几条不在文档里的实操心得
第一,压测环境要和真实环境同构。我在本地用SQLite跑压测,数据好看得离谱,因为SQLite的锁行为和PostgreSQL完全不同。上线前必须在同款数据库上验证,否则数据毫无参考价值。
第二,日志要带请求ID贯穿全链路。高峰期排查问题时,一条请求从Nginx到应用到数据库的日志能串起来,定位效率差十倍。这个投入在出问题那天会全部赚回来。
第三,限流和业务提示要配合。被限流时返回一句"当前选课人数较多,请稍后重试"远比一个裸的429友好。我甚至在前端对429做了自动退避重试,用户几乎感知不到被拦。
第四,别急着上消息队列。很多人一说高并发就想上Kafka、RabbitMQ。这套系统的量级用Redis的list做简单队列完全够用,运维复杂度低一个数量级。技术选型要看当前规模,为三年后的量做设计是浪费。
第五,留一个降级开关。我在配置里放了几个开关:关缓存直连数据库、关异步走同步、关限流放全量。极端情况下能快速切换保可用。选课这种集中式场景,可用性比极致的性能更重要,宁可慢一点也不能全挂。
最后分享一个我反复验证过的小技巧:把选课接口的数据库操作压缩到"一条insert加一次带索引的锁查询",其他的判断全放到前面用缓存做。这个原则确立后,无论后面加什么功能,只要守住这条线,性能就不会失控。系统做复杂容易,做简单且稳,才是真功夫。