ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SQLAlchemy ORM实战指南:从模型设计到查询优化与排坑

SQLAlchemy ORM实战指南:从模型设计到查询优化与排坑 写SQLAlchemy之前先聊聊我这几年的感受。Python世界里如果你和数据库打过交道SQLAlchemy ORM几乎是绕不开的名字。它不是一个简单的“python 连接数据库”库而是一套完整的数据访问工具链底层帮你处理连接池、SQL方言、事务边界上层给你提供对象关系映射让你可以用操作Python类的方式来读写数据库表。这篇文章会把 SQLAlchemy ORM 从环境准备、模型设计、CRUD 操作到查询优化、常见坑这几块完整过一遍并配合爬虫数据和量化行情存储两个真实场景来做演示。不管你是刚学完 Python 基础还是在公司项目里维护老代码这篇都尽量做到能直接照做、能少踩坑。1. 为什么选择 SQLAlchemy ORM先理解它解决什么问题1.1 ORM 解决的三个核心痛点先别急着写代码我觉得把 ORM 存在的意义搞清楚比学会调用 API 重要得多。假如你直接用原生 SQL 操作数据库最常见的工作流是用连接驱动比如pymysql或psycopg2拿到游标手写一条 SQL 字符串执行然后从结果集里一条条取出元组再手动把元组拆成 Python 对象。这个流程在小项目里勉强能忍项目一复杂就暴露问题。第一SQL 字符串容易和业务代码耦合你要在 Python 里拼WHERE user_id ? AND status ?每加一个条件就要改一处拼接逻辑稍不留意还会被注入。第二取结果集时全是元组查出来的数据没有语义你只能用下标取字段表结构一变一堆代码全得跟着改。第三不同的数据库方言有差异今天用 SQLite 开发明天切到 MySQL分页写法、占位符格式、日期函数全都不一样换库等于重写一遍查询。SQLAlchemy ORM 做的事情翻译成人话就是让你用 Python 类去描述数据库表的结构用 Python 对象来代表表中的一行数据。映射关系建立好之后增删改查都变成了操作对象和类SQL 由框架去生成。这不是说 ORM 帮你屏蔽了 SQL而是帮你把“表和对象之间的翻译工作”自动化了。它并不是“不用学 SQL”而是让 SQL 从你身边琐碎的重复劳动中退场只在需要精细优化时才亲手写。1.2 SQLAlchemy 的 Core 与 ORM 两套 API 怎么选很多新手容易把 SQLAlchemy 理解成“一个 ORM 库”但准确地说它由两部分组成一层是SQLAlchemy Core一层是它的 ORM 实现。Core 层面的主要对象是Table、Column、select()你写的其实还是风格的 SQL 结构只不过是用 Python 表达式来构造而 ORM 层面是在 Core 基础上进一步提升抽象让你用声明式模型类直接操作。这两个怎么选我的建议是业务型项目直接用 ORM因为模型类本身就能当项目里的数据模型用迁移和关联关系也好维护如果你只做一次性的数据查询脚本、ETL 任务或者要写非常复杂的原生 SQLCore 更轻、更灵活也不用初始化 Session。不过即使你主用 ORM也绕不开 Core 的很多基础概念比如Engine、select()对象二者实际是贯通的。SQLAlchemy 2.0 之后官方推荐的风格是统一使用select()风格来写查询这套风格在 Core 和 ORM 里长得几乎一样学会了哪边都可以用。2. 环境准备与项目初始化从安装到 Engine 配置2.1 安装 SQLAlchemy 与数据库驱动写正文之前先把 Python 环境准备好。如果你还没有 Python先去官网下载安装版Windows 用户在安装时记得勾选“Add Python to PATH”不然后面在命令行里敲pip会提示找不到命令。安装完成后在终端里确认版本python --version pip --version然后安装 SQLAlchemy。现在官方已经进入 2.x 时代新项目直接装最新版就行pip install sqlalchemy2.0但注意SQLAlchemy 本身只是 SQL 生成和 ORM 的框架它不直接负责连接数据库。真正和数据库通信还需要各自的驱动包。常见的组合是这样数据库连接串写法需要安装的驱动SQLitesqlite:///data.db无需驱动MySQLmysqlpymysql://用户名:密码主机/库名pip install pymysqlPostgreSQLpostgresqlpsycopg2://用户名:密码主机/库名pip install psycopg2-binaryOracleoracleoracledb://用户名:密码主机/库名pip install oracledb我自己做本地练习和写演示代码时最常用 SQLite因为它零配置、单文件随用随删非常适合当学习环境但生产环境我多数用 PostgreSQL因为它的类型系统更严格、事务特性也更可靠。装好之后打开 Python 交互环境运行下面这行确认版本import sqlalchemy print(sqlalchemy.__version__)能正常输出版本号说明这一关过了。2.2 创建 Engine连接池、echo 与 URL 细节SQLAlchemy 中使用数据库的第一步永远是创建Engine。它相当于整个程序的数据库入口负责管理连接池和方言转换。一个常见的写法是from sqlalchemy import create_engine engine create_engine(sqlite:///demo.db, echoTrue, pool_size5)这里有几个参数值得展开讲一讲。先看连接串SQLite 写法是sqlite:///demo.db三个斜杠后面跟的是文件路径如果想用完全在内存里的临时库写sqlite:///:memory:程序一结束数据就没了适合跑测试。再看echoTrue这个参数会把你所有的 SQL 语句打印到控制台开发调试时看它你能清楚知道 ORM 背地里执行了什么 SQL。我特别建议新手开发期开着它它能帮你建立“对象操作”和“实际 SQL 语句”之间的直觉映射但是生产环境一定关掉否则日志会被 SQL 刷爆。pool_size5设定的是连接池里保存的数据库连接数。数据库建立连接是开销很大的操作如果每个请求都新建一个连接会给数据库服务端带来巨大压力。连接池就是让一组连接被多次复用的机制。对于 SQLite 来说连接池的意义不大因为它是本地文件数据库但对于 MySQL、PostgreSQL 这种服务型数据库这个参数在业务系统里很关键。2.3 声明式基类与第一个模型类Engine 只是入口真正描述表结构的是模型类。SQLAlchemy 2.0 的写法是用DeclarativeBase来定义基类然后每个模型类继承这个基类。看一下这个标准示例from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy import String, Integer class Base(DeclarativeBase): pass class User(Base): __tablename__ users id: Mapped[int] mapped_column(Integer, primary_keyTrue, autoincrementTrue) name: Mapped[str] mapped_column(String(50), nullableFalse) age: Mapped[int] mapped_column(Integer, default0)这里Mapped[int]是在告诉类型检查器“这个属性对应整数类型的列”同时它也配合 SQLAlchemy 做类型推断。后面mapped_column(...)则负责定义列的约束参数。primary_keyTrue声明主键autoincrementTrue表示自增String(50)表示字符串最大长度 50nullableFalse表示该字段不能为空default0则是为age设置默认值插入时如果没传年龄就用 0。有的老项目里你会看到另一种写法用db.Column加类型对象来定义字段id Column(Integer, primary_keyTrue)这是 SQLAlchemy 1.x 时代的经典写法在新版里依然兼容。如果维护老项目你要能看懂如果写新项目我会建议直接跟着 2.0 的Mapped风格走类型提示更完整IDE 补全也更友好。3. 模型设计与关系映射一对一、一对多和多对多3.1 一对多关系用户与文章绝大多数业务系统里真正辛苦的不是 CRUD而是表之间的关联关系。最常见的一对多场景一个用户可以发表多篇文章文章表中通过外键指向用户表。我们用两个模型来说明因为 Motivation 和这种关系是 ORM 最体现价值的地方from datetime import datetime from sqlalchemy import ForeignKey, DateTime, Text from sqlalchemy.orm import relationship class User(Base): __tablename__ users id: Mapped[int] mapped_column(Integer, primary_keyTrue, autoincrementTrue) name: Mapped[str] mapped_column(String(50), nullableFalse) articles: Mapped[list[Article]] relationship(back_populatesauthor) class Article(Base): __tablename__ articles id: Mapped[int] mapped_column(Integer, primary_keyTrue, autoincrementTrue) title: Mapped[str] mapped_column(String(200), nullableFalse) content: Mapped[str] mapped_column(Text) created_at: Mapped[datetime] mapped_column(DateTime, defaultdatetime.now) user_id: Mapped[int] mapped_column(ForeignKey(users.id), nullableFalse) author: Mapped[User] relationship(back_populatesarticles)这里有三条关键内容值得逐一说清楚。第一ForeignKey(users.id)是数据库层面的真实外键约束它告诉数据库这一列引用的是users表的主键。第二relationship(back_populatesarticles)是 ORM 层面的关系映射它并不创建任何数据库约束而是帮你建立对象之间的导航属性。有了它你可以直接写user.articles来获取这个用户下的所有文章也可以写article.author拿到文章对应的作者。第三back_populates两边的名字要互相指向它让两个模型类之间形成双向关系。这里有一个新手特别容易忽略的细节ForeignKey和relationship是两个独立的东西一个管“数据库里的外键”一个管“Python 对象的导航”。你可以只建外键不写 relationship查询时拿到user_id再手动查你也可以只写 relationship 不建外键但那样关系在数据库层面不受保护数据一致性容易出问题。正常开发里我是两个一起写让关系在数据库和应用层都成立。3.2 多对多关系用中间表解决多对多关系比一对多稍微绕一点。比如一个社区里用户可以关注多个话题同一个话题也有多个用户关注。这种关系需要在中间引入一张关联表把多对多拆成两个一对多from sqlalchemy import Table, Column follow_topic Table( follow_topic, Base.metadata, Column(user_id, ForeignKey(users.id), primary_keyTrue), Column(topic_id, ForeignKey(topics.id), primary_keyTrue), ) class Topic(Base): __tablename__ topics id: Mapped[int] mapped_column(Integer, primary_keyTrue, autoincrementTrue) name: Mapped[str] mapped_column(String(50), nullableFalse) followers: Mapped[list[User]] relationship(secondaryfollow_topic, back_populatestopics)同时在User里加上topics: Mapped[list[Topic]] relationship(secondaryfollow_topic, back_populatesfollowers)。注意relationship里的secondaryfollow_topic这个参数是关键它告诉 ORM这两个对象之间的关联要通过哪张中间表来查。中间表一般不需要定义对应的模型类只需要声明一个Table对象就够了。至于联合主键primary_keyTrue两次是为了避免同一对关系被重复插入也算是一条保底约束。多对多关系不要试图用“一个字段存多个 ID 逗号拼接”的方式来做那会让统计、关联查询、数据完整性全都很痛苦。ORM 里宁可多建一张小表也别偷懒把数据塞成一个字符串。3.3 延迟加载与 N1 查询问题关系建好之后访问user.articles时 ORM 默认采取“延迟加载”策略执行SELECT只在真正访问.articles的那一刻才发出一条查询语句。这在单条数据时没有任何问题但在循环里就会引发经典的 N1 问题。举个例子。如果你这样写users session.scalars(select(User)).all() for user in users: print(user.name, len(user.articles))第一条查询拿到全部用户如果用户有 100 个循环里访问user.articles会再触发 100 次查询。数据库总共要执行 101 次查询这就是 N1一次主查询N 次关联查询。小数据量感觉不出来数据一多整个接口会明显变慢。解决办法是用“急加载”。SQLAlchemy 里有两种常用的加载方式joinedload和selectinload。joinedload通过一条LEFT OUTER JOIN把关联数据一次性取出来selectinload则是先查出主对象然后用一条IN查询把关联数据取回来。比较推荐的做法是from sqlalchemy.orm import selectinload users session.scalars( select(User).options(selectinload(User.articles)) ).all()这样两条 SQL 就能解决原本 101 条 SQL 的问题。selectinload在集合关系上通常比joinedload更稳因为joinedload查一对多时会把主表记录重复展开如果集合数据很大结果集会膨胀得很厉害。4. 建表、事务与 Session 管理掌握 CRUD 的正确姿势4.1 用 metadata.create_all 建表但生产环境别依赖它模型定义好之后下一步要让数据库里真正出现这些表。最简单的办法是调用Base.metadata.create_all(engine)它会扫描所有继承Base的模型类按__tablename__自动创建表。这个方法在本地开发、跑单元测试、做学习演示时非常方便。不过注意一个前提对应的模型类必须先被导入并注册到Base.metadata里。如果你把模型类和主脚本拆分成多个文件一定要在创建表之前把模型文件 import 进来否则 SQLAlchemy 根本不知道有这张表create_all会静默跳过它。但生产环境我不建议依赖create_all。原因是它只做“缺什么建什么”不会管字段变更。你后来给User加了一个phone字段create_all不会自动往已存在的表里加列更不会处理数据迁移。真实项目里表结构的变更需要靠迁移工具来管理SQLAlchemy 官方推荐的方案是Alembic。它可以把模型的变化生成迁移脚本做到版本化、可回滚。这里我们不展开迁移的具体命令但你心里要有数create_all是脚手架Alembic 才是工程化方案。4.2 Session事务边界与工作单元在 ORM 里操作数据库核心对象是Session可以把它理解成“一次业务操作的事务边界”。SQLAlchemy 的工作流程大致是这样的创建一个Session往里添加对象提交前所有变更都在内存中执行commit()才真正写入数据库。基本用法是from sqlalchemy.orm import Session with Session(engine) as session: user User(name张三, age25) session.add(user) session.commit()这里with Session(engine) as session保证了 Session 用完后会关闭底层的连接但要注意它不会自动帮你提交事务。只有执行session.commit()数据才会落库。如果你想在事务里做完一批操作再统一提交可以这样with Session(engine) as session: try: session.add(User(name李四, age30)) session.add(User(name王五, age28)) session.commit() except Exception: session.rollback() raise一旦中间某个操作报错rollback()会把当前事务回滚到 begin 状态避免半截数据落库。我自己的习惯是简单的单条操作可以用with包住commit()结束复杂的多步骤业务务必显式捕获异常并做rollback这是保证数据一致性的底线。4.3 增删改查最常写的五种操作有了 Session我们来把最常用的 CRUD 操作完整过一遍。查询在 2.0 风格下要用select()构造查询语句然后通过session.scalars()执行。新增数据session.add(User(name赵六, age22)) session.add_all([ User(name孙七, age27), User(name周八, age35), ]) session.commit()注意add对应的对象可以是在 Python 里 new 出来的普通实例不需要手动指定主键。如果主键是自增列提交后 SQLAlchemy 会自动把生成的主键回填到对象的id属性上你直接print(user.id)就能看到值。这个特性在提交前是看不到的因为数据库尚未执行插入语句。查询数据from sqlalchemy import select # 获取指定主键记录 user session.get(User, 1) # 查询所有符合条件的记录 users session.scalars( select(User).where(User.age 18).order_by(User.age.desc()) ).all()session.get(User, 1)是根据主键查单条记录的快捷方式如果不存在会返回None。session.scalars()返回的是ScalarResult.all()会把它变成列表。这里要提醒一个点不要用session.query(User)的旧写法它在 2.0 里虽然兼容但已经不再是推荐风格新代码里统一用select()会让你学和用 Core 时也更顺。更新数据ORM 的更新逻辑很容易理解先查出对象再修改属性最后 commit。user session.get(User, 1) if user: user.age 26 session.commit()这里有个细节你只改了内存里对象的属性SQLAlchemy 会在commit()前自动生成一条UPDATE语句并且只更新变化的字段没变的列不会被塞进 SQL。如果你在修改属性之后、commit 之前又打印这个对象的其他字段可能会触发“对象刷新”也就是说 ORM 会重新从数据库拉一遍数据这是正常行为。删除数据user session.get(User, 1) if user: session.delete(user) session.commit()删除时有一个特别容易踩的坑如果你删掉的对象还被其他表外键引用且数据库外键约束没有开启级联删除就会抛外键约束错误。是否级联删除需要你在relationship里配置cascade参数或者在数据库层面设置ON DELETE CASCADE设计表结构时就要想清楚不要在运行时才来后悔。5. 查询进阶筛选、排序、分页与聚合分析5.1 条件筛选where、filter 与常见比较操作日常业务查询不会只是“查全部”条件筛选才是高频场景。2.0 风格里统一用select().where(...)来加条件。支持的操作符很多常用的几个有# 等值查询 select(User).where(User.name 张三) # 模糊查询 select(User).where(User.name.like(张%)) # 范围查询 select(User).where(User.age.between(18, 30)) # IN 查询 select(User).where(User.id.in_([1, 2, 3])) # 组合条件与、或 from sqlalchemy import and_, or_ select(User).where(and_(User.age 18, User.name ! 张三)) select(User).where(or_(User.age 18, User.age 60))这里在列对象的上下文里不是真正的 Python 比较它会被 SQLAlchemy 重载成 SQL 的等值判断。这个转变是新手最容易困惑的地方你写的User.name 张三并不是在检查某个 User 对象的 name 是否等于“张三”而是在构造一个 SQL 表达式真正执行时才会计较真假。一旦写错成user.name 张三注意是小写开头的对象就变成了 Python 的普通比较查出来的结果就是错的。所以记住一个口诀在where()内部对列做比较用Model.column在 Python 代码里比较对象属性才用instance.attr。5.2 排序、分页与去重排序用order_by()支持多字段也支持方向和 NULL 值位置select(User).order_by(User.age.asc(), User.id.desc())分页最直接的方式是limit()和offset()select(User).order_by(User.id).limit(10).offset(20)这条 SQL 翻译过来就是“跳过前 20 条取接下来 10 条”一般用于页码式的列表接口。不过数据量大了以后offset越深越慢因为数据库要把前面跳过的记录都扫一遍才能定位。如果你在做内部系统且数据量过百万我更建议用“游标分页”以上一次拿到的最大 ID 作为起点。select(User).where(User.id last_id).order_by(User.id).limit(10)这样直接走主键索引数据量再大也不会因为页数变深而明显变慢。缺点是没有总页数和跳页功能但很多信息流场景根本不需要跳页。我个人在开发时后台表格类的场景能用 offset 就用 offset够简单对延迟敏感的高频接口再上 keyset 分页。5.3 聚合查询count、group_by 与 havingORM 不止能查原始记录也能做聚合分析。比如统计每个用户的文章数量可以这样写from sqlalchemy import func stmt ( select(User.name, func.count(Article.id)) .join(Article, Article.user_id User.id) .group_by(User.id) .having(func.count(Article.id) 2) ) rows session.execute(stmt).all()这段代码里join(Article, Article.user_id User.id)表示把用户表和文章表按条件关联起来func.count(Article.id)生成 SQL 的COUNT()聚合函数group_by(User.id)按用户分组having(...)是对分组后的结果再做条件过滤。最终session.execute()返回的是原生行结果每行可以用元组解包来拿。这种查询已经有一点“SQL 味”了其实底层的 SQL 语句也很经典从用户表 LEFT JOIN 文章表按用户分组再统计数量。ORM 的价值在于你不需要手写那段字符串也不用手动处理结果集到对象的转换。当你发现自己写 ORM 查询非常吃力时可以把echoTrue打开让 SQLAlchemy 帮你把生成的 SQL 打印出来对照着原生 SQL 分析能力提升很快。6. 实战场景爬虫数据入库与量化行情缓存6.1 场景一爬虫数据如何优雅落库很多 Python 爬虫项目的前半段是抓页面、解析数据后半段就是数据存储。热搜词里那个“sqlalchemy储存爬虫数据”的需求我见得特别多。用 SQLAlchemy ORM 来做这件事最大的优势是抓到的数据可以直接组装成模型对象不用手写 INSERT 语句也不用担心字段名拼错。举个例子假设你在爬一个公开的新闻站点抓到的每条新闻包含标题、链接、发布时间和内容。可以设计这样的模型class News(Base): __tablename__ news id: Mapped[int] mapped_column(Integer, primary_keyTrue, autoincrementTrue) title: Mapped[str] mapped_column(String(200), nullableFalse) url: Mapped[str] mapped_column(String(500), uniqueTrue, nullableFalse) published_at: Mapped[datetime] mapped_column(DateTime, defaultdatetime.now) content: Mapped[str] mapped_column(Text) created_at: Mapped[datetime] mapped_column(DateTime, defaultdatetime.now)这里我给url加了uniqueTrue防止同一篇文章被重复抓入数据库。插入时先查一下这条链接是否存在不存在再插入或者在数据库层面改用INSERT IGNORE/ON CONFLICT DO NOTHING语义。最简单的方式是查一遍exists session.scalars( select(News.id).where(News.url url) ).first() if not exists: session.add(News(titletitle, urlurl, contentcontent))爬虫往往是循环分批抓取我建议每抓一批就 commit 一次比如每 50 条提交一次。不要每一条都 commit太慢也不要把成千上万条攒到最后一次性提交一旦中途异常全部丢失。分批提交算是工程上的中庸之道。6.2 场景二量化行情数据的简单行情表你可能看到热搜里有“python量化交易策略代码”那我把行情数据缓存表也作为一个实战例子。量化策略里经常需要把历史 K 线数据或者实时 tick 数据存入本地库之后回测或者分析时再从库里读。用 ORM 来管理行情数据关键的收益是数据模型清晰代码可读性好。class KLine(Base): __tablename__ kline_daily id: Mapped[int] mapped_column(Integer, primary_keyTrue, autoincrementTrue) symbol: Mapped[str] mapped_column(String(20), nullableFalse, indexTrue) trade_date: Mapped[datetime] mapped_column(DateTime, nullableFalse) open: Mapped[float] mapped_column(Float, nullableFalse) high: Mapped[float] mapped_column(Float, nullableFalse) low: Mapped[float] mapped_column(Float, nullableFalse) close: Mapped[float] mapped_column(Float, nullableFalse) volume: Mapped[float] mapped_column(Float, default0)写入策略代码可能长这样bars [ KLine(symbol000001, trade_dateday, openo, highh, lowl, closec, volumev) for day, o, h, l, c, v in daily_bars ] session.add_all(bars) session.commit()如果每天定期更新直接add_all会产生大量重复记录。此时可以利用数据库的唯一约束来做“有则更新、无则插入”。先给symbol和trade_date加UniqueConstraint再用数据库方言的 upsert 语法处理。如果不想折腾方言差异最实用的方案仍然是先查后插把已存在的日期的记录过滤掉再把新数据写入。看起来多了一次查询但对行情数据这种批量插入场景稳定性远比那一点性能重要。这两个场景合在一起能看得出 ORM 的统一价值不管数据来自爬虫还是行情接口落到 SQLAlchemy 的模型对象之后存储逻辑都是一套上游数据长什么样只要转成模型对象的属性就行下游读取也是一整套select()查询不用为每个源单独造轮子。7. 常见问题与排查技巧实录7.1 运行时报错或表不存在的几种原因使用过程中最常遇到的一个问题就是明明调了create_all打开数据库却看不到表。排查思路一般是这样第一检查模型类是否真的被导入到当前进程里只定义了模型但不 importBase.metadata里没有注册它create_all当然不会创建。第二注意连接串指向的数据库是否和你想的是同一个文件比如相对路径和绝对路径不一样很容易建到两个不同的.db文件里。第三create_all只建新表不会修改已存在的表结构如果你改过模型但表里缺列它并不会帮你去加。排查时可以用 SQLAlchemy 自带的 inspector 看真实表结构from sqlalchemy import inspect inspector inspect(engine) print(inspector.get_table_names()) print(inspector.get_columns(users))这样能直接看到当前库里有什么表、每张表的字段和类型都是什么。遇到“表不存在”时先跑一下段代码很快能定位是建表没执行还是连接串不对。7.2 Session 使用不当引发的 DetachedInstanceError另一个高频问题是DetachedInstanceError: Instance is not bound to a Session或者懒加载查询报错。这个错误通常发生在 Session 关闭之后你又去访问了这个 Session 里查出来的对象的懒加载属性。比如这样with Session(engine) as session: user session.get(User, 1) # session 已经关闭 print(user.articles) # 报错因为 articles 还没有加载Session一旦关闭之前查出来的对象会变成“游离状态”此时访问懒加载属性ORM 想向数据库发查询却没有可用的 Session 连接于是抛出异常。解决办法有三条路一是在 Session 关闭前把需要的关联数据先加载出来用selectinload之类急加载二是使用session.expunge(user)把对象从 Session 中剥离之后再使用快照属性但这只对已经加载的普通属性有效三是让 Session 的生命周期尽量跟随业务操作而不是一查完就关。这条错误在 Web 后端框架里特别容易出现因为很多框架会在请求结束时自动关闭 Session如果你在模板渲染阶段才去访问对象的懒加载属性马上就会踩中。所以最好的防御方式是在业务逻辑层就把需要的数据查完整视图层只做展示不做数据访问。7.3 日期时间与时区为什么数据库时间跟你本地差 8 小时日期时间字段也是经常踩坑的点。如果你用datetime.now()作为默认值这个值是本地时间如果你的服务器时区是 UTC而你的用户在中国那么存进去的时间就比北京时间慢 8 小时。等到查询出来再用前端格式化就会出现时间偏移。建议把时间基准统一掉。有两种做法一种是全部用带时区的时间DateTime(timezoneTrue)配合datetime.now(timezone.utc)写入另一种是全部约定使用 UTC 存储展示层再做本地化转换。最怕的是混着来一会儿存本地时间一会儿存 UTC查出来数据就乱套了。SQLAlchemy 本身不替你做时区转换它只负责把 Python 的 datetime 对象映射成数据库的时间类型时区逻辑一定得自己设计清楚。7.4 连接池耗尽与“MySQL server has gone away”在长期运行的 Python 服务里数据库连接可能会被服务端关闭或者因为网络问题断开于是看到MySQL server has gone away这类报错。原因一般有两个一是连接空闲时间过长被数据库服务端断开二是连接池里的连接没有及时回收。SQLAlchemy 针对这种情况提供了处理参数连接池在把连接交给你之前会做一次检测相关配置是pool_pre_pingTrue。engine create_engine( mysqlpymysql://user:passhost/db, pool_size10, max_overflow5, pool_pre_pingTrue, )pool_pre_pingTrue的作用是每次从连接池取连接时先发一条轻量探测语句如果连接已经断了就换一条新的给你。这个参数开启之后能解决绝大多数“服务跑一段时间就偶发性报错”的问题。我记得有一次帮朋友排查一个定时任务的报错现象是每天凌晨第一次跑任务总是失败白天就正常。排查到最后就是数据库连接夜里长时间空闲被服务端断开连接池里却还存着这条死连接。加上pool_pre_pingTrue之后问题就再没出现过。这类问题靠日志很难定位因为报错信息和业务本身毫无关联先检查连接池配置往往更高效。最后分享一个我写 SQLAlchemy 项目时养成的习惯开发环境一定开着echoTrue看 SQL但把 SQL 日志的关键字单独过滤出来避免控制台被刷屏正式环境则建议把echoFalse关掉同时打开慢查询监控真正需要优化时再看 SQL。ORM 不是银弹但当你把 Session、关系加载、连接池这几块真正搞明白之后它确实能帮你把数据访问这层变得非常顺滑。这篇文章里的代码都是可以直接复制跑起来的你也找一个小项目实际试一遍很多细节光看是记不住的动手踩一遍坑记忆才最深。
返回列表