☰
Django选课系统高并发优化:Redis预占与异步落库实战
2026/10/28 11:28:07 网站建设 项目流程

每年选课周,运维群里总有人盯着监控大屏上那条陡然拉起来的曲线,嘴里念叨着"别崩别崩"。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 有时很啰嗦,N+1查询是常态,模板渲染比现代前端框架重。应对办法是——关键路径上绕开 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) ) ENGINE=InnoDB;

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) ) ENGINE=InnoDB;

这里有个细节:uk_student_section是唯一索引,学生退课后记录不删(保留历史),而是把status改成退课状态。这样唯一约束还在,学生重新选这门课时会撞唯一键。解法是用INSERT ... ON DUPLICATE KEY UPDATE,把 status 改回来,同时判断影响行数。这个技巧后面代码里会写。

2.3 名额扣减:三种方案的实测对比

名额扣减是整道题的核心。我试过三种写法,效果差异巨大,值得摊开说。

方案一:先查后改,最朴素也最危险。

section = Section.objects.get(id=section_id) if section.enrolled < section.capacity: section.enrolled += 1 section.save()

这段代码在并发下必然超卖。两个请求同时读到enrolled=59、capacity=60,都判断通过,都加一,结果 61。我在本地用 50 并发压测,100 个名额卖出去了 137 个,触目惊心。这是新手最常踩的坑,务必避开。

方案二:悲观锁,select_for_update。

with transaction.atomic(): section = Section.objects.select_for_update().get(id=section_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( id=section_id, status=1, enrolled__lt=F('capacity') ).update(enrolled=F('enrolled') + 1) if not updated: return False # 名额已满

这条 SQL 在数据库层就完成了"判断 + 扣减",一条UPDATE ... WHERE enrolled < capacity,InnoDB 会加行锁,但持锁时间极短(就是执行这一条语句的时间),而且没有名额时直接返回 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(bind=True, max_retries=3) def persist_enrollment(self, payload): student_id, section_id, req_id = payload.split(':') try: with transaction.atomic(): updated = Section.objects.filter( id=section_id, status=1, enrolled__lt=F('capacity') ).update(enrolled=F('enrolled') + 1) if not updated: # 数据库层面已满,说明缓存和实际有偏差 raise StockMismatchError(section_id) Enrollment.objects.create( student_id=student_id, section_id=section_id, status=1, created_at=timezone.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(exc=exc, countdown=2 ** self.request.retries)

这里的设计要点是双向补偿。缓存说有名额,数据库说没有,这就是偏差,必须把 Redis 里扣掉的名额加回去,否则这门课的 Redis 库存会永久性地少一个,越积越多,最后一门 60 人的课在缓存里只剩 40 个名额。同理,唯一键冲突意味着这个人之前确实选过,缓存的学生集合可能因为过期丢了数据,也要回补。

对账任务是我强烈建议加的。每天凌晨跑一次,把 Redis 里每个教学班的库存和 MySQL 的capacity - enrolled对一遍,不一致就以数据库为准刷新缓存,同时记录差异日志。这个任务平时看起来毫无产出,但关键时刻能救命——我在测试环境故意 kill 掉消费者进程,制造了 17 条丢失记录,对账任务一键修好,不用人工排查。

提示:异步落库意味着用户提交后状态是"处理中"。前端一定要轮询或长连接拉取最终状态,并且给出明确的进度提示。我见过有系统提交后页面直接跳转,用户以为成功了,结果后台落库失败,等到第二天上课发现名单里没自己。

3.3 限流、排队与降级策略

限流的目的不是拦住用户,是保护后端不被瞬时流量打死。选课场景下我用了两层限流:

第一层在 Nginx 层做连接数限制,按 IP 限速,防止有人写脚本刷接口。配置大概这样:

limit_req_zone $binary_remote_addr zone=select_zone:10m rate=5r/s; location /api/enroll/ { limit_req zone=select_zone burst=10 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 + Django(gunicorn 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/?page=1") @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_AGE=0),高并发下频繁建连断开,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_mode='STRICT_TRANS_TABLES'", }, } }

CONN_MAX_AGE=600表示连接复用 10 分钟。CONN_HEALTH_CHECKS在 Django 4.1 后加入,复用前探活,避免用到已断开的连接。

这里要算一笔账:8 个 gunicorn worker,每个 worker 的持久连接数是 1(同步 worker 单线程),总共 8 个连接。看起来很少,但 Celery 消费者呢?我开了 4 个并发消费者,每个 4 个连接,16 个。加上后台管理、定时任务、监控探针,实际峰值可能在 40 个左右。MySQL 默认max_connections=151,够用但要盯着。

如果你用的是 gevent 或者异步 worker,情况就完全不同了——一个 worker 内可能同时有几十个协程在查库,这时必须显式配置连接池。我测过 gevent 模式下 8 个 worker 打满,连接数飙到 300 多,直接把 MySQL 拒了。所以我的建议是:同步 worker 就老老实实用同步,别为了"看起来并发高"去上 gevent,选课系统的瓶颈不在 worker 类型。

Redis 这边算的是内存。一个教学班两个 key(库存 + 已选集合),假设 2000 个教学班、每班 60 人,库存 key 大约 100 字节 × 2000 = 200KB,已选集合最坏情况 60 个成员 × 2000 = 12 万成员,每个成员几十字节,总共 10MB 上下。加上课程列表缓存,整个 Redis 占用不超过 200MB,1G 内存的实例绰绰有余。这个账要提前算,别等到 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, f"section:stock:{section_id}", f"student: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 统计各学院的选课人数,运维和教务能在选课过程中实时看到态势,出问题提前干预。这三个方向里,我觉得最值得先做的是分时段放行——它不需要改代码架构,只要在开放时间上加个判断,削峰效果立竿见影。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询