在線教育系統(tǒng)實戰(zhàn):從單體拆分到部署避坑)
簡介一套基于SpringCloud微服務(wù)架構(gòu)的在線教育系統(tǒng)完整項目面向Java初中級開發(fā)者、畢業(yè)設(shè)計選題學(xué)生及需要微服務(wù)實戰(zhàn)經(jīng)驗的程序員。系統(tǒng)分為前臺用戶網(wǎng)站和后臺運營平臺兩部分前臺包含課程、問答、文章三大核心模塊后端采用SpringBoot、SpringCloud、MyBatis-Plus、MySQL、Docker等主流技術(shù)前端基于Node.js和Vue.js構(gòu)建并整合Redis、ActiveMQ、阿里云OSS與視頻點播同時使用ECharts實現(xiàn)圖表展示POI完成用戶批量上傳注冊JWT實現(xiàn)分布式單點登錄。壓縮包大小約198KB內(nèi)容以項目源碼、數(shù)據(jù)庫腳本與說明文檔為主可幫助讀者快速理解前后端分離的微服務(wù)項目結(jié)構(gòu)。目前已有1154人學(xué)習(xí)下載適合參考其架構(gòu)設(shè)計、接口文檔生成及分布式組件集成方式用于個人項目練手或畢業(yè)設(shè)計二次開發(fā)。1. 從單體撐不住到 online_edu 微服務(wù)改造這個在線教育系統(tǒng)到底在解決什么在線教育系統(tǒng) online_edu 的典型痛點不在“寫接口”而在“拆模塊”。前臺用戶要刷課程、發(fā)問答、看文章后臺運營要傳視頻、導(dǎo)報表、盯數(shù)據(jù)面板如果全部揉在一個 SpringBoot 單體里熱部署慢、并發(fā)扛不住、運營活動一上線就得整個重啟。所以這類系統(tǒng)最常見的落地方案是SpringBoot SpringCloud 做微服務(wù)底座MyBatis-Plus 做數(shù)據(jù)訪問Vue.js 做前后端分離MySQL 存業(yè)務(wù)數(shù)據(jù)Redis 扛熱點ActiveMQ 解耦異步任務(wù)OSS 和視頻點播管文件ECharts 出圖表POI 導(dǎo) Excel。這套技術(shù)組合在 2026 年的今天依然是中小團隊搭建在線教育平臺的主流選型因為每一層都有成熟生態(tài)招人容易、排錯資料多、踩坑成本低。這篇文章適合兩類人一是正在做畢業(yè)設(shè)計或課程設(shè)計、需要從零跑通一個完整微服務(wù)項目的學(xué)生二是公司要自建在線教育平臺、想用現(xiàn)成方案避免從頭造輪子的后端開發(fā)。我會按“怎么拆服務(wù)、怎么寫業(yè)務(wù)、怎么部署、怎么避坑”的順序把這套系統(tǒng)從骨架到上線講透。先說明一點微服務(wù)不是越拆越好online_edu 這種體量拆 3 到 4 個核心服務(wù)就夠拆多了反而被分布式事務(wù)拖死。2. SpringCloud 微服務(wù)骨架搭建從 Maven 多模塊到 Nacos 注冊中心2.1 服務(wù)拆分的判斷標(biāo)準online_edu 為什么拆成這四個服務(wù)很多初學(xué)者拿到“微服務(wù)架構(gòu)”這個要求就慌不知道拆幾個服務(wù)、按什么拆。我的經(jīng)驗是按“獨立部署 獨立數(shù)據(jù)域 獨立伸縮”三個標(biāo)準來判斷。online_edu 的前臺系統(tǒng)包含課程、問答、文章三大部分這三塊業(yè)務(wù)的數(shù)據(jù)表互不重疊、訪問頻率差異大——課程是高頻讀問答是寫多讀少文章是內(nèi)容管理為主。把它們拆成三個服務(wù)再加上一個管文件上傳、視頻點播和后臺報表的后臺服務(wù)正好四個不多不少。拆服務(wù)的時候有一個常見誤用把“微服務(wù)”理解成“每個 Controller 一個服務(wù)”結(jié)果一個查詢要跨五次 HTTP 調(diào)用延遲直接爆炸。我一般會這樣判斷——如果兩個功能要頻繁聯(lián)查同幾張表它們就應(yīng)該在同一個服務(wù)里如果只是偶爾通過 ID 互相引一下才值得拆開。課程服務(wù)要查講師信息和視頻播放地址這些表天然在一塊硬拆反而不是微服務(wù)是給自己上刑。服務(wù)拆完每個服務(wù)獨立一個數(shù)據(jù)庫至少做到邏輯隔離。online_edu 這種規(guī)模不需要分庫分表但四個數(shù)據(jù)庫是底線。MySQL 5.7 和 8.0 都能跑建議直接上 8.05.7 的官方支持已經(jīng)走到盡頭新項目沒必要為難自己。2.2 用 Maven 多模塊搭建項目骨架父 POM 鎖定依賴版本微服務(wù)項目第一步是建 Maven 多模塊工程。父 POM 管依賴版本子模塊管具體實現(xiàn)。這一步的坑主要在版本兼容SpringBoot 和 SpringCloud 的版本號不是隨便配的SpringCloud 的每個大版本對應(yīng)一個 SpringBoot 版本配錯了啟動直接報 NoSuchMethodError而且是啟動到一半才爆查起來非常惡心。我一般用 SpringBoot 2.6.x 配 SpringCloud 2021.x這套組合在 2026 年依然大量運行在生產(chǎn)環(huán)境資料最多、坑基本都被踩平了。SpringBoot 3.x 配 SpringCloud 2022 當(dāng)然更好但對 MyBatis-Plus 和 ActiveMQ 的兼容性要求更高學(xué)生項目和老系統(tǒng)升級沒必要賭這個。!-- 父 POM 關(guān)鍵配置片段 -- 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 統(tǒng)一管理 Spring 全家桶的版本SpringCloud 的 dependencyManagement 統(tǒng)一管微服務(wù)組件Nacos、Gateway、OpenFeign 等的版本MyBatis-Plus 和 MySQL 驅(qū)動單獨鎖版本。這樣每個子模塊只需要聲明用到了什么不需要寫版本號避免子模塊之間依賴版本漂移。注意 SpringCloud 的版本號是倫敦地鐵站名命名的2021.0.x 對應(yīng) Jubilee不要寫錯。2.3 Nacos 注冊中心與 Gateway 網(wǎng)關(guān)配置服務(wù)發(fā)現(xiàn)是微服務(wù)的地基服務(wù)拆完之后服務(wù)之間要互相找到對方這就需要注冊中心。Nacos 是當(dāng)前 SpringCloud 生態(tài)里用得最多的注冊中心比 Eureka 多了配置中心功能而且控制臺是中文的排查問題方便很多。online_edu 的四個服務(wù)全部注冊到 Nacos前臺請求統(tǒng)一走 Gateway 網(wǎng)關(guān)轉(zhuǎn)發(fā)不直接調(diào)服務(wù)地址。# 每個微服務(wù)模塊的 application.yml 關(guān)鍵配置 server: port: 8101 # 不同服務(wù)用不同端口 spring: application: name: service-course # 服務(wù)名網(wǎng)關(guān)和 Feign 都靠這個名字路由 cloud: nacos: discovery: server-addr: 192.168.1.10:8848 namespace: online-edu-dev # 區(qū)分環(huán)境用dev/prod 隔離# Gateway 網(wǎng)關(guān)模塊路由配置 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/**注冊中心這一段邏輯是服務(wù)啟動時向 Nacos 上報自己的 IP 和端口網(wǎng)關(guān)從 Nacos 拉取服務(wù)列表根據(jù)請求路徑的前綴把請求轉(zhuǎn)發(fā)到對應(yīng)服務(wù)。lb://前綴表示走負載均衡Gateway 會輪詢分發(fā)到服務(wù)實例。我在配置里加了namespace字段開發(fā)環(huán)境和生產(chǎn)環(huán)境用同一個 Nacos 但不同命名空間防止聯(lián)調(diào)的時候服務(wù)串了。這里有個參數(shù)值得注意spring.application.name是微服務(wù)里的“身份證”所有注冊、發(fā)現(xiàn)、配置管理都靠這個名字。如果你把服務(wù)名寫錯了Nacos 控制臺里能看到服務(wù)但網(wǎng)關(guān)路由永遠轉(zhuǎn)發(fā)不過去日志里只會報 503非常隱蔽。3. 前臺用戶系統(tǒng)落地課程、問答、文章三大模塊的代碼實現(xiàn)3.1 課程模塊MyBatis-Plus 分頁查詢與視頻點播的配合課程模塊是 online_edu 的前臺核心用戶需要按分類瀏覽課程、搜索課程、查看課程詳情、播放視頻。數(shù)據(jù)庫設(shè)計上課程表、課程分類表、講師表、課程視頻表四張表就夠了不需要過度設(shè)計。課程列表頁的典型接口是分頁查詢這里直接用 MyBatis-Plus 的 Page 對象避免手寫 LIMIT 分頁的邊界判斷。MyBatis-Plus 的 BaseMapper 已經(jīng)封裝了大部分單表 CRUD寫代碼的核心就變成“怎么組合條件”。// 課程分頁查詢接口實現(xiàn) Service public class CourseServiceImpl implements CourseService { Autowired private CourseMapper courseMapper; Override public PageCourse pageCourses(Integer current, Integer size, CourseQueryDTO query) { // 1. 創(chuàng)建分頁對象current 從 1 開始 PageCourse page new Page(current, size); // 2. 構(gòu)建查詢條件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. 執(zhí)行分頁查詢MyBatis-Plus 會自動生成 COUNT 查詢 return courseMapper.selectPage(page, wrapper); } }這段代碼的關(guān)鍵邏輯是LambdaQueryWrapper它用方法引用的方式指定列名編譯期就能發(fā)現(xiàn)字段名拼寫錯誤不用等運行期報 SQL 異常。like和eq第一個參數(shù)是布爾條件為 true 才拼接這個條件這樣查詢參數(shù)為空的時候不會生成多余 WHERE 子句。selectPage會自動執(zhí)行一條 COUNT 查詢和一條分頁查詢返回的 Page 對象里帶總條數(shù)和頁數(shù)前端直接拿來渲染分頁組件。課程詳情頁要展示視頻播放地址這就涉及阿里云視頻點播的接入。常見做法是后端調(diào)用視頻點播的接口獲取播放憑證PlayAuth返回給前端前端配合視頻播放器 SDK 播放。播放憑證有過期時間一般是 100 秒所以不能緩存必須每次進入播放頁時實時獲取。這里有個小坑憑證獲取接口要加防盜鏈配置否則播放地址泄露出去別人可以直接扒流。3.2 問答模塊Redis 緩存熱點問題與 ActiveMQ 異步通知問答模塊的業(yè)務(wù)特征是用戶提問、其他用戶回答、提問者采納答案。這個模塊的并發(fā)模型和課程模塊不一樣——一個熱門問題可能在短時間內(nèi)被大量瀏覽但回答數(shù)遠小于瀏覽數(shù)。所以問答模塊的緩存策略是題目詳情頁用 Redis 緩存回答列表也緩存用戶提交新回答時先更新數(shù)據(jù)庫再刪除緩存等下次查詢時重建。這里我用的是“先更新數(shù)據(jù)庫再刪除緩存”的模式而不是“先刪緩存再更新數(shù)據(jù)庫”。原因很簡單先刪緩存的話更新數(shù)據(jù)庫那段時間內(nèi)來了請求緩存沒有會直接去打數(shù)據(jù)庫如果請求量大的話數(shù)據(jù)庫瞬間被壓垮這就是緩存擊穿。先更新庫再刪緩存雖然極端條件下會有幾百毫秒的臟讀但對問答場景完全可接受。// 回答問題的異步處理邏輯 Service public class AnswerServiceImpl implements AnswerService { Autowired private StringRedisTemplate redisTemplate; Autowired private JmsTemplate jmsTemplate; Override public void submitAnswer(Answer answer) { // 1. 保存回答到數(shù)據(jù)庫 answerMapper.insert(answer); // 2. 刪除問題詳情的緩存下次查詢時自動重建 redisTemplate.delete(qa:question:detail: answer.getQuestionId()); // 3. 發(fā)送異步消息通知提問者 jmsTemplate.convertAndSend(edu.qa.answer, JSON.toJSONString(answer)); } }ActiveMQ 在這里的作用是解耦把“新回答通知”這個非關(guān)鍵路徑剝離出去。如果不用消息隊列用戶提交回答后要同步調(diào)郵件或短信服務(wù)一旦短信服務(wù)掛了回答就提交失敗。用 ActiveMQ 之后回答只要存進庫里就算成功通知失敗可以重試互不影響。上面代碼里convertAndSend是 JmsTemplate 最簡單的用法消息體直接傳 JSON 字符串消費者那邊用JmsListener注解接消息就行。Redis 緩存這里還有一層設(shè)計熱門的瀏覽計數(shù)也不直接寫數(shù)據(jù)庫而是先寫 Redis再定時批量同步到 MySQL。不然用戶每打開一次問題詳情頁就 UPDATE 一次數(shù)據(jù)庫問答這種讀多寫少的場景也能把數(shù)據(jù)庫打滿。3.3 文章模塊富文本編輯與靜態(tài)化方案的取舍文章模塊相對簡單核心就是兩類頁面文章列表和文章詳情。文章詳情如果每次請求都從數(shù)據(jù)庫查、再用模板渲染數(shù)據(jù)庫壓力大不說響應(yīng)速度也慢。常見做法是文章發(fā)布時生成靜態(tài) HTML 頁面存到 OSS 或者本地磁盤前臺詳情頁直接返回靜態(tài)文件。我做過一個簡化版本前端用 Vue.js 的vue-quill-editor做富文本編輯器文章正文以 HTML 片段存進 MySQL。詳情頁請求時后端把 HTML 片段拼進 Vue 組件里渲染。這個方案的優(yōu)點是改動少、好維護缺點是首屏加載慢一點但對文章這種非實時性內(nèi)容用戶完全感知不到區(qū)別。// 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 } }) // 后端返回結(jié)構(gòu): { records: [], total: 100 } this.articleList res.data.records this.total res.data.total } } }這里要說一個很多人忽略的細節(jié)分頁接口返回的records是文章列表但列表里不應(yīng)該包含文章正文這個字段。文章正文可能幾十 KB列表頁一次性拉 10 篇就是幾百 KB移動端網(wǎng)絡(luò)扛不住。正確做法是列表接口只返回 ID、標(biāo)題、摘要、封面圖、發(fā)布時間詳情頁再單獨查正文。這個字段裁剪邏輯要看 SQL 里 select 了哪些列MyBatis-Plus 里用select(Course::getId, Course::getTitle, ...)指定就好。4. 后臺運營平臺ECharts 看板與 POI 報表導(dǎo)出的實現(xiàn)細節(jié)4.1 運營后臺的數(shù)據(jù)面板ECharts 圖表接口怎么設(shè)計才不卡后臺運營平臺要展示課程銷售趨勢、用戶增長曲線、問答活躍度這些圖表。ECharts 渲染本身是純前端的事后端的工作是提供聚合好的數(shù)據(jù)接口。最容易犯的錯誤是后端直接返回明細數(shù)據(jù)讓前端去聚合。用戶量大了以后報表接口一次返回幾萬條記錄前端渲染直接卡死而且圖表數(shù)據(jù)不需要那么細的粒度后端聚合完只返回十幾個點的坐標(biāo)就夠了。我的做法是后端按時間粒度聚合。按天、按周、按月給聚合接口前端拿到的是[{date: 2026-01-01, count: 32}, ...]這種結(jié)構(gòu)ECharts 塞進去直接渲染。// 后臺數(shù)據(jù)面板的聚合查詢 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 按天聚合課程上架數(shù)量返回的 Map 列表就是圖表的數(shù)據(jù)源。這里能用 JdbcTemplate 直接寫 SQL是因為聚合查詢往往是多表 JOIN 加復(fù)雜條件用 MyBatis-Plus 的 Wrapper 反而難寫。一個項目里別只用一種數(shù)據(jù)訪問方式聚合報表類和業(yè)務(wù) CRUD 類用不同工具很正常。圖表數(shù)據(jù)接口的響應(yīng)速度一般要求 200ms 以內(nèi)超過這個時間前端就能感知到卡頓。如果聚合查詢超過 1 秒優(yōu)先加聯(lián)合索引只要 WHERE 條件和 GROUP BY 的字段在同一個索引里性能通常能提升一個數(shù)量級。4.2 POI 實現(xiàn) Excel 導(dǎo)出百萬行數(shù)據(jù)的正確導(dǎo)出姿勢后臺運營平臺離不開導(dǎo)出功能課程清單、學(xué)員信息、問答列表都要導(dǎo)成 Excel。POI 是最常用的庫但很多人只會用XSSFWorkbook數(shù)據(jù)量一超過幾萬行就內(nèi)存溢出。POI 有 SXSSFWorkbook 專門處理大文件導(dǎo)出用內(nèi)存和磁盤交換的方式避免 OOM百萬行數(shù)據(jù)毫無壓力。// POI 導(dǎo)出課程列表數(shù)據(jù)量大時使用 SXSSFWorkbook public void exportCourses(ListCourse courses, HttpServletResponse response) { // 1. 用 SXSSFWorkbook1000 行刷一次盤避免內(nèi)存堆積 SXSSFWorkbook workbook new SXSSFWorkbook(1000); Sheet sheet workbook.createSheet(課程列表); // 2. 設(shè)置表頭樣式 CellStyle headerStyle workbook.createCellStyle(); headerStyle.setAlignment(HorizontalAlignment.CENTER); Font headerFont workbook.createFont(); headerFont.setBold(true); headerStyle.setFont(headerFont); // 3. 寫表頭 String[] headers {課程ID, 課程名稱, 講師, 價格, 上架狀態(tài)}; 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. 寫數(shù)據(jù)行 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. 寫響應(yīng)流 response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filenamecourses.xlsx); workbook.write(response.getOutputStream()); workbook.dispose(); // SXSSFWorkbook 用完必須調(diào) dispose 清理臨時文件 }這段代碼的要點是new SXSSFWorkbook(1000)這行的參數(shù)1000 表示內(nèi)存里最多保留 1000 行超過的行會刷到磁盤臨時文件。dispose()必須調(diào)用否則臨時文件會留在磁盤上一次導(dǎo)出清不干凈時間長了磁盤被占滿。數(shù)據(jù)量大導(dǎo)出耗時超過幾秒時前端要配合 loading 狀態(tài)不然用戶會以為界面卡死了。POI 導(dǎo)出還有一個常見的亂碼坑文件名里的中文如果直接拼在Content-Disposition里瀏覽器會變成下劃線。需要做 URL 編碼URLEncoder.encode(課程列表.xlsx, UTF-8)這樣 Chrome、Edge、Safari 都不會亂。4.3 運營后臺的權(quán)限處理為什么把權(quán)限判斷放在網(wǎng)關(guān)層后臺運營平臺不允許普通用戶訪問管理員和運營人員要分開角色。微服務(wù)架構(gòu)下權(quán)限有網(wǎng)關(guān)層攔截和服務(wù)層校驗兩層。網(wǎng)關(guān)層做粗粒度校驗判斷請求路徑是否要求登錄、用戶有沒有這個角色的訪問權(quán)限服務(wù)層做細粒度校驗判斷用戶對某條數(shù)據(jù)有沒有操作權(quán)。SpringCloud Gateway 的全局過濾器可以統(tǒng)一做登錄態(tài)校驗和角色判斷這樣四個服務(wù)不用每個都寫一遍權(quán)限邏輯。token 用 JWT 格式網(wǎng)關(guān)解析出用戶 ID 和角色 ID 后通過 Header 傳給下游服務(wù)。這里有個坑下游服務(wù)直接信任網(wǎng)關(guān)傳來的 Header 有安全風(fēng)險要加一個內(nèi)部密鑰校驗防止服務(wù)端口暴露時被繞過網(wǎng)關(guān)直接調(diào)用。5. 避坑與排查online_edu 從開發(fā)到部署的 6 個真實踩坑記錄5.1 MySQL 連接報錯SSL 連接錯誤和時區(qū)問題一起出現(xiàn)現(xiàn)象SpringBoot 項目啟動時控制臺報Cannot create PoolableConnectionFactory具體原因里有The server time zone value й? is unrecognized和SSL connection error兩個問題同時出現(xiàn)。原因MySQL 8.0 默認啟用 SSL 連接而且時區(qū)配置從SYSTEM改成了需要明確指定。JDBC 連接串里既沒配useSSLfalse也沒配serverTimezoneAsia/Shanghai導(dǎo)致驅(qū)動校驗時區(qū)失敗整個連接池創(chuàng)建失敗。解決JDBC 連接串改成這樣jdbc:mysql://localhost:3306/online_edu?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue。其中allowPublicKeyRetrievaltrue是 MySQL 8.0 的另一個坑不配它連接時可能報Public Key Retrieval is not allowed。這行參數(shù)建議直接寫進每個微服務(wù)的配置文件里不要漏。5.2 Docker 安裝 MySQL 失敗鏡像拉不下來和容器起不來現(xiàn)象用 Docker 部署環(huán)境時docker pull mysql:8.0卡住不動或者docker run之后容器秒退docker logs看報錯是權(quán)限不足。原因拉鏡像卡住通常是網(wǎng)絡(luò)問題容器秒退則是 MySQL 初始化時往宿主機掛載目錄寫文件沒有權(quán)限。很多教程讓你掛載/my/own/datadir:/var/lib/mysql但這個目錄如果宿主機上沒有合適的權(quán)限MySQL 的mysqld進程無法寫入數(shù)據(jù)。解決掛載數(shù)據(jù)目錄時先建目錄再調(diào)整權(quán)限mkdir -p /data/mysql chmod -R 777 /data/mysql。然后啟動時加上--usermysql參數(shù)運行命令是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這個參數(shù)很重要MySQL 默認的latin1編碼存不了中文不配的話建表時必須每個表單獨指定字符集非常麻煩。5.3 微服務(wù)之間調(diào)用超時HttpClient 連接池耗盡現(xiàn)象商城或問答服務(wù)調(diào)用課程服務(wù)時偶爾報Connection pool timeout高峰時段特別頻繁。原因online_edu 的服務(wù)間調(diào)用用了 HttpClient而很多人的 HttpClient 配置是默認的。默認最大連接數(shù)只有 20而且連接空閑 60 秒后會被回收。一旦課程服務(wù)響應(yīng)變慢調(diào)用方積壓的請求占滿全部連接后續(xù)請求全部排隊等連接超時時間一到就報錯。解決給 HttpClient 設(shè)置更大的連接池和合理的超時時間。PoolingHttpClientConnectionManager的setMaxTotal(200)和setDefaultMaxPerRoute(100)RequestConfig里的connectTimeout(5000)需要 5 秒socketTimeout(10000)是 10 秒。同時開啟evictExpiredConnections()和evictIdleConnections(60, TimeUnit.SECONDS)定期清理空閑連接避免連接池被死連接占滿。5.4 Vue 打包放進 SpringBoot 之后的刷新 404 問題現(xiàn)象前端 Vue.js 項目打包后的 dist 目錄放到 SpringBoot 的static目錄下訪問首頁沒問題但點擊前端路由跳轉(zhuǎn)到/course/1這種路徑后按 F5 刷新就報 404。原因Vue 是單頁應(yīng)用路由用的 history 模式刷新時瀏覽器向服務(wù)器請求/course/1這個路徑但 SpringBoot 里沒有對應(yīng)的靜態(tài)文件后端直接返回 404。開發(fā)環(huán)境沒有這個問題是 vite 或 webpack 的 dev server 做了 history 回退。解決后端加一個轉(zhuǎn)發(fā)規(guī)則把非接口路徑的請求都轉(zhuǎn)發(fā)到首頁。寫一個配置類實現(xiàn)WebMvcConfigurer添加一個 ViewController 把錯誤路徑轉(zhuǎn)發(fā)到index.html。但是要注意/api/**和/admin/**這些接口路徑不能轉(zhuǎn)否則請求后端接口會被錯誤地返回 HTML排查起來更迷惑。5.5 阿里云 OSS 視頻上傳失敗STS 臨時憑證失效現(xiàn)象后臺運營在管理臺上傳課程視頻時偶爾上傳到一半報InvalidAccessKeyId特別是上傳大視頻時幾乎必現(xiàn)。原因視頻點播和 OSS 的臨時憑證 STS 默認有效期是一個小時但視頻上傳不是一次性的前端分片上傳大文件可能超過憑證有效期。憑證過期后后續(xù)分片攜帶舊憑證請求 OSS服務(wù)端直接拒絕。解決不要在前端固定一個 STS 憑證上傳所有文件。每次開始上傳時后端重新頒發(fā) STS前端檢測到上傳失敗時捕獲InvalidAccessKeyId錯誤后重新獲取憑證并重試當(dāng)前分片。同時在服務(wù)端把 STS 的有效期適當(dāng)延長比如 3 個小時但不要超過 12 小時安全性和體驗要權(quán)衡。5.6 ActiveMQ 消息堆積消費速度跟不上生產(chǎn)速度現(xiàn)象問答模塊的異步通知越來越多ActiveMQ 控制臺看到某個隊列的PendingMessage數(shù)量持續(xù)上漲用戶反饋收不到回答通知。原因消費者處理每條消息的邏輯里有調(diào)用遠程接口的操作比如發(fā)送郵件或短信這個遠程操作慢導(dǎo)致消費吞吐量遠低于生產(chǎn)量。解決先看消費者日志確認是遠程調(diào)用慢然后改消費者邏輯把發(fā)郵件的操作改成先入庫、再異步批量發(fā)送。同時給JmsListener配置并發(fā)消費者concurrency 5-10讓 5 到 10 個線程同時消費。這里要提醒一下消息的消費冪等性一定要做否則消費者重啟時重復(fù)消費用戶會收到兩條重復(fù)通知。在數(shù)據(jù)庫里加一個message_id唯一索引消費前先嘗試插入插入沖突說明處理過了直接跳過。6. 進階驗證用 Docker Compose 一鍵部署 壓測確認系統(tǒng)能上線項目開發(fā)完本地能跑通不等于能上線。我習(xí)慣把所有中間件編排進 Docker Compose在測試環(huán)境一鍵拉起然后立刻做一輪簡單的壓測確認瓶頸不在基礎(chǔ)設(shè)施層。Docker 的部署方案分兩層中間件層用 Docker Compose 管應(yīng)用層用 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中間件的參數(shù)說明TZ: Asia/Shanghai同時設(shè)置了容器的時區(qū)和 MySQL 的時區(qū)解決 5.1 提到的 serverTimezone 報錯Redis 加了appendonly yes開啟持久化避免容器重啟后緩存數(shù)據(jù)全部丟失ActiveMQ 映射了 61616服務(wù)端口和 8161控制臺端口控制臺可以用來排查消息堆積。應(yīng)用服務(wù)編排進同一個 Compose 文件里用 depends_on 指定依賴順序。網(wǎng)關(guān)服務(wù)對外暴露 8080其余服務(wù)只暴露給內(nèi)部容器網(wǎng)絡(luò)不映射宿主機端口這樣外部只能走網(wǎng)關(guān)服務(wù)端口不會被直接打穿。部署之后驗證系統(tǒng)能不能扛住基本流量我用一個很簡單的方法用ab壓測網(wǎng)關(guān)接口看吞吐量和錯誤率。# 壓測課程列表接口模擬 200 個并發(fā)每個并發(fā)發(fā) 1000 個請求 ab -n 200000 -c 200 -k \ -H token: your-test-token \ http://localhost:8080/api/course/list # 壓測結(jié)果重點看這 3 個指標(biāo) # Time per request: 平均每個請求耗時應(yīng)小于 200ms # Failed requests: 應(yīng)接近 0 # Requests per second: 吞吐量單機網(wǎng)關(guān)應(yīng)超過 500壓測結(jié)果如果 Failed requests 不為零先用dmesg看是不是端口隊列滿了再逐個排查是 MySQL 慢查詢還是 Redis 連接數(shù)不夠。我的經(jīng)驗是online_edu 這種體量的系統(tǒng)先確認基礎(chǔ)設(shè)施沒選錯型號再談業(yè)務(wù)優(yōu)化。做 docker compose 部署和壓測這件事我吃了不少虧。以前圖省事本地直接把服務(wù)跑起來就給測試看結(jié)果每次環(huán)境不一致要么 MySQL 配置不一樣要么 Redis 版本不同測試報 bug 我本地又復(fù)現(xiàn)不了最后發(fā)現(xiàn)是環(huán)境差異。用 Docker Compose 之后整個團隊的環(huán)境完全一致這個“在我電腦上能跑”的玄學(xué)問題算是徹底根治了。最后的習(xí)慣是每次改完代碼先跑一遍壓測接口確認響應(yīng)時間沒有明顯劣化再提交合并。壓測腳本放在項目的script目錄下隨手就能執(zhí)行不用臨時敲命令。希望這些步驟和踩坑記錄能幫你少走一段彎路把 online_edu 從本機跑通做到真正敢上線。本文還有配套的精品資源點擊獲取