做这个系统其实是个偶然。当时朋友所在的设计培训机构需要一套给学员用的自主学习平台,要求能放图文课程、记录学习进度、支持学员提交作品,还要让老师能看出谁真正学完了。前前后后对比了一圈现成方案,要么太重,要么不好改,最后决定用Flask把这套设计课程自主学习系统后端从零搭起来。本文就把整个开发过程中的选型思考、数据库设计、认证方案、跨域处理和部署实测完整记录下来,给准备用Flask做类似教学类后端的同学一个可直接参考的样本。
1. 这个学习系统后端到底要解决什么问题
任何项目在动手前,先想清楚边界。设计类课程自主学习系统和常见的视频课程平台不同,它的内容形态更丰富,不仅有多章节图文教程,还有大量的参考图、设计稿源文件、作业提交,以及按阶段推进的学习任务。从后端的职责来看,核心要解决四件事。
第一是课程内容的结构化存取。一门设计课通常被拆成多个模块,每个模块下有若干章节,章节里包含正文内容、图片素材、附件下载。有些课程还有版本更新的需求,比如讲师觉得第三章不够好,改了之后不希望影响已经学过的人的进度记录。这就要求课程内容与学习进度解耦,内容可以变,但每个人的学习状态是独立的。
第二是用户体系的建立与权限控制。系统里至少有两类角色:学员和教师(或者管理员)。学员能看到自己选了什么课、学到哪里、交过哪些作业;教师能创建课程、更新章节、查看学员的完成情况。不能用一张users表一个is_admin字段糊弄过去,因为教学系统里还需要区分"课程作者"和"平台管理员",权限维度比普通CMS复杂。
第三是学习进度的实时记录。这看起来简单,实际上是个很容易想当然的功能。很多新手会直接在课程表里加一个is_finished字段,或者把进度存在用户表里。但只要课程章节数超过十个,就知道这种设计有多难维护。进度必须是一张独立的关联表,记录"哪个用户、在哪个课程、完成了哪个章节、完成时间是什么",这样才能支持断点续学、课程完成率统计、教师端的学情查看。
第四是作品与作业的提交管理。设计课程的输出物通常是图片或者工程文件,大小不一,格式多样。后端需要提供稳定的上传接口、合理的文件命名策略、访问权限控制,还要防止上传路径被恶意构造。这一块我在开发时踩过坑,后面专门用一章来写。
规模上不用过分担心。对于这种面向特定培训机构或院校的自主学习系统,几千名学员、几百门课程的数据量,Flask加关系型数据库单机部署完全扛得住。只要不设计出烂到离谱的慢查询,瓶颈几乎不会出现在后端语言层面。
2. Flask与FastAPI的选择:学习系统场景下的权衡
最近两年一提Python后端,FastAPI的呼声很高,异步高性能、自动生成OpenAPI文档、Pydantic校验确实香。我也认真考虑过要不要用FastAPI来写,但最终选择了Flask,这个决定在开发中期和部署阶段被证明是正确的。先说结论:Flask的成熟生态与渐进式复杂度,更适合中小型业务系统的快速落地和长期维护。
做个直观对比:
| 对比维度 | Flask | FastAPI |
|---|---|---|
| 异步支持 | 原生WSGI,同步为主,配合gunicorn多worker | 原生ASGI异步,高并发IO场景占优 |
| 数据库生态 | SQLAlchemy集成资料多,Flask-SQLAlchemy开箱即用 | SQLAlchemy同样可用,但异步ORM搭配需要额外学习 |
| 认证授权 | Flask-JWT-Extended、Flask-Login方案成熟 | python-jose等方案同样可行,但样板代码偏多 |
| 学习成本 | 文档通俗,周边教程丰富,遇到问题搜得到答案 | 上手快,但异步思维和依赖注入需要适应 |
| 部署资料 | nginx+gunicorn+Flask教程一大把,宝塔也有一键流程 | uvicorn部署简单,但生产调优资料相对少 |
| 适合场景 | 传统业务系统、CMS、教学平台、中小型API | 高并发API服务、实时数据接口、AI模型服务 |
对于学习系统这个具体场景,有个很关键的现实因素:团队的协作成本。如果后续接手的人不熟悉异步编程,FastAPI 的 async def 用得不好,反而会在 IO 密集型操作上写出阻塞代码。Flask 的同步模型简单直接,一个视图函数处理一个请求,逻辑清清楚楚,任何有 Python 基础的开发都能快速上手。
另外一个让我坚定选 Flask 的点是Flask-SQLAlchemy 与 Flask-Migrate 的组合。教学系统的数据库结构在开发期会频繁变动——今天加一个课程封面字段,明天给作业表加一个评分字段。Flask-Migrate 基于 Alembic,可以平滑地做数据库迁移,不会因为改表结构导致数据丢失。FastAPI 里虽然也能用 Alembic,但需要手动初始化配置,样板代码多一层。
还有一个细节值得提:Flask 的路由和蓝图机制在组织这类多模块系统时非常舒服。我会把用户认证、课程内容、学习进度、作品上传拆成独立的蓝图,每个蓝图对应一个 Python 模块。FastAPI 用 APIRouter 也能做类似的事,但 Flask 蓝图的理念更贴近传统 MVC 项目的组织习惯,目录结构一看就懂。
如果你要做一个面向公众、预期并发极高的 API 服务,我推荐 FastAPI。但如果你要做的是内部教学平台或培训机构使用的业务系统,Flask 是更务实的选择。两者没有绝对的好坏,只有场景匹配度的问题。
3. 数据库模型设计:课程、用户、学习进度是三个核心
数据库是整个后端系统的地基,模型设计得好不好,直接决定后面写接口是费劲还是顺畅。我在这个项目里最终落地的模型结构,是在推翻两版设计之后定下来的。这里分享最终的方案,并且说明每个关键设计的理由。
3.1 用户模型:不止是账号密码
用户表不能只存用户名和密码。因为我需要区分平台管理员、教师、学员三类角色,而且教师还可能是某几门课程的作者。最终我用的是角色字段加关联表的方式,而不是单独建三张用户表。
class User(db.Model): __tablename__ = 'users' 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(128), nullable=False) email = db.Column(db.String(120), unique=True, nullable=False) role = db.Column(db.String(20), nullable=False, default='student') # admin / teacher / student avatar_url = db.Column(db.String(256)) created_at = db.Column(db.DateTime, default=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 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)密码加密用的是 Werkzeug 的generate_password_hash,默认的 pbkdf2 算法已经足够安全,不要自己去写什么加盐算法,没有意义,标准库帮你把该考虑的事情都考虑好了。
用户表的role字段用字符串而不是数字,是考虑到可读性。写接口的时候if user.role != 'teacher'比if user.role != 2直观得多,也不会出现"2到底是老师还是管理员"的困惑。
3.2 课程与章节:内容结构要有序
设计类课程和普通文档课程的最大区别是内容形态多。一个章节里可能有正文、图片、附件、甚至一个小的测验。所以课程和章节需要分成两张表,并且章节必须有一个sort_order字段来维护顺序。
class Course(db.Model): __tablename__ = 'courses' id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(128), nullable=False) subtitle = db.Column(db.String(256)) cover_image = db.Column(db.String(256)) description = db.Column(db.Text) teacher_id = db.Column(db.Integer, db.ForeignKey('users.id')) is_published = db.Column(db.Boolean, default=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) class Chapter(db.Model): __tablename__ = 'chapters' id = db.Column(db.Integer, primary_key=True) course_id = db.Column(db.Integer, db.ForeignKey('courses.id'), index=True) title = db.Column(db.String(128), nullable=False) content = db.Column(db.Text) # 正文,用 Markdown 或富文本 HTML video_url = db.Column(db.String(256)) # 可选的视频链接 attachment_url = db.Column(db.String(256)) # 可选的附件下载地址 sort_order = db.Column(db.Integer, default=0, index=True) is_free = db.Column(db.Boolean, default=False) # 是否允许试读 created_at = db.Column(db.DateTime, default=datetime.utcnow)有个细节容易被忽略:is_free字段。很多教学平台的课程会开放前几章让游客试读,以吸引注册。这个字段放在章节级别而不是课程级别,是因为试读策略通常是"前两章能读,后面要报名",放在章节上才能灵活控制。
课程与教师的关系为什么要用外键而不是简单地冗余一个教师名字?因为后期做学情统计时,经常要根据教师 ID 聚合查询"这个老师名下所有课程的学员完成率",有了外键和索引,一条 SQL 就能算出来,不用在业务层做内存级联筛选。
3.3 学习进度表:这是整个系统的灵魂
学习进度的设计经历了三个版本。
V1 是在 users 表里加一个current_course_id和progress_json字段,用 JSON 存一个映射表,比如{"course_1": {"chapter_5": True}}。这种方案看着简单,字符串拼接就能搞定,但完全没法做 SQL 查询,比如"找出所有学完第三章的人"就只能全表扫描再解析 JSON,数据量大了必炸。
V2 是在课程表加一个learners的 JSON 数组,记录每个学完课程的人。这更糟糕,因为用户和课程是多对多关系,一个用户可以学多门课,这样设计是在重复造一张表。
V3 就是最终方案,建一张独立的关联表,既是学习进度记录,也是用户选课关系表:
class Enrollment(db.Model): __tablename__ = 'enrollments' id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), index=True) course_id = db.Column(db.Integer, db.ForeignKey('courses.id'), index=True) progress = db.Column(db.Integer, default=0) # 已学章节数,方便列表页展示 is_completed = db.Column(db.Boolean, default=False) completed_chapters = db.Column(db.Text, default='') # 逗号分隔的章节ID集合 created_at = db.Column(db.DateTime, default=datetime.utcnow) completed_at = db.Column(db.DateTime, default=None) __table_args__ = ( db.UniqueConstraint('user_id', 'course_id', name='uq_user_course'), )这里有两个关键设计点。
第一,progress是一个冗余的整数字段,直接存"已经学完了几个章节"。虽然可以通过completed_chapters拆开算出这个数字,但课程列表页要显示学习进度条,如果每次都要把字符串解析成列表再数元素,性能就会成为问题。用一个整数字段,一个前端接口直接返回,一条 SQL 都不用多查。
第二,completed_chapters用逗号分隔的 ID 字符串,而不是单独的关联表。这里我要坦白说,这是一个妥协方案。严格的关系型设计会建一张chapter_completions表,每完成一章插一条记录,这样能精确查询"每个章节被多少人完成过"。但实际上这个系统里,我的需求只是从整体上判断学到了哪里,精确到"某个章节已完成"就足够了。一张关联表会让接口逻辑冗长,插入操作多好几倍。字符串方案在千级章节数以下完全够用,而且用SELECT FIND_IN_SET(某章节ID, completed_chapters)这种 MySQL 函数或者 Python 端的简单判断都能处理。
进度更新的逻辑也很关键。前端在用户学完一章后调用进度更新接口,后端需要做"增量"处理而不能是"全量覆盖"。代码思路是:先把旧的completed_chapters解析成集合,把新完成的章节 ID 加进去,再写回字符串。同时更新progress字段,判断progress是否等于该课程的总章节数,如果是就标记is_completed=True并写completed_at时间。
def update_progress(user_id, course_id, chapter_id): enrollment = Enrollment.query.filter_by( user_id=user_id, course_id=course_id ).first() if not enrollment: # 首次学习则自动选课 enrollment = Enrollment(user_id=user_id, course_id=course_id) db.session.add(enrollment) completed_set = set( int(x) for x in enrollment.completed_chapters.split(',') if x ) completed_set.add(chapter_id) enrollment.completed_chapters = ','.join(str(x) for x in sorted(completed_set)) enrollment.progress = len(completed_set) chapter_count = Chapter.query.filter_by(course_id=course_id).count() if enrollment.progress >= chapter_count: enrollment.is_completed = True enrollment.completed_at = datetime.utcnow() db.session.commit()3.4 作业提交表:设计课程特有的一块
因为教学设计课程的特殊性,我增加了作业提交功能。模型也分为两个角色视角:学员提交作品,教师给反馈。
class Assignment(db.Model): __tablename__ = 'assignments' id = db.Column(db.Integer, primary_key=True) course_id = db.Column(db.Integer, db.ForeignKey('courses.id')) chapter_id = db.Column(db.Integer, db.ForeignKey('chapters.id')) user_id = db.Column(db.Integer, db.ForeignKey('users.id')) file_url = db.Column(db.String(256), nullable=False) thumbnail_url = db.Column(db.String(256)) submission_note = db.Column(db.Text) # 学员的自述说明 feedback = db.Column(db.Text) # 教师评语 score = db.Column(db.Integer) # 评分 0-100 reviewed_at = db.Column(db.DateTime) created_at = db.Column(db.DateTime, default=datetime.utcnow)thumbnail_url是我在初版设计时漏掉的字段,后来补上的。设计课的作品提交通常是图片,列表页如果直接加载原图,页面大小会很夸张。我在上传时用 Pillow 库生成一张压缩过的缩略图,列表页加载又快又省流量。这个细节强烈建议所有涉及图片上传的开发者都考虑进去。
4. 认证与权限:JWT方案在前后端分离下的落地
这个系统的前端是 Vue 做的,与后端完全分离部署。Session 认证在这种架构下体验很差——跨域要处理 Cookie,移动端还要兼容 Cookie 存储。所以认证方案我直接用了 JWT,具体是flask-jwt-extended这个扩展。
4.1 登录接口与 Token 设计
登录接口很简单:接收用户名和密码,校验通过后返回一个 access_token 和 refresh_token。access_token有效期我设置为2 小时,refresh_token有效期设置为7 天。为什么分开?因为如果只有一个 token,过期了用户就要重新登录,对于一个学习平台来说,让人家学到一半跳出去登录是很糟糕的体验。双 token 机制下,前端检测到 access_token 过期后,自动用 refresh_token 换取新的,用户基本无感知。
from flask_jwt_extended import create_access_token, create_refresh_token @app.post('/api/auth/login') def login(): data = request.get_json() user = User.query.filter_by(username=data.get('username')).first() if not user or not user.check_password(data.get('password')): return jsonify(code=400, message='用户名或密码错误'), 400 access_token = create_access_token(identity=str(user.id)) refresh_token = create_refresh_token(identity=str(user.id)) return jsonify( code=0, data={ 'access_token': access_token, 'refresh_token': refresh_token, 'user_info': { 'id': user.id, 'username': user.username, 'role': user.role, 'avatar_url': user.avatar_url, } } )这里有个值得分享的坑:create_access_token的 identity 参数只接受字符串,如果传 int 会直接报错。我第一次写的时候传了user.id的整数,跑测试才发现这个问题。建议习惯性用str(user.id)。
4.2 角色权限校验的装饰器设计
@jwt_required()只能保证你有登录身份,不能保证你是教师。Flask 中可以通过自定义装饰器做角色控制:
from functools import wraps from flask_jwt_extended import get_jwt_identity def role_required(*roles): def wrapper(fn): @wraps(fn) @jwt_required() def decorator(*args, **kwargs): user_id = get_jwt_identity() user = db.session.get(User, int(user_id)) if user.role not in roles: return jsonify(code=403, message='没有权限访问'), 403 return fn(*args, **kwargs) return decorator return wrapper使用的时候就很灵活了:
@app.get('/api/teacher/courses') @role_required('teacher', 'admin') def teacher_courses(): courses = Course.query.filter_by(teacher_id=get_jwt_identity_as_int()).all() return jsonify(code=0, data=[c.to_dict() for c in courses])权限控制的原则是:能在装饰器层拦住的,绝不在业务代码里用 if 判断。这样每个接口的权限模型一目了然,review 代码的时候扫一眼装饰器就知道谁能访问。
另外提一下get_jwt_identity()返回的是字符串,因为前面创建 token 时传入的是str(user.id)。每次要从 token 拿到用户 ID 再去数据库查用户,这确实会多一次查询。不过学习系统这个量级完全无所谓,请放心用。如果真要优化到极致,可以考虑用get_jwt()拿到 claims 里的自定义字段,但别为了这点性能牺牲了可维护性。
4.3 token 过期的前端配合
前端那边配合这套 JWT 方案也踩了些坑。我让前端在 axios 拦截器里统一处理 401 响应:收到 401 后,判断本地是否有 refresh_token,有就静默调用刷新接口,刷新成功后重放原请求,失败就跳转登录页。后端只需要保证刷新接口本身返回语义清晰的状态码就好。
刷新接口我限制为只能使用 refresh_token,不允许 access_token 调用刷新接口,否则会带来安全隐患。用@jwt_required(refresh=True)装饰器做区分,这是 flask-jwt-extended 提供的标准能力。
5. 跨域与API设计:前后端对接时踩过的坑
前后端分离的项目,跨域问题是绕不开的。开发时前端在localhost:5173,后端在localhost:5000,浏览器的同源策略立刻就会给你颜色看。我第一次前后端联调时,前端请求后端接口直接报blocked by CORS policy,实际上就是响应头里少了Access-Control-Allow-Origin。
5.1 CORS 的正确配置方式
最简单可靠的是用flask-cors这个库。我的配置如下:
from flask_cors import CORS CORS(app, resources={ r'/api/*': { 'origins': ['http://localhost:5173', 'https://learn.example.com'], 'methods': ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], 'allow_headers': ['Content-Type', 'Authorization'], 'supports_credentials': True } })resources这个参数很关键,我一开始图省事,写的是CORS(app)直接全部放开。后来发现生产环境什么来源都能访问,才意识到这是个安全隐患。正确的做法是只允许前端的域名,并且在部署到不同环境时用环境变量来控制。
如果前端要携带Authorization请求头,必须把allow_headers里加上,同时 CORS 请求会先发一个 OPTIONS 的预检请求(preflight),Flask 的路由通常不会自动处理这个请求,flask-cors 库会在背后自动拦截并返回正确的响应头,这里不需要自己写 OPTIONS 路由处理函数。
5.2 统一响应结构:避免前后端各写一套解析逻辑
API 返回结构我定为三层:成功返回code=0,业务失败返回非 0 的 code,遇到鉴权问题返回 401 或 403 的 HTTP 状态码并带上code字段。
def success(data=None, message='ok'): return jsonify(code=0, message=message, data=data) def fail(message='error', code=1, http_status=400): return jsonify(code=code, message=message, data=None), http_status为什么 HTTP 状态码之外还要一个业务 code?因为有些场景不需要 HTTP 状态码变化,比如密码校验失败返回 400 是合理的,但"订单状态不能重复提交"这类业务冲突,用 200 加上业务 code 更容易让前端全局拦截器区分逻辑。每个团队有自己的约定,关键是前后端要一致。我在设计接口文档时,给的示例都是统一会用code/message/data的结构,避免前端每个页面写一套判断逻辑。
5.3 RESTful 资源的命名与嵌套粒度
学习系统的接口路径我遵循这么几个原则:
- 资源名用复数:
/api/courses、/api/users - 子资源用嵌套:
/api/courses/3/chapters - 动作不放在 URL 上:用 HTTP 方法区分,不搞
/api/course/getDetailById
有一个现实中容易引起争论的点是:该用整型 ID 还是 UUID 做主键?这个系统我最终选择了整型自增主键。理由很简单:内网教学系统的 ID 暴露不涉及安全风险,自增 ID 的查询性能更好,联表查询的代码也更简洁。如果你做的是面向公网且用户可互相查看他人信息的系统,才需要认真考虑用 UUID 代替自增 ID,防止被别人通过遍历 ID 爬取数据。
6. 文件上传与静态资源管理:设计课程内容的核心
前面反复提到设计类课程的附件和作业上传,这一章单独展开说说。上传是教学系统里最容易出安全事故和 bug 的地方,必须小心处理。
6.1 上传接口的实现与文件命名策略
我做了两个上传接口:一个是教师上传课程资源(图片、附件),一个是学员提交作业。两者的存储目录不同,权限也不同。
UPLOAD_BASE_DIR = os.path.join(os.getcwd(), 'uploads') ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf', 'zip', 'rar'} def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS @app.post('/api/upload') @role_required('teacher', 'admin') def upload_file(): file = request.files.get('file') if file is None or file.filename == '': return fail('未选择文件') if not allowed_file(file.filename): return fail('文件类型不允许上传', http_status=415) current_date = datetime.now().strftime('%Y/%m/%d') sub_dir = os.path.join(UPLOAD_BASE_DIR, current_date) os.makedirs(sub_dir, exist_ok=True) ext = file.filename.rsplit('.', 1)[1].lower() uuid_name = f'{uuid.uuid4().hex}.{ext}' file_path = os.path.join(sub_dir, uuid_name) file.save(file_path) url_path = f'/uploads/{current_date}/{uuid_name}' return success(data={'url': url_path})这里有一个我踩过的坑:前端传过来的 filename 可能是中文,如果直接用原来的文件名保存,既可能触发编码问题,又可能导致目录出现不可控的字符。正确做法就是上面代码里那样,用自己的uuid4().hex生成随机文件名,扩展名从原文件名里提取并白名单校验。这样既避免了文件名冲突,也防了一手路径遍历攻击(比如文件名里带../)。
6.2 图片压缩与缩略图生成
设计课程的展示位置需要图片,作业缩略图也需要压缩。我用 Pillow 做图片处理:上传时如果发现是图片类型,就生成一份宽度不超过 800px 的压缩版本,作为内容展示图;再生成一份宽度 200px 的版本作为缩略图。
from PIL import Image def generate_thumbnails(file_path, url_base_path): img = Image.open(file_path) img.thumbnail((200, 200)) thumb_path = file_path.replace('.', '_thumb.') img.save(thumb_path, quality=85) return f'{url_base_path.replace(".", "_thumb.")}'这段代码虽然简单,但要注意:不同格式的图片在 Pillow 中可能要显式处理 EXIF 旋转问题。手机上传的照片常常带了旋转信息,直接缩略会导致图片方向不对。解决方案是读取exif_transpose之后再缩略。这个细节我是在测试学员用手机传图时发现的,当时还以为是前端 CSS 的问题。
6.3 静态资源的访问控制
把文件放在uploads目录后,Flask 需要配置静态文件路由来访问:
@app.route('/uploads/<path:filename>') def uploaded_file(filename): # 不强制登录的公开资源:课程封面、公开章节的图片 return send_from_directory('uploads', filename)但作业提交的文件需要设置权限,不能谁都能下载别人的作业。我的做法是:把作业上传到uploads/assignments/子目录,然后给这个路由加上访问校验:
@app.route('/uploads/assignments/<path:filename>') @jwt_required() def assignment_file(filename): user_id = get_jwt_identity() assignment = Assignment.query.filter_by(file_url=f'/uploads/assignments/{filename}').first() if assignment is None: return fail('资源不存在', http_status=404) # 学员只能访问自己的作业,教师可以访问所有 if assignment.user_id != int(user_id) and current_user_role() not in ('teacher', 'admin'): return fail('无权访问', http_status=403) return send_from_directory('uploads/assignments', filename)这一块是多数教学系统做不好的地方,图省事直接开放了静态目录,结果学员的作业可以被随便遍历下载。给静态资源加一层访问控制在 Flask 里其实很轻量,关键是有没有这个意识。
6.4 后续存储扩展思路
当前方案是存本地磁盘,对教学系统来说够用。如果以后用户量上来,文件量变大,有两种演进路径:一是接入对象存储服务(把 URL 改为云存储提供的地址),二是用分布式存储并挂载到服务器磁盘上。
我现在的建议是:开发期用本地磁盘,上生产后如果预算充足,第一时间把 uploads 目录迁到对象存储,因为对象存储在带宽、冗余备份、访问加速上都有优势。迁移的改造成本很小,只要把上传接口的存储逻辑替换成 SDK 调用即可,对外暴露的 URL 结构和 API 参数可以保持不变。
7. 部署阶段的实测心得:nginx + gunicorn + Flask 的组合
系统开发完成后,部署我选了经典的nginx + gunicorn + Flask组合,服务器用的是宝塔面板管理。这一节把部署配置和遇到的实际问题完整记录下来。
7.1 gunicorn 配置与进程数选择
我使用的是 gunicorn 作为 WSGI 服务器,没有用 Flask 自带的 dev server——那个是真的只能开发用,并发能力差,而且会暴露调试信息。gunicorn 的启动命令如下:
gunicorn -w 4 -b 127.0.0.1:8000 -k gthread --threads 4 --timeout 60 app:app参数说明:
-w 4:启动 4 个 worker 进程-k gthread --threads 4:每个 worker 启 4 个线程--timeout 60:请求超时时间,默认 30 秒
worker 数和线程数的确定依据是服务器的 CPU 核数。一台 2 核 4G 的云服务器,我会配置-w 4 --threads 4,这样系统能同时处理 16 个并发请求,对于学习系统完全够用。这里不要盲目设置大数值,worker 太多反而会因为内存占用过高导致服务器卡死。
7.2 nginx 反向代理配置
nginx 负责对外接收请求、做静态文件缓存、负载到后端。关键的配置段:
server { listen 80; server_name learn.example.com; client_max_body_size 50m; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /uploads/ { alias /www/wwwroot/learn/uploads/; expires 7d; add_header Cache-Control "public"; } }client_max_body_size 50m这一行不能漏,否则上传超过默认 1M 的文件时,nginx 会直接给 413 错误,前端完全猜不到是什么情况。我最初就是没配这行,学员传作业图传不上去,排查了半小时。
/uploads/的静态文件由 nginx 直接处理,不经过 Flask,这样资源加载速度快很多,也减轻了 gunicorn 的压力。但这里有个坑要注意:如果/uploads/assignments/需要权限校验,就不能全量交给 nginx 直接返回静态文件。我的处理是有两套:公开的课程封面、章节图片走 nginx;作业文件走 Flask 的权限校验路由,所以 nginx 里只把公开上传目录的路径交给 alias,作业目录则全部反代到后端。
7.3 进程守护与自动重启
gunicorn 进程如果挂了,服务就断了。生产上我用 supervisor 来守护进程。配置如下:
[program:learn-backend] directory=/www/wwwroot/learn command=gunicorn -w 4 -b 127.0.0.1:8000 -k gthread --threads 4 --timeout 60 app:app autostart=true autorestart=true stderr_logfile=/www/wwwroot/learn/logs/gunicorn.err.log stdout_logfile=/www/wwwroot/learn/logs/gunicorn.out.logsupervisor 的autorestart=true会在进程意外退出时自动拉起。我实测过几次,gunicorn 内存异常退出后,几秒钟内就会被 supervisor 重新启动,服务基本不中断。不要裸跑 gunicorn,生产环境没有守护进程就是给自己埋雷。
宝塔面板本身也有 Python 项目管理器,可以用它来托管 gunicorn,但我个人更习惯直接改 supervisor 配置,因为命令行下操作可控性更高,日志路径也能自己指定。
7.4 部署时踩过的真实问题
第一,gunicorn worker 频繁超时重启。高峰期会出现Worker timed out的日志,原因是某个接口执行时间超过 60 秒,主要是批量导入课程数据时没用异步任务,直接在请求里同步处理了。解决方式:把导入操作拆成小批次,每次只处理 20 条,前端的交互改为分批轮询结果。到目前为止没有再出现长时间占用的请求。
第二,SQLite 到 MySQL 的迁移。开发时我图方便用了 SQLite,生产环境改成了 MySQL。连接配置从sqlite:///改成mysql+pymysql://user:pass@localhost/learn_db?charset=utf8mb4。这次迁移还算顺,但要注意 SQLAlchemy 模型里面如果用了 SQLite 特有的字段类型,迁移起来会很痛苦,建议一开始就用 MySQL 开发,或者至少在开发环境就用和线上一致的数据库。
第三,gunicorn 的 worker 数量与内存的关系。默认每个 worker 会加载完整的应用内存,我的应用大概占 150M,4 个 worker 就是 600M 左右,还没算 MySQL 的内存占用。如果服务器的内存只有 2G,建议 worker 数量降到 2。可以在 gunicorn 配置里加--max-requests 1000 --max-requests-jitter 100,让 worker 在处理一定量请求后自动重启,能有效缓解内存泄漏问题。
第四,跨域在生产环境又出问题。开发环境配的origins是http://localhost:5173,上线后前端域名变成了https://learn.example.com,结果所有接口又被 CORS 拦截了。所以前面我特意强调,要把允许的来源放到环境变量里,部署时改配置而不是改代码。这也是我这次实训的教训,希望大家别走弯路。
第五,日志监控的必要性。gunicorn 的错误日志要定期看,很多问题不是用户报出来的,而是日志里先出现的。我在服务器上挂了一个定时任务,每天检查日志文件大小和错误关键字,配合 supervisor 的日志切分,基本能做到问题早发现。
7.5 数据库连接池与并发
Flask-SQLAlchemy 会自动管理数据库连接池,默认pool_size=5,max_overflow=10。对于学习系统,这个配置一般够用。但如果你发现数据库连接数经常打满,可以显式调大:
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = { 'pool_size': 10, 'pool_recycle': 3600, 'pool_pre_ping': True, }pool_pre_ping=True这行很关键,它会在每次取连接前先 ping 一下,防止拿到失效的连接池连接。MySQL 默认的wait_timeout是 8 小时,如果 MySQL 主动断开了空闲连接,而连接池里还握着这个失效连接,查询就会报错,加了pool_pre_ping就能自动规避。
8. 接口文档与前后端协作的实操建议
作为一个自己独立开发的系统,接口文档好像可有可无,但正因为这套系统未来可能要交给别人维护,文档就成了必须品。我用的是 Apifox,把接口按模块分组,每个接口标好请求参数、返回示例、错误码含义。这样做的直接好处是前端对接时有据可查,不会跑来问你"这个接口返回什么字段"。接口文档我是边写代码边维护的,而不是等全部写完再补——补文档这件事一旦拖,就永远不会去做了。
在设计接口字段时,我统一了命名风格为下划线命名法(如course_id、user_name),前端在适配层做驼峰转换。一个小技巧是大多数前端网络库都支持响应拦截器,在拦截器里做 key 转换,比在每个组件里单独处理高效得多。
错误码的设计也需要提早约定。比如 401 表示未登录,403 表示无权限,404 表示资源不存在,这些语义和 HTTP 状态码保持一致。业务层面的自定义 code 我会从 1001 开始分配,1001 表示“课程未发布”,1002 表示“不能重复选课”,1003 表示“作业内容为空”等等。错误码的意义在于让前端能根据 code 做精确的提示或跳转,而不只是弹一个笼统的错误。
我想额外提醒的是:即使是两个人合作,也必须在数据库表结构定型后,第一时间把模型说明文档写出来。数据字典可以用 Excel 或在线表格维护,字段名、类型、是否必填、默认值、备注都要写清楚。这个系统开发到第三个月时,我回头再看当初建的表,有些字段的用途已经需要翻代码才能想起来了,文档化确实是省心的事。
9. 开发顺序与功能优先级:我的实际操作路径
最后分享一个偏管理层面的经验:这套系统从零开始,我的开发顺序不是按模块一个一个来,而是按下图这条主线:先打通最小闭环,再横向扩展功能。
第一步是做“用户注册登录 + 课程列表 + 课程详情章节”这条链路。虽然这看起来非常简单,但它验证的是前后端数据交互的整个通道是否畅通,包括跨域、认证、数据库读写、接口响应格式。一个能跑起来的最小闭环,比一堆写了一半的功能强得多。
第二步加“选课与学习进度记录”,让系统具备学习平台的雏形。这时每多一个接口,前端都能立刻联调,而不是等你把所有功能写完。
第三步加入“作业上传与教师评价”,把教学设计课程特有的闭环补全。
第四步做管理后台的接口,比如课程发布、章节内容管理、学员列表与进度查看。考虑到 CMS 功能其实就是基础的增删改查,我把它放到了后面,不给前期核心功能拖后腿。
第五步才是性能优化和装饰性功能,比如图片压缩、封面裁剪、接口响应速度优化、日志记录等。
这个顺序看似很基础,但它保证了项目在任何阶段都是可用的,而不是在最后一个星期才把所有模块拼在一起然后疯狂 debug。我见过太多从后端框架搭建开始就想着一步到位把权限、多角色、消息通知全部设计好,结果写了两个月连一个完整的课程详情页还没跑通的案例。
另外,开发时要善用脚手架工具。我用了flask-blueprint和flask-restx的辅助方法,但并没有引入全套重型框架。对于这种规模的后端,直接写清楚路由函数比引入太多抽象层更有价值,核心是让代码可读、可维护。
最后说一点个人体会
整套系统从设计到上线,最有价值的经验其实不是某个具体技术细节,而是始终把真实业务场景放在第一位。设计课程学习系统这种业务,后端的技术难点不在并发或者分布式,而在于数据模型是否贴合真实的教学流程、权限设计是否能覆盖多种师生互动场景、文件管理是否能兼顾安全性和易用性。Flask 恰好是这样的框架:它不强求你用什么模式,但也绝不限制你做出规整的项目结构。
如果你的项目也是中小型的教学系统,按照我上面写的模型设计和接口划分来做,开发周期大约在四到六周可以完成核心功能。后面需要扩展时,优先考虑按模块新增蓝图,而不是修改旧逻辑。Flask 这个技术栈可能不够"新潮",但在业务系统这个领域,它依旧是那个最可靠的选择之一。