Skip to content

第 15 章 基于 ORM 的批量增删改操作 ​

从一个生活场景说起 ​

要给 1000 个员工发年货,有两种方式:

  • 逐条办理:每个人分别来人事处,登记、签字、领取,跑 1000 趟;
  • 批量办理:把 1000 人的名单一次性交给后勤,按清单统一发放。

逐条 session.add(obj) + 逐条提交,就像第一种方式——每趟都有完整的"接待流程"(ORM 事件、脏检测、往返数据库)。数据量一大,开销触目惊心。本章学习三种"批量办理"的姿势,以及它们各自放弃了什么、换来了什么。

核心概念 ​

SQLAlchemy 的批量操作按"ORM 参与度"由高到低分三档:

档位方法ORM 参与度速度
标准档add_all()完整(事件、脏检测、回填 id)慢
加速档bulk_save_objects()部分(跳过部分机制)快
极速档session.execute(update/delete/insert)零(纯 SQL)最快

核心权衡:功能 vs 速度。 ORM 机制越少,跑得越快,但对象状态同步、事件钩子等便利也一并失去。

示例代码(逐行讲解) ​

python
from sqlalchemy import create_engine, update, delete
from sqlalchemy.orm import Session

engine = create_engine(
    "mysql+pymysql://root:123456@localhost:3306/demo_db?charset=utf8mb4",
    echo=True,
)

with Session(engine) as session:
    # ① 批量插入:add_all(走完整 ORM 流程)
    session.add_all([User(name=f"用户{i}") for i in range(1000)])

    # ② 批量插入:bulk(跳过部分 ORM 机制,更快)
    session.bulk_save_objects([User(name=f"用户{i}") for i in range(1000)])
    session.commit()

    # ③ 批量更新:直接执行 UPDATE,不把对象加载进内存
    session.execute(
        update(User).where(User.age < 18).values(age=18)
    )

    # ④ 批量删除:直接执行 DELETE
    session.execute(
        delete(User).where(User.name.like("临时%"))
    )

    session.commit()

③④ 特别注意写法:update(User) 里传的是映射类,但生成的就是一条赤裸裸的 SQL:

sql
UPDATE user SET age=18 WHERE age < 18
DELETE FROM user WHERE name LIKE '临时%'

数据库一条语句搞定全部,根本不产生任何 Python 对象——这是它快的原因,也是它危险的原因(见下文)。

关键知识点 ​

方法特点
add_all()标准 ORM 流程,功能完整但慢
bulk_save_objects()跳过部分 ORM 逻辑,快
session.execute(update/delete)SQL 层面直接批量操作,最快

深入理解:极速档的"失忆症" ​

SQL 级批量操作有个隐蔽陷阱:它不会同步 Session 中已加载的对象。

python
user = session.get(User, 1)                      # 加载了张三,age=15
session.execute(update(User).values(age=18))     # 数据库里所有人的 age 都变成了 18
session.commit()
print(user.age)                                  # 可能还是 15!对象没有被刷新

数据库已经变了,但你手里的对象还停留在旧值——这就是"脏数据"。对策:

  • 批量操作尽早执行(在加载对象之前);
  • 或操作后调用 session.expire_all() 强制所有对象过期,下次访问重新查库。

同理,批量 SQL 也不会触发 before_update 之类的 ORM 事件钩子。依赖钩子的业务(如自动记录审计日志)不能用极速档。

进阶内容 ​

1. 分批提交:别让事务变成"巨兽"。 百万级数据一次性 commit,MySQL 的 undo log(回滚日志)会膨胀、锁会长时间不释放,线上直接抖三抖。正确姿势是分批:

python
BATCH = 1000
users = [User(name=f"用户{i}") for i in range(100000)]
for i in range(0, len(users), BATCH):
    session.add_all(users[i:i + BATCH])
    session.commit()          # 每 1000 条提交一次

2. MySQL 专属的"存在则更新"。 数据同步场景常见需求:"主键已存在就更新,不存在就插入"。MySQL 原生支持 INSERT ... ON DUPLICATE KEY UPDATE,SQLAlchemy 通过方言接口使用:

python
from sqlalchemy.dialects.mysql import insert

stmt = insert(User).values(id=1, name="张三")
stmt = stmt.on_duplicate_key_update(name=stmt.inserted.name)
session.execute(stmt)
session.commit()

一条语句搞定"插入或更新",避免"先查再决定"的两次往返和并发竞争。

3. 怎么选?决策口诀:

  • 需要事件钩子、要拿回每条的自增 id → add_all();
  • 纯灌数据、不需要 id → bulk_save_objects();
  • 大批量改/删、没有对象层面的逻辑 → SQL 级 execute(update/delete)。

注意事项 ​

  • 批量 update/delete 绕过 ORM,不会触发对象的事件钩子,也不会同步 Session 中已加载的对象——操作后 Session 里的旧对象可能是脏数据。
  • 批量插入后如果需要拿回每条的主键 id,用 add_all + commit 更可靠。
  • 超大批量(百万级)建议分批提交(每 1000~10000 条 commit 一次),避免单个事务过大。MySQL 中大事务会撑大 undo log、长时间持锁,影响线上性能。
  • MySQL 原生有 INSERT ... ON DUPLICATE KEY UPDATE(冲突时更新),可通过 from sqlalchemy.dialects.mysql import insert 使用方言特定的 insert(...).on_duplicate_key_update(...),适合"存在则更新,不存在则插入"的批量同步场景。
  • 批量操作同样遵守第 5 章的铁律:先 select 验证条件,再执行,并检查影响行数。

本章小结 ​

  • 三档批量操作:add_all(功能全)→ bulk_save_objects(快)→ SQL 级 execute(最快);
  • 速度与 ORM 特性成反比:越快,事件钩子和对象同步越少;
  • SQL 级批量后记得 expire_all() 防脏数据;
  • 百万级数据分批 commit,保护 MySQL。