去年做模拟项目X的时候,接了一个比较完整的活儿:从零搭一个python网络课程在线学习平台。这类项目听起来好像就是“课程列表+视频播放”,但真正动手才发现,一个能被老师真正拿去用、学生愿意天天打开的平台,背后牵扯的模块远比想象中多。当时我把整个平台从数据库设计、权限控制、视频防盗链到部署上线完整走了一遍,过程中踩了不少坑,也沉淀下来一套还算顺手的方案。这篇就把整个项目的设计思路、核心实现和那些文档里不会写的经验完整梳理一遍,给准备做类似在线教育系统或者想拿全栈项目练手的朋友做个参考。
1. 项目全景:这类平台到底要做哪些事
1.1 三种角色与核心链路
先别着急写代码,我们把平台的需求想清楚。一个在线学习平台的用户通常分三种角色:学生、教师、管理员。
学生端要能浏览课程、查看课程详情、播放课程视频、记录学习进度、提交作业、查看成绩。教师端要能创建课程、上传章节视频、发布作业、批改作业、查看学生进度统计。管理员则是管用户、管课程审核、处理异常数据。
这三条链路不是孤立的,它们共享同一套数据模型。我之前见过不少朋友上手就写User和Course两张表,结果做到作业和进度时发现表结构根本撑不住,只能推倒重来。所以第一步必须先做完整的业务场景拆解,把表结构设计得够用、够稳。
核心链路是:学生选课报名后,门课程下的所有章节才对学生可见;每学完一个章节,系统记录一次学习进度;学完整门课之后可以提交结课作业,教师批改后生成最终成绩。这条链路上的每个环节,都要能追踪、能查询、能统计。
1.2 技术方案选型:为什么我最终选了这套组合
技术选型这件事,说大可大说小可小。我当时在Django和Flask之间纠结了一段时间,最终选择了Flask + SQLAlchemy + MySQL + Redis的组合。不是说Django不好,而是要看你项目实际的复杂度和团队习惯。
| 对比项 | Flask | Django |
|---|---|---|
| 上手难度 | 低,自由度高 | 中等,约定多 |
| ORM | SQLAlchemy,灵活 | 自带ORM,集成度高 |
| Admin后台 | 需自己扩展 | 自带全套后台 |
| 适合场景 | 轻量API + 定制化服务 | 快速搭建大型管理类站点 |
我选Flask的核心原因是这个平台的管理端需求不算特别重,反而需要大量定制化的API逻辑,比如视频签名的生成、学习进度的合并上报、作业文件的断点续传。这些用Flask写起来更顺手,不会被框架的“约定”限制住。如果你更在意开箱即用的后台管理,Django确实更快,这没有标准答案,看团队熟悉度。
配套组件方面:数据库用的MySQL,Redis用来做缓存和热点数据存储,文件存储走本地目录 + 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_key=True) course_id = Column(Integer, ForeignKey("course.id"), nullable=False) title = Column(String(200), nullable=False) video_url = Column(String(500), nullable=False) sort_order = Column(Integer, default=0) duration = Column(Integer, default=0) # 视频时长(秒) created_at = Column(DateTime, default=datetime.utcnow)注意这里我把视频的URL直接存在章节表里。存储的是相对路径而不是完整URL,完整URL由接口动态返回。好处是后期如果换CDN或者迁移服务器,只需要改配置,不用动数据库里面的数据。
2.2 学习进度表:最容易设计失误的表
学习进度表是这个平台里最有技术含量的表,没有之一。它的核心职责是记录“某个用户学到某个课程的哪个章节、学到多少百分比、最后学习时间是什么时候”。
最开始的版本我设计得过于简单,只记录章节ID和是否完成。结果后来需求变成“学生中断后重新进入,要从上次停下的位置继续播放”,这个需求一下子就把表结构逼复杂了。所以最终的表是这样的:
class StudyProgress(Base): __tablename__ = "study_progress" id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("user.id"), nullable=False) course_id = Column(Integer, ForeignKey("course.id"), nullable=False) chapter_id = Column(Integer, ForeignKey("chapter.id"), nullable=False) watch_seconds = Column(Integer, default=0) # 已观看秒数 duration = Column(Integer, default=0) # 章节总时长 progress_percent = Column(Integer, default=0) # 0~100 completed = Column(Boolean, default=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) __table_args__ = (UniqueConstraint("user_id", "course_id", "chapter_id", name="uq_user_course_chapter"),)2.3 进度表的查询效率细节
这里涉及一个巨大的性能细节:学生点开课程时,首页要显示所有章节的学习状态。如果你遍历章节表,每个章节去查一次进度表,那么有20个章节就是20次数据库查询,100个人同时打开课程就是2000次查询,数据库直接被打爆。
经典的写法是用一个 SQL 把章节列表和进度状态拼在一起。要么用LEFT JOIN,要么用子查询。在SQLAlchemy里我习惯用outer join:
rows = ( 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平台,不是前后端分离的移动端API,Session机制更简单、注销方便,而且天然支持服务端强制下线。
密码存储必须用哈希,绝不能明文。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_key=True) username = Column(String(80), unique=True, nullable=False) password_hash = Column(String(128), nullable=False) role = Column(Integer, default=1) # 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默认懒加载会导致N+1查询,解决方案是查询时用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是旧的服务器绝对路径 | 视频路径统一存相对路径,动态拼接域名 |
| 课程列表页响应缓慢 | N+1查询,每一章都查一次进度 | 改为outer join一次取全,嵌套用selectinload |
| 上传大视频显示413 | Nginx默认限制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_info=e) 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数量。
写在最后
做了这个项目最大的体会是,在线学习平台的核心难点不在于“写功能”,而在于把在线播放体验、学习数据准确性和并发扛压这三件事同时做好。视频防盗链、进度合并上报、大文件分片上传,每一块单独拿出来都不算特别难,但组合在一起时对系统的整体设计能力要求就高了。如果你也在做类似的项目,建议按我上面这几个模块的顺序逐步完善,每完成一个模块就压测一次,不要攒到最后一次性调试。数据模型设计上多花点时间考虑未来的扩展,后面会省下无数改表结构的痛苦。