简介:这是一套面向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.version>2.7.18</spring-boot.version> <spring-cloud.version>2021.0.8</spring-cloud.version> <mybatis-plus.version>3.5.5</mybatis-plus.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring-boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</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: - StripPrefix=1lb://表示走负载均衡,StripPrefix=1会去掉路径的第一段,这样前端请求/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 Page<CourseVO> pageCourse(int pageNum, int pageSize, String keyword) { Page<Course> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Course> wrapper = new LambdaQueryWrapper<>(); // 只查上架课程,标题模糊匹配 wrapper.eq(Course::getStatus, 1) .like(StringUtils.hasText(keyword), Course::getTitle, keyword) .orderByDesc(Course::getCreateTime); Page<Course> 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 Map<String, 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); Map<String, String> result = new HashMap<>(); result.put("policy", Base64.encode(postPolicy)); result.put("signature", signature); result.put("dir", dir); return result; }CONTENT_LENGTH_RANGE限制单文件最大 100MB,MatchMode.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()); // 再发消息,通知、计数、积分都交给消费者 Map<String, 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); List<ArticleStat> 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.active=prod"]配合 maven-dependency-plugin 把依赖复制到target/lib,启动时用-Dloader.path指定,或者打成 fat jar 也行。关键是--spring.profiles.active=prod要传进去,否则容器里读的还是开发配置。用 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 把全套依赖拉起来,跑一遍核心链路:注册、网关路由、课程分页、问答发消息、文章缓存、视频凭证。这套流程跑通再上测试环境,能挡掉八成「本地好好的,一上环境就挂」的问题。微服务项目的复杂度不在写代码,而在环境和服务之间的依赖关系,把依赖关系摸清楚,比多写几个接口有价值得多。希望帮到你。
本文还有配套的精品资源,点击获取