资讯详情

Django高校选课系统高并发优化:余量扣减、缓存削峰与限流实战

📅 2026/10/28 19:49:33 | 华诺云谱 👁 阅读
Django高校选课系统高并发优化:余量扣减、缓存削峰与限流实战
开选那天下午三点走廊里的嘈杂声比机房风扇还响。选课系统页面刚放出来不到十秒负责盯盘的同事就看到接口平均响应从 80 毫秒飙到 6 秒然后是一片 502。学生那边刷不进去老师这边后台的余量数字还在往下掉但掉得对不上——明明只剩 3 个名额的课选上的人却有 11 个。这次事故之后我把整套 Django 选课系统的并发路径从头拆了一遍做了三轮回炉重造才有了后面几轮开选时相对平稳的场面。这篇就聊聊我在这个过程中摸索出的东西Django 高校选课系统的高并发优化到底卡在哪几个点上哪些方案是真正有效的哪些是看着很美、上线就翻车的。内容偏实战经验适合正在做或准备做教务类系统、秒杀类业务的同学参考哪怕你只写过最基础的 CRUD也能看懂每个决策背后的取舍。1. 选课系统真正的敌人不是人多而是几分钟内的资源争抢结构1.1 一次开选窗口的流量形状长什么样很多人一提高并发就想到日活多少万QPS 多少这在选课场景里是完全跑偏的指标。选课系统的流量不是均匀分布的它是一条极其陡峭的尖峰曲线。平时一天可能只有几百个请求开选那一刻的两三分钟里几千名学生同时刷新请求量可能是平时的几百倍。真正决定系统生死的不是全天的总请求数而是那 120 秒内的瞬时并发和锁竞争强度。我在第一版系统里做过统计某个学期开选前 90 秒内产生的请求占了整个选课周期五天请求量的 62%。这意味着你按平均值去估算服务器配置结果是灾难性的。而且这个尖峰有个特点——它不是随机的是所有人被同一个时间点喊起来的所以请求的到达几乎是同步的前端倒计时一归零几千个 TCP 连接同时涌进来。这种模式下排队论里那些平滑到达的假设全部失效你面对的是一个脉冲。更麻烦的是尖峰里混着大量无效请求。学生为了抢课会开三四个浏览器窗口或者写好脚本疯狂点。这些请求本身不产生业务价值但一样会消耗连接、线程、数据库资源。我在日志里看到过单个 IP 在 30 秒内发起 400 多次选课请求的记录。所以做容量规划时真实有效的并发数要打折一般我按观测到的并发数的 30% 左右估算有效负载剩下的都是噪音。1.2 三类请求的代价完全不同不该用同一套方案对待一开始我把所有接口都当成普通请求来处理结果就是最重的写请求和最轻的静态查询抢同一批 worker。后来我把选课系统的请求按代价重新分了类这个分类直接决定了后面每一层的优化策略请求类型典型接口特征优化方向写-热点提交选课、退课强一致、有行竞争、必须落库前置拦截、扣减原子化、异步落库写-冷门提交选课冷门课强一致、几乎无竞争直接走数据库即可读-列表课程列表、余量展示可容忍秒级延迟、量极大缓存、合并请求读-个人我的课表、已选结果强一致、量中等短 TTL 缓存 主动失效这张表是我在第二版重构时贴在白板上的它帮我避免了一个很常见的错误把所有读请求都缓存掉。学生的我的课表如果缓存 30 秒学生选完课刷新看不到结果会以为没选上然后疯狂重试把压力又放大一轮。所以读个人结果这类接口必须保证自己写自己读的一致性缓存只能用在课程列表这种公共数据上。热点写和冷门写的区分也很关键。如果你对冷门课程也上那套Redis 预扣减 消息队列的重型方案反而会因为链路变长、失败重试引入更多不一致。我的做法是在路由层做一个简单判断课程的容量和当前报名人数差值大于某个阈值比如 20说明还在安全区间直接走数据库条件更新一旦逼近临界才切到前面的重链路。这个阈值不是拍脑袋是按单次条件更新的平均耗时和预期并发算出来的——如果一次更新要 2 毫秒20 个名额在 200 并发下大概能撑 200 毫秒足够让第一波流量被自然消化。1.3 先算清楚容量再谈优化我发现很多人做优化是先上缓存再说先加机器再说没有一个量化的基线。我在第二版动手之前先做了一件很朴素的事把一次选课请求从进来到落库的每一段耗时都打上日志包括中间件耗时、ORM 查询耗时、事务提交耗时。测出来的数字是这样的单机、模拟数据请求进入 - 中间件鉴权 : 1.2 ms 鉴权 - 查课程余量一次 SELECT: 0.8 ms 查余量 - UPDATE 扣减 : 1.1 ms UPDATE - 插入选课记录 : 0.9 ms 事务 COMMIT : 2.4 ms 响应序列化 : 0.5 ms ------------------------------------------------ 合计 : 约 6.9 ms单看 6.9 毫秒好像还行但这是单请求无竞争的情况。一旦热门课程那行被锁住后续请求全部在 COMMIT 那里排队实际 P99 能到几秒。这个拆解让我意识到优化重点不在把 6.9 毫秒降到 5 毫秒而在于让排在锁后面的请求变少。所有后续方案围绕的都是这一句话。提示不要在没有基线的系统上做并发优化。先埋点、先压测拿到各段耗时和失败率否则你改完根本不知道是变好了还是变坏了。2. 把余量这个唯一真相源管住表结构与扣减逻辑2.1 余量为什么要从课程表里拆出来第一版的表设计很教科书课程表 Course 里有capacity容量和enrolled已选人数选课的时候Course.objects.filter(idcid, enrolled__ltF(capacity)).update(enrolledF(enrolled)1)。看着挺优雅跑起来才发现问题Course 表是个宽表上面挂着课程名、简介、教师、学分、上课时间、教室等等一堆字段更新这个表的任何一行都会触发热点行锁还会影响所有读这条课程信息的查询。更严重的是课程信息本身是低频变更的数据但enrolled是高频变更的。你把低频和高频字段放在同一行等于让每次选课都去撞一个被大量读取的行。InnoDB 的行锁虽然只锁一行但这一行的更新会带来 redo log 的写入、索引维护如果 enrolled 上有索引以及潜在的间隙锁风险。我的做法是拆出一张独立的余量表class CourseSlot(models.Model): course models.OneToOneField(Course, on_deletemodels.CASCADE, primary_keyTrue, related_nameslot) capacity models.IntegerField() remain models.IntegerField(db_indexTrue) version models.IntegerField(default0) updated_at models.DateTimeField(auto_nowTrue)这张表只有四个字段行非常窄更新极快而且不会有其他查询来扫描它。课程详情页需要余量时通过course.slot.remain拿配上 select_related 一次查询就够。拆表之后我实测同一台机器上的争用情况明显下降原因很朴素——行变窄了索引少了更新的事务日志也小了。2.2 条件更新优于 select_for_update 的理由新手最容易写出的高并发扣减是这样的with transaction.atomic(): slot CourseSlot.objects.select_for_update().get(course_idcid) if slot.remain 0: raise SoldOut slot.remain - 1 slot.save()逻辑没问题但代价很大。select_for_update会在数据库层加排他锁锁从 SELECT 一直持有到事务结束中间还夹着 Python 的对象处理和 save 的 UPDATE。锁持有时间越长排队越严重。而且如果事务里还有别的操作比如插入选课记录、写入日志锁的时间会进一步拉长。我在压测里对比过同样 200 并发打同一门课这种写法 P99 大概 3 秒下面这种写法 P99 能压到 400 毫秒左右。from django.db import transaction from django.db.models import F with transaction.atomic(): updated CourseSlot.objects.filter( course_idcid, remain__gt0 ).update(remainF(remain) - 1, versionF(version) 1) if updated 0: raise SoldOut Enrollment.objects.create(student_idsid, course_idcid, statusactive)核心差别在于判断和扣减合并成了一条 SQL。UPDATE ... WHERE remain 0在数据库内部是一个原子操作数据库自己保证条件的检查和修改不会被其他事务插入。这条语句的锁持有时间极短几乎就是一条 SQL 的执行时间排队窗口小得多。这也是我一直推荐的原则能用一条 SQL 表达的原子性不要用应用层的事务加锁去模拟。注意F()表达式很关键。如果你写成slot.remain - 1再 save还是读-改-写三步会丢更新。必须让数据库在服务端完成计算。2.3 唯一索引是防重复选课最便宜的一道闸超卖解决了还有重复选课。学生在页面上连点或者网络重试同一个学生可能对同一门课提交两次。如果只靠应用层判断是否已选在高并发下依然有竞态窗口——两个请求同时查询未选都通过了然后都插入。最省事的做法是在数据库层加唯一约束class Enrollment(models.Model): student models.ForeignKey(Student, on_deletemodels.CASCADE) course models.ForeignKey(Course, on_deletemodels.CASCADE) status models.CharField(max_length16, defaultactive) created_at models.DateTimeField(auto_now_addTrue) class Meta: constraints [ models.UniqueConstraint( fields[student, course], nameuniq_student_course ) ]插入时如果重复数据库会抛IntegrityError捕获之后返回你已经选过这门课即可。这比任何应用层的分布式锁都便宜、都可靠。退课的时候我不用删除记录而是把 status 改成dropped。这样重新选课时会遇到唯一约束冲突处理方式变成 update 而不是 inserttry: with transaction.atomic(): ... Enrollment.objects.create(student_idsid, course_idcid) except IntegrityError: # 可能是重复选课也可能是退课后重选统一走 update 路径 reopened Enrollment.objects.filter( student_idsid, course_idcid, statusdropped ).update(statusactive, created_attimezone.now()) if not reopened: return JsonResponse({ok: False, msg: 已选过该课程})这里有个坑要提醒唯一约束冲突会让当前事务进入不可用状态PostgreSQL 下整个事务被标记为 aborted所以create必须放在一个独立的atomic()块里捕获异常后在块外做后续处理不能在一个大事务里吞掉 IntegrityError 继续往下跑。我第一版就是在这里栽的报错信息很隐晦查了半天才反应过来是事务已中止。2.4 热点行的极限分桶扣减怎么落地上面这些做完普通课程基本稳了。但总有那么几门爆款课比如某位老师的通识选修全校几千人抢 100 个名额。这时候不管你的 SQL 多快同一行的更新天然是串行的数据库每秒能处理的更新次数是有物理上限的压测下来大概在 2000-3000 次每秒跟机器和参数有关。超出的请求只能排队或者失败。我的解法是分桶——把这个逻辑上的余量拆成若干个物理行class SlotBucket(models.Model): course_id models.IntegerField() bucket_no models.SmallIntegerField() remain models.IntegerField() class Meta: constraints [ models.UniqueConstraint(fields[course_id, bucket_no], nameuniq_course_bucket) ]假设一门课 100 个名额拆成 10 个桶每桶 10 个。请求进来时用学生 ID 做哈希取模选一个桶保证同一学生的多次请求落在同一个桶便于退课回滚然后对该桶做条件更新bucket_no student_id % 10 with transaction.atomic(): updated SlotBucket.objects.filter( course_idcid, bucket_nobucket_no, remain__gt0 ).update(remainF(remain) - 1) if updated 0: # 本桶满了尝试在其余桶里找空位 for other in range(10): if other bucket_no: continue if SlotBucket.objects.filter(course_idcid, bucket_noother, remain__gt0).update(remainF(remain) - 1): return JsonResponse({ok: True, bucket: other}) return JsonResponse({ok: False, msg: 已满})这样一来同一门课的并发写压力被摊到了 10 行上理论上吞吐量提升一个量级。代价是余量查询变复杂了要Sum(remain)但这个查询可以走缓存不在关键路径上。分桶数量不是越多越好桶多了会导致某些桶空了、某些桶还有但用户没找到的情况增加回退逻辑的尝试次数变多。我的经验是桶数控制在 8-16 之间每桶容量不少于 5否则得不偿失。提示分桶会带来一个副作用——已选人数和剩余名额的统计变成近似值。如果业务上必须精确就要在缓存层定期汇总回写或者接受秒级误差。这一点一定要提前跟业务方讲清楚不要自己闷头改完再解释。3. Django 进程模型与连接管理很多高并发问题其实是部署问题3.1 gunicorn worker 数量与数据库连接总数的乘法关系我见过不少团队代码优化做了一堆结果一上量还是崩最后发现是数据库连接被打满了。Django 默认每个进程按需建连CONN_MAX_AGE默认是 0每个请求结束就关连接。听起来很安全实际上在高并发下频繁建连本身就是巨大开销TCP 握手加上数据库的认证握手一次可能几毫秒到十几毫秒比业务 SQL 还贵。把CONN_MAX_AGE调大之后连接被复用单请求耗时立刻下降。但新的问题来了每个 gunicorn worker 会长期持有 N 个数据库连接总连接数 worker 数 × 每 worker 并发数。假如你开了 8 个 sync worker每个处理一个请求那最多 8 个连接还算可控。但如果你用了 gevent 模式一个 worker 里跑几百个 greenlet那就可能瞬间冒出几百个连接直接把数据库的max_connections打穿。我在一个中等规模的部署上用的是这样的组合# settings.py 片段 DATABASES { default: { ENGINE: django.db.backends.postgresql, NAME: course_db, CONN_MAX_AGE: 60, # 复用 60 秒别设 None永久 CONN_HEALTH_CHECKS: True, # 复用前做健康检查 OPTIONS: { connect_timeout: 3, options: -c statement_timeout5000, }, } }CONN_MAX_AGE我刻意不设成None因为永久连接在数据库重启、网络抖动之后会变成死连接而 Django 的健康检查在旧版本里并不完善结果就是偶发的server closed the connection unexpectedly。60 秒是个折中值既能复用大部分连接又给了自动回收的窗口。statement_timeout是我强烈建议加的防止某条慢 SQL 把连接占死——选课场景里一条卡住 30 秒的查询可能拖垮整个连接池。启动命令上sync worker 的数量我一般按CPU 核数 × 2 1起步但选课系统是 IO 密集型纯 sync 模式撑不住几千并发。后来改成 geventworker 数降到 4每个 worker 500 个 greenlet同时在数据库前面加了一层连接池代理把应用侧的上百个连接收敛成数据库侧的几十个。这一步做完同样硬件下能扛的并发翻了大概三倍。3.2 ATOMIC_REQUESTS 在这类场景下要不要开ATOMIC_REQUESTS True是个很诱人的配置它把每个请求都包在一个事务里省得你手动写atomic()。但在选课这种写密集场景我建议关掉。原因在于事务的生命周期。开了这个开关之后事务从请求进入视图就开始了一直到响应生成完毕才提交。这中间包含了模板渲染、JSON 序列化、甚至一些不必要的查询。事务越长持有的锁越久并发度越低。我在压测里对比过同一个接口开ATOMIC_REQUESTS的 P99 是关掉它的 2.5 倍左右。关掉之后事务边界由我自己控制只包住真正需要原子性的那几条语句def enroll(request): # 事务外的准备工作参数校验、鉴权、读取缓存 ... with transaction.atomic(usingdefault): # 只包住扣减和插入 ... # 事务外的收尾写日志、更新缓存、返回 ...这样事务窗口可能从几十毫秒缩到几毫秒锁竞争立刻缓解。代价是你要自己保证一致性边界清晰别把该在一个事务里的操作拆散了。我的原则是凡是会改变余量和选课记录的操作必须在同一个事务里其余一律放在事务外。3.3 异步视图不是银弹同步 ORM 的边界在哪Django 3.1 之后支持 async 视图很多人以为换成async def就能提升并发。这是个很典型的误解。如果你的视图里调用的还是同步 ORMDjango 会在内部用线程池把它包起来sync_to_async本质上没有减少阻塞只是换了个地方排队。更糟的是如果你在 async 视图里直接调用同步 ORM 而没做适配会抛SynchronousOnlyOperation。我在一个非关键接口上试过纯 async 的写法实测下来收益主要来自等待外部 IO的场景比如调用第三方的短信接口、对象存储。对于选课这种以数据库交互为主的逻辑改成 async 之后吞吐量提升非常有限因为我 90% 的时间都花在等数据库上而数据库连接本身是同步的。所以我的结论是选课主链路保持同步视图 gevent 或线程池不要为了 async 而 async。只有在需要处理长连接比如选课结果的实时推送、排队进度的 SSE时才用 async 视图并且把数据库操作明确地包进sync_to_asyncfrom asgiref.sync import sync_to_async async def queue_status(request, ticket_id): status await sync_to_async(fetch_ticket_status, thread_sensitiveTrue)(ticket_id) return JsonResponse({status: status})thread_sensitiveTrue这个参数别省它保证同步代码跑在同一个线程上下文里避免数据库连接在线程间乱窜导致的事务问题。4. 缓存与削峰哪些请求根本不该见到数据库4.1 Redis 预扣减 异步落库的完整链路到了这一步如果你的爆款课还是扛不住就得上预扣减了。思路是把库存判断从数据库挪到 Redis用 Lua 脚本保证原子性数据库只负责最终落库。-- reserve.lua local key KEYS[1] local remain tonumber(redis.call(GET, key)) if remain nil then return -1 -- 库存未初始化 end if remain 0 then return 0 -- 已售罄 end redis.call(DECR, key) return remain - 1 -- 返回扣减后的余量Django 侧调用RESERVE_SCRIPT open(scripts/reserve.lua).read() reserve redis_client.register_script(RESERVE_SCRIPT) result reserve(keys[fcourse:remain:{cid}]) if result 0: return JsonResponse({ok: False, msg: 名额已满}) if result -1: # 缓存没预热回退到数据库路径 return fallback_to_db(request, cid)扣减成功后把选课请求投递到消息队列由消费者落库。前端拿到的是一个受理中的凭证然后轮询结果。这条链路的关键设计点有三个。第一Redis 里的库存必须在开选前预热并且和数据库保持一致如果 Redis 挂了重启数据丢失会导致超卖或漏卖所以必须有一个从数据库重建库存的脚本并且 Redis 要开持久化。第二Lua 脚本里只做扣减不做其他事保持极短这样才能扛住高 QPS。第三消息队列要有重试和死信落库失败的请求必须能被人工或定时任务补偿否则学生收到了受理中却永远选不上。我实测下来这套链路的单机 QPS 能到几万瓶颈完全转移到消息队列和消费速度上。但它的复杂度也是实打实的多了一套组件、多了一份状态、多了一致性问题要处理。所以我只在真正需要的时候用它普通课程老老实实走数据库条件更新。4.2 缓存一致性余量展示允许轻微不准吗课程列表页是读请求的大头一个页面可能展示上百门课每门课都要显示余量。直接查数据库每次几百次查询肯定扛不住。这里必须用缓存但缓存策略要分级。我的做法是用 Redis Hash 存每门课的余量key 是course:remainfield 是 course_id。列表页一次性HMGET拿所有需要的课程一次网络往返搞定。余量更新时数据库扣减成功之后同步更新这个 Hash写后更新不是删除。这里会有极短时间的窗口期两个并发请求可能更新顺序颠倒导致缓存里的值比实际多一点或少一点。对选课场景来说这个误差是可以接受的——学生看到的余量差 1 个最坏情况是他点进去发现满了得到名额已满的提示体验上有一点落差但不会造成业务错误因为真正的扣减是以数据库的条件更新为准的。关键原则是缓存只用于展示不作为扣减依据除非是 4.1 那套完整方案。我把这套策略写成了表格方便自己回顾时对照数据类型缓存位置TTL更新方式允许误差课程基本信息本地内存 Redis5 分钟定时刷新无课程余量展示Redis Hash无常驻写后更新±1秒级我的课表Redis String60 秒写后删除无必须精确教室/教师信息本地内存10 分钟定时刷新无我的课表我选择写后删除而不是写后更新因为它的结构比较复杂重建成本高而访问频率远低于列表页删除之后下次读取再重建完全扛得住。这是一个很实用的取舍读多写少用写后更新写多读少或者结构复杂用写后删除。4.3 限流、排队与幂等把无效流量挡在门外前面聊的都是让系统处理得更快但真正省钱的做法是少处理一些请求。开选那几分钟里至少有三分之一是无效流量。第一道闸是限流。按学生 ID 维度做而不是 IP——同一栋宿舍楼可能是一个出口 IP按 IP 限流会误伤。用 Redis 的计数器窗口 1 秒阈值设在 3 次def allow_request(student_id, limit3, window1): key frl:enroll:{student_id} pipe redis_client.pipeline() pipe.incr(key) pipe.expire(key, window) count, _ pipe.execute() return count limit超过阈值的直接返回操作过于频繁一行数据库都不用碰。这个简单的措施在压测里帮我挡掉了大概 40% 的请求。第二道闸是排队。当瞬时并发超过系统容量时与其让所有请求都超时失败不如让一部分人排队。我用一个 Redis 计数器记录当前正在处理的请求数超过阈值就返回一个排队页码前端每 2 秒轮询一次排队进度。这里有个小技巧排队页面的轮询间隔要加随机抖动否则几千人同时轮询又是一个新的尖峰。抖动的范围我设的是 1.5 到 2.5 秒之间随机。第三道闸是幂等。学生网络不好请求发出去了但没收到响应重试时会带着同一个幂等键前端生成的 UUID。服务端用 Redis 的SETNX判断这个键是否处理过处理过就直接返回上次的结果不再走业务逻辑。这个机制在弱网环境下特别有用我统计过它能减少大概 8% 的重复写入。注意限流阈值不要一刀切。我一开始对所有学生用同一个阈值结果发现有些学生确实需要连着退选再选比如调整课表被误限了。后来改成对新提交限流、对退课放宽投诉少了很多。5. 压测与线上排查怎么证明优化真的起作用了5.1 压测脚本要模拟选课不是模拟访问我见过太多压测脚本就是拿一堆 URL 疯狂 GET。这种压测对选课系统毫无意义因为真正的瓶颈在写路径的锁竞争上而 GET 请求根本不涉及锁。我的压测脚本用 locust 写核心是模拟真实行为每个虚拟用户先登录拿 token然后随机从课程池里挑一门课提交其中 70% 的请求集中在 5 门热门课上模拟真实的扎堆行为30% 分散到冷门课。每个用户还会随机触发退课、查询课表等操作比例大概是 10:1:3。from locust import HttpUser, task, between import random class StudentUser(HttpUser): wait_time between(1, 3) def on_start(self): r self.client.post(/api/login, json{sid: self.user_id}) self.token r.json()[token] self.headers {Authorization: fBearer {self.token}} task(7) def grab_hot_course(self): cid random.choice([101, 102, 103, 104, 105]) self.client.post(/api/enroll, json{course_id: cid}, headersself.headers, name/api/enroll [hot]) task(3) def grab_cold_course(self): cid random.randint(200, 400) self.client.post(/api/enroll, json{course_id: cid}, headersself.headers, name/api/enroll [cold]) task(2) def view_my_courses(self): self.client.get(/api/my-courses, headersself.headers)name参数很关键它让报表里能把热门和冷门的指标分开统计。如果混在一起看冷门课的快速响应会把平均值拉得很好看掩盖了热门课 P99 已经炸掉的事实。压测的指标我只看四个P99 延迟、错误率、数据库连接数、热门课那一行的锁等待时间。前两个看整体健康度后两个定位根因。P50 和平均值我基本不看因为平均值的迷惑性太强了——100 个请求里 99 个 10 毫秒、1 个 10 秒平均值才 110 毫秒看着挺好但那 1 个用户已经骂街了。5.2 上线后最先要看的几个指标压测环境和线上永远有差距所以上线后的观测比压测更重要。我在选课当天会盯这几个东西顺序不能乱第一是数据库的活跃连接数和等待连接数。如果等待数开始上涨说明连接池不够或者有慢查询把连接占住了这时候要立刻去看慢查询日志而不是盲目加连接。第二是 Redis 的命中率和命令耗时命中率掉到 90% 以下就说明缓存有问题了可能是 key 设计有误或者被大量穿透。第三是 gunicorn 的 worker 队列长度队列堆积说明处理速度跟不上进入速度要么加 worker 要么在前端限流。第四是那个最朴素的指标——选课接口的成功率掉到 99% 以下就得准备降级方案了。我还专门做了一个熔断开关。代码里留了一个配置项可以在不改代码、不重启的情况下把选课接口切换到只读模式——所有写请求返回系统繁忙请稍后读请求照常。这个开关在开选当天救过我一次数据库的 IOPS 打到了上限我打开开关让系统喘了 30 秒然后恢复比让整个系统雪崩要好得多。def enroll(request): if get_runtime_flag(enroll_readonly): return JsonResponse({ok: False, msg: 系统繁忙请稍后重试}, status503) ...这个 flag 存在 Redis 里改一个值就生效比发版快得多。类似的还有关闭排队降低限流阈值这些开关都值得提前准备好。5.3 几个真实踩过的坑说几个我自己踩过的都是文档里不太会写、但实际很要命的。坑一DEBUG 忘了关。有个测试环境的配置漏到了预发布环境DEBUGTrue开着Django 会把每个 SQL 都记在内存里queries列表几个小时下来内存直接爆进程被 OOM Killer 干掉。这个问题的隐蔽之处在于小流量下完全看不出来一上量就莫名其妙地进程消失。现在的做法是在配置里加断言生产环境如果DEBUG为真就直接启动失败。坑二session 存在数据库里。Django 默认的 session 后端是数据库每次请求都要读写一次 session 表。选课高峰期session 表的写入量可能比业务表还大成了隐形瓶颈。换成 Redis 后端之后这部分压力直接消失。配置很简单SESSION_ENGINE django.contrib.sessions.backends.cache SESSION_CACHE_ALIAS default CACHES { default: { BACKEND: django.core.cache.backends.redis.RedisCache, LOCATION: redis://127.0.0.1:6379/1, } }坑三批量更新的锁顺序不一致导致死锁。有一个后台任务会批量调整多门课的余量代码里用for循环逐条更新结果和前台的单条更新交叉出现了死锁。根因是两边的加锁顺序不同。解决办法很简单批量操作前先按 course_id 排序保证所有路径的加锁顺序一致。这个改动只有一行order_by(course_id)但解决了一个困扰很久的偶发问题。坑四Celery worker 里的数据库连接泄漏。异步任务跑久了会出现too many connections查下来是 worker 里的连接没有正确关闭。Celery 的worker_max_tasks_per_child设成一个合理值比如 200让子进程定期重启能规避大部分这类问题。另外在任务的 finally 里显式调用django.db.connections.close_all()也是个保险。坑五前端轮询把系统打挂。学生看到排队中之后很多人会手动刷新页面甚至用脚本高频刷新。我们后来在排队页上加了一个倒计时动画把用户的注意力从刷新转移到等待轮询频率自然就降下来了。这个不算是技术方案但对降低负载的效果比加机器还好。最后再分享一个小技巧给每次开选准备一份应急预案清单写清楚什么指标触发什么动作。比如P99 3 秒持续 30 秒开启排队、数据库连接等待 50关闭异步落库消费者、错误率 5%切只读模式。人在高压下判断力会下降有一份照着做的清单比现场讨论靠谱得多。我自己的清单就贴在显示器边上虽然大部分时候用不上但每次开选前看一眼心里会踏实很多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑