
做过高校选课系统的同学应该都有印象选课这种场景平时看着人畜无害一旦到了抢课高峰期服务的压力曲线直接能戳到天花板。也正是这个原因越来越多的高校教学选课管理系统开始从单体应用往微服务、分布式方向演进而技术栈里最典型的组合就是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.groupELECTIVE_GROUP spring.cloud.nacos.config.namespacedev命名空间按环境隔离dev、test、prodGroup按业务域隔离课程、选课、成绩配置文件的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: - StripPrefix1lb://前缀配合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.enabledtrue否则Feign调用失败不会走Sentinel的降级逻辑。我在实践中把超时时间设置成连接超时3秒、读取超时5秒超过即熔断走降级方法返回提示“课程服务繁忙请稍后再试”。这个策略的价值在选课高峰期体现得很明显——选课服务即便挂了也不会把课程服务的线程池拖垮整个系统不至于雪崩。3. 实操过程与核心环节实现3.1 数据库设计与选课表结构开始写代码前先设计数据库表结构直接决定并发方案能不能落地。核心表我列出如下表名关键字段说明studentstudent_no, name, major, credit_limit学生基础信息credit_limit为该学期学分上限coursecourse_no, name, credit, capacity, selected_count, teacher_id课程基础信息selected_count实时记录已选人数elective_recordid, student_id, course_id, term, selected_time选课记录表(student_id, course_id, term)加唯一索引scoreid, 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里这段代理配置要注意不然前端部署后接口全部404location /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 前端打包后路由刷新404Vue Router使用history模式时前端打包部署到nginx后用户访问/elective再按F5刷新nginx会直接返回404因为它找不到对应的静态文件路径。解决办法是在nginx配置里加一条try_files规则把未知路径全部回退到index.htmllocation / { 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——然后再往里面加缓存、加熔断、加消息、加分布式事务。每加一个组件前先问自己它解决了什么问题解决不了就暂时不加。这样走下来你得到的不仅是一个能跑的毕设而是一套你真正能讲明白为什么这么设计的系统。