选课系统又双叒叕被挤爆了?这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万在校学生,每学期选课时系统都会崩溃,学生怨声载道。

问题分析

  1. 架构单一:单体应用+单台MySQL,没有任何缓存
  2. 无限流保护:所有请求直达数据库,瞬间把DB压垮
  3. 业务逻辑混乱:选课逻辑全在一个方法里,没有任何优化
  4. 无监控告警:系统崩了都不知道哪里出的问题

重构方案

我们用了2个月时间,按照前面提到的架构进行了重构:

第一阶段(2周):基础架构优化

  • 部署3台应用服务器,配置Nginx负载均衡
  • 引入Redis集群,缓存热点数据
  • 数据库主从分离,读写分离

第二阶段(4周):业务逻辑优化

  • 重构选课逻辑,增加各种前置检查
  • 引入消息队列处理异步任务
  • 实现分布式锁保证数据一致性

第三阶段(2周):性能调优和压测

  • 使用JMeter进行压力测试
  • 优化数据库索引和SQL查询
  • 调整JVM参数和连接池配置

效果对比

指标优化前优化后
并发处理能力100 QPS10,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: "数据库连接池即将耗尽"

六、经验总结:这些坑你一定要避开

  1. 不要低估业务复杂度:选课系统的业务逻辑比你想象的复杂,一定要梳理清楚所有规则
  2. 缓存不是银弹:合理使用缓存,注意缓存一致性问题
  3. 数据库是最后一道防线:一定要保护好数据库,避免被压垮
  4. 充分的压力测试:上线前一定要做好压测,模拟真实的选课场景
  5. 监控告警必不可少:没有监控的系统是裸奔,出了问题都不知道
  6. 预案要充分:制定各种故障场景的应对预案

七、未来展望:选课系统的技术趋势

  1. AI智能推荐:基于学生的专业、兴趣推荐合适的课程
  2. 云原生架构:使用Kubernetes实现自动扩缩容
  3. 实时数据同步:使用CDC技术实现数据实时同步
  4. 移动端优化:针对手机端做专门的性能优化

结语

选课系统虽然看起来简单,但要做好真的不容易。它不仅仅是技术问题,更是对架构设计、性能优化、业务理解的综合考验。

记住一句话:好的选课系统不是设计出来的,而是在一次次选课季的洗礼中不断优化出来的

从单机到分布式,从同步到异步,从粗暴限流到精细化治理,每一步都凝聚了无数后端工程师的心血。但只要掌握了这6个核心技术绝招,相信你也能打造出一套稳定高效的选课系统。

最后,如果你的选课系统还在被学生们"冲击",不妨试试这些方案。有任何问题欢迎在评论区讨论,咱们一起让教务系统不再成为学生们的噩梦!

觉得有用的话,点赞、在看、转发三连走起!下期我们聊聊如何设计一个高并发的图书馆座位预约系统,敬请期待~


关注公众号:服务端技术精选
每周分享后端架构设计的实战经验,让技术更有温度!


标题:选课系统又双叒叕被挤爆了?这6个架构绝招让你的教务系统扛住10万学生抢课!
作者:jiangyi
地址:http://www.jiangyi.space/articles/2025/12/21/1766304294173.html

    0 评论
avatar