选课系统又双叒叕被挤爆了?这6个架构绝招让你的教务系统扛住10万学生抢课!
选课系统又双叒叕被挤爆了?这6个架构绝招让你的教务系统扛住10万学生抢课!
大家好,我是服务端技术精选的老司机,今天咱们来聊聊每学期都会上热搜的「选课系统」。
每到选课季,各大高校的教务系统都会被学生们"爱的冲击"给冲垮。想象一下:开课前5分钟,10万学生同时在线抢选热门课程,QPS瞬间从平时的100飙到10万+,服务器直接原地升天...
我曾经参与过某985高校的选课系统重构项目,那真是一段血泪史。第一次上线测试,系统撑了不到3分钟就崩了,学生们在网上骂声一片。经过三个月的架构重构,终于打造出了一套能扛住10万学生同时选课的系统。
今天就把这套选课系统的架构设计全盘托出,保证你看完后再也不怕学生们的"冲击"了!
一、选课系统为什么这么难搞?
选课系统看似简单,其实比秒杀系统还要复杂:
1. 瞬时并发量恐怖
- 平时QPS可能只有几十,选课开放瞬间就飙到几万甚至十万+
- 所有学生都在同一时间点开始选课,流量完全集中
- 不同于电商可以分流,选课必须在指定时间开始
2. 业务逻辑极其复杂
- 课程容量限制:每门课有最大选课人数限制,不能超卖
- 先修课程检查:很多课程需要先修完其他课程才能选
- 时间冲突检查:学生不能选择时间冲突的课程
- 学分限制:每个学生本学期选课总学分有上限
- 专业限制:某些课程只对特定专业开放
3. 数据一致性要求高
- 课程剩余名额必须准确,多选一个学生都不行
- 学生选课记录不能丢失,涉及到学费和学分
- 退课后名额要及时释放给其他学生
4. 公平性要求
- 不能因为网络快慢影响选课成功率
- 需要防止学生使用脚本刷选课接口
- 要有合理的排队机制
我之前见过最夸张的是某大学,选课系统崩溃后,学生们直接冲到教务处门前排队,场面一度失控...
二、选课系统的分层架构设计
基于多年的实战经验,我总结出了选课系统的四层架构:
1. 接入层:第一道防线
负载均衡器(Nginx):
- 配置多台应用服务器,避免单点故障
- 使用IP哈希算法,保证同一学生的请求路由到同一台服务器
- 设置限流规则,防止恶意刷接口
# Nginx配置示例
upstream course_servers {
ip_hash; # 同一IP的请求路由到同一服务器
server 192.168.1.10:8080 weight=3;
server 192.168.1.11:8080 weight=3;
server 192.168.1.12:8080 weight=2;
}
# 限流配置
limit_req_zone $binary_remote_addr zone=course:10m rate=10r/s;
server {
location /api/course/select {
limit_req zone=course burst=20 nodelay;
proxy_pass http://course_servers;
}
}
CDN加速:
- 将课程信息、学生信息等静态数据缓存到CDN
- 减轻后端服务器压力,提升页面加载速度
2. 应用层:业务逻辑核心
这一层是整个系统的大脑,需要处理复杂的选课业务逻辑:
预检查机制:
// 选课前置检查
public class CourseSelectionValidator {
public ValidationResult validateSelection(Long studentId, Long courseId) {
// 1. 检查课程是否存在且开放选课
Course course = courseService.getCourse(courseId);
if (course == null || !course.isSelectionOpen()) {
return ValidationResult.fail("课程不存在或未开放选课");
}
// 2. 检查学生是否已选择该课程
if (studentCourseService.hasSelected(studentId, courseId)) {
return ValidationResult.fail("已选择该课程");
}
// 3. 检查时间冲突
if (hasTimeConflict(studentId, course)) {
return ValidationResult.fail("与已选课程时间冲突");
}
// 4. 检查先修课程
if (!hasPrerequisites(studentId, course)) {
return ValidationResult.fail("未满足先修课程要求");
}
// 5. 检查专业限制
if (!checkMajorRestriction(studentId, course)) {
return ValidationResult.fail("专业不符合选课要求");
}
return ValidationResult.success();
}
}
异步处理:
- 选课成功后的邮件通知、课表更新等非核心操作异步处理
- 使用消息队列削峰填谷,避免瞬时压力
3. 缓存层:性能优化利器
Redis集群:
- 缓存课程信息、学生信息、选课状态等热点数据
- 使用分布式锁保证课程名额扣减的原子性
- 设计合理的缓存更新策略
// Redis存储选课信息
public class CourseSelectionCache {
// 课程剩余名额:course:capacity:{courseId}
public int getCourseCapacity(Long courseId) {
String key = "course:capacity:" + courseId;
String capacity = redisTemplate.opsForValue().get(key);
return capacity != null ? Integer.parseInt(capacity) : 0;
}
// 学生选课列表:student:courses:{studentId}
public Set<Long> getStudentCourses(Long studentId) {
String key = "student:courses:" + studentId;
return redisTemplate.opsForSet().members(key);
}
// 使用Lua脚本保证选课操作的原子性
public boolean selectCourse(Long studentId, Long courseId) {
String luaScript = """
local capacity_key = 'course:capacity:' .. ARGV[1]
local student_key = 'student:courses:' .. ARGV[2]
-- 检查课程容量
local capacity = redis.call('GET', capacity_key)
if not capacity or tonumber(capacity) <= 0 then
return 0
end
-- 检查学生是否已选课
if redis.call('SISMEMBER', student_key, ARGV[1]) == 1 then
return -1
end
-- 执行选课操作
redis.call('DECR', capacity_key)
redis.call('SADD', student_key, ARGV[1])
return 1
""";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.emptyList(),
courseId.toString(), studentId.toString()
);
return result != null && result == 1;
}
}
4. 数据层:持久化存储
MySQL主从集群:
- 主库负责写操作(选课、退课)
- 从库负责读操作(查询课程信息、学生选课情况)
- 合理设计数据库表结构和索引
-- 课程表
CREATE TABLE courses (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
course_code VARCHAR(20) NOT NULL UNIQUE,
course_name VARCHAR(100) NOT NULL,
capacity INT NOT NULL,
selected_count INT DEFAULT 0,
start_time DATETIME,
end_time DATETIME,
teacher_id BIGINT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_course_code (course_code),
INDEX idx_teacher_id (teacher_id)
);
-- 学生选课表
CREATE TABLE student_courses (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
student_id BIGINT NOT NULL,
course_id BIGINT NOT NULL,
selected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status TINYINT DEFAULT 1, -- 1:已选 2:已退课
UNIQUE KEY uk_student_course (student_id, course_id),
INDEX idx_student_id (student_id),
INDEX idx_course_id (course_id)
);
三、6个核心技术绝招,让选课系统稳如老狗
绝招1:令牌桶限流 - 从源头控制流量
选课系统最怕的就是瞬间流量冲击,我们可以用令牌桶算法从源头控制流量:
// 基于Guava的限流器
public class CourseSelectionRateLimiter {
// 全局限流:每秒最多处理5000个选课请求
private final RateLimiter globalLimiter = RateLimiter.create(5000);
// 单个课程限流:防止某个热门课程被刷
private final LoadingCache<Long, RateLimiter> courseLimiters =
CacheBuilder.newBuilder()
.maximumSize(10000)
.expireAfterWrite(1, TimeUnit.HOURS)
.build(new CacheLoader<Long, RateLimiter>() {
@Override
public RateLimiter load(Long courseId) {
return RateLimiter.create(100); // 每个课程每秒最多100次请求
}
});
public boolean tryAcquire(Long courseId) {
return globalLimiter.tryAcquire() &&
courseLimiters.getUnchecked(courseId).tryAcquire();
}
}
绝招2:分级缓存 - 让热点数据飞起来
课程信息、学生信息这些热点数据,我们要用分级缓存策略:
- L1缓存(本地缓存):使用Caffeine缓存最热门的课程信息
- L2缓存(Redis):缓存所有课程和学生信息
- L3缓存(数据库):持久化存储
@Service
public class CourseInfoService {
// L1缓存 - 本地缓存热门课程
private final Cache<Long, Course> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
public Course getCourse(Long courseId) {
// 先查本地缓存
Course course = localCache.getIfPresent(courseId);
if (course != null) {
return course;
}
// 再查Redis
course = getCourseFromRedis(courseId);
if (course != null) {
localCache.put(courseId, course);
return course;
}
// 最后查数据库
course = getCourseFromDB(courseId);
if (course != null) {
setCourseToRedis(courseId, course);
localCache.put(courseId, course);
}
return course;
}
}
绝招3:排队机制 - 让选课变得有序
为了保证公平性,我们可以引入排队机制:
@Component
public class CourseSelectionQueue {
// 使用Redis实现分布式队列
private final RedisTemplate<String, String> redisTemplate;
// 加入选课队列
public boolean joinQueue(Long studentId, Long courseId) {
String queueKey = "course:queue:" + courseId;
String studentKey = studentId.toString();
// 检查学生是否已在队列中
if (redisTemplate.opsForSet().isMember(queueKey + ":members", studentKey)) {
return false;
}
// 加入队列
long position = redisTemplate.opsForList().rightPush(queueKey, studentKey);
redisTemplate.opsForSet().add(queueKey + ":members", studentKey);
return true;
}
// 处理队列中的选课请求
@Scheduled(fixedDelay = 100) // 每100ms处理一批
public void processQueue() {
Set<String> queueKeys = redisTemplate.keys("course:queue:*");
for (String queueKey : queueKeys) {
String studentId = redisTemplate.opsForList().leftPop(queueKey);
if (studentId != null) {
// 异步处理选课请求
CompletableFuture.runAsync(() ->
processSelectionRequest(Long.parseLong(studentId), extractCourseId(queueKey))
);
}
}
}
}
绝招4:预热机制 - 开战前先热身
选课开放前,我们要对系统进行预热:
@Component
public class SystemWarmupService {
@EventListener
public void handleSelectionStarting(SelectionStartingEvent event) {
// 1. 预热Redis缓存
warmupRedisCache();
// 2. 预热数据库连接池
warmupConnectionPool();
// 3. 预热JVM
warmupJVM();
}
private void warmupRedisCache() {
// 将所有开放选课的课程信息加载到Redis
List<Course> courses = courseService.getAllOpenCourses();
for (Course course : courses) {
String key = "course:info:" + course.getId();
redisTemplate.opsForValue().set(key, course, 30, TimeUnit.MINUTES);
// 初始化课程容量
String capacityKey = "course:capacity:" + course.getId();
redisTemplate.opsForValue().set(capacityKey, course.getCapacity().toString());
}
log.info("Redis缓存预热完成,共预热{}门课程", courses.size());
}
}
绝招5:异步处理 - 削峰填谷的艺术
选课成功后的很多操作都可以异步处理:
@Component
public class AsyncCourseSelectionHandler {
@Autowired
private RabbitTemplate rabbitTemplate;
// 异步处理选课成功后的操作
public void handleSelectionSuccess(Long studentId, Long courseId) {
// 发送异步消息
CourseSelectionMessage message = new CourseSelectionMessage(studentId, courseId);
rabbitTemplate.convertAndSend("course.selection.success", message);
}
@RabbitListener(queues = "course.selection.success.queue")
public void processSelectionSuccess(CourseSelectionMessage message) {
try {
// 1. 发送选课成功通知邮件
emailService.sendSelectionNotification(message.getStudentId(), message.getCourseId());
// 2. 更新学生课表
scheduleService.updateStudentSchedule(message.getStudentId(), message.getCourseId());
// 3. 记录选课日志
auditService.logCourseSelection(message.getStudentId(), message.getCourseId());
// 4. 更新统计数据
statisticsService.updateSelectionStats(message.getCourseId());
} catch (Exception e) {
log.error("处理选课成功消息失败", e);
// 重试或者记录到死信队列
}
}
}
绝招6:熔断降级 - 壮士断腕保核心
当系统压力过大时,我们要有降级策略:
@Component
public class CourseSelectionService {
// 使用Hystrix实现熔断
@HystrixCommand(
fallbackMethod = "selectCourseFallback",
commandProperties = {
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "20"),
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50")
}
)
public SelectionResult selectCourse(Long studentId, Long courseId) {
// 核心选课逻辑
return doSelectCourse(studentId, courseId);
}
// 降级方法
public SelectionResult selectCourseFallback(Long studentId, Long courseId) {
// 1. 记录选课意向,稍后处理
intentionService.recordSelectionIntention(studentId, courseId);
// 2. 返回友好提示
return SelectionResult.builder()
.success(false)
.message("系统繁忙,已记录您的选课意向,请稍后查看结果")
.build();
}
}
四、实战案例:某985高校选课系统重构之路
让我跟大家分享一个真实的案例。某985高校有5万在校学生,每学期选课时系统都会崩溃,学生怨声载道。
问题分析
- 架构单一:单体应用+单台MySQL,没有任何缓存
- 无限流保护:所有请求直达数据库,瞬间把DB压垮
- 业务逻辑混乱:选课逻辑全在一个方法里,没有任何优化
- 无监控告警:系统崩了都不知道哪里出的问题
重构方案
我们用了2个月时间,按照前面提到的架构进行了重构:
第一阶段(2周):基础架构优化
- 部署3台应用服务器,配置Nginx负载均衡
- 引入Redis集群,缓存热点数据
- 数据库主从分离,读写分离
第二阶段(4周):业务逻辑优化
- 重构选课逻辑,增加各种前置检查
- 引入消息队列处理异步任务
- 实现分布式锁保证数据一致性
第三阶段(2周):性能调优和压测
- 使用JMeter进行压力测试
- 优化数据库索引和SQL查询
- 调整JVM参数和连接池配置
效果对比
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 并发处理能力 | 100 QPS | 10,000 QPS |
| 响应时间 | 5-30秒 | 200-500ms |
| 系统可用性 | 60% | 99.9% |
| 选课成功率 | 20% | 95% |
选课当天,系统稳定运行,2小时内处理了15万次选课请求,成功率达到95%,学生满意度大幅提升。
五、选课系统的监控告警体系
光有好的架构还不够,还要有完善的监控:
1. 业务监控
- 选课QPS、成功率、失败率
- 各门课程的选课情况
- 学生选课分布统计
2. 技术监控
- 应用服务器CPU、内存、磁盘使用率
- Redis集群状态和命中率
- MySQL主从延迟和慢查询
- 消息队列堆积情况
3. 告警策略
# Prometheus告警规则示例
groups:
- name: course-selection
rules:
- alert: HighSelectionFailureRate
expr: (course_selection_failed_total / course_selection_total) > 0.1
for: 1m
labels:
severity: warning
annotations:
summary: "选课失败率过高"
description: "最近1分钟选课失败率超过10%"
- alert: DatabaseConnectionPoolExhausted
expr: db_connection_pool_active / db_connection_pool_max > 0.9
for: 30s
labels:
severity: critical
annotations:
summary: "数据库连接池即将耗尽"
六、经验总结:这些坑你一定要避开
- 不要低估业务复杂度:选课系统的业务逻辑比你想象的复杂,一定要梳理清楚所有规则
- 缓存不是银弹:合理使用缓存,注意缓存一致性问题
- 数据库是最后一道防线:一定要保护好数据库,避免被压垮
- 充分的压力测试:上线前一定要做好压测,模拟真实的选课场景
- 监控告警必不可少:没有监控的系统是裸奔,出了问题都不知道
- 预案要充分:制定各种故障场景的应对预案
七、未来展望:选课系统的技术趋势
- AI智能推荐:基于学生的专业、兴趣推荐合适的课程
- 云原生架构:使用Kubernetes实现自动扩缩容
- 实时数据同步:使用CDC技术实现数据实时同步
- 移动端优化:针对手机端做专门的性能优化
结语
选课系统虽然看起来简单,但要做好真的不容易。它不仅仅是技术问题,更是对架构设计、性能优化、业务理解的综合考验。
记住一句话:好的选课系统不是设计出来的,而是在一次次选课季的洗礼中不断优化出来的。
从单机到分布式,从同步到异步,从粗暴限流到精细化治理,每一步都凝聚了无数后端工程师的心血。但只要掌握了这6个核心技术绝招,相信你也能打造出一套稳定高效的选课系统。
最后,如果你的选课系统还在被学生们"冲击",不妨试试这些方案。有任何问题欢迎在评论区讨论,咱们一起让教务系统不再成为学生们的噩梦!
觉得有用的话,点赞、在看、转发三连走起!下期我们聊聊如何设计一个高并发的图书馆座位预约系统,敬请期待~
关注公众号:服务端技术精选
每周分享后端架构设计的实战经验,让技术更有温度!
标题:选课系统又双叒叕被挤爆了?这6个架构绝招让你的教务系统扛住10万学生抢课!
作者:jiangyi
地址:http://jiangyi.space/articles/2025/12/21/1766304294173.html
- 一、选课系统为什么这么难搞?
- 1. 瞬时并发量恐怖
- 2. 业务逻辑极其复杂
- 3. 数据一致性要求高
- 4. 公平性要求
- 二、选课系统的分层架构设计
- 1. 接入层:第一道防线
- 2. 应用层:业务逻辑核心
- 3. 缓存层:性能优化利器
- 4. 数据层:持久化存储
- 三、6个核心技术绝招,让选课系统稳如老狗
- 绝招1:令牌桶限流 - 从源头控制流量
- 绝招2:分级缓存 - 让热点数据飞起来
- 绝招3:排队机制 - 让选课变得有序
- 绝招4:预热机制 - 开战前先热身
- 绝招5:异步处理 - 削峰填谷的艺术
- 绝招6:熔断降级 - 壮士断腕保核心
- 四、实战案例:某985高校选课系统重构之路
- 问题分析
- 重构方案
- 效果对比
- 五、选课系统的监控告警体系
- 1. 业务监控
- 2. 技术监控
- 3. 告警策略
- 六、经验总结:这些坑你一定要避开
- 七、未来展望:选课系统的技术趋势
- 结语