在线教育系统微服务拆分与缓存异步实践
简介:面向 Java 微服务与在线教育业务学习者的一套系统源码,包含前台网站与后台运营平台两部分,适合课程设计、毕业设计及 SpringCloud 实战对照参考。前台覆盖课程、问答、文章三大模块,后端采用 SpringBoot、SpringCloud、MyBatis-Plus 与 MySQL,配合 Docker、Maven 构建;前端基于 Node.js 与 Vue.js 前后端分离开发,并接入 Redis、ActiveMQ、阿里云 OSS、视频点播、JWT 单点登录、Swagger 接口文档、ECharts 图表与 POI 批量导入等组件。压缩包为 zip 格式,整体约 198KB,包内文件总数与类型明细暂缺,页面显示为 0,下载后可按目录结构自行梳理。已有 1155 人学习,读者可从中了解微服务分库设计、前后端接口联调、云存储与点播接入、单点登录及运营后台数据展示等实现思路,用于搭建同类在线教育项目的技术框架与功能清单。
1. 从「课程详情页该不该单独拆一个服务」说起
做在线教育系统的毕业设计或课程设计,很多人第一步就是先画一张大而全的微服务架构图,把能想到的服务都往上堆,然后回过头补代码,最后答辩时被问一句「这个服务为什么独立」,就答不上来了。这套 online_edu 在线教育系统的做法相反:先把前台用户系统里的课程、问答、文章三块内容,和后台运营平台的内容管理、用户管理、数据看板逐一列成清单,再判断哪些模块有独立的读写压力、独立的生命周期,值得拆出去。前台用 Vue.js + Node.js 做构建与页面,后台运营端同样前后端分离;服务端用 SpringBoot + SpringCloud 组织服务,MyBatis-Plus 扛住单表 CRUD,MySQL 存业务数据,Redis 顶热点,ActiveMQ 做异步,对象存储和视频点播负责课件与视频。适合正在交毕设、课设,或者要出一份能落地的在线教育平台方案的人。
2. 需求拆解与技术选型:前台三端与后台运营平台的边界怎么划
2.1 先把「谁在用」写清楚:两类用户、三块内容、一个后台
在线教育系统最常见的失败,是把前台和后台当成同一套页面加个角色判断。实际做下来,这两类用户的使用节奏完全不同:前台用户是「浏览 → 试看 → 报名 → 学习 → 提问」,动作密集、读多写少、对首屏时间敏感;后台运营人员是「录入 → 审核 → 上架 → 看数据」,动作稀疏、写操作重、对数据准确性和导出能力敏感。
前台用户系统的课程模块,落到功能点上是课程列表与筛选、课程详情、章节课时目录、视频播放、学习进度记录、我的课程。问答模块是提问、回答、采纳答案、按课程聚合问题列表。文章模块是文章列表、分类筛选、详情、浏览量统计。这三块的共同点是有大量匿名或弱登录访问,所以缓存和限流必须先设计好。
后台运营平台则要覆盖课程管理(新增、编辑、上架下架、章节编排)、文章管理、问答审核、用户管理、分类与标签维护。运营端还有两个容易被忽略但答辩必问的点:数据看板用 ECharts 做趋势与占比图,报表导出用 POI 生成 Excel。这两块不是「锦上添花」,它们恰恰是后台价值的体现——运营要能看见数据,也要能把数据带走。
把这张清单列完,服务边界基本就浮出来了:内容型、读多写少的走缓存;写操作集中、需要审核流程的归到后台服务;跨模块共享的用户、分类、附件抽成基础服务。这条判断标准比「一个模块一个服务」靠谱得多。
2.2 技术选型:为什么是这套组合而不是别的
后端主架构定的是 SpringBoot + SpringCloud + MyBatis-Plus + HttpClient + MySQL + Docker + Maven,前端是 Node.js + Vue.js,中间件用 Redis、ActiveMQ、对象存储 OSS 与视频点播。这套组合在课程设计和毕业设计里出现频率很高,原因不复杂:学习曲线平缓、资料密度大、每一层都能独立讲清楚。
SpringBoot 负责单服务的起步依赖和自动装配,把数据源、事务、Web 容器这些样板配置压到一份 yml 里;SpringCloud 负责服务注册发现、网关路由、服务间调用和配置管理,通常的做法是注册中心加网关,所有前台请求统一从网关进,由网关做鉴权和路由转发,服务之间只暴露内网地址。这样前端只需要认一个域名,后面加服务不用改前端。
MyBatis-Plus 的价值在单表操作。这套系统里绝大多数查询是「按条件分页查课程」「按课程 id 查章节」,用条件构造器加分页插件能省掉大量 XML;真正复杂的多表关联(比如课程 + 讲师 + 分类 + 统计)再手写 SQL,两者混合用比全手写 XML 效率高不少。HttpClient 主要用在服务端主动发起 HTTP 请求的场景,比如调用视频点播的接口获取播放凭证、处理上传回调,这类调用不适合塞进 Feign 的声明式客户端里。
前端的 Node.js 只承担构建与依赖管理角色,不参与业务。Vue.js 做单页应用,路由按前台 / 后台分区,前台走懒加载保证首屏,后台因为图表库体积大,ECharts 单独按需引入。Redis 承担三件事:课程详情和热点文章的缓存、登录态与验证码、以及少量并发场景下的分布式锁。ActiveMQ 承担异步解耦:学习进度上报、审核结果通知这类「不需要立刻返回结果」的动作全部丢进队列。
视频点播和对象存储要分清职责:图片、课件、附件这类静态文件走对象存储,前端直传 + 服务端签名;课程视频走视频点播服务,上传后由平台转码,播放时服务端签发播放凭证,前端拿凭证播放。这两条链路分开,是因为视频的转码、加密、防盗链逻辑和普通文件完全不是一回事,混在一起后期很难维护。
提示:选型章节在答辩时最容易被追问「为什么不用 XX」。准备一句话说明取舍即可,比如「消息队列选 ActiveMQ 是因为课程里已经覆盖 JMS 规范,团队上手成本低,业务量级也用不到更高吞吐的方案」。
2.3 服务清单与端口规划
拆服务之前先把清单落成表格,比在脑子里推演靠谱。下面这套划分是按「一个服务一个数据库 schema」的原则来的,服务之间不跨库联表,需要组合数据时走接口聚合。
| 服务名 | 端口 | 主要职责 | 依赖中间件 |
|---|---|---|---|
| gateway | 8080 | 统一入口、路由转发、Token 校验 | Redis |
| user-service | 8081 | 注册登录、用户信息、角色权限 | MySQL、Redis |
| course-service | 8082 | 课程、章节、课时、分类、学习进度 | MySQL、Redis、ActiveMQ |
| qa-service | 8083 | 提问、回答、采纳、审核状态 | MySQL、Redis |
| article-service | 8084 | 文章、分类、浏览量、点赞 | MySQL、Redis |
| media-service | 8085 | 文件上传签名、视频凭证、转码回调 | 对象存储、视频点播 |
| admin-service | 8086 | 后台管理聚合、看板统计、Excel 导出 | MySQL、Redis |
| common | — | 统一返回、异常、常量、工具类 | — |
端口连续排布有个实际好处:本地联调时你一眼就能记住哪个服务在哪个口,不用翻配置文件。common不作为服务启动,只作为依赖包被各服务引入,里面放统一返回体、全局异常处理、分页参数封装这类横切代码。
服务拆到这个粒度,一共七个可启动服务,对毕设来说已经足够体现微服务要素,又不至于多到跑不起来。再细拆(比如把章节从课程里剥出来)收益很低,反而增加跨服务调用次数。
3. 微服务拆分与数据模型:课程、问答、文章三条主线的表结构
3.1 课程域:课程、章节、课时三层结构
课程域的建模决定了整个系统能不能撑住后续的播放和学习进度。正确的层次是三层:课程(course)是售卖和展示单位,章节(chapter)是目录分组,课时(lesson)是实际可播放的最小单元。学习进度必须挂在课时上,而不是课程上,否则用户看到第 3 章第 2 节,系统根本记不住。
-- 课程主表:只放展示与售卖相关字段,统计字段单独维护 CREATE TABLE `course` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '课程ID', `title` VARCHAR(120) NOT NULL COMMENT '课程标题', `cover_url` VARCHAR(255) DEFAULT NULL COMMENT '封面地址(对象存储)', `category_id` BIGINT NOT NULL COMMENT '分类ID', `teacher_id` BIGINT NOT NULL COMMENT '讲师用户ID', `price` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '价格,0为免费', `study_count` INT NOT NULL DEFAULT 0 COMMENT '学习人数,异步累加', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1待审核 2已上架 3已下架', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category_status` (`category_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表'; -- 课时表:视频相关字段全部指向视频点播的标识,不存真实播放地址 CREATE TABLE `lesson` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '课时ID', `chapter_id` BIGINT NOT NULL COMMENT '所属章节ID', `course_id` BIGINT NOT NULL COMMENT '冗余课程ID,避免多一次关联', `title` VARCHAR(120) NOT NULL COMMENT '课时标题', `video_id` VARCHAR(64) DEFAULT NULL COMMENT '视频点播媒资ID', `duration` INT NOT NULL DEFAULT 0 COMMENT '时长(秒)', `free` TINYINT NOT NULL DEFAULT 0 COMMENT '是否试看 0否 1是', `sort` INT NOT NULL DEFAULT 0 COMMENT '排序值', PRIMARY KEY (`id`), KEY `idx_chapter_sort` (`chapter_id`, `sort`), KEY `idx_course` (`course_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课时表';lesson表里冗余了course_id,这是有意为之:查「某课程下所有课时」是高频操作,多一层 chapter 关联会多一次索引回表;而章节调整频率极低,冗余带来的更新成本可以忽略。video_id存的是视频点播平台的媒资标识,真正的播放地址每次请求时由服务端签发凭证动态生成,绝不落库——落库就等于把防盗链白做了。
study_count之所以标注「异步累加」,是因为学习人数在播放页会被频繁读取,如果每次进入课时都update course set study_count = study_count + 1,课程表会被行锁打成瓶颈。通常的做法是前端上报学习行为到 ActiveMQ,由消费者按用户 + 课时去重后批量累加。
3.2 问答与文章域:内容型模块的建模差异
问答和文章看着都是内容,建模思路却不一样。问答是一对多的树形结构:一个问题下挂多个回答,回答里有一个被标记为采纳。文章是线性内容:一篇正文 + 分类 + 统计。
问答表这么设计:question表存标题、正文、课程 id、提问人、状态、回答数、是否解决;answer表存问题 id、回答人、内容、是否采纳、点赞数。采纳状态用answer.is_accepted加question.solved双字段冗余,是为了列表页能直接展示「已解决」角标而不必联表。
文章表则把浏览量单独考虑:article主表存标题、摘要、正文、分类、作者、状态,view_count放主表但允许短暂不一致,用 Redis 计数、定时任务或队列消费回写。文章详情页是典型的读多写少,缓存键用article:detail:{id},TTL 设 30 分钟,发布或编辑时主动删除。这里没什么玄学,就是「先删缓存再更新库,读时回填」这套最常见的组合。
两个模块都要注意一个共同点:内容详情表的正文用LONGTEXT,但列表查询绝对不能select *。列表页只需要标题、摘要、封面、统计四项,服务端必须显式列出字段,否则一页 20 条文章就能拉出几 MB 数据。
3.3 服务间调用与 DTO 约定
课程详情页需要同时拿到课程、章节、讲师信息,这三块分属 course-service 和 user-service。跨服务取数据有两种写法:网关聚合和前端多次请求。前者服务端合并、前端一次拿全,后者实现简单但首屏会多发请求。这套系统选前者,在 course-service 里通过声明式客户端调 user-service。
// 课程详情聚合:课程本体 + 章节树 + 讲师信息,一次性返回给前端 @Service public class CourseDetailService { @Resource private CourseMapper courseMapper; @Resource private ChapterMapper chapterMapper; @Resource private LessonMapper lessonMapper; @Resource private UserClient userClient; // 声明式客户端,指向 user-service public CourseDetailVO detail(Long courseId) { Course course = courseMapper.selectById(courseId); if (course == null || course.getStatus() != 2) { throw new BizException("课程不存在或未上架"); } // 章节与课时一次查出后在内存分组,避免 N+1 查询 List<Chapter> chapters = chapterMapper.selectByCourseId(courseId); List<Lesson> lessons = lessonMapper.selectByCourseId(courseId); Map<Long, List<Lesson>> lessonMap = lessons.stream() .collect(Collectors.groupingBy(Lesson::getChapterId)); // 讲师信息来自另一个服务,失败时降级为空,不影响课程主体展示 TeacherVO teacher = null; try { teacher = userClient.getTeacher(course.getTeacherId()); } catch (Exception e) { log.warn("查询讲师信息失败, courseId={}, teacherId={}", courseId, course.getTeacherId()); } CourseDetailVO vo = new CourseDetailVO(); vo.setCourse(course); vo.setTeacher(teacher); vo.setChapters(chapters.stream() .map(c -> new ChapterVO(c, lessonMap.getOrDefault(c.getId(), Collections.emptyList()))) .collect(Collectors.toList())); return vo; } }这段代码有三个设计点值得说。第一,状态校验放在最前面,未上架课程直接抛业务异常,连章节都不查,等于省掉两次数据库往返。第二,章节和课时用两次批量查询加groupingBy在内存分组,而不是循环章节逐个查课时,这就是常说的避免 N+1。第三,跨服务调用包了try-catch做降级:讲师服务挂了,课程主体和目录照样能展示,只是讲师区域留空。这种「非核心字段可降级」的判断,是微服务里最实用的经验之一。
DTO 约定上,所有跨服务返回都用 VO 而不是直接透传实体类。实体类带status、deleted、内部备注这些字段,透出去等于把内部结构暴露给前端,后面想改表结构还要考虑兼容。
4. 中间件落地:Redis 缓存、ActiveMQ 异步、OSS 与视频点播链路
4.1 前后端分离下的接口约定
前后端分离最容易翻车的地方不是技术,是约定。前端要什么字段、分页参数叫什么、错误怎么表达,必须在上线前定死。这套系统统一用一份返回体:code、message、data,分页数据统一放在data.records和data.total里,前端封装一个请求拦截器,code != 0时统一弹提示。
// 全局返回体:所有 Controller 统一返回,前端拦截器按 code 判断 @Data public class Result<T> { private int code; // 0 成功,非 0 为业务错误码 private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.setCode(0); r.setMessage("success"); r.setData(data); return r; } public static <T> Result<T> fail(int code, String message) { Result<T> r = new Result<>(); r.setCode(code); r.setMessage(message); return r; } }配合一个@RestControllerAdvice捕获全局异常,把BizException转成fail(1001, msg),把参数校验异常转成fail(1002, ...)。这样 Controller 里只需要写return Result.ok(vo),不用每处都包 try-catch。前端那边对应地只处理两种状态:code === 0走正常流程,否则统一提示并(在 401 时)跳登录。
4.2 Redis 缓存与 ActiveMQ 异步的两条线
Redis 在这套系统里承担三块:课程详情缓存、登录 Token、热点计数。缓存最怕的不是击穿,是「缓存了不该缓存的东西」。课程详情可以缓存,但库存、购买状态、学习进度这类和用户强相关的数据绝对不能进公共缓存键。
// 课程详情缓存:空值也缓存,防穿透;更新时先删缓存 public CourseDetailVO getDetailWithCache(Long courseId) { String key = "course:detail:" + courseId; String json = redisTemplate.opsForValue().get(key); if (json != null) { // 空标记直接返回业务异常,避免每次都打到数据库 if (EMPTY_FLAG.equals(json)) { throw new BizException("课程不存在"); } return JSON.parseObject(json, CourseDetailVO.class); } CourseDetailVO vo = courseDetailService.detail(courseId); if (vo == null) { // 空结果缓存 2 分钟,防止恶意 id 反复穿透 redisTemplate.opsForValue().set(key, EMPTY_FLAG, Duration.ofMinutes(2)); throw new BizException("课程不存在"); } redisTemplate.opsForValue().set(key, JSON.toJSONString(vo), Duration.ofMinutes(30)); return vo; }缓存空值是防穿透的标准做法,TTL 要比正常数据短得多,2 分钟是经验值——既能挡住短时间内的大量重复请求,又不至于课程真上架后用户长时间看不到。更新逻辑则是「先更新数据库,再删除缓存」,而不是更新缓存,因为并发写缓存容易出现旧值覆盖新值的时序问题。
ActiveMQ 用在三个场景:学习进度上报、学习人数累加、审核结果通知。以学习进度为例,前端在播放页每 30 秒上报一次当前播放位置,服务端只做参数校验后投递消息,立刻返回,真正写库由消费者完成。
// 学习进度上报:接口只投递消息,不直接写库 @PostMapping("/progress/report") public Result<Void> report(@RequestBody @Valid ProgressDTO dto) { // 参数校验通过后立即投递,前端无感知延迟 jmsTemplate.convertAndSend("edu.progress.queue", JSON.toJSONString(dto)); return Result.ok(null); } // 消费者:按 userId + lessonId 覆盖写,天然幂等 @JmsListener(destination = "edu.progress.queue", concurrency = "3-5") public void onProgress(String payload) { ProgressDTO dto = JSON.parseObject(payload, ProgressDTO.class); progressMapper.upsert(dto); // 唯一索引 (user_id, lesson_id) 保证不重复插入 }concurrency = "3-5"表示最少 3 个、最多 5 个并发消费者,业务量小时不浪费线程,堆积时自动扩容。消费端幂等靠数据库唯一索引(user_id, lesson_id)加ON DUPLICATE KEY UPDATE实现,这比在代码里判断「是否已存在」再决定插入还是更新要可靠得多,也少一次查询。
注意:队列消息一定要设持久化,且消费失败要有重试上限。曾经见过消息消费抛异常后无限重投,把日志刷满、磁盘打爆的情况,加一个
maximumRedeliveries就能避免。
4.3 对象存储与视频点播的上传链路
文件上传的关键原则是:文件不经过业务服务器。前端向 media-service 请求一个上传签名,拿到签名后直传对象存储,成功后把返回的 URL 回传给业务服务保存。这样业务服务器的带宽和内存都不会被大文件拖垮。
// 生成前端直传签名:只签本次上传的路径与有效期,不暴露长期密钥 public UploadPolicyVO buildPolicy(String bizType, String fileName) { // 路径按业务类型分目录,便于后续按目录做生命周期管理 String objectKey = bizType + "/" + LocalDate.now() + "/" + UUID.randomUUID().toString().replace("-", "") + fileName.substring(fileName.lastIndexOf(".")); long expireAt = System.currentTimeMillis() / 1000 + 300; // 5 分钟有效期 String policy = buildPolicyText(expireAt, objectKey); // 由服务端拼装策略串 String signature = sign(policy, accessSecret); // 服务端密钥参与签名 UploadPolicyVO vo = new UploadPolicyVO(); vo.setObjectKey(objectKey); vo.setPolicy(policy); vo.setSignature(signature); vo.setExpireAt(expireAt); return vo; }签名有效期给 5 分钟足够覆盖一次上传,时间太长等于把写入权限长期撒出去。objectKey按「业务类型/日期/UUID 后缀」组织,好处是后面排查问题时能一眼看出文件属于哪个模块、哪天传的,做存储生命周期策略也能按目录批量配置。
视频走的是另一条链路:前端向 media-service 申请上传凭证,直接把视频传到视频点播服务,转码完成后平台回调media-service的回调地址,服务端校验回调签名后把媒资 id 写回lesson.video_id。播放时前端带课时 id 请求播放凭证,服务端校验用户是否有权限(免费课时或已报名),再向视频点播换取有时效的播放地址。这条链路里,「权限校验」必须在服务端做,前端隐藏播放按钮不算权限控制。
HttpClient 在这里派上用场:调视频点播的开放接口需要自定义请求头、处理签名串、解析返回的嵌套 JSON,用 HttpClient 手写反而比套一层客户端更直观,出错时也能直接打印原始报文。
4.4 ECharts 图表与 POI 导出:后台的两个门面
后台运营平台有两个功能最能体现「这是个完整系统」:数据看板和报表导出。看板用 ECharts,后端提供聚合接口;导出用 POI,把同样的数据落成 Excel。
看板接口的原则是「后端算好,前端只画」。前端不要拿几万条原始数据自己聚合,一来慢,二来计算逻辑分散在两处难维护。
// 看板数据:按天聚合近 7 日新增用户与课程学习人次 @GetMapping("/dashboard/trend") public Result<TrendVO> trend(@RequestParam(defaultValue = "7") int days) { LocalDate end = LocalDate.now(); LocalDate start = end.minusDays(days - 1L); // 一次分组查询拿到日期与数量,避免循环查库 List<DailyCount> userRows = userMapper.countByDay(start, end); List<DailyCount> studyRows = courseMapper.countStudyByDay(start, end); // 日期补零:数据库没数据的日期也要出现在折线图上 TrendVO vo = new TrendVO(); vo.setDates(fillDates(start, end)); vo.setUserCounts(fillValues(vo.getDates(), userRows)); vo.setStudyCounts(fillValues(vo.getDates(), studyRows)); return Result.ok(vo); }fillDates+fillValues这两个补零步骤很关键。数据库里某天没有新增用户,group by就直接跳过这天,前端折线图会缺一个点、X 轴错位。补零逻辑放在后端,前端拿到的dates和userCounts长度永远一致。
POI 导出同理,用SXSSFWorkbook而不是XSSFWorkbook,前者是流式写,几万行也不会把内存吃光。
// 课程报表导出:流式工作簿,每 500 行刷一次盘 public void exportCourse(HttpServletResponse response) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment;filename=courses.xlsx"); try (SXSSFWorkbook workbook = new SXSSFWorkbook(500)) { // 保留 500 行在内存 Sheet sheet = workbook.createSheet("课程"); Row header = sheet.createRow(0); String[] titles = {"课程ID", "标题", "分类", "价格", "学习人数", "状态"}; for (int i = 0; i < titles.length; i++) { header.createCell(i).setCellValue(titles[i]); } // 分批查询,避免一次性把全表读进内存 int pageNo = 1, size = 1000, rowIdx = 1; while (true) { List<Course> list = courseMapper.pageForExport((pageNo - 1) * size, size); if (list.isEmpty()) break; for (Course c : list) { Row row = sheet.createRow(rowIdx++); row.createCell(0).setCellValue(c.getId()); row.createCell(1).setCellValue(c.getTitle()); row.createCell(3).setCellValue(c.getPrice().doubleValue()); row.createCell(4).setCellValue(c.getStudyCount()); } pageNo++; } workbook.write(response.getOutputStream()); workbook.dispose(); // 清理临时文件,务必调用 } }new SXSSFWorkbook(500)的 500 是内存中保留的行数,超出的部分写到临时文件。dispose()一定要调,否则临时文件会残留在系统目录里,跑几次导出磁盘就满了——这是个典型的「不报错但很致命」的坑。
5. 避坑与排查:这套系统里最容易翻车的五个地方
5.1 服务拆得太细,本地跑不起来
现象:本地启动要开七个服务、两个中间件,机器风扇狂转,改一行代码要等半分钟;答辩现场只带了一台笔记本,网关起来了业务服务还没起完。
原因:拆服务的依据是「有没有独立演进的必要」,不是「模块多不多」。按模块名无脑拆,最后每个服务只有一个 Controller 和一个 Mapper,跨服务调用反而成了主要开销。
解决:毕设和课设场景下,把强关联的模块合并成一个服务,比如把课程、章节、课时、学习进度留在 course-service,把问答和文章合并成 content-service。真正需要独立的是媒体处理(依赖外部服务、耗时)和后台统计(聚合查询重)。合并后服务数控制在五个以内,Docker Compose 一条命令全起。
5.2 跨服务写操作没有事务,数据对不上
现象:用户报名课程时,课程服务扣了名额,订单服务写订单失败,结果名额少了但订单没生成;排查时两边日志各说各话。
原因:把单体时代的@Transactional直接搬到跨服务调用上——它只管当前服务的数据库连接,管不了另一个服务的提交。
解决:优先避免跨服务写。把「报名」这个动作完整地放在一个服务里,名额字段和订单表放在同一个库。确实无法避免时,用「先写本地 + 发消息 + 消费方幂等 + 定时对账兜底」的最终一致方案,并且一定要有对账任务,否则不一致会一直沉默地存在。
5.3 缓存与数据库的更新顺序搞反
现象:后台改了课程标题,前台刷新还是旧标题,等十几分钟才变;或者刚上架的课程前端一直提示「课程不存在」。
原因:更新时先删缓存再写库,在并发读的情况下,读请求会把旧值重新加载回缓存;另一种是空值缓存 TTL 设得和正常数据一样长,新上架课程被空标记挡了半小时。
解决:统一「先更新数据库,成功后删除缓存」,删除失败时把 key 丢进重试队列。空值缓存 TTL 单独设短,2 分钟量级。上线前用「改标题 → 立刻刷新 → 再刷新」这个手工用例走一遍,能挡住大部分问题。
5.4 视频播放权限只在前端做
现象:付费课程的播放页把按钮藏了,但打开浏览器开发者工具拿到课时 id,直接请求播放凭证接口,照样能播。
原因:权限判断写在了 Vue 的v-if里,服务端签发凭证时没校验用户与课程的报名关系。
解决:签发播放凭证的接口必须做三重校验——课时存在、用户已登录、用户与课程存在报名关系或该课时为试看。校验不通过直接返回业务错误码,不签发凭证。这套逻辑写一次放在 media-service 里,所有播放入口都走它。
5.5 上传与导出把服务器拖垮
现象:传一个 200MB 的视频,业务服务内存飙升甚至 OOM;导出五万行 Excel,接口超时、临时文件堆积。
原因:文件流经业务服务器中转;Excel 用了非流式工作簿,一次性把所有行建在内存里。
解决:文件走前端直传 + 服务端签名,业务服务只存 URL;导出用流式工作簿并分批查库,务必调用dispose()清理临时文件。这两条在验收演示时最容易翻车,因为演示环境往往是配置最低的那台机器。
6. 验收与答辩:怎么把一套微服务系统演示得不慌
验收环节和写代码是两回事。代码写得再漂亮,演示时七个服务起不来、第一个接口就 502,评审老师对你整套方案的信任度会瞬间归零。我一般会按「先证明系统活着,再证明功能对,最后证明设计有取舍」这个顺序准备。
第一步是启动脚本。把所有服务写进一份docker-compose.yml,MySQL、Redis、ActiveMQ 一起编排,答辩前用一条命令拉起,再用docker compose ps确认每个容器都是healthy而不是running。这里有个血泪经验:健康检查不能只写端口探测,要写真实的就绪探针,否则容器状态是 running,应用还在初始化数据源,前端第一个请求必然超时。
第二步是准备一条固定的演示路径,长度控制在 8 分钟内:前台首页列表(证明缓存生效)→ 课程详情(证明跨服务聚合)→ 播放一个免费课时(证明凭证签发)→ 提问并回答(证明写链路)→ 切到后台看板(证明统计接口)→ 导出 Excel(证明 POI)。每个环节提前准备好数据,不要现场新增课程,新增涉及图片上传和审核状态流转,变数太多。
第三步是准备一张「设计取舍表」,被追问时直接照着答,比临场组织语言稳得多。
| 常见追问 | 回答要点 |
|---|---|
| 为什么用微服务而不是单体 | 媒体处理与统计聚合的依赖和负载差异大,独立部署便于单独扩容;其余模块合并部署,避免过度拆分 |
| 服务间数据一致性怎么保证 | 强关联写操作收敛到同一服务同一事务;跨服务用消息 + 幂等消费 + 定时对账 |
| 缓存一致性怎么处理 | 先更新库再删缓存,空值缓存短 TTL 防穿透,热点数据只缓存公共字段 |
| 为什么视频不直接存对象存储 | 转码、加密、防盗链需要专门的媒体服务,播放地址动态签发才能控制权限 |
| 消息队列的消息丢了怎么办 | 消息持久化 + 消费重试上限 + 失败落库,关键业务配定时对账任务 |
还有一个容易被忽略的细节:答辩现场的网络。视频点播、对象存储都依赖外网,如果教室网络不稳,播放环节必然卡住。我的习惯是提前录一段课程视频作为降级素材,播放凭证接口拿不到时切到本地视频继续演示,同时在讲解里说明「这是降级方案,生产环境不会这样」。这种主动暴露边界的做法,往往比硬撑着等接口返回更能加分。
从那以后我每次带人做这类系统,都会在写第一行业务代码之前先把这份演示路径和取舍表写出来,倒推哪些功能必须做扎实、哪些可以砍。希望帮到你。
本文还有配套的精品资源,点击获取