ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringCloud微服务在线教育系统实战:从单体拆分到部署避坑

2026/10/4 23:46:23 拓冰建站 浏览量
SpringCloud微服务在线教育系统实战:从单体拆分到部署避坑 简介一套基于SpringCloud微服务架构的在线教育系统完整项目面向Java初中级开发者、毕业设计选题学生及需要微服务实战经验的程序员。系统分为前台用户网站和后台运营平台两部分前台包含课程、问答、文章三大核心模块后端采用SpringBoot、SpringCloud、MyBatis-Plus、MySQL、Docker等主流技术前端基于Node.js和Vue.js构建并整合Redis、ActiveMQ、阿里云OSS与视频点播同时使用ECharts实现图表展示POI完成用户批量上传注册JWT实现分布式单点登录。压缩包大小约198KB内容以项目源码、数据库脚本与说明文档为主可帮助读者快速理解前后端分离的微服务项目结构。目前已有1154人学习下载适合参考其架构设计、接口文档生成及分布式组件集成方式用于个人项目练手或毕业设计二次开发。1. 从单体撑不住到 online_edu 微服务改造这个在线教育系统到底在解决什么在线教育系统 online_edu 的典型痛点不在“写接口”而在“拆模块”。前台用户要刷课程、发问答、看文章后台运营要传视频、导报表、盯数据面板如果全部揉在一个 SpringBoot 单体里热部署慢、并发扛不住、运营活动一上线就得整个重启。所以这类系统最常见的落地方案是SpringBoot SpringCloud 做微服务底座MyBatis-Plus 做数据访问Vue.js 做前后端分离MySQL 存业务数据Redis 扛热点ActiveMQ 解耦异步任务OSS 和视频点播管文件ECharts 出图表POI 导 Excel。这套技术组合在 2026 年的今天依然是中小团队搭建在线教育平台的主流选型因为每一层都有成熟生态招人容易、排错资料多、踩坑成本低。这篇文章适合两类人一是正在做毕业设计或课程设计、需要从零跑通一个完整微服务项目的学生二是公司要自建在线教育平台、想用现成方案避免从头造轮子的后端开发。我会按“怎么拆服务、怎么写业务、怎么部署、怎么避坑”的顺序把这套系统从骨架到上线讲透。先说明一点微服务不是越拆越好online_edu 这种体量拆 3 到 4 个核心服务就够拆多了反而被分布式事务拖死。2. SpringCloud 微服务骨架搭建从 Maven 多模块到 Nacos 注册中心2.1 服务拆分的判断标准online_edu 为什么拆成这四个服务很多初学者拿到“微服务架构”这个要求就慌不知道拆几个服务、按什么拆。我的经验是按“独立部署 独立数据域 独立伸缩”三个标准来判断。online_edu 的前台系统包含课程、问答、文章三大部分这三块业务的数据表互不重叠、访问频率差异大——课程是高频读问答是写多读少文章是内容管理为主。把它们拆成三个服务再加上一个管文件上传、视频点播和后台报表的后台服务正好四个不多不少。拆服务的时候有一个常见误用把“微服务”理解成“每个 Controller 一个服务”结果一个查询要跨五次 HTTP 调用延迟直接爆炸。我一般会这样判断——如果两个功能要频繁联查同几张表它们就应该在同一个服务里如果只是偶尔通过 ID 互相引一下才值得拆开。课程服务要查讲师信息和视频播放地址这些表天然在一块硬拆反而不是微服务是给自己上刑。服务拆完每个服务独立一个数据库至少做到逻辑隔离。online_edu 这种规模不需要分库分表但四个数据库是底线。MySQL 5.7 和 8.0 都能跑建议直接上 8.05.7 的官方支持已经走到尽头新项目没必要为难自己。2.2 用 Maven 多模块搭建项目骨架父 POM 锁定依赖版本微服务项目第一步是建 Maven 多模块工程。父 POM 管依赖版本子模块管具体实现。这一步的坑主要在版本兼容SpringBoot 和 SpringCloud 的版本号不是随便配的SpringCloud 的每个大版本对应一个 SpringBoot 版本配错了启动直接报 NoSuchMethodError而且是启动到一半才爆查起来非常恶心。我一般用 SpringBoot 2.6.x 配 SpringCloud 2021.x这套组合在 2026 年依然大量运行在生产环境资料最多、坑基本都被踩平了。SpringBoot 3.x 配 SpringCloud 2022 当然更好但对 MyBatis-Plus 和 ActiveMQ 的兼容性要求更高学生项目和老系统升级没必要赌这个。!-- 父 POM 关键配置片段 -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.6.13/version relativePath/ /parent properties spring-cloud.version2021.0.5/spring-cloud.version mybatis-plus.version3.5.3.1/mybatis-plus.version mysql.version8.0.33/mysql.version /properties dependencyManagement dependencies dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-dependencies/artifactId version${spring-cloud.version}/version typepom/type scopeimport/scope /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version${mybatis-plus.version}/version /dependency /dependencies /dependencyManagement这段配置的逻辑是SpringBoot 父 POM 统一管理 Spring 全家桶的版本SpringCloud 的 dependencyManagement 统一管微服务组件Nacos、Gateway、OpenFeign 等的版本MyBatis-Plus 和 MySQL 驱动单独锁版本。这样每个子模块只需要声明用到了什么不需要写版本号避免子模块之间依赖版本漂移。注意 SpringCloud 的版本号是伦敦地铁站名命名的2021.0.x 对应 Jubilee不要写错。2.3 Nacos 注册中心与 Gateway 网关配置服务发现是微服务的地基服务拆完之后服务之间要互相找到对方这就需要注册中心。Nacos 是当前 SpringCloud 生态里用得最多的注册中心比 Eureka 多了配置中心功能而且控制台是中文的排查问题方便很多。online_edu 的四个服务全部注册到 Nacos前台请求统一走 Gateway 网关转发不直接调服务地址。# 每个微服务模块的 application.yml 关键配置 server: port: 8101 # 不同服务用不同端口 spring: application: name: service-course # 服务名网关和 Feign 都靠这个名字路由 cloud: nacos: discovery: server-addr: 192.168.1.10:8848 namespace: online-edu-dev # 区分环境用dev/prod 隔离# Gateway 网关模块路由配置 spring: cloud: gateway: routes: - id: course-route uri: lb://service-course predicates: - Path/api/course/** - id: qa-route uri: lb://service-qa predicates: - Path/api/qa/**注册中心这一段逻辑是服务启动时向 Nacos 上报自己的 IP 和端口网关从 Nacos 拉取服务列表根据请求路径的前缀把请求转发到对应服务。lb://前缀表示走负载均衡Gateway 会轮询分发到服务实例。我在配置里加了namespace字段开发环境和生产环境用同一个 Nacos 但不同命名空间防止联调的时候服务串了。这里有个参数值得注意spring.application.name是微服务里的“身份证”所有注册、发现、配置管理都靠这个名字。如果你把服务名写错了Nacos 控制台里能看到服务但网关路由永远转发不过去日志里只会报 503非常隐蔽。3. 前台用户系统落地课程、问答、文章三大模块的代码实现3.1 课程模块MyBatis-Plus 分页查询与视频点播的配合课程模块是 online_edu 的前台核心用户需要按分类浏览课程、搜索课程、查看课程详情、播放视频。数据库设计上课程表、课程分类表、讲师表、课程视频表四张表就够了不需要过度设计。课程列表页的典型接口是分页查询这里直接用 MyBatis-Plus 的 Page 对象避免手写 LIMIT 分页的边界判断。MyBatis-Plus 的 BaseMapper 已经封装了大部分单表 CRUD写代码的核心就变成“怎么组合条件”。// 课程分页查询接口实现 Service public class CourseServiceImpl implements CourseService { Autowired private CourseMapper courseMapper; Override public PageCourse pageCourses(Integer current, Integer size, CourseQueryDTO query) { // 1. 创建分页对象current 从 1 开始 PageCourse page new Page(current, size); // 2. 构建查询条件LambdaQueryWrapper 避免硬编码列名 LambdaQueryWrapperCourse wrapper Wrappers.lambdaQuery(); wrapper.like(StringUtils.hasText(query.getTitle()), Course::getTitle, query.getTitle()) .eq(query.getCategoryId() ! null, Course::getCategoryId, query.getCategoryId()) .eq(Course::getStatus, 1) // 只查上架的课程 .orderByDesc(Course::getPublishTime); // 3. 执行分页查询MyBatis-Plus 会自动生成 COUNT 查询 return courseMapper.selectPage(page, wrapper); } }这段代码的关键逻辑是LambdaQueryWrapper它用方法引用的方式指定列名编译期就能发现字段名拼写错误不用等运行期报 SQL 异常。like和eq第一个参数是布尔条件为 true 才拼接这个条件这样查询参数为空的时候不会生成多余 WHERE 子句。selectPage会自动执行一条 COUNT 查询和一条分页查询返回的 Page 对象里带总条数和页数前端直接拿来渲染分页组件。课程详情页要展示视频播放地址这就涉及阿里云视频点播的接入。常见做法是后端调用视频点播的接口获取播放凭证PlayAuth返回给前端前端配合视频播放器 SDK 播放。播放凭证有过期时间一般是 100 秒所以不能缓存必须每次进入播放页时实时获取。这里有个小坑凭证获取接口要加防盗链配置否则播放地址泄露出去别人可以直接扒流。3.2 问答模块Redis 缓存热点问题与 ActiveMQ 异步通知问答模块的业务特征是用户提问、其他用户回答、提问者采纳答案。这个模块的并发模型和课程模块不一样——一个热门问题可能在短时间内被大量浏览但回答数远小于浏览数。所以问答模块的缓存策略是题目详情页用 Redis 缓存回答列表也缓存用户提交新回答时先更新数据库再删除缓存等下次查询时重建。这里我用的是“先更新数据库再删除缓存”的模式而不是“先删缓存再更新数据库”。原因很简单先删缓存的话更新数据库那段时间内来了请求缓存没有会直接去打数据库如果请求量大的话数据库瞬间被压垮这就是缓存击穿。先更新库再删缓存虽然极端条件下会有几百毫秒的脏读但对问答场景完全可接受。// 回答问题的异步处理逻辑 Service public class AnswerServiceImpl implements AnswerService { Autowired private StringRedisTemplate redisTemplate; Autowired private JmsTemplate jmsTemplate; Override public void submitAnswer(Answer answer) { // 1. 保存回答到数据库 answerMapper.insert(answer); // 2. 删除问题详情的缓存下次查询时自动重建 redisTemplate.delete(qa:question:detail: answer.getQuestionId()); // 3. 发送异步消息通知提问者 jmsTemplate.convertAndSend(edu.qa.answer, JSON.toJSONString(answer)); } }ActiveMQ 在这里的作用是解耦把“新回答通知”这个非关键路径剥离出去。如果不用消息队列用户提交回答后要同步调邮件或短信服务一旦短信服务挂了回答就提交失败。用 ActiveMQ 之后回答只要存进库里就算成功通知失败可以重试互不影响。上面代码里convertAndSend是 JmsTemplate 最简单的用法消息体直接传 JSON 字符串消费者那边用JmsListener注解接消息就行。Redis 缓存这里还有一层设计热门的浏览计数也不直接写数据库而是先写 Redis再定时批量同步到 MySQL。不然用户每打开一次问题详情页就 UPDATE 一次数据库问答这种读多写少的场景也能把数据库打满。3.3 文章模块富文本编辑与静态化方案的取舍文章模块相对简单核心就是两类页面文章列表和文章详情。文章详情如果每次请求都从数据库查、再用模板渲染数据库压力大不说响应速度也慢。常见做法是文章发布时生成静态 HTML 页面存到 OSS 或者本地磁盘前台详情页直接返回静态文件。我做过一个简化版本前端用 Vue.js 的vue-quill-editor做富文本编辑器文章正文以 HTML 片段存进 MySQL。详情页请求时后端把 HTML 片段拼进 Vue 组件里渲染。这个方案的优点是改动少、好维护缺点是首屏加载慢一点但对文章这种非实时性内容用户完全感知不到区别。// Vue.js 前端文章列表页的分页逻辑 export default { data() { return { articleList: [], current: 1, size: 10, total: 0 } }, methods: { async fetchArticles() { const res await axios.get(/api/article/list, { params: { current: this.current, size: this.size, category: this.categoryId } }) // 后端返回结构: { records: [], total: 100 } this.articleList res.data.records this.total res.data.total } } }这里要说一个很多人忽略的细节分页接口返回的records是文章列表但列表里不应该包含文章正文这个字段。文章正文可能几十 KB列表页一次性拉 10 篇就是几百 KB移动端网络扛不住。正确做法是列表接口只返回 ID、标题、摘要、封面图、发布时间详情页再单独查正文。这个字段裁剪逻辑要看 SQL 里 select 了哪些列MyBatis-Plus 里用select(Course::getId, Course::getTitle, ...)指定就好。4. 后台运营平台ECharts 看板与 POI 报表导出的实现细节4.1 运营后台的数据面板ECharts 图表接口怎么设计才不卡后台运营平台要展示课程销售趋势、用户增长曲线、问答活跃度这些图表。ECharts 渲染本身是纯前端的事后端的工作是提供聚合好的数据接口。最容易犯的错误是后端直接返回明细数据让前端去聚合。用户量大了以后报表接口一次返回几万条记录前端渲染直接卡死而且图表数据不需要那么细的粒度后端聚合完只返回十几个点的坐标就够了。我的做法是后端按时间粒度聚合。按天、按周、按月给聚合接口前端拿到的是[{date: 2026-01-01, count: 32}, ...]这种结构ECharts 塞进去直接渲染。// 后台数据面板的聚合查询 RestController RequestMapping(/admin/stats) public class StatsController { Autowired private JdbcTemplate jdbcTemplate; GetMapping(/course-publish-trend) public ListMapString, Object coursePublishTrend( RequestParam String startDate, RequestParam String endDate) { String sql SELECT DATE(publish_time) AS date, COUNT(*) AS count FROM course WHERE publish_time BETWEEN ? AND ? GROUP BY DATE(publish_time) ORDER BY date; return jdbcTemplate.queryForList(sql, startDate, endDate); } }这个接口的逻辑是MySQL 的 GROUP BY 按天聚合课程上架数量返回的 Map 列表就是图表的数据源。这里能用 JdbcTemplate 直接写 SQL是因为聚合查询往往是多表 JOIN 加复杂条件用 MyBatis-Plus 的 Wrapper 反而难写。一个项目里别只用一种数据访问方式聚合报表类和业务 CRUD 类用不同工具很正常。图表数据接口的响应速度一般要求 200ms 以内超过这个时间前端就能感知到卡顿。如果聚合查询超过 1 秒优先加联合索引只要 WHERE 条件和 GROUP BY 的字段在同一个索引里性能通常能提升一个数量级。4.2 POI 实现 Excel 导出百万行数据的正确导出姿势后台运营平台离不开导出功能课程清单、学员信息、问答列表都要导成 Excel。POI 是最常用的库但很多人只会用XSSFWorkbook数据量一超过几万行就内存溢出。POI 有 SXSSFWorkbook 专门处理大文件导出用内存和磁盘交换的方式避免 OOM百万行数据毫无压力。// POI 导出课程列表数据量大时使用 SXSSFWorkbook public void exportCourses(ListCourse courses, HttpServletResponse response) { // 1. 用 SXSSFWorkbook1000 行刷一次盘避免内存堆积 SXSSFWorkbook workbook new SXSSFWorkbook(1000); Sheet sheet workbook.createSheet(课程列表); // 2. 设置表头样式 CellStyle headerStyle workbook.createCellStyle(); headerStyle.setAlignment(HorizontalAlignment.CENTER); Font headerFont workbook.createFont(); headerFont.setBold(true); headerStyle.setFont(headerFont); // 3. 写表头 String[] headers {课程ID, 课程名称, 讲师, 价格, 上架状态}; Row headerRow sheet.createRow(0); for (int i 0; i headers.length; i) { Cell cell headerRow.createCell(i); cell.setCellValue(headers[i]); cell.setCellStyle(headerStyle); } // 4. 写数据行 int rowIndex 1; for (Course course : courses) { Row row sheet.createRow(rowIndex); row.createCell(0).setCellValue(course.getId()); row.createCell(1).setCellValue(course.getTitle()); row.createCell(2).setCellValue(course.getTeacherName()); row.createCell(3).setCellValue(course.getPrice().doubleValue()); row.createCell(4).setCellValue(course.getStatus() 1 ? 上架 : 下架); } // 5. 写响应流 response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filenamecourses.xlsx); workbook.write(response.getOutputStream()); workbook.dispose(); // SXSSFWorkbook 用完必须调 dispose 清理临时文件 }这段代码的要点是new SXSSFWorkbook(1000)这行的参数1000 表示内存里最多保留 1000 行超过的行会刷到磁盘临时文件。dispose()必须调用否则临时文件会留在磁盘上一次导出清不干净时间长了磁盘被占满。数据量大导出耗时超过几秒时前端要配合 loading 状态不然用户会以为界面卡死了。POI 导出还有一个常见的乱码坑文件名里的中文如果直接拼在Content-Disposition里浏览器会变成下划线。需要做 URL 编码URLEncoder.encode(课程列表.xlsx, UTF-8)这样 Chrome、Edge、Safari 都不会乱。4.3 运营后台的权限处理为什么把权限判断放在网关层后台运营平台不允许普通用户访问管理员和运营人员要分开角色。微服务架构下权限有网关层拦截和服务层校验两层。网关层做粗粒度校验判断请求路径是否要求登录、用户有没有这个角色的访问权限服务层做细粒度校验判断用户对某条数据有没有操作权。SpringCloud Gateway 的全局过滤器可以统一做登录态校验和角色判断这样四个服务不用每个都写一遍权限逻辑。token 用 JWT 格式网关解析出用户 ID 和角色 ID 后通过 Header 传给下游服务。这里有个坑下游服务直接信任网关传来的 Header 有安全风险要加一个内部密钥校验防止服务端口暴露时被绕过网关直接调用。5. 避坑与排查online_edu 从开发到部署的 6 个真实踩坑记录5.1 MySQL 连接报错SSL 连接错误和时区问题一起出现现象SpringBoot 项目启动时控制台报Cannot create PoolableConnectionFactory具体原因里有The server time zone value йʱ is unrecognized和SSL connection error两个问题同时出现。原因MySQL 8.0 默认启用 SSL 连接而且时区配置从SYSTEM改成了需要明确指定。JDBC 连接串里既没配useSSLfalse也没配serverTimezoneAsia/Shanghai导致驱动校验时区失败整个连接池创建失败。解决JDBC 连接串改成这样jdbc:mysql://localhost:3306/online_edu?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue。其中allowPublicKeyRetrievaltrue是 MySQL 8.0 的另一个坑不配它连接时可能报Public Key Retrieval is not allowed。这行参数建议直接写进每个微服务的配置文件里不要漏。5.2 Docker 安装 MySQL 失败镜像拉不下来和容器起不来现象用 Docker 部署环境时docker pull mysql:8.0卡住不动或者docker run之后容器秒退docker logs看报错是权限不足。原因拉镜像卡住通常是网络问题容器秒退则是 MySQL 初始化时往宿主机挂载目录写文件没有权限。很多教程让你挂载/my/own/datadir:/var/lib/mysql但这个目录如果宿主机上没有合适的权限MySQL 的mysqld进程无法写入数据。解决挂载数据目录时先建目录再调整权限mkdir -p /data/mysql chmod -R 777 /data/mysql。然后启动时加上--usermysql参数运行命令是docker run -d --name mysql8 -p 3306:3306 -e MYSQL_ROOT_PASSWORDroot123 -v /data/mysql:/var/lib/mysql mysql:8.0 --character-set-serverutf8mb4 --collation-serverutf8mb4_unicode_ci。--character-set-serverutf8mb4这个参数很重要MySQL 默认的latin1编码存不了中文不配的话建表时必须每个表单独指定字符集非常麻烦。5.3 微服务之间调用超时HttpClient 连接池耗尽现象商城或问答服务调用课程服务时偶尔报Connection pool timeout高峰时段特别频繁。原因online_edu 的服务间调用用了 HttpClient而很多人的 HttpClient 配置是默认的。默认最大连接数只有 20而且连接空闲 60 秒后会被回收。一旦课程服务响应变慢调用方积压的请求占满全部连接后续请求全部排队等连接超时时间一到就报错。解决给 HttpClient 设置更大的连接池和合理的超时时间。PoolingHttpClientConnectionManager的setMaxTotal(200)和setDefaultMaxPerRoute(100)RequestConfig里的connectTimeout(5000)需要 5 秒socketTimeout(10000)是 10 秒。同时开启evictExpiredConnections()和evictIdleConnections(60, TimeUnit.SECONDS)定期清理空闲连接避免连接池被死连接占满。5.4 Vue 打包放进 SpringBoot 之后的刷新 404 问题现象前端 Vue.js 项目打包后的 dist 目录放到 SpringBoot 的static目录下访问首页没问题但点击前端路由跳转到/course/1这种路径后按 F5 刷新就报 404。原因Vue 是单页应用路由用的 history 模式刷新时浏览器向服务器请求/course/1这个路径但 SpringBoot 里没有对应的静态文件后端直接返回 404。开发环境没有这个问题是 vite 或 webpack 的 dev server 做了 history 回退。解决后端加一个转发规则把非接口路径的请求都转发到首页。写一个配置类实现WebMvcConfigurer添加一个 ViewController 把错误路径转发到index.html。但是要注意/api/**和/admin/**这些接口路径不能转否则请求后端接口会被错误地返回 HTML排查起来更迷惑。5.5 阿里云 OSS 视频上传失败STS 临时凭证失效现象后台运营在管理台上传课程视频时偶尔上传到一半报InvalidAccessKeyId特别是上传大视频时几乎必现。原因视频点播和 OSS 的临时凭证 STS 默认有效期是一个小时但视频上传不是一次性的前端分片上传大文件可能超过凭证有效期。凭证过期后后续分片携带旧凭证请求 OSS服务端直接拒绝。解决不要在前端固定一个 STS 凭证上传所有文件。每次开始上传时后端重新颁发 STS前端检测到上传失败时捕获InvalidAccessKeyId错误后重新获取凭证并重试当前分片。同时在服务端把 STS 的有效期适当延长比如 3 个小时但不要超过 12 小时安全性和体验要权衡。5.6 ActiveMQ 消息堆积消费速度跟不上生产速度现象问答模块的异步通知越来越多ActiveMQ 控制台看到某个队列的PendingMessage数量持续上涨用户反馈收不到回答通知。原因消费者处理每条消息的逻辑里有调用远程接口的操作比如发送邮件或短信这个远程操作慢导致消费吞吐量远低于生产量。解决先看消费者日志确认是远程调用慢然后改消费者逻辑把发邮件的操作改成先入库、再异步批量发送。同时给JmsListener配置并发消费者concurrency 5-10让 5 到 10 个线程同时消费。这里要提醒一下消息的消费幂等性一定要做否则消费者重启时重复消费用户会收到两条重复通知。在数据库里加一个message_id唯一索引消费前先尝试插入插入冲突说明处理过了直接跳过。6. 进阶验证用 Docker Compose 一键部署 压测确认系统能上线项目开发完本地能跑通不等于能上线。我习惯把所有中间件编排进 Docker Compose在测试环境一键拉起然后立刻做一轮简单的压测确认瓶颈不在基础设施层。Docker 的部署方案分两层中间件层用 Docker Compose 管应用层用 Dockerfile 打包镜像。先看中间件编排version: 3.8 services: mysql: image: mysql:8.0 container_name: edu-mysql environment: MYSQL_ROOT_PASSWORD: root123 TZ: Asia/Shanghai ports: - 3306:3306 volumes: - /data/mysql:/var/lib/mysql command: --character-set-serverutf8mb4 redis: image: redis:7.0 container_name: edu-redis ports: - 6379:6379 command: redis-server --appendonly yes activemq: image: rmohr/activemq:5.15.9 container_name: edu-activemq ports: - 61616:61616 - 8161:8161中间件的参数说明TZ: Asia/Shanghai同时设置了容器的时区和 MySQL 的时区解决 5.1 提到的 serverTimezone 报错Redis 加了appendonly yes开启持久化避免容器重启后缓存数据全部丢失ActiveMQ 映射了 61616服务端口和 8161控制台端口控制台可以用来排查消息堆积。应用服务编排进同一个 Compose 文件里用 depends_on 指定依赖顺序。网关服务对外暴露 8080其余服务只暴露给内部容器网络不映射宿主机端口这样外部只能走网关服务端口不会被直接打穿。部署之后验证系统能不能扛住基本流量我用一个很简单的方法用ab压测网关接口看吞吐量和错误率。# 压测课程列表接口模拟 200 个并发每个并发发 1000 个请求 ab -n 200000 -c 200 -k \ -H token: your-test-token \ http://localhost:8080/api/course/list # 压测结果重点看这 3 个指标 # Time per request: 平均每个请求耗时应小于 200ms # Failed requests: 应接近 0 # Requests per second: 吞吐量单机网关应超过 500压测结果如果 Failed requests 不为零先用dmesg看是不是端口队列满了再逐个排查是 MySQL 慢查询还是 Redis 连接数不够。我的经验是online_edu 这种体量的系统先确认基础设施没选错型号再谈业务优化。做 docker compose 部署和压测这件事我吃了不少亏。以前图省事本地直接把服务跑起来就给测试看结果每次环境不一致要么 MySQL 配置不一样要么 Redis 版本不同测试报 bug 我本地又复现不了最后发现是环境差异。用 Docker Compose 之后整个团队的环境完全一致这个“在我电脑上能跑”的玄学问题算是彻底根治了。最后的习惯是每次改完代码先跑一遍压测接口确认响应时间没有明显劣化再提交合并。压测脚本放在项目的script目录下随手就能执行不用临时敲命令。希望这些步骤和踩坑记录能帮你少走一段弯路把 online_edu 从本机跑通做到真正敢上线。本文还有配套的精品资源点击获取