
PlayEdu 这个项目我第一次接触是为了给一个做内部培训的团队评估「自建线上学习平台」的可行性。当时列了七八个开源候选最后把 PlayEdu 单独拎出来做了完整复现原因很简单它把「企业内训」这件事拆得足够细课程、课时、学员、学习进度、部门权限这些概念都是现成的不用自己从零设计一套领域模型。所谓复现不是把仓库 clone 下来跑个 demo 就完事而是要在自己的环境里把它完整地立起来——数据库建好、后端跑通、前端页面能登录、视频能上传能播放、学员端能看到进度最后再把它编排成一套可以交付的部署方案。这个过程里踩的坑相当密集从 JDK 版本对不上到跨域配置写错导致登录一直转圈再到视频切片播放器黑屏基本都是文档里一句话带过、实际要花两三个小时才能定位的问题。这篇内容面向三类人一是在做技术选型、想知道 PlayEdu 到底能干什么不能干什么的二是准备动手复现、需要一份能照着走的步骤清单的三是已经跑起来了但在某个环节卡住、想找排查思路的。我会把整个复现过程拆成项目认知、环境准备、后端启动、前端联调、容器化部署、核心链路走查、问题排查七个部分每个关键选择都会说明为什么这么做参数和配置给出可以直接抄的值也会明确标出哪些是我的环境特例、哪些是通用做法。1. 复现之前先把项目看透PlayEdu 的定位与技术底盘动手之前花半小时把项目定位搞清楚能省掉后面好几个小时的返工。很多人复现失败不是因为技术不行而是一开始的目标就设错了——想要一个功能齐全的在线教育 SaaS结果拿了个企业内训系统的架子自然怎么改都别扭。1.1 它到底解决什么问题哪些场景其实用不上PlayEdu 的核心定位是企业内部培训平台说白了就是给公司内部员工做课程学习、进度跟踪、考核管理的一套系统。它的设计重心在「管理」而不是「教学」管理员建课程、建课时、绑定部门、看学习数据学员登录后学课、看视频、记录进度。整套模型是围绕组织架构和权限来的不是围绕招生、付费、营销来的。这个定位决定了它适合什么、不适合什么。适合的场景很明确公司新员工入职培训、内部技术分享沉淀、合规类课程的统一学习、部门级的技能提升计划。这类场景的共同特征是用户量可控、课程内容以内生为主、需要按部门或角色做可见性控制、需要统计数据给管理层看。不适合的场景同样明确。如果你要做的是面向外部学员的付费课程平台那它缺订单、缺优惠券、缺分销、缺讲师结算这些不是加个字段能解决的得重新设计一整套交易模型。如果你要做的是直播教学它的重心在录播课时的组织上直播那块不是它的强项。还有一类容易误判的想做 K12 或高校教务系统那需要排课、选课、成绩、学籍PlayEdu 的组织模型是「部门—员工」跟「班级—学生」的逻辑差别很大。我当时的判断是这个团队的需求就是内部两百来号人的培训课程以录播为主需要按部门控制可见范围需要看谁学完了谁没学完。完全对得上所以值得投入时间做完整复现。这个判断动作很重要因为复现一个项目的时间成本通常是自己写一个简化版的 3 到 5 倍——你得读别人的代码、适配别人的假设、绕开别人的坑。提示评估阶段不必把项目跑起来先读仓库的 README、目录结构和数据表设计半小时内就能判断出「模型对不对」。模型不对技术再优雅也没意义。1.2 技术栈与模块划分的逐层拆解PlayEdu 的架构是典型的前后端分离拆成三块后端服务、管理端前端、学员端前端。这个三分布局本身就透露了设计意图——管理端和学员端的使用者完全不同交互密度、页面复杂度、性能要求都不一样硬塞进一个工程里会导致构建臃肿、权限耦合。后端是 Spring Boot 体系语言 Java构建工具用 Maven。数据层是 MySQL缓存用 Redis视频文件走对象存储抽象可以接本地磁盘也可以接云上的对象存储服务。视频播放采用切片方案把一个大视频文件切成若干小片段播放器按需加载这是做在线课程的标准做法——直接给一个几百兆的 MP4 让浏览器去拉手机端基本没法看拖动进度条更是灾难。管理端前端是 React 技术栈配 TypeScript带一套现成的组件库页面以表格、表单、弹窗为主典型的后台管理形态。学员端前端同样基于 React但更偏移动端 H5 的形态页面少、交互轻重点是课程的展示和播放器。这里有个选型上的细节值得说。后端为什么用 Java 而不是 Node 或者 Go站在复现者的角度这不是一个需要重新论证的问题但从使用者的角度得清楚Java 意味着部署时对 JVM 参数、内存占用要有概念一个 Spring Boot 应用的常驻内存基线通常在 500MB 以上小内存机器上必须调-Xmx同时 Java 生态的好处是配置项统一在application.yml里改造配置文件比改造代码容易得多这对复现非常友好。为什么把 Redis 单独拆出来而不是用本地缓存因为有几类数据天然需要跨实例共享登录态、验证码、视频播放的临时凭证。这些数据如果放在应用内存里一旦你部署两个实例做负载均衡用户就会莫名其妙掉登录。这个设计在单机复现时看不出价值但你要往生产环境推的时候它就是能不能水平扩展的分水岭。1.3 复现目标的三种档位选择复现这件事投入可以分三档最好一开始就定好档位否则容易陷入「反正都装了一半了不如把 XX 也配了」的无底洞。档位目标大概耗时适合谁快速验证档本地跑通登录和课程列表半天到一天只想确认项目是否值得选的评估者完整功能档前后端全跑通视频能上传能播放进度能记录两到三天要基于它做二次开发的团队可交付部署档容器化编排数据持久化能给别人演示再加一到两天要把它部署到内网长期使用的运维方我选的是第二档往第三档过渡理由是这个项目真正有价值的部分在「完整链路」上——视频上传、切片、播放、进度回传这条链路的复杂度远高于普通 CRUD只跑通登录页面看不出它的水平。而容器化编排虽然是额外的活但一旦你手工装过一次就会知道哪些步骤必须固化下来否则换台机器就得重来一遍。顺带说一句档位选择还决定了你要不要碰那些「可选依赖」。快速验证档完全可以只配本地磁盘存储、跳过复杂的对象存储对接完整功能档就必须把存储和视频处理配明白。这个取舍后面会详细讲。2. 环境与依赖把地基夯平再动手复现项目失败的最常见原因不是代码看不懂而是环境没对齐。版本差一个大版本号可能就是一个注解不识别、一个接口签名不匹配、一个配置项被废弃。我这台机器上前后装了三个版本的 JDK就是因为不同项目的要求不一样混着用迟早出事。2.1 版本矩阵一份能直接照抄的清单先说结论不要用最新版本。开源项目在发布时的依赖版本是锁定的你拿最新的 JDK 或者最新的 MySQL 去跑大概率会遇到兼容性问题。以仓库里pom.xml和package.json里声明的版本为准实在不确定就用长期支持版本。组件建议版本说明JDK8 或 11以pom.xml里的java.version为准这两个是绝大多数 Spring Boot 项目的安全区Maven3.6 及以上用来拉依赖和打包配好国内镜像源否则拉依赖能耗掉半天MySQL5.7 或 8.08.0 需要注意时区和认证插件后面细说Redis5.0 及以上单机够用不需要集群Node.js16 或 18前端工程对 Node 版本敏感16 是最稳的18 一般也没问题FFmpeg任意较新版本视频切片要用装完确保在 PATH 里能直接调关于 JDK 有个实操经验装多个版本不冲突关键是切换的时候验证一遍。Linux 和 macOS 上用update-alternatives或者环境变量JAVA_HOME切换切完立刻执行java -version echo $JAVA_HOME mvn -version三条命令的输出必须一致。我踩过一次坑java -version显示 11但mvn -version里显示的运行时是 8原因是 Maven 启动脚本读的是JAVA_HOME而不是 PATH 里的 java。结果就是编译时用的是 8打包出来的字节码版本不对运行时又报错来回折腾了快一个小时。MySQL 8.0 的两个坑要提前说。第一是时区MySQL 8.0 默认时区可能是 UTC后端写入的时间跟本地差 8 小时表现出来就是「刚创建的课程显示创建于 8 小时前」。解决办法是在 JDBC 连接串里显式加时区参数。第二是认证插件8.0 默认用caching_sha2_password老版本的驱动连不上要么升级驱动要么把用户改成mysql_native_password。这两个问题都属于「不知道的时候能查一整天知道的时候改一行就好」的类型。2.2 数据库与缓存的初始化实操数据库创建工作分三步走建库、导表、建用户。建库的时候字符集一定要显式指定别依赖默认值CREATE DATABASE playedu DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;utf8mb4而不是utf8这个区别在中文场景下可能不明显但只要课程名或者学员昵称里出现了特殊字符比如某些符号或者表情utf8会直接报错。这类问题在测试阶段很难被发现等到线上用户输入了才会暴露属于典型的「早配早安心」。导表就是执行仓库里的 SQL 脚本。这里有个顺序问题如果脚本里有外键约束导入顺序错了会直接失败。我的做法是先关掉外键检查再导SET FOREIGN_KEY_CHECKS 0; -- 执行建表和插入语句 SET FOREIGN_KEY_CHECKS 1;导入完成后验证两件事表数量对不对以及有没有初始化数据。管理端的初始账号通常在user之类的表里有默认记录密码是经过哈希处理的别想着自己插一条明文记录进去得找到项目里生成密码的方式或者看文档里给的默认账号。Redis 的初始化简单得多装完启动改两件事一是设置密码requirepass二是如果要跨机器访问把bind从127.0.0.1改成0.0.0.0并在防火墙放行。不要在生产环境用无密码的 Redis这个组件被扫到空口令然后写入异常数据的案例太多了。本地复现图省事可以不开密码但配置里得留出位置方便后面切环境。注意Redis 的默认数据库索引是 0很多项目会指定用第几个库。如果你本地同时跑着好几个项目都用 0 号库会发生 key 冲突表现是登录态互相覆盖。建议给每个项目分配独立的库号或者用不同的 key 前缀。2.3 对象存储与视频处理依赖的降级方案这是复现过程中最容易卡住的地方也是最能体现「理解设计意图」的地方。PlayEdu 的视频存储走的是对象存储抽象层它支持本地磁盘也支持云上的对象存储服务。设计成抽象层的原因是企业内部部署的环境差异极大有的公司允许用公有云存储有的公司要求数据必须落在自己的服务器上。抽象层让这两种情况都能覆盖。复现时的策略很清楚先用本地磁盘跑通链路再考虑换云存储。理由有三个本地磁盘零配置零成本不需要申请密钥不依赖外网出了问题排查简单文件就在那儿直接看链路验证为目的存储介质是次要变量。本地存储要配的东西一个存储根目录比如/data/playedu/storage以及一个可以通过 HTTP 访问的路径前缀。后者的作用是让前端拿到文件地址时能直接访问而不是拿到一个本地文件路径——浏览器可访问不了服务器的/data/xxx。这里要引出一个必须配的组件静态资源映射。后端需要把存储目录映射成 HTTP 路径或者用 Nginx 直接代理这个目录。两种做法各有优劣用后端映射配置简单但大文件传输会占用应用线程用 Nginx 代理性能好但多了一个需要维护的组件。复现阶段我建议先用后端映射等要上生产了再换成 Nginx因为 Nginx 的配置多一层会干扰初期的故障定位。FFmpeg 这块要注意的是调用方式。项目里通常是通过ProcessBuilder或者命令行方式调外部 FFmpeg 可执行文件这意味着运行应用的账号必须能在 PATH 里找到它。如果你用容器部署基础镜像里没装 FFmpeg视频上传后就会卡在「处理中」永远不动。这个现象很有迷惑性——上传接口返回成功数据库里也有记录但状态字段一直是处理中日志里可能只有一行不起眼的「command not found」。验证 FFmpeg 是否可用的命令which ffmpeg ffmpeg -version如果是在容器里跑进入容器执行同样的命令。这一步花 30 秒能省掉后面半小时的困惑。3. 后端编译启动从源码到能响应请求后端是整个系统的核心前端页面再漂亮接口不通就是白屏。这一节的重点不是「怎么点运行按钮」而是「配置改哪些、为什么改、改完怎么验证」。3.1 目录结构与配置文件的真实关系拉下代码先别急着编译花十分钟看看目录结构能做到心里有数。典型的 Spring Boot 多模块或者单模块工程大致是这样分的controller层接请求、service层放业务、dao或mapper层碰数据库、model或entity放数据对象、config放各种配置类、util放工具。看目录结构有个实用技巧先找config包。这个包里藏着项目所有的「假设」——它假设你有什么中间件、假设你的文件存在哪、假设你的跨域怎么配。复现时遇到的配置问题八成能在这个包里找到答案比翻文档快。然后是配置文件。Spring Boot 项目的配置一般在application.yml或application.properties还可能按环境拆成application-dev.yml、application-prod.yml。一定要先确认当前激活的是哪个 profile否则你改了半天application.yml实际生效的却是application-dev.yml那种改完没反应的挫败感相当消耗耐心。确认方式有两种看启动日志里的The following profiles are active: xxx或者直接看配置文件里spring.profiles.active的值。我一般两个都看因为有时候启动脚本里会用命令行参数覆盖配置文件导致你以为的和实际跑的不一样。3.2 配置文件改造实录含关键参数解释配置改造的核心原则只改必要的其余保持默认。改得越多出问题时越难判断是哪里引入的。必须改的通常是这几块数据库连接、Redis 连接、服务端口、文件存储路径。server: port: 9898 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/playedu?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: playedu password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: 127.0.0.1 port: 6379 password: database: 3这段配置里几个参数值得单独解释因为它们直接对应前面提到的坑。serverTimezoneAsia/Shanghai解决的是时间偏差问题。不写这个参数MySQL 8.0 驱动可能按 UTC 解释时间导致所有时间字段偏移 8 小时。allowPublicKeyRetrievaltrue配useSSLfalse一起用解决的是 MySQL 8.0 认证插件的问题。生产环境应该开 SSL 并用更严格的认证方式但本地复现这么配能少走弯路。database: 3是 Redis 的库号我特意没用默认的 0。原因前面说过本机多项目并存时容易串数据。这个值随便选只要跟其他项目不撞就行。port: 9898是我自己挑的不是项目默认值。挑它的原因是本机 8080 被别的服务占了换个不常用的端口省得冲突。你完全可以用默认值只要确保没被占用——检查命令是lsof -i:8080或者netstat -ano | grep 8080。文件存储相关的配置需要看项目具体怎么定义不同版本字段名可能不一样。常见的形态是配一个存储类型本地/云、一个本地根路径、一个访问域名前缀。填的时候注意路径要用绝对路径相对路径在不同启动目录下行为不一致是个隐藏的坑。提示改配置前先把原文件备份一份命名成application.yml.bak。配置改崩了直接还原比逐行回滚快得多。这个习惯在复现陌生项目时能救命。3.3 编译打包与启动验证日志怎么读编译用 Mavenmvn clean package -DskipTests加-DskipTests是有意的。开源项目的测试用例经常依赖特定的测试数据库或者外部服务本地跑大概率失败而这个失败跟你的复现目标毫无关系。跳过测试能省时间等系统整体跑通了再回头单独跑测试。当然如果你是要基于它做二次开发测试该跑还得跑只是别放在第一步。打包成功后会生成target/xxx.jar启动它java -jar target/xxx.jar --spring.profiles.activedev或者更稳妥的带内存参数的形式java -Xms512m -Xmx1024m -jar target/xxx.jar-Xms和-Xmx分别是初始堆和最大堆。复现阶段给 1G 足够但如果你的机器只有 2G 内存得往下调否则启动时可能直接被系统 OOM Killer 干掉表现是「启动到一半进程没了日志也没报错」。启动日志要重点看三段。第一段是启动横幅之后的配置加载信息确认 profile、端口、数据源地址是不是你期望的。我曾经因为端口被改成了 9090 而不是 9898对着错误的地址调试了二十分钟。第二段是数据源和 Redis 的连接信息。如果数据库连不上这里会有明确的Communications link failure或者Access denied比后面业务报错时的堆栈清晰得多。第三段是最后的启动完成标志通常是Started XxxApplication in X.XXX seconds。看到这行并且没有异常堆栈才算真正起来了。有些项目会在启动时做数据初始化如果初始化脚本有问题启动会失败在这一步。起来之后立刻验证一个最简单的接口curl -i http://127.0.0.1:9898/api/v1/health或者直接访问登录接口看返回结构。返回 200 或者返回一个结构化的 JSON 错误比如参数校验失败都说明服务正常。返回 404 可能是路径前缀不对返回 500 说明代码跑起来了但业务有问题连不上则是服务根本没起。这一步的意义在于把「服务是否活着」和「业务是否正确」两个问题切开。很多人跳过这步直接开前端结果前端报网络错误不知道是后端没起还是接口路径错了排查范围一下子放大好几倍。4. 前端工程与联调让页面真正跑起来前端这一块管理端和学员端的处理方式不太一样但基础流程是相通的装依赖、配代理、起服务、调接口。4.1 依赖安装阶段的常见卡点进入前端目录第一件事是看package.json里的engines字段和依赖版本。如果没写engines就看node-sass、vite、webpack这些构建工具的版本要求它们对 Node 版本最敏感。安装依赖npm install或者用项目指定的包管理器。这一步卡住的原因通常是三个。一个是网络问题依赖拉不下来或者极慢。解决办法是配镜像源npm config set registry https://registry.npmmirror.com这不是敏感操作就是换个下载源国内环境标配。第二个是原生模块编译失败典型报错是node-gyp相关的一堆输出。原因通常是 Node 版本太新某个依赖的预编译包没有对应版本。解决办法是降 Node 版本用 nvm 管理多个版本会方便很多。第三个是锁文件冲突。如果仓库里同时存在package-lock.json和yarn.lock说明维护者可能混用过包管理器依赖树可能不一致。这种情况下优先按文档指定的包管理器来别自己换。装完依赖先别急着npm run dev先看看package.json里的scripts有哪些命令。除了dev通常还有build、lint有些项目还有dev:mock之类的。看清楚再执行避免起了个 mock 模式的服务然后对着假数据排查半天。4.2 接口代理与跨域的三层处理跨域是前端联调绕不开的话题处理方式有三层按推荐顺序排。第一层是开发服务器代理。前端开发服务器webpack-dev-server 或 vite支持配置代理把/api开头的请求转发到后端。这样浏览器看到的是同源请求根本没有跨域问题。// vite.config.ts 示例 server: { port: 5173, proxy: { /api: { target: http://127.0.0.1:9898, changeOrigin: true } } }changeOrigin: true这个参数很关键它的作用是把请求头里的 Host 改成目标地址。不加的话某些后端会因为 Host 不匹配而拒绝请求报错信息往往很隐晦。第二层是后端 CORS 配置。如果代理没配或者不好配就得让后端放行。Spring Boot 里通常是一个配置类允许特定来源、允许携带凭证。第三层是同源部署。生产环境常见做法是用 Nginx 把前端静态文件和后端接口放在同一个域名下前端访问/api/xxxNginx 转发到后端。这样天然没有跨域也是我最终采用的方案。为什么按这个顺序推荐因为代理和同源部署都是「配置层面解决」不影响代码CORS 是「代码层面解决」会引入一个需要维护的配置类。能用配置解决的就不要动代码。这里有个坑要提醒允许跨域时如果开了allowCredentials就不能用*作为允许来源。浏览器有这条硬性限制配错了会看到「凭证模式下不允许通配符来源」的报错。复现时如果登录一直失败、控制台有这个报错就是这儿的问题。解决办法是指定具体来源或者干脆走代理方案。4.3 管理端与学员端的差异化调试两个前端虽然技术栈相同但调试重点完全不一样。管理端的调试重点在权限和表单。登录进去之后先看菜单是不是全的有些项目会根据角色动态生成菜单如果角色配置不对会看到一片空白。然后试一遍核心的增删改查建一个课程、加一个课时、传一个视频。这一步能暴露后端接口的大部分问题。学员端的调试重点在播放和进度。这一端页面少但交互重尤其是视频播放器。调试时要打开浏览器开发者工具的网络面板观察切片请求.m3u8和.ts文件是不是 200有没有 404 或者 403。403 通常意味着播放凭证或者签名有问题404 则是文件路径不对或者切片没生成。两端联调还有一个容易忽略的点它们可能用不同的接口前缀或者不同的认证方式。管理端用管理员 token学员端用学员 token两套认证逻辑。调试时别拿管理端的 token 去调学员端接口会得到莫名其妙的权限错误。我在这块的实际做法是先把管理端跑通用它造出完整的测试数据课程、课时、视频、学员账号再用这些数据去验证学员端。这样学员端出问题时可以确定数据是好的问题在展示层或者权限层缩小了排查范围。5. 容器化部署把手工步骤固化成编排手工装一遍的价值在于你知道了所有步骤和坑容器化的价值在于把这些步骤变成可重复执行的代码。这一节的前提是你已经手工跑通过一次否则直接上容器出问题时你分不清是容器配置错了还是应用本身没配好。5.1 编排文件的设计思路与逐段说明编排的核心思路是按依赖顺序组织服务并且用健康检查串起来。数据库和缓存是基础应用依赖它们前端又依赖应用。用depends_on只能保证启动顺序不能保证「对方真的准备好了」所以要配健康检查。services: mysql: image: mysql:8.0 container_name: playedu-mysql restart: always environment: MYSQL_ROOT_PASSWORD: 复杂一点的密码 MYSQL_DATABASE: playedu TZ: Asia/Shanghai command: - --character-set-serverutf8mb4 - --collation-serverutf8mb4_general_ci volumes: - ./data/mysql:/var/lib/mysql - ./init:/docker-entrypoint-initdb.d healthcheck: test: [CMD, mysqladmin, ping, -h, 127.0.0.1] interval: 10s timeout: 5s retries: 10 redis: image: redis:6 container_name: playedu-redis restart: always command: redis-server --appendonly yes --requirepass 你的密码 volumes: - ./data/redis:/data healthcheck: test: [CMD, redis-cli, ping] interval: 10s timeout: 5s retries: 10 server: build: ./server container_name: playedu-server restart: always depends_on: mysql: condition: service_healthy redis: condition: service_healthy environment: TZ: Asia/Shanghai volumes: - ./data/storage:/data/storage ports: - 9898:9898逐段说明几个关键决策。TZ: Asia/Shanghai在 MySQL 和应用容器里都配了这是为了解决容器默认 UTC 时区导致的时间偏差。容器里配时区比在应用里转时间更彻底因为数据库里的CURRENT_TIMESTAMP也会跟着变。volumes里的./init:/docker-entrypoint-initdb.d是 MySQL 镜像的特性这个目录下的.sql或.sh文件会在首次初始化时自动执行。注意是首次——如果./data/mysql目录已经有数据了这个初始化脚本不会跑。很多人改了初始化脚本发现没生效就是这个原因得先清空数据目录。healthcheck配了condition: service_healthy这是编排文件里最值得写的一段。不配的话应用容器可能比数据库先就绪连不上数据库直接启动失败然后restart: always让它不停重启日志里刷一片连接异常。配上之后应用会等到数据库真正可连接才启动稳定性提升非常明显。5.2 镜像构建优化与体积控制后端镜像的 Dockerfile 里有两个地方值得优化。一个是分层构建。Maven 项目可以先只拷贝pom.xml拉依赖再拷贝源码编译。这样源码改动时依赖层能命中缓存构建速度快很多。FROM maven:3.8-openjdk-8 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests -B FROM openjdk:8-jre-slim RUN apt-get update apt-get install -y ffmpeg rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --frombuilder /build/target/*.jar app.jar EXPOSE 9898 ENTRYPOINT [java, -Xms512m, -Xmx1024m, -jar, app.jar]这里最关键的一行是apt-get install -y ffmpeg。前面提过视频处理依赖外部 FFmpeg 可执行文件运行镜像里没装视频上传后会永远卡在处理中。这个问题在容器化时特别容易漏因为本地开发时 FFmpeg 是宿主机装的你根本意识不到应用依赖它。第二个优化点是用 JRE 而不是 JDK作为运行镜像。JDK 里有编译器、调试工具运行阶段用不上能省一两百兆。如果你的应用需要动态编译极少见才需要保留 JDK。构建完看下镜像大小docker images | grep playedu后端镜像控制在 500MB 以内算正常如果超过 1G通常是基础镜像选大了或者缓存没清。前端镜像用 Nginx 做基础加上构建产物一般几十兆。5.3 数据持久化与升级回滚容器化最容易犯的错误是数据没挂出来。容器删了数据就没了这在测试环境可能无所谓但一旦有人真的在上面建了课程传了视频损失就大了。需要持久化的有三处MySQL 数据目录、Redis 的 AOF 文件、视频存储目录。前两个配了 volume 就行第三个要特别注意权限——容器内的应用进程和宿主机目录的属主可能对不上导致写入失败。表现出来是上传接口报错或者文件写不进去日志里的关键词是Permission denied。解决办法是在编排文件里指定用户或者提前在宿主机上把目录属主改成容器内用户对应的 UID。我更推荐后者因为更直观mkdir -p ./data/storage chown -R 1000:1000 ./data/storage升级和回滚的策略要说清楚。升级流程是拉新代码、重新构建镜像、docker compose up -d重启。这里有个隐患——如果新版本改了数据库结构而老数据还在可能启动失败。所以升级前必须做两件事备份数据库看新版本的变更说明里有没有 SQL 脚本要手工执行。回滚就更简单镜像打个 tag 保留旧版本出问题就docker compose指回旧的 tag。所以构建时一定不要只用latest要带版本号或者日期docker build -t playedu-server:20240115 ./server这个小习惯在真出问题时能救命因为latest被覆盖后你想回滚都找不到旧镜像。6. 核心链路走查从建课到学员看完一节课前面都是「让它跑起来」这一节是「让它真的能用」。这条链路走通了才算真的复现成功。6.1 课程与课时的数据模型理解PlayEdu 的课程模型是两层课程Course和课时Chapter 或 Hour。一个课程下挂多个课时课时是学习的最小单元也是视频的载体。这个设计跟大多数在线学习系统一致因为课程的粒度太粗——一个课程可能有几十个视频学员需要记录到每个视频的观看进度。除了这两层还有几个关联对象值得注意。课程分类用来做内容组织通常是树形结构。部门或角色用来做可见性控制决定了谁能看到哪些课程。学员与课程的关联表记录学习状态包括是否开始学、学到哪、是否完成。理解这个模型对二次开发很重要。比如你想加一个「课程必修」的概念就明白应该加在关联表上而不是课程表上因为「必修」是学员维度的属性不是课程本身的属性。想加「课程标签」则应该新建一张标签表和关联表而不是在课程表上加个逗号分隔的字符串字段。我在走查时做了一件事把每张表的字段和索引都过了一遍。这一步的收获是发现了几个值得注意的设计。比如学习进度表上应该有「学员 ID 课时 ID」的联合唯一索引否则同一个学员反复上报进度会插入重复记录统计数据就不准了。再比如课程表上通常有排序字段和状态字段前者控制展示顺序后者控制上下架。6.2 视频上传、切片与播放链路这是整个系统技术含量最高的部分也是最容易出问题的部分。完整链路是上传原文件 → 存储 → 触发切片 → 生成索引文件 → 播放器拉取。第一步上传常见问题是文件大小限制。这个限制有好几层Nginx 的client_max_body_size、Spring Boot 的spring.servlet.multipart.max-file-size、以及应用自己可能有的校验逻辑。任何一层没放开大文件上传就会失败报错可能是 413 也可能是连接重置。三层都要配值是层层递进的关系最外层要大于等于内层。第二步存储前面讲过本地磁盘和对象存储的取舍。这里补充一个细节文件路径的生成策略。常见的是按日期分目录2024/01/15/xxx.mp4好处是单目录下的文件数量不会爆炸文件系统查找效率稳定。如果你自己改这块别把所有文件平铺在一个目录下文件上万之后ls都会卡。第三步切片核心是把 MP4 转成 HLS 格式生成一个.m3u8索引文件和一堆.ts片段。FFmpeg 的典型命令形态是ffmpeg -i input.mp4 \ -c:v libx264 -c:a aac \ -hls_time 10 \ -hls_list_size 0 \ -hls_segment_filename output_%03d.ts \ output.m3u8-hls_time 10表示每个切片 10 秒。这个值是个权衡太小则切片数量多请求数上去了太大则起播慢、拖动响应迟钝。10 到 15 秒是比较通用的选择。-hls_list_size 0表示索引文件里保留全部切片适合点播如果配了非零值索引文件只保留最近几个切片那是直播的用法用在点播上会导致前面的内容看不了。第四步播放播放器需要拿到.m3u8的地址。这个地址必须是浏览器可访问的不能是服务器本地路径。如果播放器黑屏但网络面板显示 m3u8 请求 200那问题在切片文件本身或者跨域如果 m3u8 请求就是 404那是路径映射的问题。注意切片是异步的。上传接口返回成功不代表切片完成中间有个「处理中」的状态。调试时要盯着日志看切片任务的执行情况别看到状态没变就以为逻辑写错了。大文件切片耗时可能几十秒到几分钟取决于机器性能和视频长度。6.3 学习进度上报与权限校验进度上报看起来简单实际上是并发和一致性容易出问题的地方。上报的时机通常是播放器的定时回调比如每 15 秒一次加上暂停、关闭页面的时机。前者保证进度持续更新后者保证退出时能记住最后位置。实现上有两个策略各有取舍。覆盖写是每次上报都把进度字段更新成最新值简单直接但会丢失中间记录。增量记录是每次上报插一条明细最后聚合算总进度数据完整但存储量大。多数项目用的是覆写加一个「最大进度」字段取max(当前值, 上报值)防止用户拖回进度条开头导致进度倒退。权限校验这块有三层要分清。第一层是登录态校验没登录不能调接口。第二层是数据归属校验学员只能看自己有权限的课程。第三层是操作校验比如学员不能调管理端的接口。第二层最容易出漏洞。常见错误是只在前端做了菜单隐藏后端接口没校验懂点技术的用户直接构造请求就能看到不该看的课程。复现时建议手动验证一下拿一个没有某课程权限的学员账号直接调课程详情接口看是否返回 403 或者空数据。这一步花两分钟能确认权限设计是否扎实。7. 问题排查实录那些文档里不会写的东西前面几节讲的都是「顺利路径」这一节讲的是「不顺利的时候怎么办」。我把复现过程中遇到的问题整理成了速查表按现象分类方便对号入座。7.1 启动与依赖类问题速查表现象可能原因定位与解决编译报错无效的目标发行版JDK 版本与pom.xml声明不一致检查mvn -version的运行时版本切JAVA_HOME启动报Access denied for user数据库账号密码或权限不对用命令行客户端连一次确认账号能登录该库启动卡住不报错也不完成数据库连接超时或初始化脚本在跑看日志最后一行检查网络连通性报Unknown database库名拼错或没建库执行show databases确认报时区相关异常MySQL 8 时区参数缺失连接串加serverTimezoneAsia/Shanghai端口占用启动失败端口被其他进程占用lsof -i:端口找到进程换端口或杀进程容器内启动 OOM 被杀内存限制小于 JVM 堆需求降-Xmx或提高容器内存上限Redis 连接超时密码没配或 bind 限制检查requirepass和bind配置这张表里我花时间最多的是「启动卡住不报错」那一行。第一次遇到时完全没头绪进程活着、CPU 不高、日志停在某一行不动。后来发现是连接池在等一个连不上的数据库超时时间设得很长。解决办法是先把超时时间调短让错误快点暴露出来而不是干等。7.2 功能类问题的定位路径功能类问题的特点是「服务在跑但结果不对」定位需要分层缩小范围。登录一直转圈或者提示失败按这个顺序查浏览器网络面板看请求有没有发出去看响应状态码401 是认证失败500 是服务端异常如果是 401看请求头里有没有带 token如果是跨域报错回到 4.2 节的三层处理最后看后端日志有没有异常堆栈。页面白屏先看浏览器控制台有没有 JS 报错。白屏最常见的原因是路由配置或者接口返回结构跟组件期望的不一致。如果控制台干净看网络面板里静态资源是不是 404那是构建产物路径配置的问题比如publicPath设错了。视频上传后一直处理中这一条前面提过多次排查路径是确认 FFmpeg 可执行which ffmpeg看存储目录有没有原文件看日志里切片任务的输出检查切片输出目录的写权限。这四步走完基本能定位。视频能上传但播放黑屏看网络面板的 m3u8 请求404 是路径映射问题403 是权限或签名问题200 但播放器仍黑屏则看 ts 片段请求或者看浏览器控制台的解码错误。还有一种情况是视频编码格式不被浏览器支持这时候需要重新转码成 H.264 AAC 的组合兼容性最好。学习进度不更新先确认前端有没有发上报请求网络面板找再看后端有没有收到日志最后看数据库有没有写进去。三步定位法能快速判断是前端没发、后端没处理还是存储没成功。7.3 几条踩坑经验与优化建议第一条经验日志是你的第一现场别急着改代码。复现陌生项目时大部分问题的答案就在日志里只是需要知道往哪看。我的习惯是启动的时候开两个终端一个跑服务一个tail -f日志文件这样出问题能立刻看到不用来回翻。第二条经验改动要一次只改一处。我早期犯的错是一次改五个配置项然后启动失败完全不知道是哪个改坏的。后来养成习惯一次改一处启动验证一次虽然慢一点但总的排查时间反而更短。第三条经验善用最小复现。比如视频播放有问题别在完整课程里调单独造一个短小的测试视频十几秒、低分辨率走一遍链路。变量少了问题就暴露得快。我最后定位到切片参数问题就是用了一个 10 秒的测试视频几秒钟就切完了能立刻看到结果。第四条是关于性能的给 JVM 和容器设合理的内存上限。默认的 JVM 堆是根据宿主机内存算的容器里如果不显式设-XmxJVM 可能认为可用内存很大然后被容器限制杀掉。这类问题表现得非常诡异进程突然消失、没有异常日志。显式设置堆大小同时容器内存上限设为堆的 1.5 倍左右留出元空间和直接内存的空间。第五条是关于文档的把自己踩的坑记下来。复现过程中遇到的问题、解决方式、涉及的配置随手记成一个清单。这个清单的价值在于下次换机器部署或者给别人交接时它就是你自己的「避坑指南」。我这次的清单有二十多条覆盖了版本、配置、权限、网络各个方面比原始文档更贴合实际环境。最后分享一个我觉得挺有用的小技巧。复现完成后把整个环境做成一份快照或者导出编排文件加数据目录然后删掉本地环境重新部署一次。这个过程能验证你的部署方案是否完整——如果漏了某个手工步骤第二次部署就会暴露出来。我第一次做这个验证时就发现漏了创建存储目录这一步因为第一次是手动建的第二次没建上传直接失败。这种验证做一次方案的可靠性会上一个台阶。