刚接触 Python 数据库编程的时候,我其实对 ORM 是有些抵触的。当时习惯用 pymysql 直接拼 SQL,总觉得绕一层“自动映射”会黑盒化,出了问题不好排查。后来在一个反复改字段的业务项目里,被原生 SQL 的维护成本、字符串拼接的隐患、以及连接管理的一堆重复代码磨得没脾气,才真正把 SQLAlchemy 用起来。说实话,一旦你用顺了 ORM,再让你回头手写所有 SQL 增删改查,你大概率会嫌烦。
这篇文章我不想把它写成官方文档的中文翻译,网上已经有太多从安装到 API 罗列的教程,看的时候都懂,关掉页面就忘。我更希望做一个实操向的 SQLAlchemy ORM 指南,带着你从建立连接、定义模型、理解 Session,到真正把增删改查跑通,再深入处理关联查询、批量操作和并发场景下常见的坑。适合刚学完 Python 语法、准备做课程设计或小项目的人,也适合用 SQLAlchemy 写过一些 demo、但没系统梳理过 Session 和查询机制的同学。
1. SQLAlchemy到底解决了什么问题?先搞懂ORM的适用边界
1.1 单表SQL写起来还行,等到表多了就难受
很多人最开始写 Python 连数据库,代码大概长这样:
python复制cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE username = %s", (name,))
rows = cursor.fetchall()
单张表、两三个查询的时候,这套写法没什么毛病。但一旦进入真实业务,表会多,字段会改,每个表的增删改查都需要写一遍。更麻烦的是,当你把数据库里的行映射成 Python 对象时,你会发现大量代码在做同一件事:把 cursor 返回的元组或字典,手动转换成一个带属性的对象。
SQLAlchemy 这类 ORM 解决的核心问题,就是让“数据库行”和“Python 对象”之间的转换不再需要你手工处理。你在代码里操作 user.email = "new@example.com",提交事务后数据库里的记录也会被更新。这种直观感在业务开发里非常省心,尤其是实体之间有关系的时候,user.posts 直接能拿到这个用户发布的文章列表,不用每次手动 join 再拆结果。
这个价值在项目里越到后期越明显。字段重命名、表结构调整、切换数据库,ORM 能帮你把大部分重复劳动挡在门外。SQLAlchemy 本身也是 Python 生态里最主流、设计最完整的 ORM,Flask 和 FastAPI 这边大量项目的数据访问层都是基于它的。学它不会浪费。
1.2 哪些项目不该无脑上 ORM
但如果你听我说完就打算把项目里所有 SQL 都改成 ORM,我得拦一句:ORM 不是银弹。它适合的是“业务实体读写”,也就是以对象为中心的增删改查场景。像运营后台、用户系统、内容管理系统,这类项目的读写逻辑围绕若干实体展开,用 ORM 非常顺手。
反过来说,有几类场景我会选择直接写原生 SQL 或 SQLAlchemy Core 表达式。比如复杂的报表统计,动不动就是多层子查询、临时表、窗口函数,硬套 ORM 会写得非常别扭,而且你很难把索引和 SQL 执行计划调整到最优。再比如一次性导入几百万行数据,用 ORM 逐条 add 提交可能慢到让你怀疑人生,这时直接 executemany 批量灌效率高得多。
另外,如果你只是写一个一次性的数据迁移脚本,跑完就删,也没必要建一堆模型类。判断标准很简单:代码里是否有清晰的实体边界、是否要和表结构长期打交道。有,ORM 能帮上大忙;没有,别给自己加戏。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开始前的环境准备:安装依赖、连接串、Engine参数
2.1 两条pip命令搞定基础依赖
假设你的机器已经有 Python 3.9 以上的环境,也装好了 MySQL,并且本地能正常连接。第一步安装核心依赖:
bash复制pip install "sqlalchemy>=2.0" pymysql
如果你用的是 SQLite 做本地演练,不需要额外安装驱动,SQLite 是 Python 标准库自带的。如果用的是 PostgreSQL,可以把 pymysql 换成 psycopg[binary]。这里要注意一个新手高频问题:装完依赖后 import 仍然报错,大概率是你当前终端里调用的 python 和你执行 pip install 的 pip 不在同一个虚拟环境。先用 python -c "import sqlalchemy; print(sqlalchemy.__version__)" 快速验证,比反复重装有效得多。
在版本选择上我多说一句:如果你是照着网上老教程学,可能会看到大量类似 session.query(User).filter_by(...) 的写法。这套 API 在 SQLAlchemy 2.x 里仍然能用,但官方新项目推荐的是 select() 统一风格。为了避免你被两种风格的教程搞晕,这篇文章里的查询示例统一采用 SQLAlchemy 2.x 推荐的 select 写法,模型定义则用兼容性最好的经典 Column 风格,它在 2.x 里也没有被废弃。
2.2 连接串的每个部分到底什么意思
SQLAlchemy 通过一个 URL 字符串来决定连什么数据库、用哪个驱动、连到哪台机器。常见 MySQL 连接串长这样:
text复制mysql+pymysql://root:你的密码@127.0.0.1:3306/blog?charset=utf8mb4
拆开看其实不复杂:
| 组成部分 | 示例值 | 含义 |
|---|---|---|
| 数据库方言 | mysql | 表示连接的是 MySQL |
| 驱动名 | pymysql | 负责和 MySQL 通信的 Python 库 |
| 用户名 | root | 数据库账号 |
| 密码 | 你的密码 | 账号对应的密码 |
| 主机 | 127.0.0.1 | 数据库地址 |
| 端口 | 3306 | MySQL 默认端口 |
| 数据库名 | blog | 你要操作的库 |
| 字符集 | utf8mb4 | 建议显式指定,避免中文和 emoji 乱码 |
如果你只想快速实验,不想马上装 MySQL,可以先用 SQLite 文件库,连接串改成这个样子:
text复制sqlite:///./blog.db
SQLite 对 SQLAlchemy 的支持很完整,模型定义、Session、关系映射这些核心概念在两种数据库上完全一致,先把 demo 跑通再切到 MySQL,整个心智负担会小很多。
2.3 Engine和SessionFactory需要一次配好
Engine 是 SQLAlchemy 的总入口,它负责维护连接池、方言行为和底层 DBAPI 的交互。初始化代码通常长这样:
python复制from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
DB_URL = "mysql+pymysql://root:你的密码@127.0.0.1:3306/blog?charset=utf8mb4"
engine = create_engine(
DB_URL,
echo=False,
pool_pre_ping=True,
pool_recycle=3600,
pool_size=10,
max_overflow=20,
)
这些参数不是随意加的,我逐个说下实际意义。echo=False 是日志开关,学习阶段你可以临时改成 True,这样能在控制台看到 SQLAlchemy 实际执行的 SQL 语句,排查问题非常直观,但生产环境务必关掉,否则日志会刷爆。pool_pre_ping=True 表示每次从连接池取连接前先发送一个简单的探测语句,确认连接还活着;这个参数对 MySQL 尤其重要,能有效避免“MySQL server has gone away”这类过期连接问题。pool_recycle=3600 是让连接每 3600 秒强制回收重建一次,因为 MySQL 默认的 wait_timeout 经常会空闲断掉连接。pool_size 和 max_overflow 控制连接池的容量上限,后面讲并发时我会再展开。
还有一种更省心的方式,如果你确定连接池在这个场景里发挥不了作用,可以把连接池禁用掉:
python复制from sqlalchemy.pool import NullPool
engine = create_engine(DB_URL, poolclass=NullPool)
每个连接用完立即关闭,适合脚本任务或这种连接很短暂的使用场景,但 Web 服务不建议这么干,因为高并发下频繁建连开销很大。
接下来是关键的一步,定义一个 Session 工厂:
python复制SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
这里值得解释的是 sessionmaker 返回的不是一个可直接查询的 Session 对象,而是一个“工厂函数”。调用 SessionLocal() 才会真正创建新的 Session。为什么刻意加 autoflush=False 和 expire_on_commit=False?这俩参数和事务行为强相关,我放在第四节专门说明,这里先记住配置长这样。
3. 声明式模型入门:把users表写成Python类
3.1 建立一个模型的最小骨架
SQLAlchemy 的核心玩法是声明式映射:你用 Python 类描述表结构,类属性对应列,类实例对应行。看一个经典的一对多例子,用户表和文章表:
python复制from datetime import datetime
from sqlalchemy import (
Column, DateTime, ForeignKey, Integer, String, Text, create_engine
)
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(50), unique=True, nullable=False)
email = Column(String(120), nullable=False)
posts = relationship("Post", back_populates="author")
class Post(Base):
__tablename__ = "posts"
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(200), nullable=False)
content = Column(Text, nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.now)
author = relationship("User", back_populates="posts")
类属性里的 __tablename__ 是它映射到的数据库表名。我建议每次写模型时都显式指定,别完全依赖 SQLAlchemy 的默认表名生成规则,你自己写清楚,后面维护搜索时一眼就能对上。
主键 id 用 Integer 加 primary_key=True,autoincrement=True 表示自增。在 MySQL 里这就是常见的 id INT PRIMARY KEY AUTO_INCREMENT。username 加 unique=True 和 nullable=False,对应唯一约束与非空约束。
posts 和 author 这两个属性没有对应的真实列,而是由 relationship 声明出来的对象关系。back_populates 用来把两个方向关联起来,这样 User 能通过 user.posts 拿到他的文章列表,Post 也能通过 post.author 找到作者。
3.2 列的字段类型到底怎么选
在声明式模型里,列类型通常直接用 SQLAlchemy 的通用类型,它会在创建表时转成对应数据库方言的类型。Integer 是整数,String 必须带长度,Text 适合长文本,DateTime 存时间,Boolean 存布尔值。对于初学者最常见的错误是把 String 长度不写,在 MySQL 下建表就会报错,因为 varchar 必须指定长度。
我在真实的项目里习惯这样选:短文本、需要精确长度的用 String,比如用户名、手机号、邮箱、状态码;内容不确定长度的用 Text,比如文章正文、备注字段;金额字段如果用 Integer 存的是“分”而不是“元”,避免浮点数精度问题;时间统一用 DateTime,注意别用字符串拼。这些看起来是很基础的选择,但建表一旦定下来,后面迁移成本随着项目增大是成倍增长的。
对 default=datetime.now 这个写法多说一句,它是 Python 层面的默认值,也就是说只有通过 ORM 插入时才生效。如果你想在数据库层面也写上默认值,比如希望任何方式插入时都有默认时间,应该用 server_default。两者经常被混淆,实际效果不一样。
3.3 模型定义时常见的几个隐蔽问题
如果你顺着上面的代码往自己的项目里套,下面几个点值得提前留意。
第一,避免用 SQL 保留字或 Python 内置关键字当列名。比如 type、metadata、order 这类词,看起来能跑,但以后写原生查询、迁移脚本时很容易踩到转义问题的坑。宁可列名叫 order_type,也别直接叫 type。
第二,MySQL 建库时字符集和排序规则要选对。我遇到过项目连接串里写了 charset=utf8mb4,但数据库本身还是 utf8,存中文没问题,一旦存 emoji 就报 “Incorrect string value”。建库时统一使用 utf8mb4,连接串里也写 utf8mb4,能省掉很多后续麻烦。
第三,Base.metadata.create_all(engine) 只能在表不存在时创建表,它不会帮你改已有表的结构。很多人在课程设计里改完模型类,一运行报 “Unknown column”,然后满头大汗排查,其实就是表结构没同步。这个问题的正解是使用迁移工具,我放在第七节专门讲。
4. Session事务管理:读透自动提交之前的黑盒
4.1 Session和数据库连接其实不是一回事
Session 是 SQLAlchemy 里最容易让人误解的概念。初学者想得很简单:Session 应该就是数据库连接吧?其实两者差得很远。Engine 负责维护连接池,每一个连接才是真正和数据库通信的通道。而 Session 更像是一个“工作单元”,它管理着当前事务里所有对象的状态变化,需要执行 SQL 时才会从 Engine 里借一个连接出来用。
这个设计带来的直接好处是:你在一个 Session 里连续修改多个对象,SQLAlchemy 不会每改一行就立刻发一条 SQL,它会尽量把操作攒到 flush 或 commit 的时候统一执行。从业务代码的角度看,你操纵的是 Python 对象,不必时刻关心底层连接状态。
但缺点也来自这里:如果不理解 Session 的生命周期,你会在不该关闭的时候关闭,不该提交的时候提交,导致出现各种诡异的数据状态。我见过有人在 FastAPI 里把同一个 Session 存在全局变量里跨请求复用,然后并发一上来就报错。Session 不是线程安全的,在 Web 应用里正确姿势是每个请求一个 Session,用完关闭。
下面先看一个标准的 Session 使用模板,我用 contextmanager 包了一层,避免在每个函数里都写 try-except-finally:
python复制from contextlib import contextmanager
@contextmanager
def session_scope():
db = SessionLocal()
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
这段代码把整个事务生命周期封装得很干净。正常执行完,自动提交;出异常,自动回滚;无论哪种情况,最后都关闭 Session。后面所有示例我都默认使用了这个 session_scope()。
4.2 一个标准的写操作应该长什么样
很多教程喜欢写 with SessionLocal() as db:,然后告诉你这样就能自动管理连接。但这里有个非常容易踩的坑:with SessionLocal() as db 这种写法虽然会自动关闭 Session,却不会自动提交事务。如果你在里面执行了 insert 或 update 却不调用 db.commit(),等到 with 块结束,你期待的数据变更根本不会落库,很可能直接回滚了。
因此我强烈建议你封装一个自己的 session_scope(),把 commit 和 rollback 逻辑统一管理起来。一个标准写操作的姿势是这样的:
python复制with session_scope() as db:
user = User(username="chen", email="chen@example.com")
db.add(user)
你不需要在业务代码里手动 commit,容器退出时会自动提交。这段代码想表达的流程是:进入事务 -> 操作对象 -> 成功提交 / 失败回滚 -> 释放连接。把流程固定死,是最能减少脏数据的方式。
反过来,如果只是查询,没有发生任何写操作,用 session_scope() 也没问题,最多是没东西可提交而已。但在高并发场景里,我会尽量让只读查询使用快速事务,避免长时间占用事务资源。
4.3 autoflush和expire_on_commit别忽略
前文配置里写了 autoflush=False 和 expire_on_commit=False,两个参数分别影响什么?
autoflush 默认是 True,它的含义是:当 Session 执行查询时,如果内存里还有没 flush 的修改,会自动先把这些修改发送到数据库,然后再执行查询。这个机制有时很贴心,但有时会带来意外。比如你刚改了一个对象的字段,然后立刻执行一条统计查询,结果统计 SQL 前先多了一条 update,这种隐式操作会让排查 SQL 日志变困难。我习惯把 autoflush 关掉,需要时手动 flush(),行为更可控。
expire_on_commit 默认是 True,含义是 commit 之后,Session 里所有对象的属性都会被标记为过期,下次访问任何属性都触发重新查询。在高并发场景下,这会导致一些不必要的数据库往返。把它设为 False,commit 后对象缓存还是热的,可以直接读取,性能更好。代价是如果其他进程改了同一行数据,你读到的可能是旧值。对于大多数中小型项目,我选择 False 能省非常多无谓的查询。
这两个参数没有唯一正确答案,但你要清楚它们改了以后会影响什么行为,而不是照搬别人的配置。
4.4 多线程环境下应该怎么使用Session
在 FastAPI、Flask 这类 Web 框架中,一个请求可能由独立线程处理。Session 不能被多个线程共享,所以标准做法是每个线程单独创建 Session。如果你直接写 db = SessionLocal() 并把它挂到模块级全局变量,那等于强制所有请求共用一个 Session,并发稍微上来就会出现读取到脏状态、事务互相干扰的问题。
那 scoped_session 又是怎么回事?它可以按线程或应用上下文返回同一个 Session,但这不是让多线程共享同一个 Session,而是让同一个线程内多次调用拿到同一个实例,在不同线程里各自拿各自的实例。在纯脚本里用它意义不大,我也不会在简单课程设计里刻意引入它。记住一条原则就行:Session 的生命周期应该和业务操作的生命周期一致,不要超过一个请求或一个流程函数。
如果你用 FastAPI,可以在依赖里创建 Session,请求结束通过 finally 关闭。如果用了 session_scope() 这种上下文管理器,配合 yield 依赖天然就能保证每个请求一个 Session。
5. 增删改查实操:从单条到批量都有现成代码
5.1 新增记录的关键点:add之后别急着commit
最基础的单条新增直接用 add:
python复制with session_scope() as db:
user = User(username="chen", email="chen@example.com")
db.add(user)
这段代码在容器退出时自动 commit,库里就会多一条记录。但有时候你想在事务还没提交前就拿到新记录的自增主键,比如要把 user_id 作为外键去创建另一条关联记录。这时需要调用 db.flush():
python复制with session_scope() as db:
user = User(username="chen", email="chen@example.com")
db.add(user)
db.flush()
print(user.id) # flush 后主键已经回填
flush 的作用是把当前 Session 里累积的 SQL 发送到数据库,但事务还没提交。主键此时已经由数据库生成并回填到对象上,后续代码可以放心用 user.id。如果同时要插入多条新记录,用 add_all 比写多个 add 更整齐:
python复制with session_scope() as db:
db.add_all([
User(username="a", email="a@example.com"),
User(username="b", email="b@example.com"),
])
你可能会在网上看到 bulk_save_objects 或 bulk_insert_mappings,它们能提升一点插入性能,但会绕过 Session 的完整对象状态跟踪。我的建议是:常规业务里优先用 add_all,只有当单次插入量明显大、性能瓶颈清晰时才去考虑 bulk 接口。
5.2 查询对象别只管all,四种取数方式要分清
在 SQLAlchemy 2.x 推荐风格里,查询写起来是这样的:
python复制from sqlalchemy import select
with session_scope() as db:
stmt = select(User).where(User.username == "chen")
user = db.execute(stmt).scalars().first()
这段代码执行后,scalars() 会把结果从行对象中解包成 User 对象列表,first() 取第一条,查不到时返回 None。
如果确信查询结果最多只有一条,而且查不到在业务里属于异常情况,应该用 scalar_one_or_none():
python复制user = db.execute(stmt).scalars().one_or_none()
如果你希望按主键快速取记录,不需要拼条件,可以直接用 db.get():
python复制user = db.get(User, 1)
这种做法更直观,生成的 SQL 也是按主键查询,很高效。还有一点值得提醒:有人习惯先 db.query(User).all() 把全表数据捞回来,再用 Python 循环慢慢过滤。看起来图省事,但全表扫描加数据全量拉取会成为性能灾难。能用 where 条件过滤的,尽量让数据库帮你过滤。
