去年做模拟项目X的时候接了一个比较完整的活儿从零搭一个python网络课程在线学习平台。这类项目听起来好像就是“课程列表视频播放”但真正动手才发现一个能被老师真正拿去用、学生愿意天天打开的平台背后牵扯的模块远比想象中多。当时我把整个平台从数据库设计、权限控制、视频防盗链到部署上线完整走了一遍过程中踩了不少坑也沉淀下来一套还算顺手的方案。这篇就把整个项目的设计思路、核心实现和那些文档里不会写的经验完整梳理一遍给准备做类似在线教育系统或者想拿全栈项目练手的朋友做个参考。1. 项目全景这类平台到底要做哪些事1.1 三种角色与核心链路先别着急写代码我们把平台的需求想清楚。一个在线学习平台的用户通常分三种角色学生、教师、管理员。学生端要能浏览课程、查看课程详情、播放课程视频、记录学习进度、提交作业、查看成绩。教师端要能创建课程、上传章节视频、发布作业、批改作业、查看学生进度统计。管理员则是管用户、管课程审核、处理异常数据。这三条链路不是孤立的它们共享同一套数据模型。我之前见过不少朋友上手就写User和Course两张表结果做到作业和进度时发现表结构根本撑不住只能推倒重来。所以第一步必须先做完整的业务场景拆解把表结构设计得够用、够稳。核心链路是学生选课报名后门课程下的所有章节才对学生可见每学完一个章节系统记录一次学习进度学完整门课之后可以提交结课作业教师批改后生成最终成绩。这条链路上的每个环节都要能追踪、能查询、能统计。1.2 技术方案选型为什么我最终选了这套组合技术选型这件事说大可大说小可小。我当时在Django和Flask之间纠结了一段时间最终选择了Flask SQLAlchemy MySQL Redis的组合。不是说Django不好而是要看你项目实际的复杂度和团队习惯。对比项FlaskDjango上手难度低自由度高中等约定多ORMSQLAlchemy灵活自带ORM集成度高Admin后台需自己扩展自带全套后台适合场景轻量API 定制化服务快速搭建大型管理类站点我选Flask的核心原因是这个平台的管理端需求不算特别重反而需要大量定制化的API逻辑比如视频签名的生成、学习进度的合并上报、作业文件的断点续传。这些用Flask写起来更顺手不会被框架的“约定”限制住。如果你更在意开箱即用的后台管理Django确实更快这没有标准答案看团队熟悉度。配套组件方面数据库用的MySQLRedis用来做缓存和热点数据存储文件存储走本地目录 Nginx静态服务生产环境部署用Gunicorn。这套组合在单机环境下支撑几千用户并发学习完全没问题而且每个组件都有大量踩坑资料可查出了问题不会找不到答案。1.3 项目目录结构规划项目结构决定了后续迭代效率我是按功能模块拆的不是按技术层拆的。分享一个比较舒服的Flask项目结构course_platform/ ├── app/ │ ├── __init__.py # 应用工厂 │ ├── config.py # 配置项 │ ├── extensions.py # 第三方扩展实例化 │ ├── models/ # 数据模型 │ │ ├── user.py │ │ ├── course.py │ │ └── progress.py │ ├── api/ # 蓝图模块 │ │ ├── auth.py │ │ ├── course.py │ │ ├── study.py │ │ └── admin.py │ └── utils/ # 工具函数签名、上传、分页 ├── migrations/ # 数据迁移 ├── scripts/ # 定时任务 ├── static/ # 静态资源 ├── uploads/ # 上传文件目录 └── requirements.txt按功能拆模块的好处是后面加新功能时你很清楚代码放哪儿。比如要加一个“课程收藏”功能直接在api目录下新建一个favorite.py再在models里加一张表就行不影响现有代码。2. 数据模型设计把课程、用户、进度串起来2.1 核心模型与关系梳理数据模型是整个平台的基石我一直认为表结构设计得好业务开发能省一半力气。核心模型我拆成了五张主表用户表、课程表、章节表、学习进度表、作业表。用户表在基础字段之外我加了一个role字段区分角色。这里有个细节角色用字符串还是整数我建议用整数常量比如1学生、2教师、3管理员因为整数在索引和比较时性能更好而且不会因为改动中文显示而影响数据。课程表和章节表是经典的主从结构。课程表存放标题、简介、封面图、价格如果是付费课程、教师ID等章节表通过course_id外键关联课程并带一个sort_order字段控制排序。这个sort_order是个容易忽略的关键字段没有它你后续调整章节顺序的时候只能靠改ID非常痛苦。class Chapter(Base): __tablename__ chapter id Column(Integer, primary_keyTrue) course_id Column(Integer, ForeignKey(course.id), nullableFalse) title Column(String(200), nullableFalse) video_url Column(String(500), nullableFalse) sort_order Column(Integer, default0) duration Column(Integer, default0) # 视频时长秒 created_at Column(DateTime, defaultdatetime.utcnow)注意这里我把视频的URL直接存在章节表里。存储的是相对路径而不是完整URL完整URL由接口动态返回。好处是后期如果换CDN或者迁移服务器只需要改配置不用动数据库里面的数据。2.2 学习进度表最容易设计失误的表学习进度表是这个平台里最有技术含量的表没有之一。它的核心职责是记录“某个用户学到某个课程的哪个章节、学到多少百分比、最后学习时间是什么时候”。最开始的版本我设计得过于简单只记录章节ID和是否完成。结果后来需求变成“学生中断后重新进入要从上次停下的位置继续播放”这个需求一下子就把表结构逼复杂了。所以最终的表是这样的class StudyProgress(Base): __tablename__ study_progress id Column(Integer, primary_keyTrue) user_id Column(Integer, ForeignKey(user.id), nullableFalse) course_id Column(Integer, ForeignKey(course.id), nullableFalse) chapter_id Column(Integer, ForeignKey(chapter.id), nullableFalse) watch_seconds Column(Integer, default0) # 已观看秒数 duration Column(Integer, default0) # 章节总时长 progress_percent Column(Integer, default0) # 0~100 completed Column(Boolean, defaultFalse) updated_at Column(DateTime, defaultdatetime.utcnow, onupdatedatetime.utcnow) __table_args__ (UniqueConstraint(user_id, course_id, chapter_id, nameuq_user_course_chapter),)2.3 进度表的查询效率细节这里涉及一个巨大的性能细节学生点开课程时首页要显示所有章节的学习状态。如果你遍历章节表每个章节去查一次进度表那么有20个章节就是20次数据库查询100个人同时打开课程就是2000次查询数据库直接被打爆。经典的写法是用一个 SQL 把章节列表和进度状态拼在一起。要么用LEFT JOIN要么用子查询。在SQLAlchemy里我习惯用outer joinrows ( db.session.query(Chapter, StudyProgress) .outerjoin(StudyProgress, db.and_( StudyProgress.chapter_id Chapter.id, StudyProgress.user_id current_user.id )) .filter(Chapter.course_id course_id) .order_by(Chapter.sort_order) .all() )这样一次查询就把所有章节和当前用户的学习状态取到了内存里循环拼接返回前端即可。外层再套一层Redis缓存把“课程ID 用户ID”作为key缓存10分钟高并发下数据库压力会小很多。2.4 作业表与成绩设计作业表我分了两张作业定义表教师发布的作业和作业提交表学生交的作业。这样设计是因为一个作业会有多名学生提交一对多的关系用两张表表达最清晰。作业提交表里需要包含提交时间、提交内容或附件路径、批改状态待批改/已通过/已退回、得分、教师评语等。批改状态机本身很简单但要注意一个隐藏需求学生重新提交后原记录是保留还是覆盖我选择保留历史记录新增一条提交记录并把旧记录标记为superseded。这个设计在遇到“分数争议”时特别有用你可以清楚地追溯每次提交内容和时间。3. 核心功能实现最有技术含量的几个模块3.1 用户认证与权限控制用户的注册、登录我用的方案是Flask-Login 服务端Session。为什么不用JWT因为这是一个传统Web平台不是前后端分离的移动端APISession机制更简单、注销方便而且天然支持服务端强制下线。密码存储必须用哈希绝不能明文。werkzeug.security自带的generate_password_hash和check_password_hash用的是pbkdf2算法开箱即用安全度够用。from werkzeug.security import generate_password_hash, check_password_hash class User(Base): id Column(Integer, primary_keyTrue) username Column(String(80), uniqueTrue, nullableFalse) password_hash Column(String(128), nullableFalse) role Column(Integer, default1) # 1学生 2教师 3管理员 def set_password(self, password): self.password_hash generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password)权限控制上用装饰器做隔离写一个role_required(role)装饰器在课程创建、作业批改这些接口上加上即可。注意装饰器里一定要先判断用户是否登录再判断角色顺序反了会有越权风险。3.2 课程列表与详情的查询优化课程列表页如果直接用ORM查数据库然后序列化返回后期课程量上来之后响应会越来越慢。第一次优化我用的是分页SQLAlchemy的paginate或手动limit/offset每页12条。第二次优化加了Redis缓存。这里有个经验缓存键的粒度要小。我按“页码排序方式”做key而不是把整个列表页塞一个key里。比如course:list:1:newest。这样某一页数据更新了只刷新对应页的缓存不会把整个列表缓存都废掉。课程详情页的优化重点在“老师信息”和“章节数”这类关联数据的加载。ORM默认懒加载会导致N1查询解决方案是查询时用joinedload或selectinload一次性把关联数据查出来course ( db.session.query(Course) .options(joinedload(Course.teacher)) .options(selectinload(Course.chapters)) .filter(Course.id course_id) .first() )3.3 视频播放与防盗链机制视频播放这块最初我把视频路径直接返回给前端用Nginx静态文件方式播放。整体跑通之后我发现一个严重问题只要拿到视频路径用户就能绕过登录页面直接下载视频甚至可以分享给任何人。这时候再给页面加登录校验已经拦不住了问题出在视频文件本身。我需要一个防盗链方案。市面上云厂商有全套的CDN鉴权方案但我们是自建服务器所以我选择让接口动态生成带签名的播放地址。具体做法是视频的完整URL不落库接口收到播放请求时校验用户登录状态和课程报名状态然后动态生成一个带有效期的签名URL。import hmac import hashlib import time from datetime import datetime, timedelta def generate_signed_url(video_path: str, user_id: int, expire_minutes: int 30) - str: expire_time int(time.time()) expire_minutes * 60 secret current_app.config[SIGN_SECRET] # 将过期时间、用户ID与路径一起参与签名 raw_string f{video_path}{expire_time}{user_id} signature hmac.new(secret.encode(), raw_string.encode(), hashlib.md5).hexdigest() return f/protected/{video_path}?expire{expire_time}uid{user_id}sign{signature}同时Nginx端配置对/protected/目录做签名校验。前端拿到的播放地址30分钟过期过期后重新请求接口拿新地址。这样即使链接被转发出去有效期一过就自动失效。这个方案虽然比直接把静态文件丢出去要复杂一些但确实把资源保护起来了。3.4 学习进度上报机制前端定时心跳 后端合并学习进度是最容易做错的功能。如果你的代码是”学生暂停视频时上报一次进度“一旦学生直接把页面关掉进度就丢了。如果做成每秒钟上报那服务器要被请求打爆。最终我采用“前端定时心跳 后端合并”策略。前端每30秒上报一次当前播放位置后端收到上报后把进度表里的watch_seconds和当前上报值做比较只增不减。收益是即使中间有几条请求丢失下一次上报也能覆盖上来不会导致进度回退。def report_progress(user_id, course_id, chapter_id, position): record find_or_create_progress(user_id, course_id, chapter_id) if position record.watch_seconds: record.watch_seconds position record.progress_percent min(99, int(position * 100 / record.duration)) if position record.duration * 0.95: record.completed True record.progress_percent 100 db.session.commit()还有一个细节判断章节完成的标准不要用“看到最后一秒”因为人的耐心是有限的95%的进度即可视为完成。我用duration * 0.95做阈值用户体验会好很多。3.5 作业提交与自动判分作业提交的核心难点其实是文件上传。一个视频文件大小轻松上50MB默认的Flask请求体大小限制是1MB左右不调整的话上传直接失败。我的方案是把上传拆成两步第一步前端通过接口获取一个上传凭证第二步直接PUT到Nginx的上传接口。如果做的更完整可以用分片上传前端把大文件切成5MB一片逐片上传到服务器临时目录全部完成后后端合并。这个方案对服务器磁盘和并发控制都有要求但能保证上传稳定。自动判分我实现了一个简单的关键词匹配教师创建作业时预设几个必含关键词学生提交文本答案后系统自动检测关键词覆盖率给出分数。这个功能适合客观题或者有标准答案的题目主观题还得靠人工批改。要注意自动判分结果不能直接作为最终分数我会加一个教师人工复核的开关。4. 部署上线与性能优化4.1 Gunicorn Nginx 部署要点开发和生产环境必须分离。开发环境Flask自带的开发服务器单线程且不稳定生产环境我用Gunicorn启动多worker进程。命令行示例gunicorn -w 4 -b 127.0.0.1:8000 app:create_app()4个worker意味着可以同时处理4个请求如果机器是2核4G的配置这个数字差不多worker数量不是越多越好太多反而会变慢因为CPU上下文切换开销上去了。Nginx配置里要记得做两件重要的事第一是用proxy_pass将请求转发给Gunicorn第二是静态文件图片、CSS、JS直接走Nginx本地文件系统不经过Python。后者能省掉一大半的请求量。上传的视频文件也放在Nginx能直接访问的目录下配合签名防盗链模块处理。4.2 缓存策略Redis 用在哪几个位置我给平台配了三类缓存每类一个Redis key前缀避免互相污染course:list:*课程列表缓存10分钟过期course:detail:*课程详情缓存30分钟过期user:progress:*学习进度缓存5分钟过期注意涉及用户进度的缓存很容易踩坑。如果你把用户进度直接缓存而没有设置过期时间一旦学生重新播放就可能一直读到旧进度。我后来改成“读缓存但写入时永远直接写数据库并同时更新缓存”并且缓存时间不超过5分钟这样就既快又不会错得离谱。Redis的价值在于把热点读请求从MySQL里扛下来。比如首页热门课程的SQL查询每秒被触发1000次和每秒被触发100次是完全不同的量级前者可以直接搞垮数据库。4.3 大文件上传与断点续传上传大视频文件时一定会碰到超时问题。默认Nginx的client_max_body_size是1MB不配置的话超过1MB直接报413。我调整到client_max_body_size 500m。前端上传用分片的方式每片大小我选的是4MB。为什么是4MB因为这个大小在网络不太稳定的时候重试成本低同时又能保证并发上传的效率。分片上传接口的处理逻辑接收分片时带上一个upload_id和index服务端把分片写入临时目录所有分片到齐后调用合并接口。合并时有一个隐患磁盘并发写。如果有10个学生同时在传大文件服务器的IO会飙升。我在合并接口加了信号量控制并发合并数比如同时最多只有3个合并任务在跑其他的排队。这个细节没有故障是很难想到的。5. 常见问题排查实录5.1 高频问题与解决方案速查表下面这个表格是我在这个真实项目里踩过的坑每位做在线学习平台的朋友大概率都会遇到的问题直接抄走能省不少排查时间。问题现象根本原因解决方案域名下视频能看换域名打不开视频URL是旧的服务器绝对路径视频路径统一存相对路径动态拼接域名课程列表页响应缓慢N1查询每一章都查一次进度改为outer join一次取全嵌套用selectinload上传大视频显示413Nginx默认限制1MB设置client_max_body_size并调大学完章节进度仍是99%判断完成的条件用的position duration漏看了最后一秒设置95%即完成提交作业后看不到成绩作业提交表主键冲突重复提交覆盖了原记录增加superseded标记保留历史版本Redis缓存了旧课程信息缓存未设置过期课程内容更新后未同步统一过期时间显式更新缓存修改课程排序后前端顺序不变排序字段只存在内存里没写库用sort_order字段持久化排序5.2 几个很值钱的避坑经验首先是时间字段统一用UTC存储展示层再转本地时区。我在统计“学生今天学了多少分钟”时踩过大坑。数据库存的datetime是UTC时间而教师端看的是北京时间直接统计凌晨前的数据时会出现“今天少了几十分钟”的错觉。解决方案是查询时用数据库的日期函数先转时区MySQL里用CONVERT_TZ(created_at, 00:00, 08:00)。其次是不要把数据库当文件仓库。作业附件、课程封面的二进制数据千万不要存到数据库的BLOB字段里因为如果你这样做了数据库文件会无限膨胀查询性能越来越差。图片、视频一律走文件系统存储数据库只存文件路径。第三给所有接口加上统一的异常处理。我在app工厂里注册一个全局错误处理器捕获所有未处理的异常并记录日志返回统一格式的错误信息。这个习惯让后期排查线上问题时效率翻了好几倍。app.errorhandler(Exception) def handle_exception(e): current_app.logger.error(Unhandled exception, exc_infoe) return {code: 500, message: 服务器内部错误}, 500开发期可以把这个处理器关掉让异常直接抛出方便debug生产环境一定加上否则用户看到的是丑陋的堆栈信息甚至服务直接崩溃。第四线上环境一定开MySQL的慢查询日志。做课程报表的时候我排查过一个SQL跑了3秒的问题靠的就是慢查询日志定位到一条缺少索引的统计语句加索引后降到了约50毫秒。这种问题在开发环境根本发现不了因为数据量太少了。5.3 性能压测与容量评估上线前我做了简单的压测用的压力测试工具是Locust模拟200个学生同时开课程列表。结果发现API的P95响应时间是850毫秒对课程列表来说有点慢。优化前查了慢日志发现瓶颈在数据库的JOIN和ORDER BY没走索引。给课程表的sort_order字段加了联合索引之后P95降到了120毫秒。容量评估的经验是一个2核4G的云服务器Gunicorn 4个worker加上Redis缓存命中率在70%以上支撑500个学生同时在线学习没有太大问题。如果用户量再往上走优先扩容的方向是Redis内存和数据库的连接池而不是盲目增加worker数量。写在最后做了这个项目最大的体会是在线学习平台的核心难点不在于“写功能”而在于把在线播放体验、学习数据准确性和并发扛压这三件事同时做好。视频防盗链、进度合并上报、大文件分片上传每一块单独拿出来都不算特别难但组合在一起时对系统的整体设计能力要求就高了。如果你也在做类似的项目建议按我上面这几个模块的顺序逐步完善每完成一个模块就压测一次不要攒到最后一次性调试。数据模型设计上多花点时间考虑未来的扩展后面会省下无数改表结构的痛苦。