大家做 Node.js 项目练手,最常见的选择就是博客、商城、管理系统这三件套。做得多了就会发现,这些项目的业务逻辑其实是高度相似的,无非是增删改查加个登录注册,很难真正体现 Node.js 在 I/O 密集、实时交互、流式传输这些场景下的独特优势。所以当我决定做一个课程网站时,我给自己定了几个目标:一是业务复杂度要足够,二是要覆盖 Node.js 的核心应用场景,三是做完之后能真正上线给人用,而不是停留在演示阶段。
这个项目我定位成"精品课程网站",跟普通的视频站或者资料站的区别在于:课程有完整的章节体系、有购买/报名流程、有用户学习进度追踪、有后台管理端。从技术形态上看,它是一个前后端分离的单页应用,服务端用 Node.js 提供 RESTful API,前端用 Vue 3 构建,数据库选 MySQL,缓存用 Redis。整个项目做下来,从需求梳理、数据库设计、接口开发、前端联调,到部署上线、性能优化,走了一整条完整的链路。
这篇文章我就把整个项目的核心设计和实现细节拆开来讲,重点放在那些常规教程里不会细说的环节——比如为什么这么设计表结构、为什么用这种鉴权方式、视频流是怎么处理的,部署时哪些坑必须提前避开。如果你是准备拿 Node.js 做项目练手、或者正在筹备自己的作品集,这篇应该能帮你少踩不少坑。
1. 项目整体设计与技术选型思路
1.1 核心需求解析
在写第一行代码之前,我先把需求拆了一遍。所谓"精品课程网站",用户端的核心场景是:访客浏览课程列表、查看课程详情、试看部分视频、注册登录、购买课程、开始学习并记录进度。管理端的核心场景是:管理员维护课程分类、录入课程章节、上传视频、处理订单、查看用户数据。
这个需求拆下来,可以归纳出四个核心模块:
- 用户体系:注册、登录、信息维护、权限区分(普通用户/管理员)。
- 课程体系:分类、课程信息、章节列表、视频管理、上下架状态。
- 交易与学习:下单购买、订单状态管理、学习进度记录。
- 管理后台:数据看板、课程管理、订单管理、用户管理。
这个需求模型有一个关键点值得注意:课程内容的部分(章节和视频)和交易部分(订单)是互相依赖的,但又是相对独立的。这意味着在数据库设计时,需要把课程内容表和订单表、学习记录表进行合理的解耦。这样做的好处是,将来如果我想增加一个"讲师入驻"的角色,不需要改动订单和学习记录的结构,只需要在课程内容这一侧扩展关联字段就行。
1.2 为什么选 Node.js + Express + MySQL
有的同学可能会问:做这种内容型网站,用 Python 的 Django 或者 Java 的 Spring Boot 不行吗?当然可以,但 Node.js 在这个场景下有它非常契合的一面。
首先是 I/O 密集型的天然优势。课程网站的流量大头是视频流媒体和接口请求的高并发读取,比如课程详情的访问、视频地址的获取、学习进度的上报。这些操作几乎没有 CPU 密集计算,全部是网络 I/O 和数据库 I/O。Node.js 的事件驱动 + 非阻塞 I/O 模型在这种场景下可以用更少的系统资源扛住更大的并发,这是它的看家本领。
其次是前后端统一语言的效率红利。我的前端选了 Vue 3,整个项目一套 JavaScript 打天下。接口定义、数据结构、类型约定都在同一个语言体系里,沟通成本极低。特别是处理视频上传、学习进度上报这类带复杂状态流转的逻辑,前后端联调时我可以直接读懂对方的上下文,不需要在两个语言之间来回切换思维。
第三是生态成熟度。Express 这个老牌框架虽然性能上不如 Fastify 或者原生 async_hooks 那套极致优化,但它最大的优点是稳定、中间件资源丰富、社区踩坑案例多。对于一个内容型管理系统,稳定性比极限性能重要得多。我最终选择 Express 4,搭配 mysql2 驱动和 Redis 缓存,这套组合无论是资料查询还是问题排查,都足够省心。
存储方面选 MySQL 而不是 MongoDB,原因也很简单:课程网站的订单、支付流水、用户余额这类数据有强一致性和事务需求。MySQL 的事务能力,加上我用外键约束保证课程分类和课程内容之间的完整性,都比文档型数据库更让人放心。视频本身的元数据(时长、大小、清晰度)适合用行来存储和统计,用不着文档模型的灵活性。
1.3 项目目录与工程化设计
项目的工程结构我花了点心思,目标是做到"团队协作风格"的清晰度,同时又不过度设计。
course-platform/ ├── src/ │ ├── config/ # 环境变量与全局配置 │ ├── routes/ # 路由层 │ ├── controllers/ # 控制器层(业务逻辑) │ ├── services/ # 服务层(核心业务处理) │ ├── models/ # 数据模型层 │ ├── middlewares/ # 中间件(鉴权、错误处理、参数校验) │ ├── utils/ # 工具函数 │ └── app.js # 应用入口 ├── frontend/ # Vue 3 前端工程 ├── uploads/ # 本地上传的文件资源 ├── logs/ # pm2 日志目录 └── package.json这个分层的核心思路是:路由层只做参数接收和转发,控制器层做请求的编排与响应,服务层写具体的业务规则,模型层负责和数据库打交道。四层各司其职,哪里出了问题,一眼就能定位到是参数问题、业务逻辑问题还是 SQL 问题。我之前见过不少同学把业务逻辑全写在路由回调里,一个路由函数两三百行,数据库查询和业务判断揉成一团,调试的时候想死的心都有。
2. 数据库设计与核心模块拆解
2.1 关键数据表设计逻辑
数据库的设计是整个项目的基石,这块我画了比较长时间来打磨表结构。最终核心表一共有 8 张:用户表、角色表、课程分类表、课程表、课程章节表、订单表、学习记录表、视频资源表。下面挑几个关键的设计思路说。
用户表需要区分普通用户和管理员,但没有必要设计复杂的 RBAC 权限模型。我用一个role字段(tinyint)区分,0 表示普通用户,1 表示管理员。对于课程网站这种规模的应用,复杂的权限系统反而增加维护成本。用户表里我还冗余了nickname、avatar、signature这些资料字段,避免在展示用户信息时需要频繁关联查询。
课程表是信息聚合的核心,包含课程标题、封面图、简介、分类 ID、价格、讲师信息、上架状态、总学时、学习人数、评分等字段。其中有两个字段的设计值得聊一下:status控制上下架,recommend控制是否首页推荐。把业务状态全部用整数字段表达,查询时非常灵活,管理后台只需一个 update 语句就能完成对课程状态的调整。
订单表的设计是整个交易体系的重中之重。除了order_no(订单号)、user_id、course_id、amount(金额)这些基础字段外,我还设计了status(0 待支付 / 1 已支付 / 2 已取消 / 3 已退款)和pay_time、expire_time。这里有一个经验:订单号和支付状态必须有时间戳配套。当用户支付成功后,回调里需要写支付完成时间,后续做财务对账时才能知道支付耗时和异常单。
学习记录表的粒度是"用户+章节"。核心字段是user_id、course_chapter_id、watch_duration、total_duration和is_finished。前端播放器会定时上报当前的观看进度,后端只需更新watch_duration,当播放比例超过 95% 时把is_finished置为 1。这个表是用户学习进度页面和"继续学习"功能的直接数据来源。
2.2 事务与数据一致性方案
课程网站有一个核心交易链路:用户下单 -> 支付回调 -> 更新订单状态 -> 给用户开通课程权限。在这个链路中,事务是必须的。
// 支付成功回调的处理逻辑 async function handlePayCallback(orderNo, transactionId) { const connection = await db.getConnection(); try { await connection.beginTransaction(); // 1. 查询订单并锁定行 const [order] = await connection.query( "SELECT * FROM orders WHERE order_no = ? FOR UPDATE", [orderNo] ); if (!order || order.status !== 0) { throw new Error("订单不存在或状态异常"); } // 2. 更新订单状态 await connection.query( "UPDATE orders SET status = 1, pay_time = NOW(), transaction_id = ? WHERE id = ?", [transactionId, order.id] ); // 3. 给用户开通课程权限(插入购买记录) await connection.query( "INSERT INTO user_courses (user_id, course_id, created_at) VALUES (?, ?, NOW())", [order.user_id, order.course_id] ); await connection.commit(); } catch (err) { await connection.rollback(); throw err; } finally { connection.release(); } }这里我使用了SELECT ... FOR UPDATE来锁定订单行,防止并发回调导致同一订单被重复处理。这是一个很重要的细节:如果不用行锁,两个同时到达的支付回调可能都读到status = 0,然后都执行更新操作,最终给用户开两次课程虽然问题不大,但如果业务里涉及发放优惠券或者返利,就会造成资金损失。
2.3 Redis 缓存策略
课程网站的读多写少特征非常明显,课程列表、课程详情这类数据如果不加缓存,数据库压力会很大。我的策略是两级缓存:
- 第一级:课程列表页的查询结果,缓存 key 为
course:list:{categoryId}:{page}:{size},缓存时间 10 分钟。 - 第二级:热点课程详情,缓存 key 为
course:detail:{id},缓存时间 30 分钟。
Redis 缓存和数据库的一致性,我采用主动失效策略:管理后台更新课程内容后,主动删除对应缓存 key,下一次读取时自动回源数据库并重建缓存。这个方案比设置过期时间来保证一致性更实时,也比双写一致性的方案简单可靠。
// 获取课程详情(带缓存) async function getCourseDetail(courseId) { const cacheKey = `course:detail:${courseId}`; const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); } const course = await CourseModel.findById(courseId); if (!course) { return null; } // 查询章节列表 const chapters = await ChapterModel.listByCourseId(courseId); const data = { ...course, chapters }; // 写入缓存,过期时间 30 分钟 await redis.setex(cacheKey, 1800, JSON.stringify(data)); return data; }一个提醒:缓存的数据结构中如果包含用户维度的字段(比如"是否已购买"、学习进度),建议拆成独立的缓存,不要和课程信息混在一起。否则一个用户的学习进度变化会导致所有用户看到的课程详情都重新回源,反而增加数据库压力。
3. 后端接口开发与鉴权方案
3.1 基于 JWT 的用户认证流程
用户认证我采用了 JWT(JSON Web Token)方案,配合 Redis 做服务端会话管理。之所以不用传统的 Session 方案,是为了后续扩展做铺垫——如果将来我想加一个微信小程序端,JWT 的无状态特性可以让多端共用同一套鉴权逻辑。
用户登录成功后,服务端签发 Access Token(有效期 2 小时)。Token 中只包含userId和role两个核心信息,不存敏感数据。客户端每次请求时在Authorization: Bearer <token>头部携带,服务端通过中间件统一校验。
// JWT 鉴权中间件 function authMiddleware(req, res, next) { const authHeader = req.headers.authorization || ""; const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null; if (!token) { return res.status(401).json({ code: 401, message: "未登录" }); } try { const decoded = jwt.verify(token, process.env.JWT_SECRET); req.userId = decoded.userId; req.userRole = decoded.role; next(); } catch (err) { return res.status(401).json({ code: 401, message: "登录已过期" }); } } // 管理员权限中间件 function adminMiddleware(req, res, next) { if (req.userRole !== 1) { return res.status(403).json({ code: 403, message: "无权限访问" }); } next(); }这里有一个实际开发中容易忽略的问题:JWT 的安全存储。Token 下发到前端后,如果直接存 localStorage,一旦站点被注入脚本,用户的 Token 就可能被窃取。最好的做法是存内存变量,刷新页面后从刷新接口重新换取。我在这个项目中采用了一个折中方案:Access Token 存内存,Refresh Token 存 HttpOnly Cookie。这样既保证了安全,也不会因为刷新页面就丢登录态。
3.2 课程学习权限控制
课程网站的接口权限分三个层级:公开接口(课程列表、课程详情)、登录接口(个人中心、学习记录)、付费接口(视频播放地址、完整章节内容)。付费接口的权限校验是业务的核心逻辑,也是比较容易出 bug 的地方。
// 校验用户是否有学习某门课程的权限 async function checkCoursePermission(userId, courseId) { // 管理员默认有全量权限 const user = await UserModel.findById(userId); if (user.role === 1) { return true; } // 查询购买记录 const record = await UserCourseModel.findOne({ where: { user_id: userId, course_id: courseId } }); return !!record; }实现上不复杂,但在接口设计中需要统一入口。我写了一个requireCoursePermission中间件工厂:
function requireCoursePermission(courseParam = "id") { return async function (req, res, next) { const courseId = parseInt(req.params[courseParam]); if (!courseId || isNaN(courseId)) { return res.status(400).json({ code: 400, message: "课程 ID 不合法" }); } const hasPermission = await checkCoursePermission(req.userId, courseId); if (!hasPermission) { return res.status(403).json({ code: 403, message: "请先购买课程" }); } req.courseId = courseId; next(); }; }这个中间件挂在视频播放地址、完整章节内容等付费接口上。访问受保护的视频资源时,先鉴权再返回可访问的临时签名 URL,可以有效防止未购买用户直接拿到视频地址后绕过支付观看。
3.3 视频流与上传处理
视频是整个课程网站里最特殊的资源。它不像普通图片那样直接一个静态地址就能展示。处理视频需要关注两个核心问题:上传方式和播放方式。
上传流程:管理端上传视频时,前端先向后端发起一个"获取上传凭证"的请求。后端生成一个随机的上传目录和文件名,并返回给前端。前端拿到地址后,直接通过流式方式把视频文件上传到服务器的 uploads 目录。这里我做了两个限制:一是文件类型白名单(mp4、webm、mov),二是服务端限制上传文件最大为 2GB,防止视频文件过大拖垮服务器。
播放方式:为了兼顾体验和维护成本,我采用了 Range 请求的支持。HTTP 协议中的 Range 字段允许客户端请求视频文件的一部分,用户拖动进度条时,浏览器或播放器会发送带 Range 的请求,服务器返回 206 Partial Content 状态码,实现流式播放和拖拽进度。
// 支持 Range 请求的视频流响应 async function streamVideo(req, res) { const videoPath = req.videoPath; const stat = await fs.promises.stat(videoPath); const fileSize = stat.size; const range = req.headers.range; if (range) { // 解析 Range 请求:bytes=START-END const parts = range.replace(/bytes=/, "").split("-"); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; const chunkSize = end - start + 1; res.writeHead(206, { "Content-Range": `bytes ${start}-${end}/${fileSize}`, "Accept-Ranges": "bytes", "Content-Length": chunkSize, "Content-Type": "video/mp4", }); const stream = fs.createReadStream(videoPath, { start, end }); stream.pipe(res); } else { res.writeHead(200, { "Content-Length": fileSize, "Content-Type": "video/mp4", }); fs.createReadStream(videoPath).pipe(res); } }这段代码是整个视频播放模块的核心。刚开始做的时候我以为直接静态托管视频文件就行,后来发现拖进度条时视频无法跳转,查了原因才发现是 Nginx 和 Express 默认都不支持 Range 直传响应,只有正确解析请求头并返回 206 才能实现正常的视频拖动。这个例子也说明了一个道理:做 Node.js 项目时,理解 HTTP 协议的底层机制非常关键,框架帮你封装了 90% 的细节,但关键的 10% 需要你自己来实现。
3.4 参数校验与统一响应
实际项目中,接口的参数校验是必须的。我使用了一个轻量级的校验方案,在路由层通过中间件对请求参数做基础校验,避免业务代码被参数检查的泥潭淹没。
// 统一的参数校验中间件 const validateParams = (schema) => { return (req, res, next) => { const errors = []; for (const [field, rules] of Object.entries(schema)) { const value = req.body[field] ?? req.query[field] ?? req.params[field]; if (rules.required && (value === undefined || value === null || value === "")) { errors.push(`${field} 不能为空`); } if (rules.type === "number" && value !== undefined && isNaN(Number(value))) { errors.push(`${field} 必须是数字`); } if (rules.maxLength && value && value.length > rules.maxLength) { errors.push(`${field} 长度不能超过 ${rules.maxLength}`); } } if (errors.length) { return res.status(400).json({ code: 400, message: errors.join(";") }); } next(); }; };统一响应格式也很重要。我的所有接口都遵循一个固定的结构:{ code, message, data }。code 为 0 表示成功,非 0 表示业务异常;message 是给人读的提示;data 是业务数据。这个格式定了之后,前端封装 axios 拦截器非常方便,出现错误时统一弹出 message 提示,不需要每个接口单独做错误处理。
4. 前端实现与接口联调
4.1 Vue 3 页面架构与路由
前端的结构我按业务模块划分:首页、课程列表页、课程详情页、播放页、个人中心页、管理后台。
路由设计时使用了嵌套路由来区分用户端和管理端:
const router = createRouter({ history: createWebHistory(), routes: [ { path: "/", component: Layout, children: [ { path: "", component: HomePage }, { path: "courses/:categoryId?", component: CourseListPage }, { path: "course/:id", component: CourseDetailPage }, { path: "learn/:courseId/:chapterId?", component: PlayerPage }, { path: "profile", component: ProfilePage }, ], }, { path: "/admin", component: AdminLayout, meta: { requiresAdmin: true }, children: [ { path: "", component: DashboardPage }, { path: "courses", component: AdminCoursePage }, { path: "orders", component: AdminOrderPage }, { path: "users", component: AdminUserPage }, ], }, ], });前端路由的守卫配合 Vite 的环境变量,实现在构建时注入后端 API 地址。同时路由守卫中会判断用户角色,非管理员访问 /admin 前缀的路由时直接跳转到首页,避免加载后用接口再拦一层。
4.2 播放器集成与学习进度上报
播放器我使用的是 video.js,配合 videojs-http-streaming 插件来支持不同格式的视频。播放器初始化的代码简洁,但进度上报逻辑值得重点关注。
前端每隔 5 秒上报一次学习进度,上报时机太频繁会给服务器造成压力,太久则可能导致用户退出课程时进度丢失。我采用的策略是 10 秒一个上报周期,同时在页面关闭时通过navigator.sendBeacon做最后一次进度上报。
// 学习进度上报 let reportTimer = null; function startProgressReport(courseId, chapterId) { const video = player.currentSource(); reportTimer = setInterval(() => { const currentTime = player.currentTime(); const duration = player.duration(); if (!isNaN(duration) && duration > 0) { api.reportProgress({ courseId, chapterId, watchDuration: Math.floor(currentTime), totalDuration: Math.floor(duration), isFinished: currentTime / duration > 0.95, }); } }, 10000); } window.addEventListener("beforeunload", () => { if (reportTimer) { clearInterval(reportTimer); } navigator.sendBeacon("/api/progress/report", JSON.stringify(getProgressData())); });这个上报逻辑对应的后端接口只需要做一个简单的 upsert 操作。它的核心价值不在技术难度,而在"体验":当你再次进入该课程时,点击"继续学习"可以跳转到上次观看的位置,这个功能正是靠稳定的进度上报支撑起来的。
4.3 接口联调的几个实战建议
前后端联调阶段是最容易出现沟通成本和环境问题的环节。我的经验是做好三件事:
第一,接口文档先行。我用 Apifox 管理接口文档,每个接口的请求参数、响应结构、错误码含义都提前定义清楚。前后端严格按文档开发,减少"我以为你是这个意思"的乌龙。
第二,Mock 数据兜底。后端接口没写好时,前端用 Apifox 的 Mock 能力先返回模拟数据。这样前端不会被后端阻塞,后端也不会因频繁改动接口而焦头烂额。
第三,统一错误处理。前端 axios 的拦截器里统一处理 401 跳转登录、403 提示无权限、500 提示系统错误。业务错误码(比如"课程已下架""库存不足")统一在 message 中传递给前端提示。这样接口联调时,前端只需要关注业务逻辑,不用纠结状态码的细节。
// axios 响应拦截器 service.interceptors.response.use( (response) => { const res = response.data; if (res.code === 0) { return res; } if (res.code === 401) { router.push("/login"); return Promise.reject(new Error("登录已过期")); } ElMessage.error(res.message || "请求失败"); return Promise.reject(new Error(res.message)); }, (error) => { ElMessage.error(error.message || "网络异常"); return Promise.reject(error); } );5. 部署上线与性能优化实践
5.1 服务器环境搭建
整个项目开发完成后,部署环节同样有不少细节需要处理。我的服务器配置是 2 核 4G 内存,对于这个体量的项目已经够用。系统选用 Debian,LNMP 环境手动搭建,不装面板,这样对服务运行状态更可控。
部署架构是经典的三层:
- Nginx(80/443 端口):静态资源托管 + 反向代理 API 请求
- Node.js 应用:监听 3000 端口,通过 PM2 守护进程
- MySQL + Redis:分别监听 3306 和 6379,仅绑定内网地址
关键配置文件是 Nginx 的站点配置。需要注意两个点:一是前端使用 history 路由模式时,需要配置try_files回退到 index.html,否则刷新页面会 404;二是 API 请求用proxy_pass转发到 Node.js 服务时,需要正确传递 Host 头。
server { listen 80; server_name your-domain.com; root /var/www/course-platform/frontend/dist; index index.html; # 前端路由回退 location / { try_files $uri $uri/ /index.html; } # API 反向代理 location /api/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }5.2 PM2 进程管理与日志排查
Node.js 应用我用 PM2 来管理。PM2 的好处是自带进程守护、日志管理、内存监控。线上进程如果意外崩溃,它会自动重启,保证服务可用性。
PM2 的启动配置我放在ecosystem.config.js中:
module.exports = { apps: [ { name: "course-api", script: "src/app.js", instances: 2, // 集群模式启动 2 个实例 exec_mode: "cluster", max_memory_restart: "500M", env: { NODE_ENV: "production", PORT: 3000, }, error_file: "./logs/err.log", out_file: "./logs/out.log", merge_logs: true, }, ], };启动两个实例的好处是 Node.js 默认单线程,多实例可以充分利用 CPU 的多核性能。但要注意:多实例环境下如果用了内存态的 Session 或者其他本地态数据,就会出现"请求到 A 实例有数据、请求到 B 实例没数据"的问题。这也是我在设计认证方案时选择 JWT 的重要原因——Token 验签不依赖服务端状态,多实例完美兼容。
线上排查问题时,日志是首要线索。PM2 的pm2 logs命令可以实时查看日志输出,但如果应用频繁报错,可以在/logs/err.log中集中排查。我还会在接口层做一个简单的请求日志中间件,记录每个请求的路径、状态码、耗时,生产环境的性能分析和错误定位全靠它。
5.3 性能优化:缓存、压缩、资源分割
部署完成后,我对网站做了一轮性能压测,发现有几个瓶颈需要优化。
首先是 API 响应速度。课程首页的接口在无缓存状态下大概需要 180ms,启用 Redis 缓存后直接降到 20ms 以内。首页接口的缓存策略是 60 秒,对于首页信息这种低频变化的数据,这个策略是性价比最高的。
其次是静态资源加载。前端打包产物中,Vue 3 的运行时、Element Plus 组件库、video.js 播放器加起来体积不小。我在 Vite 配置中做了三件事:路由级代码分割让首屏只加载当前页面需要的 JS;公共依赖单独打包利用浏览器长期缓存;生产构建开启 gzip 压缩,配合 Nginx 的gzip_static on直接返回预压缩文件。
gzip on; gzip_static on; gzip_types text/plain text/css application/json application/javascript video/mp4; gzip_min_length 1k;第三是数据库索引优化。随着测试数据增加,课程列表的分页查询变慢了。通过EXPLAIN分析,发现课程表的category_id和status字段没有走索引。加上复合索引(status, category_id)之后,查询耗时从 300ms 以上降到 30ms 以下。这让我意识到一个道理:MySQL 优化器的索引选择有时候和你预想的不一样,不要凭感觉优化,要分析执行计划。
5.4 前后端分离部署的域名与端口细节
前后端分离项目的部署,最容易踩坑的是跨域和资源路径。
开发环境下,我通过 Vite 的代理解决跨域问题:
// vite.config.js export default defineConfig({ server: { proxy: { "/api": { target: "http://localhost:3000", changeOrigin: true, }, }, }, });生产环境下,Nginx 已经将/api转发到了 Node.js 服务,同域下不存在跨域问题。但上传的视频文件存在/uploads目录下,前端页面打开的视频地址是相对路径,需要通过 Nginx 配置把/uploads映射到物理目录:
location /uploads/ { alias /var/www/course-platform/uploads/; expires 7d; }这里expires 7d的作用是给视频资源设置 7 天的浏览器缓存。视频文件一般不会更新,设置长缓存可以减少带宽压力和加载等待时间。
6. 常见问题与排查技巧实录
6.1 用户登录失效与 Token 过期问题
现象:用户在使用过程中突然退出登录,接口返回 401。
排查思路:我最初以为只是 Token 过期时间的问题,仔细排查后发现有两个原因。一是前端 axios 拦截器在 401 时直接跳转登录页,但并没有清空 localStorage 中残留的 Token 信息;二是部分接口没有在请求头中正确携带 Token。
解决方法:前端在 401 拦截时,除了跳转登录页,还要执行登出操作,清空所有鉴权信息;后端在鉴权中间件中补充一份日志,记录每个 401 请求的来源路径和 Token 的前几位,方便识别问题请求。
6.2 视频加载卡顿与拖动进度失效
现象:播放器加载视频很慢,或者说拖动进度条后视频画面停住不动。
排查思路:这个问题有 80% 的可能性是 Range 请求没有生效。我通过浏览器的开发者工具查看网络请求,发现视频请求的响应状态是 200,而不是 206 Partial Content。这说明服务器根本没有处理 Range 头。
解决方法:在我使用的 Express 版本中,静态资源中间件确实不支持自动处理 Range,于是我对视频播放的接口做了自定义处理,手动解析 Range 头并返回对应的字节范围。另外一个容易忽略的问题是 Nginx 层的缓冲设置,如果 Nginx 开启了proxy_buffering,视频流会先缓冲到 Nginx 再转发给客户端,导致播放延迟。关闭这个缓冲能明显改善体验:
location /api/video/ { proxy_pass http://127.0.0.1:3000; proxy_buffering off; proxy_set_header Host $host; }6.3 接口偶发 502 与内存泄漏
现象:服务器运行几天后,偶尔出现 502 Bad Gateway,重装进程后恢复,但过几天又复发。
排查思路:502 说明 Nginx 无法从上游 Node.js 服务获取响应。查看 PM2 的日志和内存监控后发现,Node.js 进程的内存占用达到了max_memory_restart阈值并触发重启。这个问题的根因很可能是内存泄漏。
定位方式:我用 heapdump 分析了进程的内存快照,发现大量音频/视频资源的 Buffer 没有被释放。仔细检查代码后发现,我在处理视频上传时,使用multer的memoryStorage将整个视频文件加载到内存中,2GB 的视频会直接压垮内存。
解决方法:改用diskStorage将上传文件直接写入磁盘,处理完后删除临时文件。同时,在本地开发这种低负载环境下,这个问题不容易暴露,但线上环境一旦有用户上传大视频,服务器内存就会飙升。
这个坑给我的教训是:文件上传类操作永远不要用内存存储,除非你能确认文件的大小上限足够小。
6.4 课程购买后无法学习的问题
现象:用户反馈扫码支付成功后,课程仍然无法播放,提示"请先购买课程"。
排查思路:查看订单表发现订单状态仍然是待支付,说明支付回调没有正确触发。
定位方式:排查了支付平台的异步通知配置,发现回调地址填写的是测试环境的域名。生产环境已经上线了,但回调地址没有同步更新。另外,支付回调接口没有做幂等处理,重复通知时所有逻辑都重新执行,虽然最终结果一致,但重复写操作有潜在风险。
解决方法:把回调地址统一改为线上域名,并优化回调逻辑——先查询订单状态,只有待支付订单才执行后续处理,其他状态直接返回成功标识,防止重复处理。
6.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 前端请求 API 跨域 | 开发环境未配置代理 | Vite 配置 proxy |
| 刷新页面 404 | Nginx 未配置 try_files | 增加前端路由回退 |
| 上传大视频内存飙升 | multer 使用了 memoryStorage | 改为 diskStorage |
| 视频无法拖动进度 | 未正确处理 Range 请求 | 手动实现 206 响应 |
| 接口偶尔超时 | MySQL 慢查询 | EXPLAIN 分析索引 |
| 订单支付成功但未开通 | 支付回调 URL 配置错误 | 检查回调地址和幂等逻辑 |
| 日志文件无限增长 | PM2 未配置日志切割 | 使用 pm2-logrotate 插件 |
| 缓存与数据库不一致 | 未主动失效缓存 | 更新操作时删除对应缓存 |
写在最后的实操心得
这个项目做下来,我最大的感触是:Node.js 做 Web 应用,真正的功夫不在 JavaScript 语法上,而在对 HTTP、数据库、缓存、部署这些底层机制的理解上。你写一个 Express 接口很容易,但要写出一个经得起并发、能安全处理支付回调、视频流能顺畅播放的接口,需要你对 HTTP Range 请求、事务隔离级别、缓存一致性这些概念有真实的实践经验。
课程网站这个项目做完之后,可扩展的方向很清晰。比如接入推荐算法,根据用户的学习记录推送相似课程;比如增加讲师入驻体系,让课程内容由外部讲师贡献,平台做审核和分成;再比如把视频转码放到异步任务队列里,上传视频后自动生成不同清晰度的版本。技术上,这些方向对应的知识是算法、多角色权限体系、消息队列,每一块都够再开一个项目练手。
如果你准备照着这个思路做一个类似的项目,我建议你重点把事务处理和视频流这两块啃透。这两个环节涉及的知识是通用的,无论以后做电商、做内容平台还是做 SaaS 工具,都会反复用到。踩坑不可怕,关键是踩完之后要能说清楚为什么踩坑,下次怎么避免。
最后再分享一个实用的小技巧:开发阶段不要把数据库表结构和接口文档写死在脑子里,用版本管理工具管理数据库变更脚本,用接口文档工具管理接口定义。项目迭代到第二周,你就会发现这两件"小事"帮你节省的沟通时间远比写它们的时间多。