
做过几个高校信息化项目的老人都知道选课系统是最能检验一套架构是否过关的试金石。这篇文章要聊的就是一个基于SpringBoot Vue SpringCloud微服务架构的高校教学选课管理系统。它解决的问题很具体——教务排课、学生选课、成绩管理这些高校刚需业务要扛得住开学选课那几分钟的流量洪峰又不能让代码烂成一锅粥。无论你是拿它做毕业设计还是工作中要接手类似的教务系统这套拆分思路和落地细节应该都能给你一点参考。我尽量把整个项目从设计到实现到踩坑的过程都摊开讲包含服务划分、选课并发控制、分布式锁、事务处理、前后端联调、部署运维这些环节。有的地方会放代码有的地方只讲思路因为有些坑不是代码能写明白的。1. 选课系统为什么需要微服务业务痛点与技术选型1.1 高校选课的真实业务场景先说业务。高校选课和电商秒杀看着像实际有区别。电商秒杀是有限库存抢购选课系统除了要处理并发还在背后涉及培养方案、课程池、预选、正选、退补选、课程冲突检测、学分上限、教师录入成绩等多个状态流转。传统单体系统把所有逻辑塞进一个工程里学期初选课高峰期一到数据库连接池被打满接口响应从200毫秒变成20秒全校学生一起卡死在提交中页面。我见过不少学校的做法是在选课前一周让各班班长手动统计再录入系统本质上是为了绕开并发。这种方案治标不治本。以某普通本科院校为例一个年级四五千人热门课程就二三百个名额开放选课后的前10秒内查询请求可能冲到每秒五千以上。单体服务加一台MySQL很难撑住这种瞬时压力。于是微服务就成了一个顺理成章的选项。不是说微服务能凭空增加性能而是它能把选课这条链路里的各个模块拆开独立部署、独立扩缩容。课程列表服务顶不住了就多开两个实例成绩服务平时没什么流量保持单实例就行。资源用在刀刃上系统的稳定性上限就高了。1.2 技术选型为什么是SpringCloud SpringBoot Vue这套组合已经是国内Java后端圈子的事实标准。SpringBoot负责快速构建业务服务SpringCloud提供微服务治理能力Vue负责前端页面的交互与展示。具体到组件我的选择如下组件选型理由注册中心 / 配置中心Nacos同时解决服务发现和配置管理中文文档全社区活跃网关Spring Cloud Gateway统一入口做路由、鉴权、限流远程调用OpenFeign声明式HTTP客户端使用体验接近本地方法调用服务保护Sentinel限流、降级、熔断三合一控制台可视化认证JWT OAuth2资源服务器无状态网关统一校验缓存 / 分布式锁Redis Redisson缓存课程信息锁选课操作前端Vue3 Element Plus Vite生态成熟组件丰富后台管理系统够用数据库MySQL 8 MyBatis-Plus关系型数据库适合教务数据插件减少重复SQL这里有个容易踩的坑SpringCloud版本和SpringBoot版本必须严格对应。SpringCloud 2022.0.x对应SpringBoot 3.xSpringCloud 2021.0.x对应SpringBoot 2.6.x。如果直接在网上找一个SpringBoot 2.3的教程配SpringCloud Alibaba 2022大概率启动就报错。用IDEA创建项目的时候最好到Spring Initializr官网选好版本再导进来。选这套架构还有一个实际考量招聘市场上会SpringBoot和Vue的人多后续接手维护不需要太高的门槛。技术栈不够炫但足够稳。2. 服务拆分与数据边界设计模块怎么分才不乱2.1 按业务域拆分的六个服务很多第一次做微服务的人最容易犯的毛病是把Controller拆开当成微服务。比如一个项目里按userController、courseController、selectionController分成三个模块但是数据库还是同一个事务还是靠Spring本地事务管那就只是换了个皮的分布式单体该卡还是卡该崩还是崩。正确的拆分方式是按业务域拆分每个服务拥有独立的数据库独立部署通过接口通信。这个项目我拆成了六个服务gateway-server网关服务负责路由转发、统一鉴权、限流不写业务代码。auth-server认证授权服务负责登录、JWT签发、验证码、密码加密是用户的入口。system-server系统管理服务负责学生、教师、管理员的基础信息维护也处理院系列表、班级列表这类公共数据。course-server课程服务负责课程库、开课计划、教师开课信息是选课的数据源头。selection-server选课服务核心业务服务负责选课、退课、选课结果查询、冲突校验、容量控制。这个服务是流量的主要冲击点需要重点关注。grade-server成绩服务负责成绩录入、成绩查询、学分统计。注意成绩服务与选课服务的数据需要最终一致但不要求强一致。服务边界划分的原则是一个服务内的修改不影响其他服务服务之间通过API通信不直接共享数据库表。选课服务需要知道课程的容量信息但课程表在course服务库里所以course服务必须提供查询课程剩余容量的接口或者选课服务在本地冗余一份课程容量的缓存。实际项目中我选择了缓存方案后面细说。2.2 数据库独立与核心表设计每个服务独占一个数据库这是微服务和单体在数据层最大的区别。以选课服务为例核心表如下selection_record选课记录表字段包括id、student_id、course_id、course_name、teacher_name、class_time、credit、semester、status、create_time。关键设计是联合唯一索引uk_student_course(student_id, course_id)这是防止同一学生重复选同一门课的最后一道防线。course_stock课程容量表存储课程的总容量和已选人数字段为id、course_id、total_capacity、selected_count、version。这个表会频繁更新并发高时容易产生热点行更新问题。drop_record退课记录表用于审计。课程服务那边还有course_info课程基本信息、teacher_course教师开课关联、semester_plan学期开课计划等表这里不逐一展开。特别强调一下容量控制。选课服务里不能用先查剩余容量再判断是否大于0最后插入选课记录这种三步走的流程因为并发时多个请求同时查到容量还剩1同时通过校验最后超出容量。正确做法是把容量扣减放在选课记录插入的同一个事务里用带条件的UPDATE原子操作UPDATE course_stock SET selected_count selected_count 1 WHERE course_id #{courseId} AND selected_count total_capacity;如果影响行数为0说明容量已满直接返回课程已选满。这个SQL是原子性的不需要额外加锁也是防超卖的基础。再加上前面说的唯一索引双保险兜底。2.3 接口设计与服务间调用的坑服务间调用我用OpenFeign。比如selection-server需要查询课程的基本信息不可能直接访问course-server的数据库只能是course-server暴露一个GET /api/course/info/{courseId}接口selection-server写一个Feign Client调用。有个实际的坑Feign调用默认会继承请求头但如果你用了JWT网关已经把用户信息解析出来了转发到具体服务时服务并不知道当前用户是谁。我的做法是在网关层面把用户ID放入请求头比如X-User-Id然后在Feign调用时通过RequestInterceptor把这个header透传到下一个服务。否则选课服务根本不知道是谁在选课。Feign配置示例Configuration public class FeignConfig { Bean public RequestInterceptor requestInterceptor() { return requestTemplate - { ServletRequestAttributes attrs (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs ! null) { HttpServletRequest request attrs.getRequest(); requestTemplate.header(X-User-Id, request.getHeader(X-User-Id)); requestTemplate.header(X-User-Role, request.getHeader(X-User-Role)); } }; } }新人也容易忽略跨服务的异常处理不能只靠HTTP状态码。Feign调用失败时抛的是FeignException如果你的业务接口返回了200但body里带上错误码调用方很难判断成功还是失败。我的建议是设计一个统一的响应体结构ResultT包含code、message、data三个字段Feign调用时先判断code是否为200才算真正成功。3. 核心功能实现认证、选课、前端联动3.1 登录认证与网关鉴权链路整个认证闭环是这样的学生或教师在浏览器输入账号密码请求打到Gateway路由到auth-serverauth-server校验账号密码后生成JWT返回。前端把JWT存到localStorage或pinia里的state后续每个请求都在Authorization头带着这个Token。Gateway有两个活儿要干。第一是鉴权拦截请求判断白名单外的路径都要携带合法JWT。白名单包括登录接口、验证码接口、课程查询接口未登录时可浏览。第二是转发把带Token的请求转发到对应的微服务同时把解析出的用户信息放入请求头。网关鉴权不能只做有没有Token的判断还要校验Token是否过期、签名是否正确。我用了Spring Cloud Gateway的GlobalFilter加JWT解析Component public class AuthGlobalFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); String path request.getPath().value(); // 白名单直接放行 if (isWhiteList(path)) { return chain.filter(exchange); } String token getTokenFromRequest(request); // 解析JWT失败则返回401 Claims claims JwtUtil.parseToken(token); if (claims null) { return unauthorized(exchange); } // 把用户信息放入后续请求头 ServerHttpRequest newRequest request.mutate() .header(X-User-Id, claims.get(userId).toString()) .header(X-User-Role, claims.get(role).toString()) .build(); return chain.filter(exchange.mutate().request(newRequest).build()); } }这里有个细节网关采用的是WebFlux响应式编程模型不能直接用Servlet API的HttpServletRequest要用ServerHttpRequest。很多从单体转过来的开发者在这个地方卡很久。3.2 选课核心流程与分布式锁实践选课是系统的心脏我把它的完整流程拆开讲。学生提交选课请求带上课程ID和学期。selection-server先校验这个课程在当前学期是否开放选课——开放时间是由教务在后台配置的不在开放时间窗口内的请求直接拒绝。这个校验不能只靠前端倒计时因为可以绕过前端直接打接口。接着做冲突检测。一门课有固定上课时间比如周一3-4节另一门课也是周一3-4节两门都选就冲突了。这个逻辑需要在选课服务里完成查询学生的已选课程列表逐个对比上课时间。考虑到性能学生当前学期的已选课程数量一般不超过30门直接内存循环比较开销可以接受。容量扣减用前面说的原子UPDATE。这一步和插入选课记录需要在一个本地事务里完成但如果选课服务有多个实例普通的本地事务只能保证单个实例内部的一致性两个学生同时选同一门容量只剩1的课时光靠SQL条件更新还不够——因为一个请求可能在实例A扣减成功另一个在实例B扣减时锁等待或超时。这时候就需要分布式锁。我使用Redisson实现分布式锁锁的粒度是课程ID而不是学生ID。为什么要锁课程因为瓶颈在课程容量只要同一门课程的选课请求被串行化处理就不会出现超卖。锁学生ID没有意义因为不同学生选同一门课互不冲突关键竞争点是课程容量。Autowired private RedissonClient redissonClient; public Boolean selectCourse(Long studentId, Long courseId, String semester) { String lockKey SELECT_LOCK: courseId; RLock lock redissonClient.getLock(lockKey); boolean acquired false; try { // 尝试等待2秒锁自动过期10秒 acquired lock.tryLock(2, 10, TimeUnit.SECONDS); if (!acquired) { return false; // 当前课程操作繁忙请重试 } // 这里执行容量扣减 插入选课记录 冲突检测 return doSelectCourse(studentId, courseId, semester); } catch (Exception e) { log.error(选课失败, e); return false; } finally { if (acquired lock.isHeldByCurrentThread()) { lock.unlock(); } } }锁的时间设置有个学问。选课操作的业务逻辑通常几十毫秒就执行完了为什么不设置成1秒甚至更短因为高峰期可能出现线程排队一个请求在锁里跑得慢后面的请求都卡在tryLock等待如果锁自动过期时间太短前一个线程还没跑完锁就释放了后面的线程拿锁进入又会造成并发覆盖。10秒是我在测试环境压出来的一个比较稳的值实际部署时可以根据接口耗时调整。锁粒度这里我再啰嗦一句不要把整个选课接口用一把全局锁比如SELECT_LOCK:ALL。那样的话不同课程之间也会互相阻塞系统吞吐量会直线下降。按课程ID加锁不同课程互不影响才是分布式锁的正确打开方式。3.3 Vue前端动态路由与选课页面的交互细节前端用的是Vue3加Element Plus。管理端页面包括课程管理、学生管理、教师管理、选课规则配置、成绩录入等模块学生端页面包括选课大厅、我的课表、成绩查询、个人中心。动态路由是前端的核心难点之一。不同角色登录后能看到的路由不同。我的做法不是把路由表写死在代码里而是登录之后根据用户角色动态生成。具体实现用户信息从auth-server返回时带一个role字段student/teacher/admin前端拿到后遍历一份角色与路由的映射表用router.addRoute()动态添加可访问的页面路由。这样学生访问管理端页面的URL时由于路由根本不存在会直接落到404页面比单纯靠按钮隐藏更安全。选课大厅页面的交互有一个高频问题选课按钮点击后的状态反馈。选课接口是异步的后端处理需要时间如果用户连续点击三次就会发出三个相同请求虽然后端有分布式锁和唯一索引兜底但用户体验会很差还会白白消耗服务器资源。我建议在按钮点击后立即置为loading状态并禁用el-button typeprimary :loadingselectingMap[course.id] :disabledselectedSet.has(course.id) clickhandleSelect(course) {{ selectedSet.has(course.id) ? 已选 : 选课 }} /el-buttonVue中处理这类防重复提交还可以在handleSelect方法里用状态位拦截而不是依赖后端的锁。前端做一层拦截后端做一层兜底双保险。课程余量刷新我用的是轮询每30秒请求一次剩余容量接口。高峰期可以把间隔缩短到5秒但要注意别把接口打爆。更好的方案是WebSocket推送但考虑成本和复杂度轮询对选课系统已经够用了。3.4 教务管理与教师端的功能拆解除了核心的选课流程系统还有教务排课和教师成绩管理两大块。教务管理员登录后在课程服务里维护开课计划选择学期、课程、授课教师、上课时间、上课地点、容量上限。开课计划发布后进入选课服务的course_stock表生成对应课程的容量记录初始selected_count为0。这个同步动作我在course-server发布开课计划后直接调用selection-server的接口完成但这里就引出跨服务数据一致性的问题接下来专门讲。教师端功能相对简单查看自己名下的课程和选课学生列表、录入成绩、导出成绩单。成绩录入完成后数据落在grade-server学生端在我的成绩页面通过调用grade-server的查询接口展示成绩。这里有个业务规则需要处理成绩是否对所有人可见还是只对本人可见出于隐私考虑查询成绩接口只允许学生本人访问教师只能看自己教的课程的学生成绩这些在网关鉴权和接口参数校验里都要做。4. 分布式架构的几个硬骨头事务、限流与幂等4.1 跨服务事务别一上来就Seata选课系统里跨服务操作的场景不少开课计划发布要同时写入course库和selection库教师录入成绩要写grade库还要更新学生的已修学分汇总。单体系统里一个Transactional就搞定了微服务里就麻烦了。很多人一听到分布式事务就想到Seata然后把系统搞得很复杂。我的经验是能不引入分布式事务框架就不引入优先通过业务设计规避。什么叫规避开课计划发布后selection服务需要生成容量记录这个可以做成补偿式course-server发布成功后发一条消息到RabbitMQselection-server消费消息生成容量记录。如果生成失败通过定时的对账任务扫描course表和selection表发现缺失就自动补齐。这样做的代价是最终一致而不是实时一致。考虑到容量记录晚几秒生成对选课没有实质影响这个方案完全成立。如果业务确实强一致再考虑Seata。Seata的AT模式对代码侵入小但要注意它依赖全局锁在高并发场景下会把性能拖垮。选课服务里我不用Seata因为容量扣减和选课记录插入本来就在同一库的同一个事务里不需要分布式事务。4.2 高并发下的限流与降级选课高峰期不能让请求无限量涌进系统。网关层面我用Sentinel做限流规则是每个用户每秒钟最多请求选课接口10次整体选课接口每秒最多5000次。超出阈值的请求直接返回系统繁忙请稍后重试。Sentinel也可以按来源限流比如某条热门课程的选课接口单课程QPS限制200防止热点请求压垮数据库。配置方式支持控制台可视化操作也支持在代码里硬编码PostMapping(/select) SentinelResource(value selectCourse, blockHandler selectCourseBlockHandler) public ResultString select(RequestParam Long courseId) { // 选课处理逻辑 } public ResultString selectCourseBlockHandler(Long courseId, BlockException e) { return Result.error(选课人数过多请稍后重试); }还有一层降级策略course-server查询课程列表时依赖数据库一旦数据库压力过大直接降级返回缓存里的课程信息。Redis里缓存一份课程列表有效期30秒哪怕数据库短暂不可用学生也能看到课程列表只是可能不及时。4.3 选课接口的幂等设计选课的幂等性好多人忽略。用户在极端情况下可能重复提交选课请求网络超时也会导致前端重试。后端幂等设计有两个维度。第一层已经讲过了选课记录表的联合唯一索引同一学生同一课程只要插入一次重复插入会报DuplicateKeyException。我在代码里捕获这个异常直接返回您已选择该课程。第二层是请求级别的幂等前端在发起选课请求时生成一个requestId后端收到请求先查Redis里有没有这个requestId的标记有说明已经处理过直接返回上次结果没有才继续处理。这个机制能防止网络重试导致的重复扣减容量。public ResultString selectWithIdempotent(String requestId, Long studentId, Long courseId) { String idempotentKey IDEMPOTENT: requestId; // SETNX若返回false说明已处理过 Boolean first redisTemplate.opsForValue() .setIfAbsent(idempotentKey, courseId.toString(), 30, TimeUnit.MINUTES); if (!Boolean.TRUE.equals(first)) { return Result.success(选课请求已处理请勿重复提交); } // 走正常选课流程 return doSelectCourse(studentId, courseId); }这里的key有效期设置30分钟足够了选课接口的响应时间通常不超过几百毫秒30分钟内不会有人用同一个requestId再次请求。就算有也是业务上的异常情况。5. 项目环境搭建与部署实操5.1 本地开发环境Nacos、Redis、MySQL的初始化这个项目本地跑起来需要Nacos、Redis、MySQL三个中间件。我给新人的建议是别一个个手动下载安装用Docker Compose一把梭。项目根目录放一个docker-compose.ymlversion: 3.8 services: mysql: image: mysql:8.0 container_name: edu-mysql environment: MYSQL_ROOT_PASSWORD: root123 ports: - 3306:3306 volumes: - ./db:/docker-entrypoint-initdb.d:ro redis: image: redis:7.0 container_name: edu-redis ports: - 6379:6379 nacos: image: nacos/nacos-server:v2.2.3 container_name: edu-nacos environment: MODE: standalone NACOS_AUTH_ENABLE: false ports: - 8848:8848 - 9848:9848启动命令就一条docker compose up -d。Nacos默认占用88489848是gRPC端口2021年之后的Nacos版本一定要映射这个端口不然后端的服务注册心跳会报错。运行微服务工程时有个每人都要踩一遍的坑服务启动顺序。如果你还没启动Nacos就把auth-server启动了服务会反复重试连接Nacos然后报错。正确顺序是先启动Nacos再启动业务服务最后启动Gateway。业务服务之间互相调用时只要注册中心是通的Feign会自动找到目标服务实例不要求服务启动顺序。5.2 SpringBoot服务的端口规划六个服务端口如果不规划好启动时会冲突。我的约定如下服务端口说明gateway-server8080对外统一入口auth-server8081认证授权system-server8082用户与基础数据course-server8083课程服务selection-server8084选课核心服务grade-server8085成绩服务每个服务在application.yml里配置各自的server.port同时配置Nacos地址和数据库连接。需要注意Nacos上配置的数据库地址不能写成localhost如果你用的是Docker起的MySQL要从宿主机访问地址应该是你本机局域网IP或者直接用mysql这个容器服务名前提是SpringBoot服务也在同一个Docker网络里。5.3 前端打包与Nginx部署Vue前端开发模式跑起来很容易npm run dev就行。但生产环境要打包成静态文件然后扔到Nginx里。打包之前有件事必须做配置生产环境的后端接口地址。我在开发环境用Vite的代理解决跨域请求/api代理到http://localhost:8080。生产环境没有Vite帮忙Nginx得配置反向代理server { listen 80; server_name your-domain.com; # 前端静态文件 location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } # 后端接口代理 location /api/ { proxy_pass http://gateway-server:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里有个最常见的部署事故SPA路由模式下的刷新404问题。因为前端是history路由直接访问/student/course时Nginx找不到这个路径对应的物理文件。上面的配置已经写了try_files $uri $uri/ /index.html意思是找不到文件就回退到index.html由前端JS接管路由。这一行没写刷新页面必白屏。5.4 Docker Compose整合部署完整项目部署时我建议用Docker Compose把所有服务编排在一个网络里包括前端Nginx、后端六个服务、Nacos、Redis、MySQL。后端每个微服务的Dockerfile很简单基础镜像用eclipse-temurin对应JDK版本然后把打包好的jar复制进去FROM eclipse-temurin:17-jre WORKDIR /app COPY target/*.jar app.jar EXPOSE 8084 ENTRYPOINT [java, -jar, app.jar]然后用docker-compose.yml里按顺序定义服务并设置依赖关系。注意JVM内存参数每个服务默认堆内存可能吃掉几百兆服务器内存不够会直接OOM。我在启动命令里加-Xms128m -Xmx256m限制内存六个服务加起来占1.5G左右2G内存的服务器勉强能跑4G比较稳。6. 常见问题与排查技巧实录6.1 选课超卖问题这是选课系统最核心的问题即使做了分布式锁和原子UPDATE仍然可能出问题的点在于锁的时间太短导致提前释放。我遇到过一次真实故障Redisson的锁过期时间设置成5秒但某次数据库慢查询导致锁内业务逻辑跑了6秒。锁在第5秒自动释放另一个线程拿到锁进来开始扣减两个线程同时操作同一课程的容量最终selected_count超出total_capacity。排查时发现数据库的慢查询日志里有一条全表扫描的SQL原因是course_stock表少了索引。解决办法除了延长锁过期时间更要紧的是优化锁内代码。锁内不要查数据库查两次以上尽量在锁外把前置条件全部准备好。还有个办法是使用Redisson的看门狗机制默认情况下锁会自动续期每10秒检查一次只要线程还在执行就延长锁时间。配置RLock lock redissonClient.getLock(lockKey); lock.lock(); // 不指定过期时间使用默认看门狗续期 try { // 业务逻辑 } finally { lock.unlock(); }6.2 网关鉴权后Feign调用丢失用户信息现象学生选课成功但选课记录里student_id是空的。排查链路Gateway把用户ID放进了X-User-Id请求头转发到selection-server的Controller后Controller能通过RequestHeader拿到。但Controller内部又通过Feign调用了selection-server下游的某个服务此时请求头丢了。原因Feign的默认配置不传递所有请求头。解决办法就是前面写的RequestInterceptor把当前请求上下文里的X-User-Id和X-User-Role取出来手动加到Feign请求上。这里还有一个隐藏问题如果用了Spring Boot 2.x与Feign获取HttpServletRequest时如果请求不是从网关转发过来的而是内部定时任务触发的Feign调用RequestContextHolder.getRequestAttributes()会返回null。我的RequestInterceptor代码里做了null判断但网络上很多教程没有新人cv过去直接空指针。6.3 Nacos服务注册失败现象服务启动不报错但过一会儿GPU的日志里能看到ServiceRegistError。最可能的原因是网络服务器防火Q没有开放9848端口。Nacos 2.x的客户端使用gRPC通信除了8848还会连9848只开8848会导致注册失败。这个坑在云服务器上尤其常见安全组规则里必须同时放行8848和9848。另一个可能Nacos运行在单机standalone模式时如果机器内存不足Nacos服务本身宕机客户端日志全是Connection refused。建议Nacos部署时给足内存至少512M堆内存或者在docker-compose里加上JVM参数限制。6.4 Vue打包后页面白屏打包部署后打开页面控制台报Failed to fetch dynamically imported module这是Vite打包时生成的动态路由文件路径问题。解决方案在vite.config.js里设置base: ./让资源引用改为相对路径不然所有资源都会指向根路径/assets/放在Nginx子路径下就全部404。6.5 常见问题速查表问题排查要点快速解决方案服务启动报端口占用检查端口规划是否冲突关闭无关进程或改端口Feign调用报500看目标服务日志和本地异常堆栈检查RequestBody序列化兼容Redis连接超时确认密码/端口/网络本机测试ping和telnet选课慢定位是锁等待还是数据库慢打开慢查询日志和Arthas前端跨域网关未开启CORS配置Gateway配置全局CorsFilter接口401JWT过期或签名不匹配确认JWT密钥一致刷新Token7. 收尾前最后分享几个实际经验这套系统从前到后做下来我最大的体感是微服务的复杂度不在技术框架本身而在边界管理。服务拆分不清事务补偿逻辑就会变成一团乱麻接口设计不约束联调时大家各自为政后端改一个字段前端就要跟着改三天。如果让我再做一遍我会优先把选课链路的数据模型和接口契约在需求阶段就固定下来因为这是全系统最敏感的部分。其余的管理功能模块哪怕服务多一点复杂度也远低于选课这条链路。另外要提一句看到这里的朋友如果是要做毕业设计不建议把服务拆得太碎。六个服务是合理上限拆到十几个Nacos上密密麻麻全是服务列表本地开发机器跑起来内存直接爆掉。微服务的首要目标是解决问题不是为了指标好看。最后一个小技巧把每个服务的启动命令写成一个shell脚本或Makefile比如./start.sh all、./start.sh gateway。这样每次本地联调不用在IDEA里一个一个点启动按钮省下不少时间。这个习惯我延续到所有微服务项目里实测效率提升非常明显。