ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue在线教育微服务实战:拆分、中间件与避坑

SpringBoot+Vue在线教育微服务实战:拆分、中间件与避坑 简介这是一套面向Java开发者与在线教育项目学习者的微服务实战资源聚焦前后端分离架构下的在线教育系统分为前台用户系统与后台运营平台两大部分。前台涵盖课程、问答、文章三大业务模块适合希望掌握SpringCloud微服务拆分、分布式单点登录与云服务集成的中高级开发者参考。压缩包为zip格式整体约198KB文件总数与类型明细上游暂未提供从描述看应包含项目源码、配置说明及接口文档等核心内容。后端采用SpringBoot SpringCloud MyBatis-Plus HttpClient MySQL Docker Maven前端基于Node.js Vue.js并接入Redis、ActiveMQ、阿里云OSS与视频点播使用ECharts做图表展示、POI完成用户信息批量上传注册、JWT实现分布式单点登录Swagger生成接口文档微服务分库设计。目前已有1154人学习下载可帮助读者理解教育类项目的业务建模、微服务分库与云服务集成思路适合作为课程设计或技术选型参考。1. online_edu 在线教育系统微服务拆分的第一刀切在哪很多团队做在线教育项目第一反应是先把课程、问答、文章三块业务堆到一个 SpringBoot 工程里能跑通就行。等到课程要接视频点播、问答要接消息通知、文章要接全文检索改一处编译全挂这时候才想起来拆微服务。online_edu 这个系统就是典型的「前台网站 后台运营平台」双端结构前台面向学员包含课程、问答、文章三条主线后台面向运营管内容、管订单、管数据看板。它用 SpringBoot SpringCloud MyBatis-Plus 做后端骨架Node.js Vue.js 做前后端分离的前端中间件铺了 Redis、ActiveMQ、阿里云 OSS 和视频点播图表用 ECharts报表导出用 POI。这套组合不是炫技而是被业务逼出来的课程详情页要扛高并发读问答要异步通知文章要独立检索运营后台还要跑定时统计。如果你正准备做一个基于 SpringBoot Vue 的在线教育项目或者手里已经有一个单体系统想拆成微服务这篇笔记会从拆分边界、工程结构、关键中间件接入一路讲到部署和踩坑帮你少走几段弯路。2. 微服务拆分与工程结构从单体到 SpringCloud 的第一版骨架2.1 为什么按「课程 / 问答 / 文章」拆而不是按「前台 / 后台」拆按前台后台拆是最容易翻车的做法。前台和后台都要读课程数据、都要写文章按端拆会导致同一份业务逻辑写两遍数据库还要跨服务直连。正确的切法是按业务域拆课程域负责课程、章节、视频、订单问答域负责提问、回答、采纳文章域负责文章、分类、评论。后台运营平台不单独成服务而是作为网关层的一个入口调用各业务域的服务。这样拆的好处是每个域的数据表内聚课程域的表不会被问答域直接 join。坏处是跨域查询变多比如后台要看「某课程下的问答数量」就得走服务调用或者做数据冗余。常见做法是在课程域冗余一个问答计数字段由 ActiveMQ 异步更新而不是每次实时调问答服务。拆分粒度上我一般建议第一版只拆 4 到 5 个服务网关、课程服务、问答服务、文章服务、用户服务。再细就会陷入分布式事务的泥潭再粗就失去了微服务的意义。SpringCloud 的组件选型上注册中心用 Nacos 或 Eureka网关用 Gateway配置中心跟注册中心共用 Nacos熔断降级用 Sentinel。这套组合在国内资料最多遇到问题好查。2.2 用 Maven 搭多模块工程的目录结构微服务项目最怕的是每个服务一个独立仓库改一个公共类要开五个窗口。用 Maven 多模块把公共依赖收拢到一个 parent 下是性价比最高的做法。下面是一个可以直接抄的目录结构online-edu/ ├── pom.xml # 父工程统一管理版本 ├── edu-common/ # 公共模块工具类、统一返回、常量 │ └── pom.xml ├── edu-gateway/ # 网关服务 │ └── pom.xml ├── edu-service-course/ # 课程服务 │ └── pom.xml ├── edu-service-qa/ # 问答服务 │ └── pom.xml ├── edu-service-article/ # 文章服务 │ └── pom.xml └── edu-service-user/ # 用户服务 └── pom.xml父工程的 pom 里用 dependencyManagement 锁死 SpringBoot 和 SpringCloud 的版本子模块只引坐标不写版本。这里有个血泪经验SpringBoot 版本太高会和 SpringCloud 的某些组件对不上比如 SpringBoot 3.x 默认要求 JDK17而很多老项目还在 JDK8。稳妥的组合是 SpringBoot 2.7.x 配 SpringCloud 2021.x这个组合在社区里验证最充分。!-- 父 pom 关键片段 -- properties spring-boot.version2.7.18/spring-boot.version spring-cloud.version2021.0.8/spring-cloud.version mybatis-plus.version3.5.5/mybatis-plus.version /properties dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-dependencies/artifactId version${spring-boot.version}/version typepom/type scopeimport/scope /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-dependencies/artifactId version${spring-cloud.version}/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement版本号写在 properties 里子模块升级时只改一处。MyBatis-Plus 单独管理是因为它的版本迭代快和 SpringBoot 的兼容性要单独盯。如果团队用 Gradle思路一样用 platform 引入 BOM但国内在线教育项目用 Maven 的占多数资料也更多。2.3 服务注册与网关路由的最小配置每个业务服务启动时要注册到 Nacos网关负责统一入口和鉴权。课程服务的 application.yml 最小配置如下server: port: 8081 spring: application: name: edu-service-course cloud: nacos: discovery: server-addr: 127.0.0.1:8848 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: truespring.application.name是服务在注册中心里的唯一标识网关路由和 Feign 调用都靠它。map-underscore-to-camel-case打开后数据库的course_id会自动映射到实体的courseId省掉大量 Results 注解。网关侧配置路由spring: cloud: gateway: routes: - id: course_route uri: lb://edu-service-course predicates: - Path/api/course/** filters: - StripPrefix1lb://表示走负载均衡StripPrefix1会去掉路径的第一段这样前端请求/api/course/list到服务端就变成/course/list。这里容易踩的坑是路径前缀和服务端 Controller 的 RequestMapping 对不上导致 404排查时先看网关日志里转发后的真实路径。3. 课程与视频点播MyBatis-Plus 分页、OSS 上传和播放凭证3.1 课程列表分页用 MyBatis-Plus 拦截器而不是手写 limit课程列表是前台访问量最大的接口分页写不好直接拖垮数据库。MyBatis-Plus 的分页插件把 count 查询和 limit 查询自动拼好比手写limit #{offset},#{size}更安全因为它会先算总数再取数据避免页码越界返回空列表却不知道总数。配置拦截器Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 分页插件指定数据库类型为 MySQL interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }分页查询的 Service 写法public PageCourseVO pageCourse(int pageNum, int pageSize, String keyword) { PageCourse page new Page(pageNum, pageSize); LambdaQueryWrapperCourse wrapper new LambdaQueryWrapper(); // 只查上架课程标题模糊匹配 wrapper.eq(Course::getStatus, 1) .like(StringUtils.hasText(keyword), Course::getTitle, keyword) .orderByDesc(Course::getCreateTime); PageCourse result courseMapper.selectPage(page, wrapper); return result.convert(this::toVO); }PaginationInnerInterceptor必须注册否则selectPage不会真正分页而是把全表查出来在内存里切数据量一大就 OOM。convert方法把实体转成 VO避免把数据库字段直接暴露给前端。参数上pageNum从 1 开始pageSize建议限制上限比如超过 100 就强制改成 100防止有人传 10000 拖库。3.2 阿里云 OSS 上传课程封面后端签名直传方案课程封面、讲师头像这类文件不要走后端中转流量全压在你的应用服务器上。正确做法是后端生成上传签名前端直传 OSS。后端生成签名的核心代码public MapString, String buildOssPolicy(String dir) { // 过期时间设为 10 分钟避免签名被长期盗用 long expireEndTime System.currentTimeMillis() 600 * 1000; Date expiration new Date(expireEndTime); PolicyConditions conditions new PolicyConditions(); conditions.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 104857600); conditions.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir); String postPolicy ossClient.generatePostPolicy(expiration, conditions); String signature ossClient.calculatePostSignature(postPolicy); MapString, String result new HashMap(); result.put(policy, Base64.encode(postPolicy)); result.put(signature, signature); result.put(dir, dir); return result; }CONTENT_LENGTH_RANGE限制单文件最大 100MBMatchMode.StartWith限制上传路径必须以指定目录开头防止前端把文件传到别人的目录下。签名有效期给 10 分钟足够太长会留下安全隐患。前端拿到 policy 和 signature 后直接 POST 到 OSS 的 endpoint不经过你的服务器。3.3 视频点播的播放凭证与防盗链课程视频用阿里云视频点播上传后拿到 VideoId播放时后端根据 VideoId 换取播放凭证而不是把播放地址写死在前端。凭证接口的核心逻辑public String getPlayAuth(String videoId) throws ClientException { // 凭证有效期 3000 秒过期后前端需重新获取 GetVideoPlayAuthRequest request new GetVideoPlayAuthRequest(); request.setVideoId(videoId); request.setAuthInfoTimeout(3000L); GetVideoPlayAuthResponse response vodClient.getAcsResponse(request); return response.getPlayAuth(); }AuthInfoTimeout设成 3000 秒是常见值太短会导致学员看到一半凭证过期太长则失去防盗意义。前端用 playAuth 初始化播放器播放地址由点播服务动态下发配合 Referer 防盗链和 URL 鉴权能挡住大部分盗链。这里有个坑本地开发时 Referer 是 localhost如果控制台配了防盗链白名单本地会播不了记得把开发域名加进去。4. 问答与文章ActiveMQ 异步解耦和 Redis 缓存穿透防护4.1 问答通知为什么必须走 ActiveMQ 而不是同步调用学员提交回答后要通知提问者、更新回答数、给回答者加积分。这三件事如果同步做接口响应时间会从 50ms 涨到 300ms 以上而且任何一个下游挂了都会导致回答提交失败。用 ActiveMQ 把通知类操作异步化Autowired private JmsMessagingTemplate jmsTemplate; public void submitAnswer(AnswerDTO dto) { // 先落库保证回答本身不丢 Answer answer answerMapper.insert(dto.toEntity()); // 再发消息通知、计数、积分都交给消费者 MapString, Object msg new HashMap(); msg.put(answerId, answer.getId()); msg.put(questionId, dto.getQuestionId()); msg.put(userId, dto.getUserId()); jmsTemplate.convertAndSend(edu.answer.notify, msg); }队列名edu.answer.notify用点号分层方便在控制台按前缀筛选。消费者里做幂等因为消息可能重复投递用 answerId 做唯一键去重。ActiveMQ 的持久化模式默认是 KahaDB消息落盘后即使 broker 重启也不丢但会牺牲一点吞吐教育场景消息量不大持久化是划算的。4.2 文章详情用 Redis 缓存重点防穿透和雪崩文章详情读多写少是缓存的典型场景。缓存 key 用article:detail:{id}过期时间设 30 分钟加随机偏移避免同一时刻大批 key 同时失效造成雪崩。防穿透的做法是查不到时缓存一个空对象过期时间短一些比如 5 分钟public ArticleVO getArticleDetail(Long id) { String key article:detail: id; String cached redisTemplate.opsForValue().get(key); if (cached ! null) { // 空串代表数据库也没有直接返回不再查库 return .equals(cached) ? null : JSON.parseObject(cached, ArticleVO.class); } Article article articleMapper.selectById(id); if (article null) { redisTemplate.opsForValue().set(key, , 5, TimeUnit.MINUTES); return null; } ArticleVO vo toVO(article); // 30 分钟基础过期时间 0~300 秒随机偏移 long expire 1800 new Random().nextInt(300); redisTemplate.opsForValue().set(key, JSON.toJSONString(vo), expire, TimeUnit.SECONDS); return vo; }空值缓存的过期时间要明显短于正常数据否则数据库新增了文章缓存里还是空用户要等 5 分钟才能看到。更新文章时用「先更新数据库再删除缓存」的策略不要更新缓存因为并发更新缓存容易产生脏数据。删除失败时要有重试或者用消息队列补偿。4.3 文章列表的 ECharts 统计接口怎么设计后台运营平台要用 ECharts 展示文章发布趋势、分类占比。这类统计不要实时扫全表而是按天预聚合。建一张article_stat_daily表每天凌晨用定时任务跑前一天的数据Scheduled(cron 0 10 0 * * ?) public void buildDailyStat() { LocalDate yesterday LocalDate.now().minusDays(1); ListArticleStat stats articleMapper.countByCategory(yesterday); for (ArticleStat stat : stats) { statMapper.insertOrUpdate(stat); } }cron表达式0 10 0 * * ?表示每天 0 点 10 分执行避开整点的其他任务高峰。统计接口直接查这张聚合表响应时间稳定在毫秒级。ECharts 前端只需要拿到{date, count}数组后端不要返回一堆用不上的字段。如果运营要看实时数据可以再加一个 Redis 计数器但不要为了实时去扫明细表。5. 避坑与排查微服务在线教育项目最容易翻车的 5 个点5.1 服务注册上了但网关 503现象Nacos 控制台能看到服务实例但通过网关访问一直返回 503 Service Unavailable。原因通常是网关的lb://后面跟的服务名和注册名不一致或者服务注册的是内网 IP网关所在机器访问不到。解决先核对spring.application.name和路由里的服务名是否完全一致大小写敏感再检查服务实例的 IP 是不是 127.0.0.1如果是说明服务注册时没指定网卡加spring.cloud.nacos.discovery.ip显式指定本机对外 IP。5.2 MyBatis-Plus 分页查出来总数不对现象列表数据正确但 total 总是等于当前页条数。原因是没有注册PaginationInnerInterceptor或者注册了但被其他拦截器覆盖。解决确认配置类里mybatisPlusInterceptor这个 Bean 被 Spring 扫描到且addInnerInterceptor的顺序里分页插件在最后。如果用了多数据源每个数据源都要单独配分页插件。5.3 ActiveMQ 消息消费重复导致积分加两次现象学员反馈积分莫名多了一倍。原因是消息队列的 ack 模式是自动确认消费者处理到一半抛异常broker 重发但业务已经执行了一部分。解决把 ack 模式改成客户端手动确认业务处理成功后再acknowledge()同时消费逻辑做幂等用消息里的业务 ID 查一次记录表存在就跳过。幂等表要加唯一索引靠数据库兜底。5.4 Redis 缓存和数据库数据不一致现象后台改了文章标题前台还是旧标题要等缓存过期才更新。原因是更新数据库后删除缓存失败或者先删缓存再更新数据库中间有并发读把旧数据又写回缓存。解决统一用「先更新数据库再删除缓存」删除失败时把 key 投递到消息队列重试读缓存时如果发现是旧数据不要主动回写等下次查询自然回填。5.5 视频点播凭证过期导致播放中断现象学员看长视频看到一半提示凭证失效。原因是AuthInfoTimeout设得太短或者前端没有在过期前重新获取。解决把超时时间设成视频最长时长的 1.5 倍前端监听播放器的错误事件遇到凭证过期自动调后端接口换新凭证再续播。另外注意点播服务的时钟要和服务器同步时间偏差过大会导致签名校验失败。6. 用 Docker 和 POI 收尾部署脚本与报表导出的两个实用技巧6.1 每个服务一个 Dockerfile用分层构建加速微服务多了以后手动打包上传效率极低。给每个服务写一个 Dockerfile用 Maven 分层构建把依赖和业务代码分开改代码时只重建最后一层FROM openjdk:8-jre-slim WORKDIR /app # 先拷贝依赖利用 Docker 缓存 COPY target/lib /app/lib COPY target/edu-service-course.jar /app/app.jar EXPOSE 8081 ENTRYPOINT [java, -jar, /app/app.jar, --spring.profiles.activeprod]配合 maven-dependency-plugin 把依赖复制到target/lib启动时用-Dloader.path指定或者打成 fat jar 也行。关键是--spring.profiles.activeprod要传进去否则容器里读的还是开发配置。用 docker-compose 把 Nacos、MySQL、Redis、ActiveMQ 和服务编排在一起一条docker-compose up -d就能起全套环境新人入职当天就能跑起来。6.2 POI 导出课程报表SXSSFWorkbook 处理大数据量运营后台要导出课程学习报表数据量几万行。用普通的 XSSFWorkbook 会把所有行放在内存里几万行就 OOM。换成 SXSSFWorkbook只保留滑动窗口内的行在内存其余写临时文件// 保留 100 行在内存其余刷到磁盘 SXSSFWorkbook workbook new SXSSFWorkbook(100); Sheet sheet workbook.createSheet(课程报表); for (int i 0; i courseList.size(); i) { Row row sheet.createRow(i); row.createCell(0).setCellValue(courseList.get(i).getTitle()); row.createCell(1).setCellValue(courseList.get(i).getStudyCount()); // 每 1000 行清理一次临时文件防止磁盘占满 if (i % 1000 0) { ((SXSSFSheet) sheet).flushRows(100); } }new SXSSFWorkbook(100)里的 100 是内存中保留的行数太小会频繁刷盘影响性能太大又占内存100 到 500 之间比较合适。flushRows手动触发刷盘配合定时清理临时文件避免导出大报表把服务器磁盘写满。导出接口要设超时前端用异步下载别让用户干等。6.3 一个我坚持了三年的习惯每次上线新服务前我都会先在本地用 docker-compose 把全套依赖拉起来跑一遍核心链路注册、网关路由、课程分页、问答发消息、文章缓存、视频凭证。这套流程跑通再上测试环境能挡掉八成「本地好好的一上环境就挂」的问题。微服务项目的复杂度不在写代码而在环境和服务之间的依赖关系把依赖关系摸清楚比多写几个接口有价值得多。希望帮到你。本文还有配套的精品资源点击获取
返回列表