基于Flask的在线教育平台,是我最近完整走了一遍设计与实现全流程的模拟项目。老实说,刚拿到这个需求的时候,我以为在线学习网站只要套一套现成的模板就能跑,真正动手之后才发现,难度全藏在那些“看上去很简单”的功能上——视频课件怎么放、学习进度怎么记、学生和教师怎么共处一个系统。这篇文章会把整个项目的来龙去脉、核心表结构、双角色认证、视频处理、进度追踪和上线部署都串起来讲。如果你正准备做一个Flask项目,或者正在规划一个类似的在线教育系统,这份思路可以直接拿过去改。
1. 从一次选题调研说起:为什么是Flask
1.1 在线教育平台真正面对的问题是什么
一个在线教育平台如果只做“课程列表+视频播放页面”,那确实不难。但既然叫“平台”,需求方真正关心的往往不是播放器本身,而是这几件事:
- 谁能看哪些课程,教师和学生之间怎么隔离权限;
- 课程内容以什么形态组织,是粗糙的整段视频,还是“课程→章节→课时”这样清晰的结构;
- 学习过程怎么留痕,学生看到哪一课时了,能不能续播,教师能不能看到完成情况;
- 后台能不能便捷管理课程的上线和下架。
不管这个项目是用于课程设计的模拟项目X,还是一个要真正落地的产品,这些核心诉求基本一致。所以我建议,在写第一行代码之前,先做一轮需求梳理。最简单的方法就是把用户都贴上角色标签,整理成“用户故事”。我这里列了三组:
- 普通学生:注册登录 → 浏览课程 → 选择课时 → 播放视频 → 系统自动记录学习进度,下次进来能回到上次观看位置。
- 教师用户:登录后台 → 创建课程 → 添加课时 → 上传视频 → 查看课程的学习人数和完成情况。
- 管理员:审核内容 → 控制课程上下架 → 处理异常用户。
这三组故事直接决定了系统的模块边界。后面所有的代码和表结构,都是为了支撑这三个角色能顺畅完成各自的任务。
1.2 Flask 对比其他框架的取舍判断
做这种中小型系统,如果语言层面选了Python,最常见的纠结就是Flask还是Django。我在这套项目里最终选Flask,核心原因是“轻”和“可控”。
Flask只提供了一个最小的请求-响应核心,路由怎么写、ORM用不用、后台怎么搭,全都自己决定。这意味着我可以一边开发一边根据自己的节奏加组件,而不是一开始就接受一套全家桶约束。对于在线教育平台这种业务边界清晰、但需要自己控制业务细节的项目,Flask的开发体验会更顺手一些。
Django的优势是内置admin、ORM、认证系统,开箱即用程度高。可问题也在这里:内置功能越强,自定义时越容易被框架的默认逻辑牵制。比如Django自带admin对“课程管理”这种业务型后台帮助有限,最终还是要自己写视图;而Flask的灵活反而让我能更专注在业务上。当然,如果你追求开发速度,团队又没有太多自定义诉求,Django也是理性选择。这两种没有绝对的好坏,主要是取舍。
| 判断维度 | Flask方案 | Django方案 |
|---|---|---|
| 学习曲线 | 平缓,框架暴露面少 | 中等,内置概念多 |
| 自定义灵活度 | 高,组件按需引入 | 中,会被默认设计影响 |
| 开发速度 | 前期稍慢,后期稳定 | 前期快,复杂场景反而费劲 |
| 适合场景 | 中小业务、微服务、API | 内容管理型、团队标准化项目 |
1.3 初版功能边界怎么划
功能边界划不好,是这类项目最容易出现的问题。我第一版列表上曾经同步考虑过课程支付、直播、讨论区和考试功能,后来砍掉了大半。初版只保留六个核心功能:
- 用户注册、登录、退出;
- 课程列表与课程详情;
- 课时列表与视频播放;
- 学习进度记录与续播断点;
- 教师端课程和课时管理;
- 管理员课程上下架。
直播、支付、社群这种功能,要么靠第三方成熟方案,要么等核心闭环跑通了再迭代。为什么要这么砍?因为“平台”两个字太容易让人贪大,而一个能稳定跑起来的MVP,远比一堆没做完的界面重要。先把“选课→上课→记录进度”这条主链路跑通,后面扩展才有意义。
2. 项目骨架与模块划分:解耦才是可维护的前提
2.1 用蓝图拆分业务域
Flask项目最怕的就是把所有路由写在一个主文件里,几百个视图函数堆在一起,半个月之后自己看着都头疼。在线教育平台的业务域其实很清晰,我按职责拆成了四个蓝图,分别是认证、课程、学习记录和管理后台。
用蓝图的好处有三个:路由能按模块加载;模板和静态文件可以跟着模块走;代码review的时候边界很清楚。我在应用工厂函数里统一注册,而不是把蓝图对象散落在全局各处。这样也方便后续写测试,测试环境可以只加载部分蓝图。
def create_app(): app = Flask(__name__) app.config.from_object(Config) db.init_app(app) from auth.views import auth_bp from course.views import course_bp from study.views import study_bp from admin.views import admin_bp app.register_blueprint(auth_bp, url_prefix="/auth") app.register_blueprint(course_bp, url_prefix="/course") app.register_blueprint(study_bp, url_prefix="/study") app.register_blueprint(admin_bp, url_prefix="/admin") return app2.2 目录结构怎么组织
我的目录结构参考了日常开发里比较常见的“按业务模块分包”方式,而不是把全部模板放一个文件夹、全部代码放一个文件夹。每个模块内部自带view、service和模板,只有公共的东西才上提。
app/ ├── app.py # 应用入口 ├── config.py # 配置类 ├── utils/ # 通用工具,比如登录装饰器 ├── auth/ # 登录注册模块 │ ├── __init__.py │ ├── views.py │ └── templates/ ├── course/ # 课程展示与课程管理 │ ├── __init__.py │ ├── views.py │ ├── service.py │ └── templates/ ├── study/ # 学习记录与进度 │ ├── __init__.py │ ├── views.py │ └── service.py ├── admin/ # 后台管理 │ ├── __init__.py │ ├── views.py │ └── templates/ └── models/ # 数据库模型集中定义这个结构不是拍脑袋定的。课程模块肯定会被admin复用,所以模型单独放;学习记录和课程又有较强关联,但接口相对独立,也拆开。拆模块的时候尽量按“变化频率”来划分,变化快和变化慢的分开,后续改需求才不会被牵连。
2.3 分层原则:让视图层保持轻薄
只把文件分开还不够,代码内部的分层更重要。我给自己定了一条准则:视图函数只处理HTTP相关的输入输出,不写复杂业务逻辑。
登录注册、课程创建这些功能,如果业务逻辑都堆在视图里,视图会很快膨胀到几百行。正确的做法是,视图负责拿参数、校验参数、调用service、返回response;service层负责计算和学习规则;数据模型层只负责表的读写。比如,“计算一个学生的课程完成率”这种逻辑就应该放service,而不是在模板里临时算。这样模板也能保持干净,只在展示层拿到已经算好的值。
3. 核心表结构设计:用户、课程、课时和学习记录
3.1 开发库和生产库的取舍
数据存储上我做了一个配置层面的切换:本地开发用SQLite,接近零成本,文件型数据库随便删随便建;生产环境切到MySQL,用配置项控制,业务代码不需要改。
我用的是Flask-SQLAlchemy,所以切换相当简单。config里根据环境变量来读取数据库连接串:
import os class Config: SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-secret") SQLALCHEMY_DATABASE_URI = os.environ.get( "DATABASE_URL", "sqlite:///education.db" ) SQLALCHEMY_TRACK_MODIFICATIONS = False本地开发不需要启动数据库服务,这对前期调表结构非常友好。到了部署阶段,我再把DATABASE_URL设置成mysql+pymysql://用户名:密码@localhost/数据库名。这种方式对刚开始接触Flask的人是值得养成的习惯,数据库选型永远别写死在代码里。
3.2 四张核心表的字段设定
整个系统其实就围绕四张表展开。我最初设计的时候字段比现在多一倍,后来砍掉不少。核心原因还是那句话:能用字段解决的事情不要多建表,但也不能把所有信息都塞进一张表。
| 数据表 | 关键字段 | 作用 |
|---|---|---|
| user | id, username, password_hash, display_name, role, status, created_at | 用户主表,role区分学生/教师/管理员 |
| course | id, teacher_id, title, category, description, cover_url, status, created_at | 课程基本信息,teacher_id关联教师 |
| lesson | id, course_id, title, video_url, video_duration, sort_order | 课时,一门课包含多个课时 |
| study_record | id, user_id, lesson_id, is_finished, last_position, total_watch_time, first_viewed_at, last_viewed_at | 学生学习每个课时的进度快照 |
user表里的role字段只存字符串,不是外键,因为角色就固定几个值,不需要单独建表。course表里status是控制课程上下线的关键字段,0为草稿,1为已发布,2为下架。lesson表的sort_order用来控制课时排序,这个字段从一开始就加上,后面调整章节顺序会很轻松。
3.3 SQLAlchemy建模代码
我把模型集中放在models/目录下。这里给出User和StudyRecord两个典型例子:
from datetime import datetime from werkzeug.security import generate_password_hash, check_password_hash from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() class User(db.Model): __tablename__ = "user" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(64), unique=True, nullable=False, index=True) password_hash = db.Column(db.String(256), nullable=False) display_name = db.Column(db.String(64), nullable=False) role = db.Column(db.String(20), default="student", index=True) status = db.Column(db.Integer, default=1) # 1正常 0禁用 created_at = db.Column(db.DateTime, default=datetime.now) 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)class StudyRecord(db.Model): __tablename__ = "study_record" __table_args__ = ( db.UniqueConstraint("user_id", "lesson_id", name="uq_user_lesson"), ) id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) lesson_id = db.Column(db.Integer, db.ForeignKey("lesson.id"), nullable=False) is_finished = db.Column(db.Boolean, default=False) last_position = db.Column(db.Integer, default=0) # 秒 total_watch_time = db.Column(db.Integer, default=0) # 秒 first_viewed_at = db.Column(db.DateTime, default=datetime.now) last_viewed_at = db.Column(db.DateTime, default=datetime.now)这些字段看起来简单,但每一条都有讲究。password_hash存的是哈希结果而不是明文,这个是底线。StudyRecord里的联合唯一约束,保证了同一个学生对同一课时的学习记录不会重複插入,这个约束在并发情况下特别有用。
3.4 外键、索引和那些容易踩的坑
字段定了不代表表结构就没问题了。实际开发里还要处理几个隐藏点:
外键与级联删除。删除课程的时候,如果数据库层面没有级联配置,ORM的关联对象很容易变成悬挂记录。我在外键上加了
ondelete="CASCADE",同时在ORM的relationship里也定义了cascade="all, delete-orphan",让两层行为保持一致。列表查询的索引。学习记录表里最常用的查询是“某个用户的所有记录”,所以
user_id必须建索引。课程课时表里最常用的是“某课程下所有课时”,course_id也要建索引。索引不是越多越好,但要覆盖高频查询。并发重复插入问题。如果前端连续触发两次学习进度上报,服务端可能同时执行两条INSERT。直接SELECT再判断很慢而且有竞态。比较好的做法是直接利用唯一约束做upsert,先尝试INSERT,如果报唯一冲突就转UPDATE。这套逻辑写起来稍微绕一点,但确实可靠。
4. 双角色登录认证:Session方案与权限装饰器
4.1 Session登录的流程设计
这是一个服务端渲染的Flask项目,不是前后端分离项目,所以我没引入JWT,直接用Session方案。登录状态通过Cookie里的会话标识来保持,逻辑清晰,也足够安全。
登录视图的完整流程是这样的:
- 接收POST表单提交的用户名和密码;
- 按用户名查数据库,检查用户是否存在,状态是否正常;
- 用
check_password验证密码哈希; - 密码正确时调用
session.clear(),再写入user_id和role,这一步是为了防止会话固定攻击; - 根据角色跳转:学生去课程首页,教师和管理员进各自后台。
session.clear()是我特别想强调的一步。如果不做这个操作,攻击者可能事先塞好一个session ID诱导用户登录,登录后这个session就变成了带身份的session,攻击者就能复用。开发阶段偷懒不写,上线之后就是实实在在的漏洞。
一个简易登录视图大概是这样的:
from flask import Blueprint, render_template, request, session, redirect, url_for, flash from models import db, User auth_bp = Blueprint("auth", __name__) @auth_bp.route("/login", methods=["POST"]) def login(): username = request.form.get("username", "").strip() password = request.form.get("password", "") user = User.query.filter_by(username=username).first() if user is None or not user.check_password(password): flash("用户名或密码错误", "danger") return redirect(url_for("auth.login_page")) if user.status == 0: flash("账号已被禁用,请联系管理员", "warning") return redirect(url_for("auth.login_page")) session.clear() session["user_id"] = user.id session["role"] = user.role if user.role == "teacher": return redirect(url_for("admin.teacher_dashboard")) return redirect(url_for("course.index"))4.2 密码安全存储的细节
用户密码绝不能明文存储,这个现在基本是共识。但具体怎么做,很多新手还是会写出md5(password)这种危险代码。
我使用的是werkzeug.security提供的generate_password_hash和check_password_hash。它内部带随机盐,同一个密码每次生成的哈希都不一样,而且计算代价比普通MD5高,这能显著拖慢暴力破解的速度。我用的是默认的scrypt算法,如果没有特殊原因,不建议在安全相关的代码里自创密码方案。
user = User(username=username, display_name=display_name, role="student") user.set_password(password) db.session.add(user) db.session.commit()要注意的一点是,password_hash列的宽度要给够。我设的是String(256),因为不同哈希算法生成的字符串长度不同,太短会导致存储溢出。
4.3 权限控制装饰器的写法
Session里存了角色,但光有Session还不够,还需要在需要特定权限的视图上做拦截。我用装饰器来统一处理。
from functools import wraps from flask import session, redirect, url_for, flash def login_required(view): @wraps(view) def wrapped(*args, **kwargs): if session.get("user_id") is None: flash("请先登录", "warning") return redirect(url_for("auth.login_page")) return view(*args, **kwargs) return wrapped def teacher_required(view): @wraps(view) def wrapped(*args, **kwargs): if session.get("user_id") is None: flash("请先登录", "warning") return redirect(url_for("auth.login_page")) if session.get("role") != "teacher": flash("需要教师权限", "danger") return redirect(url_for("course.index")) return view(*args, **kwargs) return wrapped使用的时候直接在视图上叠装饰器即可。比如教师创建课程的视图,就写成@teacher_required。这里要注意一点,Session里的role只是一个快速判断,真正的权限兜底还是应该在操作数据库之前再查一次用户状态,尤其是教师删除课程这类敏感操作。
4.4 登录模块的边界问题
除了正常登录,登录模块还会遇到这些边界情况:
- 用户名大小写敏感问题。MySQL默认排序规则下,varchar比较可能不分大小写,导致“Admin”和“admin”撞车。建表时最好明确字符集排序规则,或者在应用层强制统一小写。
- 账号禁用。用户被管理员封禁后,如果Session还存着登录态,下一次请求就应该被拦截。我在相关查询中都会检查
user.status,保证禁用立刻生效。 - Cookie过期与退出登录。退出要记得
session.clear()而不是只删某一个key,避免残留任何身份信息。 - Session内容是经过签名但未加密的,不要在Session里放敏感数据,比如密码或身份证这类。我只放了user_id和role两个字段。
5. 视频课件上传与播放:本地存储方案的取舍
5.1 视频文件放在哪里更合适
在线教育平台绕不开视频。项目初期我考虑过几种方案:上对象存储服务、自建流媒体服务器、还是先本地存储。最后决定先用本地文件存储,把精力集中在业务功能上。
但如果只是把文件丢到Flask项目根目录下的static/uploads,等下就麻烦了。因为static默认是公开可访问的,任何人都能直接拼URL下载视频,权限控制形同虚设。我的做法是把媒体文件放在项目根目录外,或者至少是一个独立目录,并通过单独的路由来做访问授权。
我这里的目录规划如下:
media/ ├── covers/ # 课程封面图 │ └── course_1.jpg └── videos/ └── 1/ # course_id ├── a1b2c3d4e5f6.mp4 └── 2f3g4h5j6k7l.mp4配置里加一个MEDIA_ROOT:
import os BASE_DIR = os.path.abspath(os.path.dirname(__file__)) class Config: MEDIA_ROOT = os.path.join(BASE_DIR, "media") MAX_CONTENT_LENGTH = 500 * 1024 * 1024 # 单次上传最大500MB5.2 上传接口的安全校验
教师上传视频的接口,除了要@teacher_required,还要做文件本身的校验。主要涉及三件事:
- 扩展名白名单。只允许
mp4、webm、mov。 - 文件大小限制。Flask的
MAX_CONTENT_LENGTH可以限制请求体大小,在到达视图之前就会报错。 - 文件名重写。永远不要信任用户上传的文件名,我用UUID重新生成文件名,避免路径穿越和重名覆盖。
import os import uuid from werkzeug.utils import secure_filename ALLOWED_EXTENSIONS = {"mp4", "webm", "mov"} def save_video(file_storage, course_id): filename = file_storage.filename or "" ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" if ext not in ALLOWED_EXTENSIONS: raise ValueError("不支持的视频格式") new_filename = f"{uuid.uuid4().hex}.{ext}" dest_dir = os.path.join(current_app.config["MEDIA_ROOT"], "videos", str(course_id)) os.makedirs(dest_dir, exist_ok=True) dest_path = os.path.join(dest_dir, new_filename) file_storage.save(dest_path) return f"/media/videos/{course_id}/{new_filename}"这里还有个细节,MAX_CONTENT_LENGTH是全局限制,它会同时限制表单上传和普通请求体,调太大可能会被恶意利用。我在生产环境靠Nginx做了更细的请求体限制,Flask这边只做一个兜底。
5.3 受保护的视频分发视图
文件存好后,播放页不能直接引用公开静态路径,而要经过一个带权限检查的分发路由。我用send_file来返回视频文件:
import os from flask import send_file, abort from models import db, Course, Lesson, StudyRecord @app.route("/media/videos/<int:course_id>/<path:filename>") @login_required def protected_video(course_id, filename): # 检查课时是否属于这个课程,避免拼接路径 lesson = Lesson.query.filter_by(course_id=course_id).first() if lesson is None: abort(404) # 检查学生是否已经看过该课程任意课时,简单判断是否有学习记录 record = StudyRecord.query.filter_by( user_id=session["user_id"], lesson_id=lesson.id ).first() if record is None: # 未购买/未选课时,禁止播放 abort(403) safe_filename = os.path.basename(filename) video_path = os.path.join( current_app.config["MEDIA_ROOT"], "videos", str(course_id), safe_filename ) if not os.path.isfile(video_path): abort(404) return send_file(video_path, conditional=True)send_file配合conditional=True时,Flask会处理HTTP Range请求,这样播放器的拖进度功能就不会出问题,也不会每次拖一下都重新下载整个文件。看完这段代码你可能会问,这样通过Python读文件流会不会很慢?在小型项目里没问题,但如果视频多、并发高,更合适的方式是让Nginx直接处理文件,我放在第7章部署部分展开。
5.4 播放页面的小优化
播放页面我用HTML5原生video标签,没有引入复杂的播放器框架,因为需求只是“能放、能续播、能收进度”。几个值得记录的设置:
preload="metadata",让浏览器只先加载视频元数据,不要下载整个文件,节省带宽;- 播放器宽高比固定为16:9,用CSS处理,避免页面布局跳动;
- 视频地址每次由后端模板渲染时生成,不提前写死在静态页面里;
- 在视频容器下方显示“学习进度”文字,用户能直观看到当前课时的完成状态。
6. 学习进度追踪:这个功能比想象中难一点
6.1 进度到底怎么定义
最开始我以为学习进度就是“用户打开过视频页面”,后来和教师角色一讨论,不对——教师真正想知道的是“学生有没有认真看完”。如果点击就算完成,系统里的完成率会严重失真。
最后我定义的规则是:课时播放到最后95%的位置,才标记为“已完成”。这个阈值不是拍脑袋定的,太严格容易因为视频结尾演职员表拖拽问题判成未完成,太宽松又会纵容拖进度条。95%是本地模拟测试后一个比较合适的值。
除了完成标记,系统还要记录每节课的最后观看位置和累计观看时长。这三个数据合起来才能支撑后续的“我的学习进度”“续播”“教师统计”等功能。
6.2 前端上报机制
前端逻辑我用一个时间监听来驱动:视频每播放5秒,上报一次当前播放位置。直接在timeupdate事件里发请求会太频繁,所以要做节流。
// 假设 video 是页面上唯一的 video 元素 let lessonId = document.getElementById('lesson-id').value; let lastReportTime = 0; let duration = 0; video.addEventListener('loadedmetadata', function () { duration = video.duration; }); video.addEventListener('timeupdate', function () { let now = Math.floor(video.currentTime); let nowTimestamp = Date.now(); // 每5秒上报一次 if (nowTimestamp - lastReportTime >= 5000) { lastReportTime = nowTimestamp; reportProgress(now, false); } }); video.addEventListener('ended', function () { reportProgress(Math.floor(video.currentTime), true); }); window.addEventListener('beforeunload', function () { // 页面关闭前尽力上报一次,适合丢续播断点 reportProgress(Math.floor(video.currentTime), false); }); function reportProgress(position, finished) { navigator.sendBeacon('/study/report_progress', new URLSearchParams({ lesson_id: lessonId, position: position, duration: Math.floor(duration), finished: finished ? '1' : '0' })); }这里有两个细节值得说。第一,我用了navigator.sendBeacon而不是普通fetch,是因为页面关闭时普通请求大概率发不出去,而sendBeacon是浏览器专门为这种“最后一条请求”设计的。第二,beforeunload上报的是断点位置,不标记完成;完成状态只在ended事件或进度达到95%时上报。
6.3 后端合并逻辑
后端接口收到上报数据后,要做的是合并而不是简单覆盖。因为前端可能同时发出多条请求,如果只取最后一条,先到的请求可能把后到的覆盖掉。
处理思路是:利用study_record表的唯一约束,执行upsert。用户对该课时已有记录就更新,没有就插入。is_finished字段遵循一个规则——如果前端明确传了完成标志为1,或者当前position / duration >= 0.95,就置为True;否则保持现状,不能把已完成的记录改回未完成。
from datetime import datetime from models import db, StudyRecord @app.route("/study/report_progress", methods=["POST"]) @login_required def report_progress(): lesson_id = request.form.get("lesson_id", type=int) position = request.form.get("position", default=0, type=int) duration = request.form.get("duration", default=0, type=int) finished_flag = request.form.get("finished", default="0") == "1" lesson = Lesson.query.get(lesson_id) if lesson is None: return {"code": 404, "msg": "课时不存在"} record = StudyRecord.query.filter_by( user_id=session["user_id"], lesson_id=lesson_id ).first() if record is None: record = StudyRecord( user_id=session["user_id"], lesson_id=lesson_id, last_position=position, ) db.session.add(record) else: record.last_position = position record.last_viewed_at = datetime.now() if duration > 0 and position >= int(duration * 0.95): record.is_finished = True elif finished_flag: record.is_finished = True db.session.commit() return {"code": 0, "msg": "ok"}并发情况下两个请求都认为没有记录,可能都会走INSERT分支。这时靠UniqueConstraint兜底,如果插入冲突会抛异常,再接一个UPDATE分支即可。这个我在第3章提过,是真正的生产级细节。
6.4 课程列表进度统计的N+1问题
进度记录做出来后,我很快遇到一个性能问题:课程列表页要显示每个课程的进度百分比。最朴素的写法是在循环里每门课程查一次进度记录,比如查询10门课就要发出10条SQL,这就是典型的N+1查询。
优化方式是先查当前用户的所有学习记录,再一次性处理:
records = StudyRecord.query.filter_by(user_id=session["user_id"]).all() course_progress = {} for rec in records: lesson = lesson_map.get(rec.lesson_id) if lesson is None: continue course_id = lesson.course_id course_progress.setdefault(course_id, {"finished": 0, "total": 0}) course_progress[course_id]["total"] += 1 if rec.is_finished: course_progress[course_id]["finished"] += 1 # 再根据 total 计算出 completed 百分比同时用一次查询把所有课时拿到手,用字典做映射,避免在for循环里再查课时。这种优化非常有效,课程页的响应时间下降了一个数量级。如果你在自己的项目里发现页面越来越慢,首先要怀疑的就是循环里的查询。
7. 部署上线:Gunicorn + Nginx 的实践记录
7.1 生产环境配置切换
功能开发完不等于项目做完,部署环节才是各种隐性问题的集中爆发点。第一步是准备生产环境依赖,我用venv创建独立虚拟环境,并导出requirements.txt:
python3 -m venv venv source venv/bin/activate pip install flask flask-sqlalchemy flask-wtf gunicorn pymysql pip freeze > requirements.txt切换生产环境时,我还做了几件必须做的事:
- 关闭
debug=True,绝不把调试器暴露到公网; - 设置强随机的
SECRET_KEY,从环境变量读取; - 把
DATABASE_URL切换到MySQL; - 调整Session Cookie的安全属性,比如
SESSION_COOKIE_HTTPONLY=True和SESSION_COOKIE_SAMESITE="Lax"。
这些配置项看起来零散,但线上出问题往往就在这些细节上。
7.2 Gunicorn 进程管理
生产环境不能使用Flask自带的开发服务器,它单线程且并发能力差。我用Gunicorn作为WSGI服务器。
启动命令看似简单,参数要说明白:
gunicorn -w 4 -b 127.0.0.1:8000 app:app-w 4表示启动4个worker进程,-b 127.0.0.1:8000表示只监听本机端口,等待Nginx转发过来。为什么不直接监听公网80端口?因为把Gunicorn直接暴露给外界不够灵活,也无法处理静态文件、请求压缩、连接限制等任务。
我进一步把它注册成systemd服务,保证服务器重启后项目能自动拉起。服务文件大致如下:
[Unit] Description=Flask Online Education Platform After=network.target [Service] User=www-data Group=www-data WorkingDirectory=/srv/www/myedu EnvironmentFile=/srv/www/myedu/.env ExecStart=/srv/www/myedu/venv/bin/gunicorn --workers 4 --timeout 60 --bind 127.0.0.1:8000 app:app Restart=always [Install] WantedBy=multi-user.target写服务文件时我踩过一个坑:EnvironmentFile里的配置项名如果和环境变量重名会覆盖进程环境,所以我在.env里写的每一项都检查过,尤其避免PATH这种系统变量被覆盖。
7.3 Nginx 反向代理与媒体文件分发
Nginx在这个架构里承担两个职责:反向代理动态请求,以及直接处理静态文件和部分媒体文件的访问。
反向代理配置比较简单:
server { listen 80; server_name myedu.example.com; client_max_body_size 600m; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 120s; } }client_max_body_size必须大于Flask里的MAX_CONTENT_LENGTH,否则视频上传会先在Nginx层被拒绝。proxy_read_timeout调大到120秒,避免大视频上传或转码等待时连接被切断。
媒体文件如果也全部走send_file,每个视频都要经过Gunicorn进程,高并发时CPU立刻被打满。更合理的做法是分两个层级:视频如果不需要权限验证,Nginx直接alias到本地目录;如果必须验证权限,就用Nginx内部跳转X-Accel-Redirect。这里有个基础优化配置:
location /media/ { alias /srv/www/myedu/media/; internal; # 只允许Nginx内部跳转访问,不允许外部直接请求 }对应的Flask视图在验证权限后,不再返回视频文件,而是返回一个X-Accel-Redirect响应头,由Nginx把文件真正发给用户。这样视频流不经Python进程,速度和并发能力都会明显提升。条件允许的话,这是最推荐的方案。
7.4 上线后最值得检查的几个点
项目上线不等于万事大吉,我按自己的经验列了一份检查清单:
- 访问日志和错误日志是否分开记录。Python应用至少要能看到Gunicorn的error日志,否则排查线上问题全靠猜。
- 上传功能是否完整走通。测试时用一个真实超过100MB的视频测一遍,不要只传小文件。
- 视频拖进度是否正常。
send_file的conditional=True和Nginx的Range支持都要验证。 - 数据库连接池配置是否合理。如果用SQLAlchemy连MySQL,连接池大小要根据worker数和并发量设定,默认值在小并发下够用,但最好显式确认。
- 定时任务是否需要。比如清理未完成的上传临时文件、生成课程统计缓存,项目复杂后一定会用到。
我在模拟项目X上线后的第一次高峰访问里,就发现Gunicorn的默认timeout太短,视频请求一多就频繁重启worker。把timeout调到60秒并加长proxy_read_timeout之后,问题才彻底消失。这种问题不真正上线是永远不会遇到的,靠读文档也很难提前发现。
还有一个容易被忽略的点:Session Cookie要尽快加上Secure属性,确保只在HTTPS下传输。如果暂时只跑HTTP,至少先把HttpOnly和SameSite设好,然后再考虑域名证书和HTTPS跳转。教育平台的用户数据比普通博客敏感,安全上的投入都是值得的。