SpringBoot+SpringCloud在线教育系统微服务架构与核心链路
简介:online_edu 是一套面向 Java 开发者与在线教育业务学习者的微服务项目源码,覆盖前台课程、问答、文章与后台运营管理场景,适合用来理解前后端分离、服务拆分和分布式登录等工程实践。项目后端采用 SpringBoot、SpringCloud、MyBatis-Plus,结合 MySQL、Redis、ActiveMQ、Docker、Maven 与 HttpClient,前端使用 Node.js、Vue.js,并接入阿里云 OSS、视频点播;业务侧还使用 ECharts 做图表展示、POI 完成用户信息批量上传与注册,单点登录基于 JWT,接口文档由 Swagger 生成。压缩包约 198KB,文件总数与类型明细上游暂未提供,可结合目录结构按模块查阅。已有 1155 人浏览学习,可作为课程服务、内容管理、运营后台与微服务分库设计的参考样例,帮助读者梳理依赖关系、接口划分和中间件接入方式,适合具备 Java 与 Spring 基础、希望积累在线教育项目经验的中级开发者。
1. 课程视频看到一半断了,学员的追问没人接
课程视频看到第 37 分钟突然黑屏,学员在问答区连发三条追问,运营只能手动翻数据库查学习记录——这是不少在线教育系统上线三个月后常见的场面。这套 online_edu 在线教育系统就是冲着这类场景拆出来的:前台学员端负责课程浏览、视频学习、提问回答和文章阅读,后台运营端负责课程上架、内容审核与数据看板,两侧共用一套基于 SpringBoot + SpringCloud 的微服务后端和 Vue 前端工程。它适合两类人:一是正在做毕设或课程设计、需要一套结构完整且能讲清楚的服务拆分方案的学生;二是要交付一套可运行在线教育平台原型的开发者。下面按需求、架构、模块实现、避坑、验收的顺序,把这份方案摊开讲。
2. 总体架构设计:服务拆分边界与前后端分离的接口契约
架构这一步最容易出现的偏差不是技术选型错,而是拆得太碎。有人一上来按表拆服务,课程一张表一个服务、章节一张表一个服务,结果一次课程详情页要跨四个服务调用,链路长得像一条绳子,出问题连日志都串不起来。这套系统按业务域切,切完每个服务能独立打包、独立部署、独立扩缩容,同时保证一次页面请求的跨服务调用不超过两次。
2.1 按业务域切出五个服务加一个网关
业务域的划分依据是「谁的数据谁负责写入」。课程、章节、课时、学习记录属于课程域;问答、文章、评论属于内容域;用户、角色、菜单、鉴权属于用户域;后台的统计报表、导出、审核属于运营域。四个域各自一个服务,前面挂一个网关统一收口。
| 服务 | 职责 | 主要依赖 | 是否对外 |
|---|---|---|---|
| edu-gateway | 路由转发、JWT 校验、跨域、限流 | Redis | 是 |
| edu-user | 注册登录、角色权限、菜单 | MySQL、Redis | 否 |
| edu-course | 课程、章节、课时、学习记录、视频凭证 | MySQL、Redis、对象存储、视频点播 | 否 |
| edu-content | 问答、回答、文章、评论 | MySQL、ActiveMQ | 否 |
| edu-admin | 数据看板、报表导出、内容审核 | MySQL、各服务 OpenAPI | 否 |
| edu-common | 统一响应、异常、工具类、常量 | 无 | 否 |
网关是唯一对外暴露的入口,其余服务只开内网端口。这样做的好处是鉴权只写一遍:网关解出 JWT 里的 userId 和角色,塞进请求头往下透传,下游服务不再重复解析 token。很多项目在这里翻车,网关剥了一次头,下游又解析一次,两处逻辑不一致,改一个忘一个,权限就成了玄学。
服务之间的同步调用走 HttpClient 封装好的客户端,异步解耦走 ActiveMQ。为什么不全用 OpenFeign?因为课程服务调用视频点播和对象存储的外部接口本身就是 HTTP,统一用一层 HttpClient 封装,超时、重试、日志埋点只维护一份,少一层抽象少一处坑。服务内部的分层是 controller → service → mapper,MyBatis-Plus 负责单表 CRUD,复杂统计手写 XML,别指望条件构造器把多表聚合也包了。
2.2 前后端分离:先定接口契约再写页面
前后端分离最怕的是接口随写随改。这套方案的约定是:所有接口返回统一结构,前端 axios 拦截器只认一种壳,业务代码里不再写 if (res.code === 0) 之外的判断。
// edu-common 模块:统一响应体 @Data public class Result<T> implements Serializable { /** 业务状态码:200 成功,401 未登录,403 无权限,500 业务异常 */ private Integer code; /** 提示信息,前端可直接 toast */ private String message; /** 业务数据,列表接口统一返回 PageResult */ private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static <T> Result<T> fail(Integer code, String message) { Result<T> r = new Result<>(); r.setCode(code); r.setMessage(message); return r; } }这个类看着简单,但它决定了后面所有接口的形态。三个字段各自承担一件事:code 给前端做分支判断,message 给人看,data 给页面渲染。分页接口的 data 固定包一层 PageResult,里面是 total、pages、current、records 四个字段,前端表格组件直接对接,不用每个页面单独适配。
异常处理配一个 @RestControllerAdvice 全局拦截,业务代码里只管抛 BusinessException,不再写 try-catch 包满整个 controller。参数校验用 @Validated 加注解,校验失败的信息由同一个 advice 转成 Result.fail,前端拿到的永远是同一种结构。
跨域在网关层处理,不要在每个服务里加 @CrossOrigin。原因是浏览器只会跟网关打交道,服务里的跨域配置纯属摆设,配多了还会出现响应头重复导致浏览器拒绝请求。网关里统一配置允许的来源、方法和请求头,预检请求直接放行。
2.3 构建与部署:Maven 多模块出包,Docker 起环境
工程用 Maven 多模块组织,父 pom 锁版本,子模块只声明依赖不写版本号。这是避免「本地能跑、服务器报 NoSuchMethodError」最省事的办法,版本冲突在父 pom 里一次解决。
# 父工程根目录:跳过测试打包全部模块 mvn clean package -DskipTests # 只打课程服务及其依赖的 common 模块 mvn clean package -pl edu-course -am -DskipTests # 构建镜像,镜像名带模块与版本,方便回滚 docker build -t edu/course:1.0.0 -f edu-course/Dockerfile . # 用 compose 起中间件与服务,配置文件挂载在宿主机便于改参数 docker compose -f deploy/docker-compose.yml up -dDockerfile 要分层写:先 COPY pom.xml 跑一次 dependency:go-offline 拉依赖,再 COPY 源码打包。这样改一行 Java 代码不会把依赖层全部失效,构建时间从几分钟降到几十秒。容器内时区、字符集、JVM 堆参数通过环境变量注入,别在镜像里写死,测试环境和生产环境的堆大小往往差一倍。
docker-compose 里至少要包含 MySQL、Redis、ActiveMQ 和服务本身。数据库初始化脚本挂到 /docker-entrypoint-initdb.d,首次启动自动建库建表,这样别人拿到方案后一条命令就能起来,答辩现场也不用现场导入 SQL。部署顺序是 MySQL → Redis → ActiveMQ → 业务服务 → 网关,compose 用 depends_on 加健康检查控制,不然服务起来了数据库还没初始化完,第一批请求全是连接异常。
3. 课程模块实现:从课程发布到视频播放的完整链路
课程模块是整个系统里链路最长的一块:运营在后台建课程、传视频,学员在前台看列表、进详情、播视频、记进度。这条链路上任何一环设计得糙,后面都要返工。所以先把表定死,再谈视频凭证,最后处理进度存储。
3.1 数据结构:课程、章节、课时与学习记录
课程和章节是一对多,章节和课时是一对多,学习记录按「用户 + 课时」唯一。这张设计的关键点是不把学习进度塞进课时表,否则一万个学员就有一万份课时数据,课时表会被写爆。
-- 课程主表 CREATE TABLE edu_course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(120) NOT NULL COMMENT '课程标题', cover_url VARCHAR(500) DEFAULT NULL COMMENT '封面图地址', teacher_id BIGINT NOT NULL COMMENT '讲师ID', category_id BIGINT NOT NULL COMMENT '分类ID', price DECIMAL(10,2) DEFAULT 0.00 COMMENT '价格,0为免费', status TINYINT DEFAULT 0 COMMENT '0草稿 1上架 2下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_category_status (category_id, status) ) COMMENT '课程表'; -- 章节表 CREATE TABLE edu_chapter ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, title VARCHAR(120) NOT NULL, sort INT DEFAULT 0 COMMENT '章节顺序', KEY idx_course (course_id) ) COMMENT '章节表'; -- 课时表:视频元信息只存引用,不存文件 CREATE TABLE edu_lesson ( id BIGINT PRIMARY KEY AUTO_INCREMENT, chapter_id BIGINT NOT NULL, course_id BIGINT NOT NULL COMMENT '冗余课程ID,避免详情页回表', title VARCHAR(120) NOT NULL, video_id VARCHAR(64) DEFAULT NULL COMMENT '点播服务返回的视频ID', duration INT DEFAULT 0 COMMENT '时长,秒', free_flag TINYINT DEFAULT 0 COMMENT '1可试看', sort INT DEFAULT 0, KEY idx_chapter (chapter_id), KEY idx_course (course_id) ) COMMENT '课时表'; -- 学习记录:用户+课时唯一 CREATE TABLE edu_learning_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, course_id BIGINT NOT NULL, lesson_id BIGINT NOT NULL, progress INT DEFAULT 0 COMMENT '已观看秒数', finished TINYINT DEFAULT 0 COMMENT '1看完', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_lesson (user_id, lesson_id), KEY idx_user_course (user_id, course_id) ) COMMENT '学习记录表';三个设计取舍值得说一下。课时表冗余 course_id,是为了课程详情页一次查询把整棵树拉出来,不用先查章节再回查课时,省一次往返。video_id 存的是点播服务的视频标识,视频文件本身在对象存储里,数据库只存引用,这是视频类系统的基本纪律——把文件塞进数据库或塞进应用服务器磁盘,迁移和扩容时会非常难受。学习记录用 user_id + lesson_id 做唯一索引,配合 INSERT ... ON DUPLICATE KEY UPDATE,把「新增还是更新」的判断交给数据库,应用层不用先查后写,减少一次并发下的竞态。
3.2 视频上传与播放:凭证签发而不是转发流量
视频这条链路上最常见的错误做法是:前端把文件传到业务服务,业务服务再转发到对象存储。文件一过业务服务,内存和带宽立刻吃紧,几个并发上传就能把服务拖垮。
正确的做法是直传。业务服务只负责签发上传凭证和播放凭证,文件流不经过业务服务。
@Service public class VideoService { @Autowired private HttpClientUtil httpClientUtil; /** 签发上传凭证:前端拿到后直传对象存储,服务端不碰文件流 */ public UploadAuthVO createUploadAuth(String fileName, String title) { Map<String, Object> param = new HashMap<>(); param.put("fileName", fileName); param.put("title", title); // 调用点播服务的创建视频接口,返回视频ID与上传地址、凭证 String resp = httpClientUtil.postJson(VOD_CREATE_URL, param); JSONObject json = JSON.parseObject(resp); if (json.getInteger("code") != 200) { throw new BusinessException("视频凭证签发失败:" + json.getString("message")); } UploadAuthVO vo = new UploadAuthVO(); vo.setVideoId(json.getString("videoId")); vo.setUploadAuth(json.getString("uploadAuth")); vo.setUploadAddress(json.getString("uploadAddress")); return vo; } /** 签发播放凭证:带有效期,前端每次进入播放页重新获取 */ public PlayAuthVO getPlayAuth(Long lessonId, Long userId) { Lesson lesson = lessonMapper.selectById(lessonId); if (lesson == null || lesson.getVideoId() == null) { throw new BusinessException("该课时暂无可播放视频"); } // 付费课程要在这里校验购买记录,试看课时放行 if (lesson.getFreeFlag() != 1 && !orderService.hasBought(userId, lesson.getCourseId())) { throw new BusinessException("尚未购买该课程"); } String resp = httpClientUtil.postJson(VOD_PLAY_URL, Collections.singletonMap("videoId", lesson.getVideoId())); PlayAuthVO vo = new PlayAuthVO(); vo.setPlayAuth(JSON.parseObject(resp).getString("playAuth")); vo.setExpireSeconds(1800); return vo; } }这段代码有两个关键点。第一,播放凭证的有效期设成 30 分钟,前端播放器在到期前重新拉一次。凭证有效期设太长,链接被分享出去就等于课程被白嫖;设太短,学员看长视频中途断流,体验很差。半小时是个平衡点。第二,权限校验放在签发凭证之前,而不是在前端做按钮置灰。前端隐藏播放按钮只是交互层的礼貌,真正的门禁必须在服务端,凭证签发这一关守住了,视频就漏不出去。
上传凭证的返回结构里,uploadAuth 和 uploadAddress 是前端 SDK 直接消费的,服务端不要尝试解析或改写,原样透传即可。HttpClient 封装里要设置连接超时和读取超时,外部服务偶尔抖动是常态,超时时间设成 3 秒和 10 秒,配合一次重试,基本能扛住。
3.3 学习进度:Redis 承接高频写,定时任务落库
播放器每 15 秒上报一次进度,一个热门课程同时在线几百人,这个写频率直接打 MySQL 是扛不住的。方案是把进度先写 Redis,定时任务批量落库。
@Service public class ProgressService { @Autowired private StringRedisTemplate redisTemplate; private static final String KEY_PREFIX = "learn:progress:"; /** 上报进度:Hash 结构,field 为课时ID,value 为已观看秒数 */ public void report(Long userId, Long courseId, Long lessonId, Integer seconds) { String key = KEY_PREFIX + userId + ":" + courseId; String field = String.valueOf(lessonId); // 取旧值比较,只前进不后退,防止拖动进度条把记录改小 String old = (String) redisTemplate.opsForHash().get(key, field); if (old != null && Integer.parseInt(old) >= seconds) { return; } redisTemplate.opsForHash().put(key, field, String.valueOf(seconds)); // 7 天过期,给定时任务留足落库窗口 redisTemplate.expire(key, Duration.ofDays(7)); } }写入逻辑里那句「只前进不后退」是血泪经验。学员把进度条拖回开头重看,如果照单全收,学习记录就被改回 10 秒,第二天进来发现要从头看,投诉就来了。用 max 比较之后,进度只增不减。
落库用 Spring 的 @Scheduled,每 5 分钟扫一批 key。扫描不要用 keys 命令,生产环境上百万 key 会直接把 Redis 卡住,用 scan 游标分批遍历。落库语句走 ON DUPLICATE KEY UPDATE,天然幂等,任务重复执行也不会写脏数据。
INSERT INTO edu_learning_record (user_id, course_id, lesson_id, progress, finished) VALUES (#{userId}, #{courseId}, #{lessonId}, #{progress}, #{finished}) ON DUPLICATE KEY UPDATE progress = GREATEST(progress, VALUES(progress)), finished = GREATEST(finished, VALUES(finished));GREATEST 这一手是第二道保险。即使 Redis 里的比较因为并发漏了一次,数据库层再兜一次,进度依然不会回退。课时时长在落库时一起查出来比对,progress 大于等于 duration 的 90% 就标记 finished,别要求 100%,很多人看完最后一个画面就关掉了,永远卡在 99%。
4. 内容与运营:问答、文章、图表与导出的落地细节
内容和运营是这套系统里最容易被轻视的部分。课程模块写完了,很多人觉得问答就是两张表加 CRUD,文章就是一个富文本字段,后台看板就是把 count 查出来丢给前端。真做起来,问答的状态流转、文章的分页检索、报表导出的内存,每一块都能让人加班。
4.1 问答模块:用状态机管住回答与采纳
问题的生命周期是固定的:待回答 → 已回答 → 已采纳 → 已关闭。状态不乱跳,业务才不会乱。最典型的乱象是没有状态校验,一个已关闭的问题还能被回答,一个已采纳的问题还能再改采纳对象,数据看着没错,逻辑上已经烂了。
public enum QuestionStatus { WAIT_ANSWER(0), ANSWERED(1), ACCEPTED(2), CLOSED(3); private final int code; QuestionStatus(int code) { this.code = code; } public int getCode() { return code; } } @Service public class AnswerService { /** 采纳回答:只有提问者本人、且问题处于已回答状态才能操作 */ @Transactional(rollbackFor = Exception.class) public void accept(Long questionId, Long answerId, Long currentUserId) { Question q = questionMapper.selectById(questionId); if (q == null) { throw new BusinessException("问题不存在"); } if (!q.getUserId().equals(currentUserId)) { throw new BusinessException("只有提问者可以采纳回答"); } if (q.getStatus() != QuestionStatus.ANSWERED.getCode()) { throw new BusinessException("当前状态不允许采纳"); } // 先清掉旧的采纳标记,再置新的,保证一个问题只有一条被采纳 answerMapper.clearAccepted(questionId); answerMapper.markAccepted(answerId); questionMapper.updateStatus(questionId, QuestionStatus.ACCEPTED.getCode()); } }三个判断缺一不可:身份、状态、唯一性。身份判断防越权,状态判断防跳步,唯一性靠先清后置保证。这三个点在答辩时被问到的概率很高,因为它们对应的是「你的系统怎么防止脏数据」,比背框架特性有说服力得多。回答列表按「是否采纳 → 点赞数 → 时间」三级排序,采纳的置顶,这个排序规则写在 SQL 的 ORDER BY 里,不要拿到内存里排,分页会错。
4.2 文章模块:分页检索与索引配合
文章模块的查询比问答复杂,因为要支持分类筛选、关键词模糊匹配、按时间或热度排序。用 MyBatis-Plus 的条件构造器写,把动态条件组装清楚,别在 XML 里堆 if 标签。
public PageResult<ArticleVO> page(ArticleQuery query) { Page<Article> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<>(); // 只查已发布的,草稿对前台不可见 wrapper.eq(Article::getStatus, 1); wrapper.eq(query.getCategoryId() != null, Article::getCategoryId, query.getCategoryId()); wrapper.like(StringUtils.hasText(query.getKeyword()), Article::getTitle, query.getKeyword()); // 排序:置顶优先,其次按发布时间倒序 wrapper.orderByDesc(Article::getTopFlag).orderByDesc(Article::getCreateTime); Page<Article> result = articleMapper.selectPage(page, wrapper); return PageResult.of(result, ArticleVO.class); }条件构造器里每个 eq/like 的第一个参数是布尔开关,条件为空时自动跳过,这是 MyBatis-Plus 里最实用的一个设计。但要注意 like '%关键词%' 前置通配符走不了索引,文章量上了十万级就要换成全文索引或者独立的搜索服务,这一点在方案里要写清楚边界,别把一个演示级的实现说成能扛百万数据。
文章正文存 longtext,列表页千万不要 select 正文,只查 id、标题、摘要、封面、时间这几个字段。有人图省事直接 select *,列表页一次拉回二十篇长文,响应体几兆,前端渲染卡顿,还以为是网络问题。摘要字段单独存一份,发布时截取正文前 200 字,列表页直接用,这是最省事的做法。文章详情页的阅读数用 Redis 的 incr 累加,定时回写数据库,跟学习进度是同一个套路。
4.3 后台运营:图表接口与大数据量导出
后台看板用 ECharts 渲染,前端要的数据形状是「类目轴数组 + 数值数组」。接口直接按这个形状返回,别让前端做二次转换。
{ "code": 200, "data": { "xAxis": ["2024-01", "2024-02", "2024-03", "2024-04"], "series": [ { "name": "新增学员", "data": [120, 180, 210, 260] }, { "name": "课程销量", "data": [80, 95, 130, 175] } ] } }接口这一层要做的是把 SQL 查出来的行转成列。数据库按月份 group by 出来是「月份、类型、数量」三列,服务端用 Map 按月份聚合,再拆成两条 series。这个转换放在服务端做,前端只管画图,换图表库也不用改后端。
导出是后台最容易出事的环节。用 POI 的 XSSFWorkbook 导十万行订单,堆内存直接爆掉,现象是页面转圈半天然后 502,日志里一行 OutOfMemoryError。解决办法是换 SXSSFWorkbook,它把数据分批刷到临时文件,内存里只留固定窗口的行。
public void exportOrder(HttpServletResponse response, OrderQuery query) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment;filename=orders.xlsx"); // 第二个参数 500 表示内存中最多保留 500 行,超出写临时文件 try (SXSSFWorkbook workbook = new SXSSFWorkbook(500); ServletOutputStream out = response.getOutputStream()) { Sheet sheet = workbook.createSheet("订单明细"); Row header = sheet.createRow(0); String[] titles = {"订单号", "学员", "课程", "金额", "下单时间"}; for (int i = 0; i < titles.length; i++) { header.createCell(i).setCellValue(titles[i]); } // 分批查库,每批 1000 条,避免一次把全表拉进内存 int pageNo = 1, rowIndex = 1; while (true) { List<OrderVO> batch = orderMapper.pageForExport(query, (pageNo - 1) * 1000, 1000); if (batch.isEmpty()) { break; } for (OrderVO o : batch) { Row row = sheet.createRow(rowIndex++); row.createCell(0).setCellValue(o.getOrderNo()); row.createCell(1).setCellValue(o.getUserName()); row.createCell(2).setCellValue(o.getCourseTitle()); row.createCell(3).setCellValue(o.getAmount().doubleValue()); row.createCell(4).setCellValue(o.getCreateTime()); } pageNo++; } workbook.write(out); // 删除临时文件,否则磁盘会被慢慢吃满 workbook.dispose(); } }三个参数值是关键:窗口 500 行决定内存占用,批大小 1000 决定数据库压力,dispose() 决定磁盘会不会被临时文件撑爆。导出接口要单独设超时,不能跟普通查询共用同一条线程池,一个导出把线程占满,前台页面就全挂了。
5. 避坑与排查:五个最容易翻车的点
这一章列的是我在类似项目里真实见过、或者调试过的问题。共同特点是:本地跑得好好的,一上多实例或者一带数据量就出问题,排查时靠猜,最后发现原因都特别朴素。
5.1 服务间调用超时叠成雪崩
现象是某个下午开始,课程详情页大面积转圈,重启服务能好几分钟,然后又挂。查日志发现课程服务在等用户服务返回,用户服务在等数据库,数据库连接池满了。原因是 HttpClient 没设超时,用了默认的无限等待,一个慢请求占住线程,请求越积越多,线程池耗尽,整个服务不可用。
解决办法是三层都要设死:连接超时 3 秒,读取超时 10 秒,重试最多一次。重试只对幂等的 GET 请求开,POST 一律不重试,否则会出现一次下单扣两次款这种事。网关层再加熔断,某个下游错误率超过阈值就快速失败,返回兜底数据,不要把请求堵在网关。
5.2 播放凭证过期,前端缓存了旧地址
现象是学员反馈「看到一半提示无权限」,刷新页面又好了。原因是前端把播放凭证缓存在了组件状态里,学员暂停去接了杯水,回来继续播,凭证已经过期。播放器报 403,用户看到的就是黑屏加一句莫名其妙的提示。
解决办法有两个层面。前端在播放器里监听错误事件,遇到鉴权类错误自动重新拉一次凭证再续播,这个逻辑必须写,不能指望用户自己刷新。后端在凭证过期时间上留余量,签发时返回的 expireSeconds 比实际有效期短 60 秒,让前端提前续期。另外凭证不要存 localStorage,页面关闭即失效,减少泄露面。
5.3 Redis 和数据库的进度对不上
现象是学员看完一节课,第二天进来发现进度退回去了。原因通常是定时任务落库时用了覆盖写,或者 Redis 的 key 到期了但任务还没扫到,数据直接丢了。
解决办法分两步。写入时用 GREATEST 保证只增不减,这是数据库层的兜底;扫描时用 scan 而不是 keys,并且把过期时间设得比扫描周期长得多,7 天对 5 分钟,留足了容错空间。如果还是担心丢,在用户退出播放页时主动触发一次同步落库,把最关键的那个时间点保住。
5.4 消息重复消费,积分翻倍
现象是学员反馈积分莫名其妙多了一倍。查下来是 ActiveMQ 的消息重投机制:消费者处理完业务但还没返回 ACK 时网络抖动, broker 认为消费失败又投了一次,业务被执行两遍。
解决办法是消费端做幂等。给每条消息带一个业务唯一键,比如「用户ID + 订单号 + 行为类型」,消费前先查这张去重表,存在就直接 ACK 返回。去重表的唯一索引是最后一道防线,两个实例同时消费同一条消息时,数据库会拦住其中一个。消息队列只能保证「至少一次」,做不到「恰好一次」,幂等必须业务自己做,这是设计时就要想清楚的事。
5.5 网关鉴权头丢失,后台接口裸奔
现象是后台某些接口不登录也能访问,前端明明做了路由守卫。原因是网关的路由配置里,某条路径用了 stripPrefix 过滤,把携带用户信息的请求头一并处理掉了,下游服务拿不到 userId,权限拦截器判断为空就直接放行。
解决办法是权限拦截器的默认策略必须是拒绝,取不到用户信息就返回 401,而不是放行。同时网关的过滤器顺序要固定:先解析 token 注入请求头,再做路由转发,顺序反了头就丢了。这类问题最隐蔽的地方在于「有时候好有时候坏」,取决于请求走了哪条路由,排查时要在网关打一条完整日志,把入参、注入的头、转发的目标地址都打出来,别靠猜。
6. 验收与答辩:用一条链路把系统讲透
验收这件事,很多人栽在「功能太多讲不完」。四十分钟里想把课程、问答、文章、后台全演示一遍,结果是每个都点两下,评委什么也没记住。我的建议是只讲一条主链路,把这条链路讲穿。
主链路选「学员从首页搜索课程 → 进入详情 → 试看视频 → 购买 → 记录进度 → 提问 → 讲师回答 → 学员采纳」。这条链路串起了网关鉴权、课程查询、视频凭证、订单校验、Redis 进度、问答状态机六个技术点,覆盖了系统八成的设计含量。
演示前先跑一遍自检清单:
| 检查项 | 通过标准 | 常见问题 |
|---|---|---|
| 环境启动 | compose 一条命令起全栈 | 数据库初始化脚本没挂载 |
| 鉴权 | 未登录访问后台接口返回 401 | 拦截器默认放行 |
| 视频 | 试看课时能播,付费课时提示购买 | 凭证签发没校验订单 |
| 进度 | 刷新页面后进度不回退 | 覆盖写导致回退 |
| 问答 | 非提问者采纳被拒绝 | 缺身份校验 |
| 导出 | 一万行导出不超时、不 OOM | 用了 XSSFWorkbook |
| 图表 | 切换时间范围数据联动 | 接口返回结构不统一 |
答辩提问高频集中在三处:为什么这样拆服务、Redis 和数据库不一致怎么办、消息重复消费怎么处理。前两个前面都写了,第三个记住一句话就够:队列保证至少一次,幂等必须自己做。
还有一个容易被忽略的点是压测数据。不用真压出多高的 QPS,但至少要有一次并发验证:用几十个线程同时上报学习进度,观察 Redis 写入和落库结果是否一致。有这组数据,讲「高频写为什么走 Redis」就不是空话。
从那以后我每次带这类项目,验收前都强制自己走一遍完整主链路,从注册新账号开始,不用任何现成数据,走完再交给别人看。因为开发过程中用的都是自己造的数据,状态永远是「刚好合适」,只有从零走一遍,才会发现那些藏在边界里的问题。希望这套结构能帮到你,把方案交出去的时候心里有底。
本文还有配套的精品资源,点击获取