1. 从零开始:为什么Flask项目迟早要接数据库
先说个我自己的体会。很多人刚开始学Flask时,写完几个路由、渲染几个模板,就觉得“Web开发不过如此”。但一旦做起真实项目——不管是博客、管理系统还是简单的数据上报工具——很快就会发现,用户注册的信息没地方存,页面刷新一次数据就丢了,这时候数据库就成了绕不开的关卡。
Flask本身不绑定任何数据库,这是它作为微框架的特点,也是新手最容易迷茫的地方。你可以用MySQL、PostgreSQL、SQLite,甚至NoSQL系的MongoDB、Redis,全凭自己选。但也正因为没有默认方案,网上一搜Flask操作数据库,出来一堆说法:有人让你用Flask-SQLAlchemy,有人让你直接用pymysql写原生SQL,还有人提flask-migrate做迁移,新手很容易被绕晕。
这篇内容我打算从实际使用的角度,把Flask连接数据库的完整路径捋一遍。不吹某个方案有多万能,而是把常见的选择、背后的理由、以及我踩过的坑都讲清楚。不管你是刚接触Flask,还是已经写了几个demo但没正式碰过数据库,这篇应该都能给你一个可以上手的路线。
2. 环境准备:先把依赖和连接方式理清楚
2.1 选驱动还是选ORM?
在写代码之前,有个问题必须先想明白:你打算用什么方式操作数据库?这个决策直接决定后续写多少代码、踩多少坑。
第一种方式是租用“数据库驱动”,比如pymysql、psycopg2、sqlite3。驱动的作用是建立Flask应用和数据库之间的底层通信通道,你拿到的是一个Connection对象,之后用SQL字符串去查询、插入、更新。这种方式贴近数据库本身,执行效率高,SQL能力强的开发者会觉得非常自由。
# 原生驱动示例(pymysql + MySQL) import pymysql conn = pymysql.connect( host='127.0.0.1', user='root', password='your_password', database='flask_demo', charset='utf8mb4' ) cursor = conn.cursor() cursor.execute("SELECT * FROM users WHERE id = %s", (1,)) row = cursor.fetchone() conn.close()第二种方式是使用ORM(对象关系映射),最典型的就是Flask-SQLAlchemy。它把数据库表映射成Python类,把行记录映射成对象,你操作的是对象和类方法,而不是SQL字符串。这种方式代码可读性好,迁移库表结构时也有现成的工具支持,对业务复杂、多人协作的项目帮助很大。
我在实际项目中的倾向是:小工具、一次性脚本、对SQL执行计划有严格要求的场景,直接用驱动操作,简单直接;而业务系统、需要频繁改表结构、团队里有新人参与的,优先选ORM,减少低级错误。
2.2 连接字符串到底怎么写?
无论选哪种方式,第一步都是建立连接。SQLAlchemy里连接数据库靠的是URI(统一资源标识符),不同数据库的写法有细微差别,这里列几个常见的:
| 数据库 | SQLAlchemy连接URI | 对应驱动 |
|---|---|---|
| SQLite | sqlite:///app.db | 内置sqlite3 |
| MySQL | mysql+pymysql://user:password@host:port/dbname | pymysql |
| PostgreSQL | postgresql+psycopg2://user:password@host:port/dbname | psycopg2 |
| Oracle | oracle+cx_oracle://user:password@host:port/dbname | cx_Oracle |
有个地方特别容易写错:SQLite的文件路径。如果你是Windows开发、Linux部署,路径里的相对路径和绝对路径差异会坑人。sqlite:///app.db里的三个斜杠,前两个是协议标志,第三个是相对路径开头;如果写绝对路径,比如/home/user/data/app.db,要写成sqlite:////home/user/data/app.db——四个斜杠。这个问题我在一次部署时排查了将近半小时,最后发现就是斜杠数量不对。
MySQL的连接还有几个参数容易忽略:charset='utf8mb4'必须显式指定,否则中文可能出现乱码;connect_timeout建议设置一个较短的值,比如5秒,免得数据库不可达时应用挂起半天。
3. 核心设计思路解析:连接池、Session与请求生命周期
3.1 为什么不能每个视图函数都新建连接?
新手最容易写出的代码就是:在视图函数里pymysql.connect(),查完数据conn.close()。单用户、低并发的时候没什么问题,一旦有几个人同时访问,这种写法的缺陷立刻就出来了:每次请求都要经历TCP握手、认证、建立连接的过程,数据库负载直线上升,响应时间也明显变慢。
更靠谱的做法是使用连接池:提前建立一批连接放在池子里,请求来了从池里取一条用,用完归还,而不是销毁。连接池不仅能复用连接,还能控制连接总数,防止应用把数据库连接耗尽。
如果你用的是SQLAlchemy,连接池是内置的,不用额外配置。几个关键参数我建议重点关注:
from flask_sqlalchemy import SQLAlchemy app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:password@127.0.0.1:3306/flask_demo?charset=utf8mb4' app.config['SQLALCHEMY_POOL_SIZE'] = 10 # 池中保持的连接数 app.config['SQLALCHEMY_POOL_TIMEOUT'] = 20 # 获取连接超时时间(秒) app.config['SQLALCHEMY_POOL_RECYCLE'] = 3600 # 连接回收时间,防止MySQL8小时超时断开 app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False # 关闭对象修改追踪,省内存 db = SQLAlchemy(app)POOL_RECYCLE是我特别想说的一项。MySQL服务器默认有个wait_timeout参数,空闲连接超过8小时会被服务端主动断开。如果你的应用一直跑着,连接池里的连接却已经失效,下一次请求就会报“Lost connection to MySQL server during query”。设置POOL_RECYCLE小于8小时(比如3600秒),让SQLAlchemy在回收周期内主动废弃旧连接,就能规避这个问题。
3.2 请求生命周期:Session到底在什么时候关闭?
用Flask-SQLAlchemy时,很多人对db.session的生命周期搞不清楚。它不像普通变量一样可以随便new一个,它绑定在请求上下文上。
我的理解方式是这样的:一次HTTP请求进来,Flask-SQLAlchemy自动创建一个session,视图函数里对db.session.add/commit/query的操作都在这个session中进行。请求结束后,框架自动调用db.session.remove(),把session里的连接归还连接池。也就是说,正常情况下你根本不需要手动调session.close()。
但有两个例外情况需要自己处理:
- 在后台脚本/定时任务里使用Flask应用上下文时,用
with app.app_context():包裹之后,依然要手动db.session.remove()。 - 在使用原生驱动的场景中,没有ORM帮你管理,必须自己在
finally块里关闭连接。
# 原生驱动 + Flask 的推荐写法 from flask import g def get_db(): if 'db' not in g: g.db = pymysql.connect( host='127.0.0.1', user='root', password='your_password', database='flask_demo', charset='utf8mb4' ) return g.db @app.teardown_appcontext def close_db(error): db = g.pop('db', None) if db is not None: db.close()利用g对象存放连接,用teardown_appcontext统一关闭,这个模式比在每个函数里都写try/finally优雅得多,也保证了请求结束时连接一定被释放。
3.3 建表:ORM模型定义还是手写SQL?
使用SQLAlchemy时,模型定义写在模型文件里,然后用db.create_all()建表。这种方式对快速开发很友好,模型即文档,代码里能看到表结构。
class User(db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True, autoincrement=True) username = db.Column(db.String(80), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False) created_at = db.Column(db.DateTime, server_default=db.func.now()) def __repr__(self): return f'<User {self.username}>'但create_all()有一个特点必须明确:它只在表不存在时创建,表结构发生变化(比如加一列)它不会帮你改。如果你想改动表结构,需要引入Alembic或者Flask-Migrate。
我自己在早期犯过蠢:直接删掉db.create_all()对应的数据库,然后重新执行。这在开发环境没问题,生产环境就是事故。后来老老实实用Flask-Migrate做迁移:
flask db init flask db migrate -m "create user table" flask db upgrade迁移文件会自动生成,表结构变更记录在版本文件里,团队协作时也可以跟着代码一起提交。这一步早点习惯,后期能省很大的事。
4. 实操过程与核心环节实现
4.1 从零搭建一个Flask数据库项目
下面我以一个简单的“用户注册查询”功能为例,完整走一遍流程。环境假设:Python 3.9+,MySQL 5.7/8.0,使用Flask-SQLAlchemy。
先创建虚拟环境并安装依赖:
python -m venv venv source venv/bin/activate # Windows: venv\Scripts\activate pip install flask flask-sqlalchemy pymysql然后创建项目结构:
flask_demo/ ├── app.py ├── models.py └── templates/ └── index.html编写app.py,初始化Flask和SQLAlchemy:
from flask import Flask, request, render_template, redirect, url_for from models import db, User app = Flask(__name__) app.config['SECRET_KEY'] = 'dev-secret-key-change-me' app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:your_password@127.0.0.1:3306/flask_demo?charset=utf8mb4' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False db.init_app(app) @app.route('/') def index(): users = User.query.order_by(User.created_at.desc()).all() return render_template('index.html', users=users) @app.route('/register', methods=['POST']) def register(): username = request.form.get('username') email = request.form.get('email') if not username or not email: return '用户名和邮箱不能为空', 400 existing = User.query.filter_by(username=username).first() if existing: return '用户名已存在', 400 user = User(username=username, email=email) db.session.add(user) try: db.session.commit() except Exception as e: db.session.rollback() return f'数据库操作失败: {str(e)}', 500 return redirect(url_for('index')) if __name__ == '__main__': app.run(debug=True)这里有几个细节值得单独说。
第一,db.init_app(app)是工厂模式下的标准做法,它让SQLAlchemy与Flask应用关联,但不在导入时立即创建连接。相比直接db = SQLAlchemy(app),这种方式更灵活,适合后面做配置分离。
第二,User.query.filter_by(username=username).first()这种写法是查询单个对象的惯用方式。.first()返回第一条记录的实例,没有结果时返回None,不会抛异常。如果你用.one(),结果数量不为1时会抛异常,需要自己处理,日常业务很少用。
第三,在commit()之前要检查重复数据,但仅仅这样还不够。因为在高并发场景下,两个请求同时发现“用户名不存在”然后同时注册,就会出现唯一键冲突。所以数据库层面的unique=True约束依然是最后一道防线,代码里捕获IntegrityError做兜底更稳妥。
4.2 原生SQL操作:什么时候我选择绕开ORM
虽然SQLAlchemy很强大,但我必须客观说:有些场景下原生SQL更合适。比如复杂报表查询、多表关联且涉及子查询时,原生SQL表达能力更强,执行计划也更可控。
from pymysql import IntegrityError def get_user_by_username(username): conn = get_db() with conn.cursor() as cursor: sql = "SELECT id, username, email, created_at FROM users WHERE username = %s" cursor.execute(sql, (username,)) row = cursor.fetchone() return row这段代码的关键在于%s占位符和参数化查询。很多人初学时喜欢用字符串拼接SQL:
# 危险写法,绝对不要这样写 cursor.execute(f"SELECT * FROM users WHERE username = '{username}'")一旦username里包含恶意内容,比如' OR '1'='1,整条SQL语句就变成了查询所有用户,这就是经典的SQL注入。使用参数化查询后,驱动会帮你处理转义,从根本上消除了这类注入风险。
我再补充一个原生SQL的链接持坑:不要在with conn.cursor()外面执行需要结果的查询后再关闭cursor,这样可能产生不可预期的报错。正确姿势是在with块内完成所有操作并取出结果,或者显式调用cursor.close()。
4.3 事务处理:什么时候该commit,什么时候该rollback
事务是数据库操作里最容易翻车的地方,新手尤其容易犯两个错:忘了commit,或者出错后不回滚。
先明确概念:数据库事务是一组不可分割的操作,要么全部成功,要么全部失败。银行转账就是典型例子:A账户扣钱和B账户加钱必须同时成功,不能出现A少了钱B没增多。
在SQLAlchemy里,事务的边界就是session:
def transfer(sender_id, receiver_id, amount): sender = User.query.get(sender_id) receiver = User.query.get(receiver_id) sender.balance -= amount receiver.balance += amount db.session.commit()上面这个例子如果sender.balance -= amount执行失败,必须在异常处理里调用db.session.rollback(),否则这个失败的操作会停留在session里,继续污染后续请求。
从实战中总结的事务规范:
- 单一写操作:提交前检查业务条件,失败立即rollback。
- 多个写操作组合:要么放在一个事务里,要么确保每个操作独立提交且出错时能补偿。
- 不要在事务里做高延迟操作(如HTTP请求、文件下载),会长时间占用数据库连接。
我见过一个真实事故:某个定时任务在事务里循环发送邮件,每发一封耗时几百毫秒,结果事务长时间不提交,数据库连接被占满,最终整个应用崩溃。事务要短小精悍,这是底线。
5. 常见问题与排查技巧实录
5.1 连接问题速查表
我在实际操作中,把自己遇到过的和身边同事遇到过的连接类问题整理了一下,做成了一张排查表,建议先收藏再对照:
| 报错信息 | 可能原因 | 处理方法 |
|---|---|---|
Can't connect to MySQL server on '127.0.0.1' | MySQL没启动 / 端口不对 / 防火墙拦截 | 检查服务状态:systemctl status mysql或netstat -tlnp | grep 3306 |
Access denied for user 'root'@'localhost' | 用户名/密码错误,或账号未授权 | 确认密码;用GRANT ALL ON flask_demo.* TO 'user'@'host'授权 |
Unknown database 'flask_demo' | 数据库不存在 | CREATE DATABASE flask_demo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; |
Lost connection to MySQL server during query | 连接超时被服务端断开 | 设置SQLALCHEMY_POOL_RECYCLE |
Too many connections | 连接数超过MySQL最大限制 | 调整max_connections或优化连接池大小 |
ModuleNotFoundError: No module named 'pymysql' | 未安装驱动 | pip install pymysql |
5.2 我用debug模式追踪过的三个疑难问题
第一个印象最深的问题是在一个Windows部署环境中遇到的:代码在本地跑得好好的,一上服务器就报“Unable to load authentication plugin 'caching_sha2_password'”。查了一圈发现,服务器上MySQL 8.0默认使用这种认证插件,而本地MySQL是5.7,驱动版本不兼容。解决办法是在连接URI里指定auth_plugin参数,或者给用户改成mysql_native_password插件。这类环境差异问题,单靠看报错很难快速定位,养成查看MySQL版本的意识很重要。
第二个问题是字符集导致的乱码。现象是数据库里存的中文变成乱了,但Navicat里看又是对的。后来发现是建库时用了默认的latin1字符集,连接字符串里又没有指定charset。彻底解决方案分两步:第一步把连接URI加上?charset=utf8mb4,第二步把数据库、表都改成utf8mb4。只改连接不建库,治标不治本。
第三个问题很隐蔽:db.session.add(user)之后,同一个请求里立刻db.session.query(User).all(),发现新数据没有查到。原因是SQLAlchemy的session在事务未提交前,默认查询是会看到未提交数据(同一session内),但如果你走的是两个不同session,或者中间有自动commit的机制,就看不到。解决办法很简单:先commit再查询,或者如果只是想拿到自增id,commit后访问user.id即可。
5.3 连接泄漏是怎么发生的?
连接泄漏的意思:程序不再使用数据库连接,但连接没有归还连接池或关闭,导致池里的连接越来越少,直到耗尽。最典型的引发方式就是异常路径没有释放连接。
# 错误示例:如果execute出错,conn不会关闭 def bad_query(): conn = get_db() cursor = conn.cursor() cursor.execute("SELECT ...") return cursor.fetchall() conn.close() # 永远不会执行到 # 正确示例:用try/finally或上下文管理器 def good_query(): conn = get_db() try: with conn.cursor() as cursor: cursor.execute("SELECT ...") return cursor.fetchall() finally: conn.close()用SQLAlchemy时,这个问题稍好一些,因为框架在请求结束时会自动remove session。但在多线程或后台任务场景下,如果不停创建db.session而不调用db.session.remove(),同样会积累连接。
我练成一个习惯:凡是自定义函数里用到db.session或原生连接,都会检查是否有对应的释放逻辑。用一句不太严谨但有效的话来说——谁创建,谁负责关闭。
6. 整理一下我印象最深的几个实战经验
写了这么多,最后聊几句实际项目的体会。
某个内部管理系统的改造中,前后用SQLite存数据跑了几个月,后来数据量上来了,查询开始变慢,才迁移到MySQL。那次迁移给我最大的教训是:在项目一开始就确定数据库选型,不要抱着“以后再说”的心态。SQLite适合开发验证、单机小应用,但并发稍高、数据稍多就会出现性能瓶颈。而Flask-SQLAlchemy的URI换一下就能切数据库,这个好处只有真正做过迁移的人才体会得到。
另外,我强烈建议项目从一开始就用Flask-Migrate。很多人觉得表结构很少变,不值得加一层工具。可真实情况是:只要业务一跑起来,加字段、改索引、调长度都是迟早的事。手动去数据库执行ALTER TABLE,日子久了必定出问题——不是忘记在另一台环境执行,就是改动记录对不上。Flask-Migrate生成的迁移文件可以作为变更记录的一部分提交到代码仓库,谁改的、改了什么,一目了然。
数据库操作这事,看着表象是“连接”、“增删改查”这些动作,核心其实是几个习惯:连接要管理好、事务要短平快、异常要考虑释放。把这几个习惯练进肌肉记忆里,Flask连数据库这件事就不再是坑,而会是你在开发中最可靠的一环。真遇到文章里没覆盖到的问题,也别慌,先看日志,再查MySQL本身的状态,大概率问题不是出在Flask上,而是出在连接参数或者数据库配置上。