基于Flask的在线教育平台是我最近完整走了一遍设计与实现全流程的模拟项目。老实说刚拿到这个需求的时候我以为在线学习网站只要套一套现成的模板就能跑真正动手之后才发现难度全藏在那些“看上去很简单”的功能上——视频课件怎么放、学习进度怎么记、学生和教师怎么共处一个系统。这篇文章会把整个项目的来龙去脉、核心表结构、双角色认证、视频处理、进度追踪和上线部署都串起来讲。如果你正准备做一个Flask项目或者正在规划一个类似的在线教育系统这份思路可以直接拿过去改。1. 从一次选题调研说起为什么是Flask1.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、返回responseservice层负责计算和学习规则数据模型层只负责表的读写。比如“计算一个学生的课程完成率”这种逻辑就应该放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设置成mysqlpymysql://用户名:密码localhost/数据库名。这种方式对刚开始接触Flask的人是值得养成的习惯数据库选型永远别写死在代码里。3.2 四张核心表的字段设定整个系统其实就围绕四张表展开。我最初设计的时候字段比现在多一倍后来砍掉不少。核心原因还是那句话能用字段解决的事情不要多建表但也不能把所有信息都塞进一张表。数据表关键字段作用userid, username, password_hash, display_name, role, status, created_at用户主表role区分学生/教师/管理员courseid, teacher_id, title, category, description, cover_url, status, created_at课程基本信息teacher_id关联教师lessonid, course_id, title, video_url, video_duration, sort_order课时一门课包含多个课时study_recordid, 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_keyTrue) username db.Column(db.String(64), uniqueTrue, nullableFalse, indexTrue) password_hash db.Column(db.String(256), nullableFalse) display_name db.Column(db.String(64), nullableFalse) role db.Column(db.String(20), defaultstudent, indexTrue) status db.Column(db.Integer, default1) # 1正常 0禁用 created_at db.Column(db.DateTime, defaultdatetime.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, nameuq_user_lesson), ) id db.Column(db.Integer, primary_keyTrue) user_id db.Column(db.Integer, db.ForeignKey(user.id), nullableFalse) lesson_id db.Column(db.Integer, db.ForeignKey(lesson.id), nullableFalse) is_finished db.Column(db.Boolean, defaultFalse) last_position db.Column(db.Integer, default0) # 秒 total_watch_time db.Column(db.Integer, default0) # 秒 first_viewed_at db.Column(db.DateTime, defaultdatetime.now) last_viewed_at db.Column(db.DateTime, defaultdatetime.now)这些字段看起来简单但每一条都有讲究。password_hash存的是哈希结果而不是明文这个是底线。StudyRecord里的联合唯一约束保证了同一个学生对同一课时的学习记录不会重複插入这个约束在并发情况下特别有用。3.4 外键、索引和那些容易踩的坑字段定了不代表表结构就没问题了。实际开发里还要处理几个隐藏点外键与级联删除。删除课程的时候如果数据库层面没有级联配置ORM的关联对象很容易变成悬挂记录。我在外键上加了ondeleteCASCADE同时在ORM的relationship里也定义了cascadeall, 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(usernameusername).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(usernameusername, display_namedisplay_name, rolestudent) 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_ROOTimport 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_okTrue) 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_idcourse_id).first() if lesson is None: abort(404) # 检查学生是否已经看过该课程任意课时简单判断是否有学习记录 record StudyRecord.query.filter_by( user_idsession[user_id], lesson_idlesson.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, conditionalTrue)send_file配合conditionalTrue时Flask会处理HTTP Range请求这样播放器的拖进度功能就不会出问题也不会每次拖一下都重新下载整个文件。看完这段代码你可能会问这样通过Python读文件流会不会很慢在小型项目里没问题但如果视频多、并发高更合适的方式是让Nginx直接处理文件我放在第7章部署部分展开。5.4 播放页面的小优化播放页面我用HTML5原生video标签没有引入复杂的播放器框架因为需求只是“能放、能续播、能收进度”。几个值得记录的设置preloadmetadata让浏览器只先加载视频元数据不要下载整个文件节省带宽播放器宽高比固定为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, typeint) position request.form.get(position, default0, typeint) duration request.form.get(duration, default0, typeint) finished_flag request.form.get(finished, default0) 1 lesson Lesson.query.get(lesson_id) if lesson is None: return {code: 404, msg: 课时不存在} record StudyRecord.query.filter_by( user_idsession[user_id], lesson_idlesson_id ).first() if record is None: record StudyRecord( user_idsession[user_id], lesson_idlesson_id, last_positionposition, ) 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 课程列表进度统计的N1问题进度记录做出来后我很快遇到一个性能问题课程列表页要显示每个课程的进度百分比。最朴素的写法是在循环里每门课程查一次进度记录比如查询10门课就要发出10条SQL这就是典型的N1查询。优化方式是先查当前用户的所有学习记录再一次性处理records StudyRecord.query.filter_by(user_idsession[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.txtpython3 -m venv venv source venv/bin/activate pip install flask flask-sqlalchemy flask-wtf gunicorn pymysql pip freeze requirements.txt切换生产环境时我还做了几件必须做的事关闭debugTrue绝不把调试器暴露到公网设置强随机的SECRET_KEY从环境变量读取把DATABASE_URL切换到MySQL调整Session Cookie的安全属性比如SESSION_COOKIE_HTTPONLYTrue和SESSION_COOKIE_SAMESITELax。这些配置项看起来零散但线上出问题往往就在这些细节上。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] DescriptionFlask Online Education Platform Afternetwork.target [Service] Userwww-data Groupwww-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 Restartalways [Install] WantedBymulti-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的conditionalTrue和Nginx的Range支持都要验证。数据库连接池配置是否合理。如果用SQLAlchemy连MySQL连接池大小要根据worker数和并发量设定默认值在小并发下够用但最好显式确认。定时任务是否需要。比如清理未完成的上传临时文件、生成课程统计缓存项目复杂后一定会用到。我在模拟项目X上线后的第一次高峰访问里就发现Gunicorn的默认timeout太短视频请求一多就频繁重启worker。把timeout调到60秒并加长proxy_read_timeout之后问题才彻底消失。这种问题不真正上线是永远不会遇到的靠读文档也很难提前发现。还有一个容易被忽略的点Session Cookie要尽快加上Secure属性确保只在HTTPS下传输。如果暂时只跑HTTP至少先把HttpOnly和SameSite设好然后再考虑域名证书和HTTPS跳转。教育平台的用户数据比普通博客敏感安全上的投入都是值得的。