做过高校选课系统的同学应该都有印象,选课这种场景平时看着人畜无害,一旦到了抢课高峰期,服务的压力曲线直接能戳到天花板。也正是这个原因,越来越多的高校教学选课管理系统开始从单体应用往微服务、分布式方向演进,而技术栈里最典型的组合就是SpringBoot + Vue + SpringCloud。这个项目我完整跟过一轮,从需求拆分到架构设计,从前端交互到后端并发控制,踩了不少坑,也沉淀了一些实际可用的方案,今天就把它拆开聊聊。
先说清楚这套系统到底是什么、能干什么:它是以微服务架构为基础的高校选课管理平台,前端用Vue承担教务、选课、成绩查看等交互界面,后端按业务域拆分为多个SpringBoot服务,服务间通过SpringCloud体系(注册中心、网关、声明式调用、熔断降级等)完成分布式协作。它解决的痛点有两个层面:一是高校选课场景下高并发的抢课压力,二是教务管理多模块、多角色复杂业务的协作效率。适合正在做毕业设计的同学、想从单体切换到微服务的初级开发,以及准备用真实项目练手SpringCloud全家桶的人。
1. 内容整体设计与思路拆解
1.1 为什么高校选课系统需要微服务
我在很多技术社区看到过这类疑问:一个选课系统而已,用得着微服务吗?我的回答是:如果你只服务一个三百人的学院,单体确实够用;但如果是全校统一选课,开放选课的第一分钟就可能涌入上千次请求,而选课动作本身包含了对课程余量、学生身份、时间冲突、学分上限等多个条件的判断。
选课高峰期的典型特征:读多写多且瞬时并发高、写操作锁竞争激烈、部分模块(课程信息浏览、成绩查询)与核心选课逻辑负载特征完全不同。把用户认证、课程管理、选课业务、成绩管理拆成独立服务之后,最直接的好处是“削峰”和“隔离”——选课服务可以单独做集群扩容,即使选课模块压力爆掉,成绩查询和课程浏览也不受影响。
另一个重要的驱动因素是团队协作和独立交付。业务上高校教务系统往往按部门推进需求,教务处管课程开课、学院管培养方案、学生管选课和个人信息。微服务按业务域切分后,每个服务可以独立开发、独立测试、独立部署,不必每次改动都回归整个系统。
1.2 微服务拆分粒度与业务边界
很多第一次接触微服务的人容易掉进“按技术层拆分”的坑——拆一个controller服务、一个service服务、一个dao服务,看起来“微”了,实则把业务耦合变成网络调用,性能还更差。正确的拆分方式是“按业务能力拆分”,也就是围绕高校教学的领域模型来划分服务边界。
这套选课管理系统我建议拆成下面几个核心服务:
- 用户认证服务(auth-service):负责登录、JWT签发、token校验、角色权限,内置学生、教师、管理员三类角色体系。
- 课程服务(course-service):负责课程开课、排课、教师任课、课程信息维护等静态数据管理,是选课的核心数据源。
- 选课服务(elective-service):承担选课、退课、选课名单、选课时间窗口控制等核心交易逻辑,是系统中压力最大、并发最高的服务。
- 成绩服务(score-service):负责成绩录入、学分计算、绩点换算、成绩单查询。
- 教学通知服务(notice-service):负责选课公告、课表变更通知等消息推送,算是辅助模块。
从依赖关系上看,auth-service被所有服务依赖,course-service与elective-service之间有数据依赖,成绩服务直接依赖选课结果和课程信息,notice-service是消息消费型的旁路服务。这样的边界划分让每个服务都围绕明确的业务实体运转,而不是围绕技术分层运转。
1.3 为什么选这套技术栈
技术栈的选型逻辑其实很直白:SpringBoot负责服务端业务开发,它的自动装配和起步依赖让团队能快速把每个微服务跑起来;SpringCloud负责分布式的“基础设施”,服务注册发现、配置管理、网关路由、熔断限流这些能力不需要自己造轮子;Vue负责管理后台和用户交互界面,组件化和生态完善程度对教务类管理系统来说足够友好。
具体到SpringCloud组件,我建议用Nacos做注册中心和配置中心,而不是老牌的Eureka加SpringCloud Config。Nacos一个组件同时搞定服务发现和动态配置,运维成本低,而且国内资料多、社区活跃,遇到问题很容易找到对应方案。网关层用SpringCloud Gateway,相比Zuul它基于WebFlux,性能更好,路由配置的书写方式也更现代化。服务间调用用OpenFeign,声明式HTTP客户端,加上Sentinel做熔断和限流,这套组合在教务系统这个量级下非常顺手。
前端部分,Vue我建议直接上Vue3加Element Plus,如果你团队里有人更熟Vue2那也不算错,但新项目趁早用Vue3。状态管理用Pinia,替代Vuex,类型体验和写法都轻量很多。路由用Vue Router,动态注册路由来实现菜单权限控制,这是后台管理系统的基本操作。
2. 核心细节解析与实操要点
2.1 Nacos注册中心与配置中心的落地细节
微服务架构里,服务注册发现是地基。Nacos启动后会维护一张“服务清单”,每个SpringBoot服务启动时向Nacos注册自己的IP和端口,消费者调用时通过服务名从Nacos拿到服务实例列表,再由负载均衡策略选一个发起请求。这样调用方和服务方之间就没有硬编码的IP地址了。
配置中心的作用容易被低估。教务系统的配置项其实很杂:选课开放时间窗口、每学期学分上限、课程容量默认值、JWT密钥、各服务数据库地址。这些配置如果散落在每个服务的application.yml里,改动一次等于发布一轮。用Nacos的统一配置后,配置修改推送即可生效,不需要重启服务。
我在实际操作中会把配置按环境划分:
spring.cloud.nacos.discovery.group=ELECTIVE_GROUP spring.cloud.nacos.config.namespace=dev命名空间按环境隔离(dev、test、prod),Group按业务域隔离(课程、选课、成绩),配置文件的dataId用统一约定格式:服务名-环境名.yaml。比如选课服务在开发环境就是elective-service-dev.yaml。这个规范一旦定下来,项目里所有服务都按它执行,后期维护时会感谢自己。
还要强调一点:不要把数据库密码、密钥这种高敏感配置放在配置中心明文里,至少做一层jasypt加密,密钥本身用环境变量注入。
2.2 网关层的统一入口与鉴权机制
所有前端请求先经过SpringCloud Gateway再做转发,这是微服务的标准姿势。网关层主要做三件事:路由、鉴权、限流。路由好理解,前端请求带一个X-global-token表明目标服务,网关按路由规则转发。
鉴权这块我踩过一次坑,最初在网关里只校验token是否过期,具体的方法级权限放到各服务里处理。结果发现前端隐藏了按钮并不代表后端安全,直接构造HTTP请求就能绕过界面调用后台接口。后来统一改成JWT解析后把用户角色信息放进Header转发给下游,同时网关做一层url-权限黑白名单校验,比如/score/admin/**只能由管理员角色访问,不匹配直接返回拒绝。
网关层限流我直接采用Sentinel的网关限流模块,按接口路径配置QPS阈值。比如选课提交接口单机QPS限制100,超过了就返回“选课人数过多,请稍后重试”,这比让请求全部穿透到后端服务再打满数据库要理智得多。
Gateway路由配置的写法大致是:
spring: cloud: gateway: routes: - id: elective-service uri: lb://elective-service predicates: - Path=/api/elective/** filters: - StripPrefix=1lb://前缀配合Nacos做服务发现,网关会把请求负载均衡到该服务的多个实例上。注意StripPrefix的使用,前端路径与后端controller的RequestMapping前缀之间靠它做映射裁剪,配错会导致404,这是新手最容易犯的错。
2.3 分布式事务与分布式锁的取舍
微服务拆分的最大代价就是原来单体里一个本地事务搞定的事,现在跨服务了。选课这个业务典型地涉及多个服务的状态变更:选课记录写入、课程已选人数+1、学生已选学分累计。这三步如果跨了服务,本地事务就撑不住了。
但我不建议一上来就引入Seata这种重量级分布式事务框架,原因很实际:选课场景对强一致性的容忍度比对账系统高,课程余量偶尔因为超卖回滚,对用户来说体验很差,对系统来说恢复成本很高。我的做法是“分布式锁保证并发安全,事务边界尽量缩小,失败靠补偿”。
具体来说,选课提交接口里我加了一把Redis分布式锁,锁的key是studentId + 选课学年学期,这样同一个学生在同一轮选课中只有一个线程能进入选课逻辑,防止用户在多个终端同时提交请求导致重复选课。锁实现我用Redisson的RLock,它的看门狗机制会自动续期,不会因为业务执行时间过长导致锁自动过期而放行第二个线程。
事务边界上,我会在选课服务内开启本地事务,把“选课记录插入 + 课程已选人数更新”放在同一个事务里保证提交;学分统计如果放在学生服务,就通过事件消息去更新,失败走定时任务补偿。这个方案比全局分布式事务简单,且对选课系统完全够用。
2.4 服务间调用与熔断降级的必要配置
选课服务需要知道课程详情,如果每次查询都远程拉取课程服务,压力全落在课程服务上。我的方案是:选课不可避免地要查课程数据,但高频查询走Redis缓存,课程详情、课程余量在课程服务修改后主动更新到Redis,选课服务直读Redis,只在缓存缺失时才回源Course服务。
服务间调用用OpenFeign,熔断降级接Sentinel。注意OpenFeign和Sentinel的整合需要额外引入sentinel-spring-cloud-alibaba-starter,并在配置里开启feign.sentinel.enabled=true,否则Feign调用失败不会走Sentinel的降级逻辑。
我在实践中把超时时间设置成连接超时3秒、读取超时5秒,超过即熔断走降级方法,返回提示“课程服务繁忙,请稍后再试”。这个策略的价值在选课高峰期体现得很明显——选课服务即便挂了,也不会把课程服务的线程池拖垮,整个系统不至于雪崩。
3. 实操过程与核心环节实现
3.1 数据库设计与选课表结构
开始写代码前先设计数据库,表结构直接决定并发方案能不能落地。核心表我列出如下:
| 表名 | 关键字段 | 说明 |
|---|---|---|
| student | student_no, name, major, credit_limit | 学生基础信息,credit_limit为该学期学分上限 |
| course | course_no, name, credit, capacity, selected_count, teacher_id | 课程基础信息,selected_count实时记录已选人数 |
| elective_record | id, student_id, course_id, term, selected_time | 选课记录表,(student_id, course_id, term)加唯一索引 |
| score | id, student_id, course_id, score, term | 成绩表 |
选课并发控制的核心就在course表的selected_count字段。我选了“数据库乐观锁 + Redis预扣减”双重方案:先用Redis的Lua脚本原子性地做“余量检查-扣减”两步操作,Redis的原子性保证不会超扣;然后把真正落库的操作放到本地事务中,落库时用UPDATE course SET selected_count = selected_count + 1 WHERE id = ? AND selected_count < capacity这个语句再做一次容量校验,防止极端情况下Redis和数据库数据不一致导致超卖。
有个重要细节是elective_record表必须加唯一索引,否则即便应用层做了各种校验,高并发下还是可能插入重复的选课记录。唯一索引是最后一道兜底防线,比任何代码判断都可靠。
3.2 选课接口的并发控制落地
选课接口是系统的核心,我前后改了三版,从最开始的synchronized锁本地对象,到用MySQL行级锁,最后稳定在Redisson分布式锁 + Redis Lua预扣减方案。本地锁的问题很明显,多实例部署下每个节点的锁互不可见,起不到全局互斥作用。
Redis Lua脚本这一步很关键,它保证“检查容量”和“扣减数量”两步操作在同一脚本内原子执行,不会出现两个请求同时读到余量为1、又同时扣减成功的场景。脚本大致是:
local key = KEYS[1] local capacity = tonumber(ARGV[1]) local current = tonumber(redis.call('get', key) or '0') if current < capacity then redis.call('incr', key) return 1 end return 0Java侧用RedisTemplate执行脚本并判断返回值,返回1表示扣减成功继续选课逻辑,返回0表示余量不足直接返回“课程已满”。
然后进入选课本地事务,表结构上我已经加了selected_count和capacity的WHERE条件双重判断,即使在Redis尚未更新时直接从数据库读到旧值,也不会导致真实的超卖。整套流程下来,我在JMeter压测里模拟200个并发同时抢同一门容量为100的课程,最终落库的选课记录严格等于100条,没有一条超量。
3.3 前端Vue的权限控制与选课交互
前端这块我用Vue3 + Vue Router + Pinia。登录后拿到JWT和用户角色信息,动态生成当前用户可见的菜单和路由。具体做法是路由分为两部分:一部分是登录页等静态路由,另一部分是根据权限接口返回的菜单配置,在前端通过router.addRoute动态注册。
选课页面的核心体验点有两个:余量实时展示和选课结果的即时反馈。余量展示我接了一个轻量级的轮询接口——每10秒拉一次课程余量,避免用户看到的是过期数据。选课提交后,前端等待后端返回“选课成功”或“课程已满”,然后立即刷新余量数据。高峰期时我加了按钮防抖,用户点击选课按钮后按钮进入loading状态,防止用户重复提交增加后端压力。
页面交互上,课程检索支持按课程名、任课教师、学分范围筛选,列表用分页加载而不是一次查全表。前端所有请求统一封装在axios实例里,请求头自动附加token,响应拦截器统一处理会话过期跳转登录页。
3.4 前后端联调与部署方案
开发环境我习惯用Maven多模块管理后端工程,父工程下拆出common(公共依赖)、auth-service、course-service、elective-service等子模块,每个模块独立打成可执行jar包。本地跑微服务时按依赖顺序启动:先Nacos、再Gateway、然后各业务服务,最后一个一个注册到Nacos控制台里确认。
部署到服务器时我用Docker Compose编排,把Nacos、MySQL、Redis、GateWay和各业务服务都定义在docker-compose.yml里,构建镜像时注意每个服务的Dockerfile用多阶段构建,先Maven打包再拷贝jar包进入运行镜像。前端Vue项目单独构建镜像,用nginx做静态托管,同时把/api开头的请求反向代理到网关地址,避免前端跨域问题。
nginx里这段代理配置要注意,不然前端部署后接口全部404:
location /api/ { proxy_pass http://gateway-server:8888/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }proxy_pass末尾带了/,会把/api前缀去掉再转给网关,具体要不要保留前缀,跟你网关里路由的StripPrefix配置保持对应就行。
4. 常见问题与排查技巧实录
4.1 选课高峰期Redis锁失效导致超卖
第一次压测时就翻过车,200个并发直接把容量100的课程挤出了110条选课记录。排查后发现Redis锁正常,但锁只锁了“提交选课”这一步,而事务提交发生在释放锁之后,第二个线程拿到锁读到的是第一个线程事务提交前的数据,导致容量校验失效。
解决方案就是前面提到的保持容量判断和扣减两步的原子性,把“检查余量-扣减余量”用Lua脚本放Redis里原子执行,让数据库层面的校验不再是唯一的防超卖手段,这样即使锁释放后有短暂的数据不一致,也不会产生超卖。
4.2 Feign调用超时导致选课失败
学生提交选课时,选课服务会调用用户服务校验学生状态,再调课程服务获取课程信息。高峰期一旦课程服务响应变慢,Feign默认1秒的读超时就会触发异常,选课接口直接返回失败。学生那边看到的是“选课失败”,但实际上只是服务间通信超时。
排查时先看调用链日志,确认是哪个服务慢,再决定调超时配置还是加缓存。我的做法是连接超时3秒、读超时5秒,同时给课程信息的获取加Redis缓存兜底。如果缓存里有数据,就不会走到Feign调用,自然也就不会出现超时失败。
4.3 网关路由偶发503 Service Unavailable
服务刚启动时,Nacos注册中心还没完成服务发现,网关转发请求时找不到可用实例,就会短暂返回503。这个现象多发生在服务滚动发布期间,某个服务实例下线的瞬间,网关的路由表还没来得及更新。
解决方式是在Gateway里配置懒加载并做重试机制,让网关在目标服务暂不可用时尝试下一个实例而不是直接报错。同时服务下线时走Nacos的优雅下线流程,先摘除流量再停止实例,而不是直接kill进程。
4.4 前端打包后路由刷新404
Vue Router使用history模式时,前端打包部署到nginx后,用户访问/elective再按F5刷新,nginx会直接返回404,因为它找不到对应的静态文件路径。
解决办法是在nginx配置里加一条try_files规则,把未知路径全部回退到index.html:
location / { try_files $uri $uri/ /index.html; }这条配置解决的是前端路由所有刷新404的问题,属于Vue部署必配项。如果你用的是hash模式就没有这个问题,但URL会带#号,观感差一些。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 选课成功后名单里没有记录 | 事务提交前响应了前端,或异步落库延迟 | 查看选课记录表是否最终一致,检查事务边界 |
| 分布式锁一直用到超时 | 业务内远程调用耗时过长,pipeline较长 | 缩小锁内业务范围,缓存预热,远程调用异步化 |
| 网关路由全部失效 | Nacos服务列表为空,服务未注册成功 | 检查服务启动日志、Nacos控制台服务列表 |
| 前端请求跨域 | 网关未配置跨域,或nginx未设置header | 在Gateway加全局CORS配置,或nginx反向代理同源访问 |
| 数据库连接池打满 | 慢SQL太多,连接占满未释放 | 分析慢查询日志,加索引,调大连接池并限流 |
4.6 排查建议
微服务系统的故障排查比单体复杂一个量级,最基础的工具就是整合Sleuth + Zipkin做链路追踪。每个请求会生成一个traceId,贯穿网关和各服务的日志,排查时按traceId去聚合所有服务的日志,就能还原一次完整请求的调用链。
推荐把日志统一输出到文件并接入ELK做集中检索,然而如果你只是学习或毕设,可以先在本地用logback把traceId打印到控制台,肉眼追踪简单的调用链也够用。真正上线前再把日志平台做起来。
结尾
这套系统跟下来,我最大的体会是:微服务架构真的不难搭,难的是把并发和一致性的细节处理好。选课系统的核心不在用了哪个注册中心、哪代网关,而在于当你把单体拆成多个服务后,数据的一致性怎么保证、高峰期的冲击怎么扛住、故障出现后怎么快速定位。Redis分布式锁、Lua原子脚本、数据库乐观锁、唯一索引、网关限流、熔断降级,这些方案单独看都不复杂,组合起来却能在真实选课场景下稳定运行,这是最让我有成就感的地方。
最后再分享一个我的私人心得:如果你也是第一次从零搭建这类微服务项目,不要一开始就追求组件数量多、架构复杂。先把“一个学生成功选上一门课”这条最小链路跑通——注册中心、网关、认证服务、课程服务、选课服务、MySQL、Redis——然后再往里面加缓存、加熔断、加消息、加分布式事务。每加一个组件前先问自己它解决了什么问题,解决不了就暂时不加。这样走下来,你得到的不仅是一个能跑的毕设,而是一套你真正能讲明白为什么这么设计的系统。