每年选课周运维群里总有人盯着监控大屏上那条陡然拉起来的曲线嘴里念叨着别崩别崩。Django 开发高校选课系统这件事平时写起来顺风顺水ORM一套、模板一渲染功能就跑通了真正让人掉头发的是开抢那一分钟——几千个学生同时点提交系统要么超时、要么超卖、要么干脆把数据库连接池占满。这篇东西就是围绕 Django 选课系统的高并发优化来展开的把库存扣减、缓存分层、异步落库、限流排队这几块拆开讲顺带把我踩过的坑和压测数据一并摊出来。适合已经能用 Django 写出增删改查、但一遇到并发就心里没底的同学也适合正在做课程设计、想把这个题目做得像点样子的在校开发者。代码主要基于 Django 4.x MySQL 8 Redis思路不依赖具体版本。1. 项目整体设计与思路拆解1.1 选课高峰的真实压力到底在哪很多人一提高并发选课脑子里第一反应是数据库扛不住然后就一头扎进分库分表。我实测下来选课场景的压力分布其实很不均匀绝大部分时间系统是闲的压力全挤在开抢后的 30 到 90 秒里。这种脉冲式流量和电商秒杀很像但有个关键区别选课有明显的身份约束——每个学生每门课只能选一次退课再选有次数限制学分上限还要校验。这意味着你没法像卖商品那样简单地把请求全丢进队列慢慢处理因为用户要立刻知道我到底选上没有。所以选课系统的核心矛盾是写操作必须强一致不能超卖一门课 60 个名额不能录进 61 个人但读操作可以容忍短暂的不一致。抓住这条很多设计就顺了。我的做法是把整个链路拆成快路径和慢路径快路径在 Redis 里完成名额判断和预占毫秒级返回结果给学生慢路径通过异步任务把预占结果落到 MySQL保证最终一致。学生在页面上看到的是选课成功处理中几秒后刷新变成确定状态体验上完全能接受。还有个容易被忽略的压力源选课页面本身的渲染。一个学生打开选课页要看到几百门课的可选状态、余量、时间冲突提示如果每次都实时查库聚合单次请求可能就是几十条 SQL。开抢瞬间几千人同时刷这个页面数据库直接被打穿。这块必须靠缓存顶住后面会细说。1.2 技术选型为什么没换框架有人会问Django 这种重框架做高并发是不是自讨苦吃为什么不换 FastAPI 或者 Go。我的判断是选课系统的瓶颈不在框架在数据库和业务流程设计。Django 的同步视图跑在 gunicorn 的多进程模型下单机能撑的并发量对于一所高校的选课场景是够的问题出在你会不会用。具体来说Django 有几个地方是天然的助力ORM 的事务管理和select_for_update用起来很直观做悲观锁的时候不容易写错F()表达式可以直接在数据库层做原子自增自减避免读-改-写的竞态自带的中间件和信号机制方便做限流和审计日志。这些在课程设计或者中小规模系统里开发效率优势非常明显。劣势也明摆着ORM 生成的 SQL 有时很啰嗦N1查询是常态模板渲染比现代前端框架重。应对办法是——关键路径上绕开 ORM直接写原生 SQL 或者用values()精简字段非关键路径照常用 ORM 保证开发速度。我个人的取舍是选课提交接口全程原生 SQL Redis Lua课程列表页用 ORM 加缓存后台管理随便写。技术栈最终定成这样层次选型主要作用备注Web 层Django 4.2 gunicorn处理请求、路由、权限用 gevent worker 还是 sync worker 视业务而定缓存层Redis 7名额预占、课程列表、限流计数单实例够用主从做备份数据库MySQL 8选课记录、课程、学生主数据事务隔离级别用 READ COMMITTED异步任务Celery Redis broker落库、通知、统计单独队列避免和业务抢资源压测locust模拟真实选课行为比 ab 更贴近真实场景1.3 分层设计与责任边界划分分层这件事听起来老套但在选课系统里边界划不清就是灾难。我见过一个反面例子视图函数里既查缓存、又扣库存、又发通知、还写日志一个接口 200 多行出了问题是缓存脏了还是库存超了根本没法定位。我的划分是这样的视图层只做参数校验、身份识别、调用服务、组装响应。不碰数据库不碰 Redis 的具体 key。服务层选课服务的核心逻辑包括学分校验、时间冲突校验、名额预占、结果回写。这一层是唯一允许操作库存的地方。数据访问层封装 Redis 的 Lua 脚本和 MySQL 的原生 SQL外层不关心底层是缓存还是库。异步层只负责把服务层产出的预占成功事件落库不参与任何判断逻辑。这条边界的价值在于当你想把 Redis 预占换成别的方案时只改数据访问层当你想加一个候补队列功能时只改服务层。视图层几乎是稳定的。注意不要让 Celery 任务反过来调用服务层的判断逻辑。异步任务只做执行不做决策否则并发下会出现同一个学生被扣两次名额这种诡异问题。2. 数据模型与数据库层的核心设计2.1 课程、教学班与容量的建模取舍新手最容易犯的建模错误是把课程和教学班合成一张表。一门高等数学可能有 8 个教学班每个班容量不同、上课时间不同、老师不同。合成一张表的话学生选课时你要在同一个课程 ID 下反复区分哪个班的余量索引设计会变得很难看。正确的拆法是三张表course课程基本信息、section教学班含容量、已选人数、上课时间、enrollment选课记录学生 教学班 状态 时间戳。选课操作实际作用于section表course只做展示。section表里最关键的字段是这两个CREATE TABLE section ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, teacher_id BIGINT NOT NULL, capacity INT NOT NULL, enrolled INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL, INDEX idx_course (course_id), INDEX idx_teacher_time (teacher_id, status) ) ENGINEInnoDB;enrolled这个字段是冗余的——理论上可以用SELECT COUNT(*) FROM enrollment算出来。为什么要冗余因为选课提交那一瞬间你要判断还有没有名额如果每次都去 count等于每次都在一张几百万行的表上做聚合必死。冗余一个计数字段配合原子更新是这道题的标准答案。代价是要保证enrolled和实际记录数一致这靠扣减和插入在同一个事务里来保证。version字段是给乐观锁用的后面会讲。capacity和enrolled的差值就是余量前端展示的剩余名额直接读这个差值。2.2 索引设计与查询路径规划索引不是越多越好选课系统的写操作频繁每个多余索引都会拖慢写入。我按查询路径来倒推索引第一条路径学生打开选课页要列出所有可选教学班及其余量。查询条件是status 1按课程分组。这条路径走idx_course但如果一次要查全校几百个班命中率会很低。解法是这个列表整体放缓存5 到 10 秒过期一次数据库只在缓存失效时被查一次。所以这条路径对索引要求不高。第二条路径提交选课时校验时间冲突查这个学生已有的选课记录比对上课时间段。查询条件是student_id ? AND status IN (1,2)。这条是高频路径必须有索引。enrollment表上建(student_id, status)联合索引把 status 放后面因为 student_id 区分度更高。第三条路径退课和后台统计。退课按(student_id, section_id)精确查建联合唯一索引顺便防止同一学生重复选同一门课——这个唯一约束很重要它是并发下的最后一道防线即使上面的逻辑判断被绕过数据库也会报错拦住。CREATE TABLE enrollment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, section_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 1, score DECIMAL(4,1) DEFAULT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_student_section (student_id, section_id), INDEX idx_student_status (student_id, status), INDEX idx_section_status (section_id, status) ) ENGINEInnoDB;这里有个细节uk_student_section是唯一索引学生退课后记录不删保留历史而是把status改成退课状态。这样唯一约束还在学生重新选这门课时会撞唯一键。解法是用INSERT ... ON DUPLICATE KEY UPDATE把 status 改回来同时判断影响行数。这个技巧后面代码里会写。2.3 名额扣减三种方案的实测对比名额扣减是整道题的核心。我试过三种写法效果差异巨大值得摊开说。方案一先查后改最朴素也最危险。section Section.objects.get(idsection_id) if section.enrolled section.capacity: section.enrolled 1 section.save()这段代码在并发下必然超卖。两个请求同时读到enrolled59、capacity60都判断通过都加一结果 61。我在本地用 50 并发压测100 个名额卖出去了 137 个触目惊心。这是新手最常踩的坑务必避开。方案二悲观锁select_for_update。with transaction.atomic(): section Section.objects.select_for_update().get(idsection_id) if section.enrolled section.capacity: return False section.enrolled 1 section.save()这个方案正确性没问题事务里拿到行锁其他请求排队等待。但问题在于锁的粒度太粗一门热门课的所有请求全串行化。1000 个请求抢 60 个名额本来 60 个成功之后剩下的 940 个都是无名额直接返回但在悲观锁下这 940 个请求要一个一个拿到锁、判断、发现没名额、释放锁排队时间累积起来非常可观。我压测时观察到热门教学班的接口 P99 从 80ms 飙到 1.8s还拖累了同库的其他请求。方案三原子更新加影响行数判断最终采用的方案。updated Section.objects.filter( idsection_id, status1, enrolled__ltF(capacity) ).update(enrolledF(enrolled) 1) if not updated: return False # 名额已满这条 SQL 在数据库层就完成了判断 扣减一条UPDATE ... WHERE enrolled capacityInnoDB 会加行锁但持锁时间极短就是执行这一条语句的时间而且没有名额时直接返回 0 行不产生锁等待堆积。实测同样的 1000 并发抢 60 名额P99 降到 120ms 左右数据库 CPU 从 90% 降到 45%。方案三在纯 MySQL 层面已经够用了。但选课系统的真实峰值往往超过单库能承受的范围所以还要在上面加一层 Redis 预占这就是后面第 3 章要讲的内容。这里强调一点Redis 预占只是减压阀MySQL 的原子更新必须保留它是最终一致性的保障任何声称有缓存在就不需要数据库约束的方案都是在给自己埋雷。3. 高并发核心实现缓存、原子脚本与异步落库3.1 名额预占的 Redis Lua 脚本怎么写Redis 单线程执行命令天然适合做原子判断。但要注意GET加SET是两条命令中间可能被插队。所以必须用 Lua 脚本Redis 保证脚本执行期间不会插入其他命令。我的名额预占脚本长这样-- KEYS[1]: 教学班库存 key, 如 section:stock:1001 -- KEYS[2]: 学生已选集合 key, 如 student:sections:2002 -- KEYS[3]: 预占成功待落库队列, 如 section:pending -- ARGV[1]: 学生ID -- ARGV[2]: 教学班ID -- ARGV[3]: 请求唯一ID用于幂等 -- 幂等检查同一个请求重复提交直接返回 if redis.call(SISMEMBER, KEYS[2], ARGV[2]) 1 then return 2 end local stock tonumber(redis.call(GET, KEYS[1]) or -1) if stock 0 then return -1 -- 缓存未初始化交给降级逻辑 end if stock 0 then return 0 -- 名额已满 end redis.call(DECR, KEYS[1]) redis.call(SADD, KEYS[2], ARGV[2]) redis.call(RPUSH, KEYS[3], ARGV[1] .. : .. ARGV[2] .. : .. ARGV[3]) return 1返回值约定1成功0无名额2重复选课-1缓存缺失需要降级。调用方根据返回值走不同分支。几个必须注意的点幂等靠学生的已选集合不靠请求 ID。学生连点三次提交第一次成功后两次直接返回已选避免重复扣减。这比维护请求 ID 集合省内存得多。库存 key 的初始化时机要设计好。我的做法是选课开始前 5 分钟用定时任务把当天开放的教学班余量批量刷进 Redis。如果脚本返回-1说明缓存没预热或者 key 过期了直接降级走 MySQL 原子更新同时在日志里打点告警。DECR之后一定要紧跟SADD和RPUSH三条命令在同一个脚本里保证原子性。如果拆成两次调用中间进程挂了库存扣了但没入队名额就丢了。关于pending队列这里用的是 Redis List由 Celery 消费者BRPOP消费。为什么不用 Redis 的 Stream因为选课场景对消息可靠性要求不是极致严格业务上允许极小概率的补偿后台有对账任务兜底List 更简单出问题好排查。3.2 异步落库与对账补偿机制消费者拿到pending队列里的记录执行真正的数据库写入shared_task(bindTrue, max_retries3) def persist_enrollment(self, payload): student_id, section_id, req_id payload.split(:) try: with transaction.atomic(): updated Section.objects.filter( idsection_id, status1, enrolled__ltF(capacity) ).update(enrolledF(enrolled) 1) if not updated: # 数据库层面已满说明缓存和实际有偏差 raise StockMismatchError(section_id) Enrollment.objects.create( student_idstudent_id, section_idsection_id, status1, created_attimezone.now() ) except StockMismatchError: # 回滚 Redis 预占补偿名额 rollback_redis_stock(section_id, student_id) except IntegrityError: # 唯一键冲突说明重复选课回滚 Redis 预占 rollback_redis_stock(section_id, student_id) except Exception as exc: raise self.retry(excexc, countdown2 ** self.request.retries)这里的设计要点是双向补偿。缓存说有名额数据库说没有这就是偏差必须把 Redis 里扣掉的名额加回去否则这门课的 Redis 库存会永久性地少一个越积越多最后一门 60 人的课在缓存里只剩 40 个名额。同理唯一键冲突意味着这个人之前确实选过缓存的学生集合可能因为过期丢了数据也要回补。对账任务是我强烈建议加的。每天凌晨跑一次把 Redis 里每个教学班的库存和 MySQL 的capacity - enrolled对一遍不一致就以数据库为准刷新缓存同时记录差异日志。这个任务平时看起来毫无产出但关键时刻能救命——我在测试环境故意 kill 掉消费者进程制造了 17 条丢失记录对账任务一键修好不用人工排查。提示异步落库意味着用户提交后状态是处理中。前端一定要轮询或长连接拉取最终状态并且给出明确的进度提示。我见过有系统提交后页面直接跳转用户以为成功了结果后台落库失败等到第二天上课发现名单里没自己。3.3 限流、排队与降级策略限流的目的不是拦住用户是保护后端不被瞬时流量打死。选课场景下我用了两层限流第一层在 Nginx 层做连接数限制按 IP 限速防止有人写脚本刷接口。配置大概这样limit_req_zone $binary_remote_addr zoneselect_zone:10m rate5r/s; location /api/enroll/ { limit_req zoneselect_zone burst10 nodelay; limit_req_status 429; proxy_pass http://django_backend; }第二层在 Django 服务层做全局令牌桶用 Redis 实现控制在途请求数。为什么要有第二层因为 Nginx 限的是单 IP如果一个宿舍 200 人共用一个出口 IP第一层直接把整个宿舍限死了这会引发投诉。服务层的令牌桶按全局速率放行允许突发但总速率可控。排队机制我用得比较克制。只有在预估并发超过容量上限比如选课首分钟时才开启排队其他时段直接放行。排队的实现是一个 Redis 计数器加前端轮询用户看到前面还有 132 人比看到一个转圈的加载动画体验好得多也减少了用户疯狂刷新造成的二次压力。降级策略写三条Redis 不可用 → 直接走 MySQL 原子更新接口耗时会上升但功能不丢。Celery 消费积压超过阈值 → 暂停接受新请求返回系统繁忙同时扩容消费者。数据库连接池耗尽 → 非核心接口课程详情、教师信息直接返回缓存旧数据把连接让给选课接口。这三条不是理论是我在压测时逐条验证过的。降级开关做成配置项能动态改别写死在代码里否则真出事的时候你还要发版。4. 实操过程从压测到上线的完整记录4.1 环境搭建与压测脚本设计环境我用的是 4 核 8G 的云主机两台一台跑 Nginx Djangogunicorn 8 个 sync worker一台跑 MySQL 和 Redis。这个配置在真实的高校场景里偏小但作为课程设计或中小规模系统完全够用压测出来的数据也有参考价值。gunicorn 的启动参数值得说一下gunicorn config.wsgi:application \ --workers 8 \ --worker-class sync \ --bind 0.0.0.0:8000 \ --timeout 30 \ --max-requests 2000 \ --max-requests-jitter 200 \ --access-logfile - \ --error-logfile ---workers 8是 CPU 核数的 2 倍4 核 × 2。这是同步 worker 的经验公式因为 Django 的同步视图大部分时间在等 IO多开几个进程能提高利用率。--max-requests 2000加随机抖动是为了防止潜在的内存泄漏累积worker 处理一定请求后自动重启。--timeout 30比默认的 30 秒保持一致选课接口正常情况下不该超过 3 秒超过 30 秒基本就是卡死了让它超时释放比拖着强。压测脚本用 locust 写模拟真实行为比纯压接口有意义得多。我的脚本里虚拟用户会先访问课程列表读多再随机挑一门课提交写提交后 30% 概率再刷一次列表看余量。这个比例是根据真实访问日志估的读写比大约 8:2。from locust import HttpUser, task, between import random class SelectUser(HttpUser): wait_time between(0.5, 1.5) def on_start(self): self.client.post(/api/login/, json{username: stu001, password: x}) self.section_ids [random.randint(1001, 1200) for _ in range(20)] task(8) def view_list(self): self.client.get(/api/sections/?page1) task(2) def enroll(self): sid random.choice(self.section_ids) self.client.post(/api/enroll/, json{section_id: sid})压测前有个细节必须处理每个虚拟用户要登录成不同的学生。如果所有请求都用一个账号唯一索引直接把人拦住压测数据全是 409根本测不出真实压力。我的做法是预先在数据库里灌 3000 个测试学生on_start里按 worker 序号分配。压测结果我整理成一张表并发数无优化 QPS加 Redis 预占 QPS无优化 P99优化后 P99超卖情况2003101150620ms95ms无500330已饱和21002.4s130ms无1000报错率 12%2600超时180ms无2000大面积超时2450接近上限超时240ms无从数据能看出两个结论一是加不加 Redis 预占吞吐差 6 到 8 倍二是优化后并发到 2000 左右吞吐不再增长说明瓶颈转移到了 gunicorn 的 worker 数或者数据库连接池。这符合预期2000 并发对一所高校来说已经远超实际峰值了。4.2 数据库连接池与关键参数计算连接池这个坑我踩得比较深。Django 默认每个请求结束后关闭数据库连接CONN_MAX_AGE0高并发下频繁建连断开MySQL 的Threads_created指标疯涨。改成持久连接DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: course_selection, CONN_MAX_AGE: 600, CONN_HEALTH_CHECKS: True, OPTIONS: { charset: utf8mb4, init_command: SET sql_modeSTRICT_TRANS_TABLES, }, } }CONN_MAX_AGE600表示连接复用 10 分钟。CONN_HEALTH_CHECKS在 Django 4.1 后加入复用前探活避免用到已断开的连接。这里要算一笔账8 个 gunicorn worker每个 worker 的持久连接数是 1同步 worker 单线程总共 8 个连接。看起来很少但 Celery 消费者呢我开了 4 个并发消费者每个 4 个连接16 个。加上后台管理、定时任务、监控探针实际峰值可能在 40 个左右。MySQL 默认max_connections151够用但要盯着。如果你用的是 gevent 或者异步 worker情况就完全不同了——一个 worker 内可能同时有几十个协程在查库这时必须显式配置连接池。我测过 gevent 模式下 8 个 worker 打满连接数飙到 300 多直接把 MySQL 拒了。所以我的建议是同步 worker 就老老实实用同步别为了看起来并发高去上 gevent选课系统的瓶颈不在 worker 类型。Redis 这边算的是内存。一个教学班两个 key库存 已选集合假设 2000 个教学班、每班 60 人库存 key 大约 100 字节 × 2000 200KB已选集合最坏情况 60 个成员 × 2000 12 万成员每个成员几十字节总共 10MB 上下。加上课程列表缓存整个 Redis 占用不超过 200MB1G 内存的实例绰绰有余。这个账要提前算别等到 OOM 了才想起来。4.3 完整选课接口的实现与踩坑记录把前面几块拼起来选课服务层的核心代码大概是这样def enroll_course(student_id: int, section_id: int, req_id: str) - dict: # 1. 前置校验学分上限、时间冲突走缓存或轻量查询 if not check_credit_limit(student_id): return {code: 4001, msg: 超出学分上限} if check_time_conflict(student_id, section_id): return {code: 4002, msg: 上课时间冲突} # 2. Redis 原子预占 result redis_client.evalsha( LUA_SHA, 3, fsection:stock:{section_id}, fstudent:sections:{student_id}, section:pending, student_id, section_id, req_id ) if result 0: return {code: 4003, msg: 名额已满} if result 2: return {code: 4004, msg: 已选过该课程} if result -1: # 降级直接走数据库 return enroll_from_db(student_id, section_id) return {code: 0, msg: 提交成功正在处理, req_id: req_id}看起来简洁但里面有几处细节是踩过坑才补上的。第一处是时间冲突校验的位置。最开始我把它放在 Redis 预占之后想着先抢名额再校验。结果发现一个学生如果时间冲突名额已经扣了再去回滚白白浪费一次库存操作。更糟的是如果回滚失败名额就永久丢了。后来改成前置校验虽然前置校验本身有性能开销但它拦掉的是无效请求反而减轻了核心路径的压力。第二处是学分校验的缓存策略。学分上限是 30 学分每门课 3 学分也就是说一个学生最多选 10 门。这个数据在选课过程中变化频繁只能实时算。我的做法是给每个学生维护一个 Redis 的已选学分计数器预占成功时INCRBY退课时DECRBY校验时直接读。这个计数器要和数据库对账防止偏差累积。第三处是冷启动时的缓存预热。选课开始前 5 分钟的预热任务我只做了库存初始化忘了初始化学生已选集合。结果开抢瞬间有些之前已经选过课的学生重复提交缓存里没有他们的已选集合脚本判断通过双份请求都进了队列全靠数据库唯一键拦住并触发回滚。这个 bug 在压测里没暴露是真实上线后从告警里发现的。后来预热脚本里加了一步把所有有历史选课记录的学生集合批量刷进 Redis。经验预热脚本写完一定要在测试环境模拟冷启动跑一遍把 Redis 清空、按预热的顺序执行、然后立刻打流量。这个流程能发现 90% 的初始化问题。5. 常见问题与排查技巧实录5.1 典型问题速查表下面这张表是我和几个同学在实际做这个项目的过程中积累的按现象—可能原因—排查方法整理遇到问题可以直接对号入座。现象可能原因排查方法处理方式名额卖超用了先查后改的写法或 Redis 脚本没用 Lua查enrolled和 enrollment 记录数是否一致改成原子更新 Lua缓存满但实际有余量落库失败后没回补 Redis 库存对账任务看差异日志加上 rollback 逻辑缓存有余量但数据库满了缓存预热数据过期或有人直接改库检查预热时间和缓存 TTL对账刷新缓存接口偶发超时数据库行锁等待堆积SHOW ENGINE INNODB STATUS看锁等待加 Redis 预占缩小事务提交后没反应Celery 消费者挂了或队列积压看 Redis List 长度、Celery worker 状态扩容消费者、加告警同一学生重复入队幂等集合没初始化或过期查该学生的 Redis 集合是否存在预热补齐、设置合理 TTL连接池耗尽持久连接没配或 worker 开太多SHOW PROCESSLIST看连接数调CONN_MAX_AGE、算连接数压测数据全失败所有虚拟用户共用一个账号查响应是否都是唯一键冲突按 worker 分配测试账号列表页卡死数据库每次实时聚合课程余量慢查询日志看聚合 SQL列表整体缓存 5-10 秒退课后名额没释放退课逻辑只改了数据库没改 Redis对比两侧库存退课同步操作两边走脚本这张表里最容易被忽略的是缓存满但实际有余量这一条。它不会立刻报错而是慢慢积累一开始只是某几门课余量显示少了等到选课高峰期几百个名额凭空消失学生投诉你查数据库发现明明有名额。这类数量对不上的问题靠人眼几乎不可能查出来必须靠对账任务发现。5.2 独家避坑心得心得一不要迷信缓存能解决一切。我见过一个方案把整个选课逻辑都放 Redis数据库只做异步落库完全不校验容量。这个方案在压测下性能爆表但一旦 Redis 主从切换丢了少量数据超卖就是必然的。选课是有行政后果的业务超卖意味着教室坐不下必须有人被请出去。所以数据库的UPDATE ... WHERE enrolled capacity这条防线无论缓存多快都不能拆。心得二事务要短短到只有一两条语句。最开始我把check_credit_limit、check_time_conflict、扣库存、插入记录全放在一个transaction.atomic()里。结果是事务持锁时间长达几百毫秒并发一上来就死锁。后来拆成校验在事务外事务里只保留扣库存 插记录两条语句。这样事务的执行时间从 300ms 降到 8ms死锁率直接归零。判断标准很简单——事务里不要有网络调用、不要有复杂查询、不要有循环。心得三日志要能回答这个请求到底经历了什么。每次选课请求生成一个req_id从前端传到 Redis 队列再到 Celery 任务最后到数据库记录的备注字段。出问题时拿req_id一搜整条链路一目了然什么时候进的服务层、Redis 返回了什么、队列什么时候消费的、落库成功还是回滚了。这个设计在排查那个重复入队的 bug 时救了我五分钟定位到问题不用去猜。心得四压测要压全流程别只压提交接口。我一开始只压/api/enroll/数据很好看2000 并发 2600 QPS。后来补上列表页的流量整体吞吐掉到 900。原因就是列表页的聚合查询把数据库连接占满了选课接口拿不到连接。真实场景里读请求远多于写请求只压写接口等于自欺欺人。心得五留一个演练开关。我在配置里加了一个CHAOS_MODE打开后可以人为让 Redis 超时、让 Celery 消费延迟、让数据库慢查询。上线前用这个开关把所有降级路径跑了一遍发现降级到数据库时有一个字段没处理导致返回 500。如果没有演练这个 bug 会在真正的故障时才暴露。演练开关在正式环境默认关闭只允许运维通过配置中心打开用完立刻关。关于后续扩展其实还有几个方向可以往下做。一是候补队列名额满了之后学生可以排队有人退课自动补位这个功能在真实高校里很受欢迎实现上就是给每个教学班维护一个 Redis List退课时LPOP一个候补学生并发通知。二是分时段放行按年级或学号段分批开放选课从源头削峰比任何技术优化都有效。三是选课数据的实时大屏用 Redis 的 HyperLogLog 统计各学院的选课人数运维和教务能在选课过程中实时看到态势出问题提前干预。这三个方向里我觉得最值得先做的是分时段放行——它不需要改代码架构只要在开放时间上加个判断削峰效果立竿见影。