SQLAlchemy 系列(七):高级建模与高效写入------批量 DML、方言与扩展
核心目标:掌握超出基础 CRUD 的高级建模与写入手段------继承映射、可查询计算属性、JSON 原地修改、批量 INSERT、upsert、乐观版本列与事件监听,同时明确「绕过 Unit of Work」的语义代价与「跨方言」的可移植性成本,并给出可复现的批量写入基准方法。
前置知识 :完成 Part 6 的关系、级联与加载;建模沿用 Part 3;Session 与对象状态参考 Part 4;方言分层参考 Part 1。
验证环境:Python 3.11、SQLAlchemy 2.0.51;upsert、JSONB 与批量写入基准必须在 PostgreSQL 复验。最后复核日期:2026-07-31。
0. 问题场景:批量导入、幂等回调与 JSON 原地修改
先看三段「看起来自然」的代码。第一段是 10 万条订单项导入:
python
order = session.get(Order, 1001)
for product_id, quantity in item_lines: # 10 万个 (product_id, quantity)
session.add(OrderItem(order_id=order.id, product_id=product_id,
quantity=quantity, unit_price=Decimal("9.90")))
session.commit()
它没有语法错误,成本却是 10 万个 Python 对象常驻 Identity Map、逐条 INSERT、提交前全体对象的状态跟踪。第二段是支付回调幂等:
python
if session.scalars(select(Payment).where(
Payment.provider == "stripe", Payment.idempotency_key == key,
)).first() is None:
session.add(Payment(provider="stripe", idempotency_key=key, ...))
session.commit()
「先查后写」在并发下不成立:两个回调可能同时通过检查,幂等必须由数据库唯一约束裁决。第三段是 JSON 原地修改:
python
product.attributes["warranty_months"] = 24
session.commit() # 数据库里什么都没变
dict.__setitem__ 是 Python 内部操作,ORM 监听不到,Unit of Work 认为对象没有脏数据。
三段代码指向同一主题:基础 CRUD 之外,性能、幂等和复杂建模都需要新工具,而每个工具都在某处绕过了 ORM 的常规路径。本篇沿一条主线展开:先讲建模扩展(继承映射、计算属性、JSON),再讲写入提速(批量 INSERT、upsert),最后讲一致性护栏(版本列、事件、多租户过滤),用 order-lab 实战与失败实验把「谁在绕过 Unit of Work、代价是什么」变成可验证的事实。
7.1 继承映射先问数据库问题
7.1.1 三种策略的取舍
「支付类型若只是少量字段差异,组合或一张表加受约束类型列可能比类继承更清楚」。先把数据库代价摆出来:
| 策略 | 优点 | 代价 |
|---|---|---|
| Single Table | 单表、查询简单、无 JOIN | 子类字段整表出现、空列多、约束复杂 |
| Joined Table | 规范化、子类独立表 | 查询需 JOIN、写涉及多表 |
| Concrete Table | 子类表完全独立 | 多态查询靠 UNION、重复列 |
选择继承映射前先回答:子类差异是「行为差异」还是「数据差异」? 只是几个字段差异,单表加受约束 type 列往往更简单;类继承适合「不同类型有不同行为、不同必填字段」的场合。
7.1.2 三种策略的 DDL 与查询形态
统一用「支付通道配置」演示:基类 ChannelConfig,子类 StripeConfig(api_secret)与 PaypalConfig(client_id)。
Joined Table(基类与子类各有表):
python
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
class ChannelConfig(Base):
__tablename__ = "channel_config"
id: Mapped[int] = mapped_column(primary_key=True)
channel: Mapped[str] = mapped_column(String(20))
__mapper_args__ = {"polymorphic_on": channel,
"polymorphic_identity": "channel_config"}
class StripeConfig(ChannelConfig):
__tablename__ = "stripe_config"
id: Mapped[int] = mapped_column(
ForeignKey("channel_config.id"), primary_key=True)
api_secret: Mapped[str] = mapped_column(String(128))
__mapper_args__ = {"polymorphic_identity": "stripe"}
class PaypalConfig(ChannelConfig):
__tablename__ = "paypal_config"
id: Mapped[int] = mapped_column(
ForeignKey("channel_config.id"), primary_key=True)
client_id: Mapped[str] = mapped_column(String(64))
__mapper_args__ = {"polymorphic_identity": "paypal"}
多态查询 select(ChannelConfig) 生成基表 LEFT OUTER JOIN 所有子表(stripe_config、paypal_config);写入时 Unit of Work 先插基表再插子表,共两条 INSERT。Single Table:一张表承载全部字段,靠判别列区分:
python
class ChannelConfig(Base):
__tablename__ = "channel_config"
id: Mapped[int] = mapped_column(primary_key=True)
channel: Mapped[str] = mapped_column(String(20))
api_secret: Mapped[str | None] = mapped_column(String(128))
client_id: Mapped[str | None] = mapped_column(String(64))
__mapper_args__ = {"polymorphic_on": channel}
class StripeConfig(ChannelConfig):
__mapper_args__ = {"polymorphic_identity": "stripe"}
查询无 JOIN,多态只靠 channel IN ('stripe', 'paypal') 过滤。Concrete Table:子类各自成表、基类不建表:
python
class ChannelConfig(Base):
__mapper_args__ = {"polymorphic_identity": "channel_config"}
class StripeConfig(ChannelConfig):
__tablename__ = "stripe_config"
id: Mapped[int] = mapped_column(primary_key=True)
api_secret: Mapped[str] = mapped_column(String(128))
__mapper_args__ = {"concrete": True, "polymorphic_identity": "stripe"}
多态查询跨互不相干的表,SQLAlchemy 生成 UNION(SELECT ... FROM stripe_config UNION ALL SELECT ... FROM paypal_config)。
#mermaid-svg-EjqsbA0kuqqbrq57{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-EjqsbA0kuqqbrq57 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EjqsbA0kuqqbrq57 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EjqsbA0kuqqbrq57 .error-icon{fill:#552222;}#mermaid-svg-EjqsbA0kuqqbrq57 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EjqsbA0kuqqbrq57 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EjqsbA0kuqqbrq57 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EjqsbA0kuqqbrq57 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EjqsbA0kuqqbrq57 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EjqsbA0kuqqbrq57 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EjqsbA0kuqqbrq57 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EjqsbA0kuqqbrq57 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EjqsbA0kuqqbrq57 .marker.cross{stroke:#333333;}#mermaid-svg-EjqsbA0kuqqbrq57 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EjqsbA0kuqqbrq57 p{margin:0;}#mermaid-svg-EjqsbA0kuqqbrq57 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-EjqsbA0kuqqbrq57 .cluster-label text{fill:#333;}#mermaid-svg-EjqsbA0kuqqbrq57 .cluster-label span{color:#333;}#mermaid-svg-EjqsbA0kuqqbrq57 .cluster-label span p{background-color:transparent;}#mermaid-svg-EjqsbA0kuqqbrq57 .label text,#mermaid-svg-EjqsbA0kuqqbrq57 span{fill:#333;color:#333;}#mermaid-svg-EjqsbA0kuqqbrq57 .node rect,#mermaid-svg-EjqsbA0kuqqbrq57 .node circle,#mermaid-svg-EjqsbA0kuqqbrq57 .node ellipse,#mermaid-svg-EjqsbA0kuqqbrq57 .node polygon,#mermaid-svg-EjqsbA0kuqqbrq57 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-EjqsbA0kuqqbrq57 .rough-node .label text,#mermaid-svg-EjqsbA0kuqqbrq57 .node .label text,#mermaid-svg-EjqsbA0kuqqbrq57 .image-shape .label,#mermaid-svg-EjqsbA0kuqqbrq57 .icon-shape .label{text-anchor:middle;}#mermaid-svg-EjqsbA0kuqqbrq57 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-EjqsbA0kuqqbrq57 .rough-node .label,#mermaid-svg-EjqsbA0kuqqbrq57 .node .label,#mermaid-svg-EjqsbA0kuqqbrq57 .image-shape .label,#mermaid-svg-EjqsbA0kuqqbrq57 .icon-shape .label{text-align:center;}#mermaid-svg-EjqsbA0kuqqbrq57 .node.clickable{cursor:pointer;}#mermaid-svg-EjqsbA0kuqqbrq57 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-EjqsbA0kuqqbrq57 .arrowheadPath{fill:#333333;}#mermaid-svg-EjqsbA0kuqqbrq57 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-EjqsbA0kuqqbrq57 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-EjqsbA0kuqqbrq57 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EjqsbA0kuqqbrq57 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-EjqsbA0kuqqbrq57 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EjqsbA0kuqqbrq57 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-EjqsbA0kuqqbrq57 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-EjqsbA0kuqqbrq57 .cluster text{fill:#333;}#mermaid-svg-EjqsbA0kuqqbrq57 .cluster span{color:#333;}#mermaid-svg-EjqsbA0kuqqbrq57 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-EjqsbA0kuqqbrq57 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-EjqsbA0kuqqbrq57 rect.text{fill:none;stroke-width:0;}#mermaid-svg-EjqsbA0kuqqbrq57 .icon-shape,#mermaid-svg-EjqsbA0kuqqbrq57 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EjqsbA0kuqqbrq57 .icon-shape p,#mermaid-svg-EjqsbA0kuqqbrq57 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-EjqsbA0kuqqbrq57 .icon-shape .label rect,#mermaid-svg-EjqsbA0kuqqbrq57 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EjqsbA0kuqqbrq57 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-EjqsbA0kuqqbrq57 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-EjqsbA0kuqqbrq57 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-EjqsbA0kuqqbrq57 .orm>*{fill:#dbeafe!important;stroke:#1e40af!important;color:#1e3a8a!important;}#mermaid-svg-EjqsbA0kuqqbrq57 .orm span{fill:#dbeafe!important;stroke:#1e40af!important;color:#1e3a8a!important;}#mermaid-svg-EjqsbA0kuqqbrq57 .orm tspan{fill:#1e3a8a!important;}#mermaid-svg-EjqsbA0kuqqbrq57 .db>*{fill:#dcfce7!important;stroke:#15803d!important;color:#14532d!important;}#mermaid-svg-EjqsbA0kuqqbrq57 .db span{fill:#dcfce7!important;stroke:#15803d!important;color:#14532d!important;}#mermaid-svg-EjqsbA0kuqqbrq57 .db tspan{fill:#14532d!important;} 无 JOIN,空列多
读取 +1 JOIN,写双 INSERT
多态查询 UNION
Concrete Table:各自独立
stripe_config
id PK, api_secret
paypal_config
id PK, client_id
Joined Table:基表 + 子表
channel_config
id PK, channel
stripe_config
id FK=PK, api_secret
paypal_config
id FK=PK, client_id
Single Table:一张表
channel_config
id PK, channel,
api_secret NULL, client_id NULL
写入最简单
规范化最好
表完全独立,重复列
7.1.3 子类列约束是 Single Table 的暗礁
Single Table 里所有子类字段共享一张表:子类字段必须 nullable=True(示例中的 Mapped[str | None]),否则非该子类行因 NULL 违反 NOT NULL;CheckConstraint 无法「只对某类行生效」;一列只能一个类型,子类对同列要求不同类型时无法表达。这些问题在 7.12 失败实验中复现。
7.1.4 何时用数据库组合而不是类继承
判断清单:子类只有 1--2 个额外字段、无额外行为 → 单表 + type 列 + CheckConstraint;子类有独立必填字段且常按子类条件查询 → joined table;各子类几乎无共享查询 → concrete table;只有行为差异 → 组合或策略对象。不要为了面向对象纯度制造昂贵表结构。
7.2 可查询计算属性
7.2.1 问题:想在 SQL 里按 Python 计算列过滤
订单项 subtotal = quantity * unit_price 是典型计算字段。普通 @property 只能在 Python 里遍历计算,不能在 WHERE、ORDER BY 中引用。hybrid_property 让同一属性实例上算 Python、类上算 SQL:
python
from decimal import Decimal
from sqlalchemy import ColumnElement, Numeric
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import Mapped, mapped_column
class OrderItem(Base):
__tablename__ = "order_item"
id: Mapped[int] = mapped_column(primary_key=True)
quantity: Mapped[int]
unit_price: Mapped[Decimal] = mapped_column(Numeric(12, 2))
@hybrid_property
def subtotal(self) -> Decimal:
return self.quantity * self.unit_price
@subtotal.inplace.expression
@classmethod
def _subtotal_expression(cls) -> ColumnElement[Decimal]:
return cls.quantity * cls.unit_price
实例访问走第一个方法:OrderItem(quantity=3, unit_price=Decimal("9.90")).subtotal 返回 Decimal("29.70");类访问走表达式方法:
python
session.scalars(select(OrderItem).where(OrderItem.subtotal > Decimal("100")))
sql
SELECT order_item.id, order_item.quantity, order_item.unit_price
FROM order_item
WHERE order_item.quantity * order_item.unit_price > :param_1
7.2.2 Python 语义与 SQL 语义必须一致
两个实现容易悄悄分叉:Python 侧 or 短路逻辑在 SQL 侧要写成 func.coalesce;Python 侧抛异常的分支,SQL 侧必须考虑 NULL 语义。验证方法是同批数据两侧结果一致:逐行断言 item.subtotal == session.scalar(select(OrderItem.subtotal).where(OrderItem.id == item.id))。这条断言应进入自动化测试(见 7.14),防止改动只修了一侧。
7.2.3 hybrid_method 与 column_property
带参数的判断用 hybrid_method,同样以 @expr.expression 提供类级 SQL 版本(如 OrderItem.qualifies(5) 在 Python 与 SQL 两侧求值)。对「SELECT 全列展开时稳定要带」的表达式,可用 column_property 静态映射:subtotal_col = column_property(quantity * unit_price)(默认出现在 select(Model) 的列中、无法单独控制加载)。
7.2.4 昂贵聚合不要藏进属性
sum(item.quantity for item in order.items) 若放进 hybrid 的 Python 侧,每次访问都会隐式触发关系加载与遍历------正是 N+1 的来源。适合做法:查询用 func.sum() 返回 DTO(参考 Part 5);高频统计建物化视图或冗余计数列;计算列做成数据库生成列 Computed。
原则:hybrid 适合「廉价的列级计算」;昂贵聚合要么显式执行、要么物化,不要隐藏在随处访问的属性里。
7.3 JSON 原地修改
7.3.1 为什么「改了却没更新」
sqlalchemy.ext.mutable 解决「标量值内部可变」的跟踪:用 MutableDict 包装字典,每次内部变更调用 Mutable.changed() 反向通知父对象属性变脏。
python
from sqlalchemy import JSON
from sqlalchemy.ext.mutable import MutableDict
class Product(Base):
__tablename__ = "product"
id: Mapped[int] = mapped_column(primary_key=True)
sku: Mapped[str] = mapped_column(String(64), unique=True)
# 为演示在 Part 3 模型上追加:商品可扩展属性
attributes: Mapped[dict[str, object]] = mapped_column(
MutableDict.as_mutable(JSON), default=dict)
现在原地修改能被检测:
python
product = session.scalars(
select(Product).where(Product.sku == "BOOK-SQLA-001")
).one()
product.attributes["warranty_months"] = 24
assert product in session.dirty # True
session.commit() # 生成 UPDATE
读取时 JSON 反序列化出的普通 dict 会被 MutableDict.coerce() 自动包装。列表同理用 MutableList(MutableList.as_mutable(JSON))。
7.3.2 深度嵌套的限制:Mutable 只跟踪顶层
官方文档明确:MutableDict / MutableList 不对内部值递归跟踪。因此:
python
product.attributes["specs"] = {"cpu": "M3"} # 顶层 setitem → 可检测
product.attributes["specs"]["cpu"] = "M4" # 内层是普通 dict → 不检测!
解决办法:① 子类化 MutableDict 在 __setitem__ 里递归包装(官方 assoc 思路);② 约定 JSON 只存一层 key-value(值不可变),从建模上消除嵌套;③ 整体替换 product.attributes = new_dict。order-lab 的约定是 JSON 只放一层可枚举字段,嵌套结构交给关系模型。
7.3.3 PostgreSQL JSONB:方言能力要隔离
PostgreSQL 下换成 JSONB(二进制存储、支持运算符与索引),保留 Mutable 包装:attributes: Mapped[dict[str, object]] = mapped_column(MutableDict.as_mutable(JSONB), default=dict),并在 __table_args__ 里加 Index("ix_product_attributes_gin", "attributes", postgresql_using="gin")。按 key 过滤、astext 转文本比较:select(Product).where(Product.attributes["color"].astext == "red"),PostgreSQL 生成 attributes ->> 'color' = 'red' 并可能走 GIN 索引。SQLite / PostgreSQL / MySQL 差异 :SQLite 的 JSON 只做序列化,["key"] 编译成 json_extract 函数(索引弱);MySQL JSON 列用 JSON_EXTRACT,索引必须借助生成列/函数索引,与 GIN 完全不同。因此 JSON 查询逻辑应隔离在 repository/query 模块并提供 PostgreSQL 集成测试。
建模代价:把所有字段塞进 JSON,等于放弃 CHECK、外键、类型检查与高效 WHERE。JSON 只适合「结构确实不稳定、整体读写或按少量已知 key 检索」的场景。
7.4 批量 INSERT
7.4.1 三种写入路径
| 路径 | 写法 | 经过 ORM 对象 | 事件/级联 | 典型成本 |
|---|---|---|---|---|
| 逐对象 add | session.add(obj) 循环 |
是 | 完整 mapper/relationship 事件 | 对象构造、Identity Map 内存、逐条 INSERT |
| ORM bulk INSERT | session.execute(insert(OrderItem), rows) |
否 | 无生命周期事件、无级联 | 一次批量入参,可能合并为多值 INSERT |
| Core INSERT | conn.execute(insert(table), rows) |
否 | 无 | 纯 Core,无 ORM 语义 |
骨架给出的 ORM bulk INSERT 是 2.x 推荐形式:
python
from sqlalchemy import insert
rows = [
{"order_id": order_id, "product_id": product_id, "quantity": quantity}
for product_id, quantity in items
]
session.execute(insert(OrderItem), rows)
rows 字典键对应 ORM 属性名(不是列名);2.0 起各字典允许键不同(会拆成多条不同 VALUES 的 INSERT)。
7.4.2 insertmanyvalues:把 executemany 变成多值 INSERT
以 PostgreSQL 和 SQLite 为例,session.execute(insert(...), rows) 会把参数列表聚合为单条多值 INSERT (VALUES (?, ?), (?, ?), ...),官方称 insertmanyvalues:一次往返写很多行。MySQL 传统驱动走 DBAPI executemany,通常逐条或按驱动方式批量发送。
单条 SQL 有行数上限 insertmanyvalues_page_size(默认 1000):
python
session.execute(
insert(OrderItem), rows,
execution_options={"insertmanyvalues_page_size": 500},
)
page size 超过一定量后不一定更快,需要基准(7.11)。
7.4.3 代价:绕过 Unit of Work 的账单
骨架列出的代价逐条展开:
- 不等同于逐对象
add():没有对象被加入 Session,无状态跟踪、无对象级 flush 排序; - Mapper 事件与关系级联语义不同 :
before_insert/after_insert等生命周期事件不触发(拦截只能用SessionEvents.do_orm_execute);cascade="save-update"不会自动写入关联对象; - Identity Map 未必包含对应对象 :写完后
session.get(OrderItem, id)对未加载的 id 会发 SELECT; - 服务端默认值/RETURNING 支持依方言:依赖数据库生成的默认值要 RETURNING 才能取回;
- 大批次延长事务并占用内存 :
rows列表、单条超长 SQL、长事务持有的锁都是成本。不要一次提交百万行,用批大小控制。
7.4.4 服务端默认值与 RETURNING
PostgreSQL / SQLite(3.35+)支持 INSERT ... RETURNING,insertmanyvalues 能按参数集顺序取回返回行:
python
ids = session.execute(
insert(OrderItem).returning(OrderItem.id), rows
).scalars().all() # 与 rows 顺序对应
- SQLite / PostgreSQL:RETURNING 可用,配合 insertmanyvalues 批量取回主键;
- MySQL :普通 INSERT 无 RETURNING(MariaDB 10.5+ 支持),多行自增主键只能靠逐条或
lastrowid,这是批量导入拿主键最常见的坑。
ORM bulk INSERT 与 legacy Session.bulk_insert_mappings() 是同一套底层;2.x 统一走 session.execute(insert(...), rows)。
结论:批量写入适合「已知完整数据、不需要对象状态、级联/事件的纯行管道」;任何需要业务规则的写入应回到 ORM 对象路径。
7.5 PostgreSQL Upsert
7.5.1 幂等回调的数据库解法
「先查后写」有竞态,幂等必须由唯一约束裁决。Payment 带 provider + idempotency_key 唯一约束,回调直接用 PostgreSQL 方言的 INSERT ... ON CONFLICT:
python
from sqlalchemy import ForeignKey, Numeric, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import insert
class Payment(Base):
__tablename__ = "payment"
__table_args__ = (
UniqueConstraint("provider", "idempotency_key",
name="uq_payment_provider_idem"),
)
id: Mapped[int] = mapped_column(primary_key=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), unique=True)
provider: Mapped[str] = mapped_column(String(32))
idempotency_key: Mapped[str] = mapped_column(String(128))
status: Mapped[str] = mapped_column(String(16), default="SUCCEEDED")
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
def upsert_payment(
session: Session, *, order_id: int, provider: str,
idempotency_key: str, amount: Decimal,
) -> int | None:
"""插入支付记录;已存在则什么都不做,返回既有主键(None 表示重复回调)。"""
stmt = (
insert(Payment)
.values(order_id=order_id, provider=provider,
idempotency_key=idempotency_key,
status="SUCCEEDED", amount=amount)
.on_conflict_do_nothing(index_elements=["provider", "idempotency_key"])
.returning(Payment.id)
)
return session.execute(stmt).scalar_one_or_none()
- 首次回调插入成功,返回新主键;重复回调命中唯一冲突,
do_nothing不改任何行,返回None;并发两个回调时唯一索引保证只有一个插入成功; - 需要「存在则更新」用
on_conflict_do_update(..., set_={"status": "SUCCEEDED"}); index_elements必须对应真实存在的唯一约束/唯一索引------幂等性最终依赖唯一约束,而不是 upsert 语法本身。
7.5.2 方言差异:SQLite / MySQL
- SQLite (3.24+):
sqlalchemy.dialects.sqlite.insert同样提供on_conflict_do_nothing/on_conflict_do_update,语法对齐,适合本地复现; - MySQL / MariaDB :
sqlalchemy.dialects.mysql.insert提供on_duplicate_key_update,语义是「主键或任一唯一键冲突即走 UPDATE」,且不区分是哪条唯一键冲突:
python
from sqlalchemy.dialects.mysql import insert as mysql_insert
stmt = (
mysql_insert(Payment)
.values(order_id=order_id, provider=provider,
idempotency_key=idempotency_key,
status="SUCCEEDED", amount=amount)
.on_duplicate_key_update(status="SUCCEEDED")
)
- RETURNING :PostgreSQL 与 SQLite(3.35+)的 upsert 支持 RETURNING;MySQL 的 INSERT 不支持 RETURNING (MariaDB 10.5+ 部分支持),冲突判定只能靠
rowcount或改写。
7.5.3 方言隔离层
不要用「字符串替换 SQL 片段」模拟可移植性------各数据库的 upsert 语法、冲突判定、返回值与并发语义都不同 。正确做法是定义方言无关接口,按 dialect.name 分发实现,方言专属 SQL 收进独立模块:
#mermaid-svg-WJO3yAKJOebOGEk1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WJO3yAKJOebOGEk1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WJO3yAKJOebOGEk1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WJO3yAKJOebOGEk1 .error-icon{fill:#552222;}#mermaid-svg-WJO3yAKJOebOGEk1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WJO3yAKJOebOGEk1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WJO3yAKJOebOGEk1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WJO3yAKJOebOGEk1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WJO3yAKJOebOGEk1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WJO3yAKJOebOGEk1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WJO3yAKJOebOGEk1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WJO3yAKJOebOGEk1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WJO3yAKJOebOGEk1 .marker.cross{stroke:#333333;}#mermaid-svg-WJO3yAKJOebOGEk1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WJO3yAKJOebOGEk1 p{margin:0;}#mermaid-svg-WJO3yAKJOebOGEk1 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-WJO3yAKJOebOGEk1 .cluster-label text{fill:#333;}#mermaid-svg-WJO3yAKJOebOGEk1 .cluster-label span{color:#333;}#mermaid-svg-WJO3yAKJOebOGEk1 .cluster-label span p{background-color:transparent;}#mermaid-svg-WJO3yAKJOebOGEk1 .label text,#mermaid-svg-WJO3yAKJOebOGEk1 span{fill:#333;color:#333;}#mermaid-svg-WJO3yAKJOebOGEk1 .node rect,#mermaid-svg-WJO3yAKJOebOGEk1 .node circle,#mermaid-svg-WJO3yAKJOebOGEk1 .node ellipse,#mermaid-svg-WJO3yAKJOebOGEk1 .node polygon,#mermaid-svg-WJO3yAKJOebOGEk1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WJO3yAKJOebOGEk1 .rough-node .label text,#mermaid-svg-WJO3yAKJOebOGEk1 .node .label text,#mermaid-svg-WJO3yAKJOebOGEk1 .image-shape .label,#mermaid-svg-WJO3yAKJOebOGEk1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-WJO3yAKJOebOGEk1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WJO3yAKJOebOGEk1 .rough-node .label,#mermaid-svg-WJO3yAKJOebOGEk1 .node .label,#mermaid-svg-WJO3yAKJOebOGEk1 .image-shape .label,#mermaid-svg-WJO3yAKJOebOGEk1 .icon-shape .label{text-align:center;}#mermaid-svg-WJO3yAKJOebOGEk1 .node.clickable{cursor:pointer;}#mermaid-svg-WJO3yAKJOebOGEk1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WJO3yAKJOebOGEk1 .arrowheadPath{fill:#333333;}#mermaid-svg-WJO3yAKJOebOGEk1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WJO3yAKJOebOGEk1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WJO3yAKJOebOGEk1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WJO3yAKJOebOGEk1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WJO3yAKJOebOGEk1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WJO3yAKJOebOGEk1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WJO3yAKJOebOGEk1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WJO3yAKJOebOGEk1 .cluster text{fill:#333;}#mermaid-svg-WJO3yAKJOebOGEk1 .cluster span{color:#333;}#mermaid-svg-WJO3yAKJOebOGEk1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WJO3yAKJOebOGEk1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WJO3yAKJOebOGEk1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-WJO3yAKJOebOGEk1 .icon-shape,#mermaid-svg-WJO3yAKJOebOGEk1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WJO3yAKJOebOGEk1 .icon-shape p,#mermaid-svg-WJO3yAKJOebOGEk1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WJO3yAKJOebOGEk1 .icon-shape .label rect,#mermaid-svg-WJO3yAKJOebOGEk1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WJO3yAKJOebOGEk1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WJO3yAKJOebOGEk1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WJO3yAKJOebOGEk1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-WJO3yAKJOebOGEk1 .orm>*{fill:#dbeafe!important;stroke:#1e40af!important;color:#1e3a8a!important;}#mermaid-svg-WJO3yAKJOebOGEk1 .orm span{fill:#dbeafe!important;stroke:#1e40af!important;color:#1e3a8a!important;}#mermaid-svg-WJO3yAKJOebOGEk1 .orm tspan{fill:#1e3a8a!important;}#mermaid-svg-WJO3yAKJOebOGEk1 .db>*{fill:#dcfce7!important;stroke:#15803d!important;color:#14532d!important;}#mermaid-svg-WJO3yAKJOebOGEk1 .db span{fill:#dcfce7!important;stroke:#15803d!important;color:#14532d!important;}#mermaid-svg-WJO3yAKJOebOGEk1 .db tspan{fill:#14532d!important;} RETURNING ✓
RETURNING ✗
RETURNING ✓
应用层 upsert_payment(...)
方言无关接口 PaymentRepo.upsert()
postgresql.insert
ON CONFLICT DO UPDATE
mysql.insert
ON DUPLICATE KEY UPDATE
sqlite.insert
ON CONFLICT DO UPDATE
PostgreSQL
MySQL / MariaDB
SQLite 3.24+
返回主键
rowcount
返回主键
接口背后仍是「每个数据库只能在自己的集成测试里验证 」:SQLite 跑通不能证明 MySQL 的 rowcount 语义正确,也不能证明 PostgreSQL 的 index_elements 命中了真实唯一索引。
7.6 乐观版本列
7.6.1 version_id_col 的机制
乐观并发:flush UPDATE 时把旧版本号写进 WHERE,受影响行数为零说明期间已有其他事务提交,数据变 stale。
python
class Product(Base):
__tablename__ = "product"
id: Mapped[int] = mapped_column(primary_key=True)
version_id: Mapped[int] = mapped_column(nullable=False)
__mapper_args__ = {"version_id_col": version_id}
两个 Session 先后读取同一商品,后提交者的 flush 生成 UPDATE product SET version_id = 6, ... WHERE id = 1 AND version_id = 5。0 行匹配时抛出 sqlalchemy.orm.exc.StaleDataError(UPDATE statement on table 'product' expected to update 1 row(s); 0 were matched.)。行数检查发生在 flush(事务内),异常后应 rollback()、重读最新数据再决定业务处理------这是「扣库存与创建订单同一事务」下防止覆盖更新的护栏。版本值默认整数递增,也可用 version_id_generator 换成业务版本号(如 uuid.uuid4)。
7.6.2 Bulk UPDATE 会绕过版本检查
ORM-enabled bulk UPDATE 是一条 SQL 直接改行 ,不逐对象带版本条件,version_id_col 默认不参与。需要版本保护的批量修改必须把版本条件写进 WHERE,由数据库裁决:
python
from sqlalchemy import update
result = session.execute(
update(Product)
.where(Product.id == product_id, Product.version_id == expected_version)
.values(stock=Product.stock - quantity)
)
if result.rowcount != 1:
raise ConcurrencyError("product changed concurrently")
与 7.5「幂等依赖唯一约束」同一思想:并发安全由数据库裁决,不由应用层先查后写。
7.7 Events 应用于横切关注点
7.7.1 事件族概览
| 事件族 | 监听对象 | 代表事件 | 典型用途 |
|---|---|---|---|
ConnectionEvents |
engine |
before_cursor_execute、after_cursor_execute |
SQL 耗时、慢 SQL 采样、trace |
PoolEvents |
engine.pool |
checkout、checkin |
连接池观测、泄漏定位(参考 Part 2) |
SessionEvents |
session_factory | before_flush、after_commit、do_orm_execute |
事务边界观测、全局查询拦截 |
MapperEvents |
映射类 | before_insert、after_update |
对象级审计元数据 |
AttributeEvents |
映射属性 | set、append |
字段级变化钩子 |
骨架的 SQL 计时示例(用 perf_counter 打时间戳,不污染业务路径):
python
import time
from sqlalchemy import event
@event.listens_for(engine, "before_cursor_execute")
def before_sql(conn, cursor, statement, parameters, context, executemany):
context._query_started_at = time.perf_counter()
@event.listens_for(engine, "after_cursor_execute")
def after_sql(conn, cursor, statement, parameters, context, executemany):
started = getattr(context, "_query_started_at", None)
if started is not None:
elapsed_ms = (time.perf_counter() - started) * 1000
# 生产环境脱敏后送入 metrics/trace,而不是 print
7.7.2 适合与不适合
适合 :SQL 耗时与 trace(采样、脱敏);连接池观测;通用审计元数据(before_insert/before_update 统一填充 created_by/updated_at);特殊数据库初始化(如 SQLite PRAGMA foreign_keys=ON)。
不适合:
- 隐藏下单、扣库存等核心业务流程------业务规则放进事件回调,调用方读不懂事务边界,也无法单测;
- 在 mapper 回调里做复杂网络 I/O------flush 期间外部调用拉长事务、引入不可控失败点;
- 依赖难以预测的事件顺序完成业务事务------
before_flush顺序、级联触发顺序都不是业务契约。
边界:事件做「观察与横切」,业务做「显式流程」。审计与指标靠事件;下单、扣库存写在服务代码里,靠数据库约束兜底。
7.8 多租户过滤
仅要求开发者每次写 .where(Model.tenant_id == tenant_id) 风险很高:总有一条查询忘记过滤。四种方案在隔离强度与成本间取舍:
| 方案 | 物理隔离 | 共享基础设施 | 主要成本 |
|---|---|---|---|
Shared Schema + tenant_id |
逻辑层 | 高 | 每条查询带租户条件,索引/约束带上租户列 |
| Schema per Tenant | Schema 层 | 中 | DDL 每租户一份、迁移 x N、切换 search_path |
| Database per Tenant | 数据库层 | 低 | 每租户连接池、运维成本高 |
| Row Level Security | 数据库强制(PostgreSQL) | 高 | 依赖数据库能力、策略维护 |
7.8.1 全局防护:with_loader_criteria
配合 SessionEvents.do_orm_execute 把租户条件自动附加到所有该实体的 ORM SELECT:
python
from sqlalchemy.orm import with_loader_criteria
@event.listens_for(session_factory, "do_orm_execute")
def _add_tenant_criteria(orm_execute_state):
tenant_id = getattr(orm_execute_state.session, "_tenant_id", None)
if tenant_id is None:
return
orm_execute_state.statement = orm_execute_state.statement.options(
with_loader_criteria(
Order,
lambda cls: cls.tenant_id == tenant_id,
include_aliases=True,
)
)
关键限制:只覆盖 ORM SELECT 路径 ------session.execute(insert(...), rows) 这类 bulk DML、带 WHERE 的 update()/delete()、裸 Core SQL 不自动附加;后台任务与管理员路径绕过 _tenant_id 也不受保护。因此它是一层防护 而非全部方案:核心业务表仍应带 tenant_id 复合索引与复合唯一约束,并对每条租户数据路径做越权测试。
7.8.2 Session bind 与读写路由
Schema/Database per tenant 需要运行时把 Session 绑定到对应引擎:Session(bind=engine_for_tenant(tenant_id))。读写路由的复杂度在于:同一事务内读写必须落在同一连接,否则出现「读己之写」------写主库后立即查从库,副本延迟导致读不到刚写的订单。副本读只用于可容忍延迟的报表类查询;不要把请求级选库做成语句级路由。
7.8.3 越权测试
多租户最危险的 bug 是「查到别的租户数据」。测试应显式构造:租户 A 的 Session 读取/修改租户 B 的订单 id,断言结果为空或被拒绝,并同时覆盖 ORM SELECT 与 bulk DML 两条路径。
7.9 性能实验记录模板
骨架模板是硬性要求------只比较耗时而不记录条件,结论不可复现:
text
Python / SQLAlchemy / driver:
Database version and hardware:
Rows and payload size:
Batch size:
Transaction count:
Cold/warm cache:
Elapsed / throughput / peak memory:
Generated SQL:
最易失真的四点:冷/热缓存 (首次查询加载数据页与执行计划,第二次可能完全不同);事务粒度 (每次 commit 有 fsync 成本,批大小与事务数混在一起比较无意义);SQL 形态 (必须记录生成的是 1 条多值 INSERT 还是 N 条单值 INSERT);环境(驱动、数据库版本、硬件、并发度写进表头)。
7.10 验收
- hybrid Python/SQL 两侧结果一致(7.2 逐行断言);
- JSON 原地修改能被检测(无 Mutable 包装则丢更新);
- 10 万行按批次写入并记录资源(时间/内存/SQL 条数);
- upsert 由唯一约束保证幂等(并发重复回调只产生一行);
- bulk DML 后验证 Session 状态(对象未入 Identity Map、事件未触发);
- 版本列产生并发冲突(两个 Session 同时改同一商品);
- 多租户越权查询被测试阻止(ORM SELECT 与 bulk DML 两条路径)。
7.11 order-lab 实战:写入基准与幂等支付
7.11.1 10 万条订单项分批写入基准
为孤立测量批量写入,实验模型刻意去掉外键与唯一约束(约束会改变执行计划与耗时):OrderItem 仅含 id(主键)、order_id(索引)、product_id、quantity、unit_price,订单按每 100 行一组切分------模型与 7.14 测试文件中的 OrderItem 同构,不再重复列出。
数据生成、清理与基准(build_rows 保证同一 order_id 内 product_id 不重复):
python
import time
import tracemalloc
from decimal import Decimal
from sqlalchemy import create_engine, delete, event, insert
from sqlalchemy.orm import Session
# 复用 7.14 tests/test_part7.py 中的 Base 与 OrderItem(含自增主键、order_id 索引)
from tests.test_part7 import Base, OrderItem
TOTAL_ROWS = 100_000
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
def build_rows(offset: int, batch_size: int) -> list[dict[str, object]]:
return [
{"order_id": (offset + i) // 100, "product_id": i % 100 + 1,
"quantity": i % 9 + 1, "unit_price": Decimal("9.90")}
for i in range(batch_size)
]
def benchmark_bulk(batch_size: int) -> dict[str, object]:
counted = {"dml": 0}
@event.listens_for(engine, "after_cursor_execute")
def _count(conn, cursor, statement, parameters, context, executemany):
if statement.lstrip().upper().startswith(("INSERT", "UPDATE", "DELETE")):
counted["dml"] += 1
with Session(engine) as session:
session.execute(delete(OrderItem)); session.commit()
tracemalloc.start()
start = time.perf_counter()
for offset in range(0, TOTAL_ROWS, batch_size):
session.execute(insert(OrderItem), build_rows(offset, batch_size))
session.commit()
elapsed = time.perf_counter() - start
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
event.remove(engine, "after_cursor_execute", _count)
return {"batch": batch_size, "elapsed_s": round(elapsed, 3),
"dml": counted["dml"], "peak_mib": round(peak / 1024 / 1024, 1)}
示例环境实测记录(示例数据,非基准 ;正式结论请在目标数据库与硬件上按 7.9 模板重测。环境:Python 3.11.6、SQLAlchemy 2.0.51、SQLite :memory:、Windows 11):
| 批大小 | 耗时(s) | DML 条数 | 峰值内存(MiB) | 说明 |
|---|---|---|---|---|
| 1 000 | 0.41 | 100 | 9.8 | 每次调用 1 条多值 INSERT(page 1000) |
| 5 000 | 0.28 | 100 | 10.4 | 每次调用被切成 5 条多值 INSERT |
| 10 000 | 0.31 | 100 | 11.9 | 单条 SQL 过长,收益不再线性 |
测量要点:
- SQL 条数看 page size,不看批大小 :
insertmanyvalues默认 page 1000,10 万行固定 100 条多值 INSERT;批大小只影响session.execute调用次数(1k 是 100 次、5k 是 20 次)与峰值内存; - 不是越大越好:批 10k 单条 SQL 达万组参数,网络包与解析成本上升;
- 事务长度:整个基准在一个事务内;改成每批 commit 会让耗时显著上升(fsync),那是另一组对照;
- 对照 :同样的 10 万行逐对象
session.add(),Identity Map 持有 10 万个对象(内存以百 MiB 计、耗时是 bulk 的数倍到数十倍),SQL 是 10 万条单值 INSERT------这就是 7.4「绕过 Unit of Work 的账单」的正面数字;但 bulk 路径没有对象、事件与级联,需要这些语义的写入不能为了数字盲目切换。
7.11.2 幂等支付 upsert
复用 7.5 的 upsert_payment,验证重复回调只落一行:两个 Session 用同一 (provider, idempotency_key) 依次调用,第一个返回新主键、第二个返回 None(唯一约束冲突被 on_conflict_do_nothing 吞掉),完整断言见 7.14 的 test_upsert_is_idempotent。SQLite 下用 sqlalchemy.dialects.sqlite.insert 的 on_conflict_do_nothing(语法与 PostgreSQL 相同);并发下的真正行为必须在 PostgreSQL 复验。JSON 原地修改的回归测试见 7.14。
7.12 失败实验
7.12.1 实验一:无 Mutable 包装时 JSON 原地修改丢失
python
class Product(Base):
__tablename__ = "product"
id: Mapped[int] = mapped_column(primary_key=True)
attributes: Mapped[dict[str, object]] = mapped_column(JSON) # 忘了 MutableDict
with Session(engine) as session:
product = session.scalars(select(Product)).one()
product.attributes["color"] = "red"
assert product not in session.dirty # True:没有变化事件
session.commit() # 数据库仍是旧值 ------ 更新静默丢失
结论:JSON 列要么 Mutable 包装、要么约定整体替换,并把「原地修改必须可见」写进回归测试。
7.12.2 实验二:single-table 继承子类列约束问题
python
class ChannelConfig(Base):
__tablename__ = "channel_config"
id: Mapped[int] = mapped_column(primary_key=True)
channel: Mapped[str] = mapped_column(String(20))
api_secret: Mapped[str] = mapped_column(String(128)) # 忘了 nullable
__mapper_args__ = {"polymorphic_on": channel}
class PaypalConfig(ChannelConfig):
__mapper_args__ = {"polymorphic_identity": "paypal"}
session.add(PaypalConfig(channel="paypal"))
session.flush() # IntegrityError: NOT NULL constraint failed: channel_config.api_secret
Single Table 里 api_secret 是整表的 NOT NULL 列,非 Stripe 行无法满足。修正:子类列全部 nullable=True(放弃列级完整性、应用层约束),或改用 Joined Table / 组合字段。
7.12.3 实验三:upsert 在不同方言的行为差异
python
# 1) SQLite:index_elements 必须对应真实唯一约束
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
stmt = sqlite_insert(Payment).values(...).on_conflict_do_nothing(
index_elements=["provider", "idempotency_key"])
# Payment 上没有该唯一约束 → OperationalError:
# ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint
python
# 2) MySQL:INSERT 没有 RETURNING(无法编译,主键只能靠 last_insert_id);
# on_duplicate_key_update 不区分冲突来源,多个唯一键时任一冲突都走 UPDATE
from sqlalchemy.dialects.mysql import insert as mysql_insert
mysql_insert(Payment).values(...).returning(Payment.id) # 编译失败
mysql_insert(Payment).values(...).on_duplicate_key_update(status="SUCCEEDED")
这些差异正是 7.5.3 建方言隔离层的原因:接口统一、实现按方言分发、各自在集成测试里验证,不做字符串替换式「可移植性」。
7.13 常见误区
- 误区一 :
MutableDict.as_mutable(JSON)能跟踪任意深度的嵌套修改------只跟踪顶层,深度嵌套要递归包装或整体替换; - 误区二 :
session.execute(insert(...), rows)是「更快的add()」------它没有对象、事件与级联语义,两者不是等价替代; - 误区三:批大小越大越快------insertmanyvalues 有 page 上限与 SQL 长度上限,收益递减,必须基准;
- 误区四 :upsert 让表「不需要唯一约束」------恰恰相反,
ON CONFLICT依赖唯一约束/索引才能判断冲突; - 误区五 :
version_id_col保护所有写入------bulk UPDATE/DELETE 不经过对象级版本检查,必须自己把版本条件写进 WHERE; - 误区六:事件适合放核心业务流程------业务规则进回调会失去显式事务边界与可测性,事件只做横切;
- 误区七 :SQLite 跑通 upsert/JSONB/批量,PostgreSQL/MySQL 一定正确------RETURNING、JSONB 运算、
ON DUPLICATE KEY语义、insertmanyvalues 支持度都不同,生产结论必须在目标方言复验。
7.14 自动化测试
创建 tests/test_part7.py,把本篇关键结论变成可重复验证的事实:
python
from collections.abc import Iterator
from decimal import Decimal
import pytest
from sqlalchemy import (
JSON, Numeric, String, URL, UniqueConstraint,
create_engine, select,
)
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.exc import StaleDataError
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
class Base(DeclarativeBase):
pass
class OrderItem(Base):
__tablename__ = "order_item"
id: Mapped[int] = mapped_column(primary_key=True)
order_id: Mapped[int] = mapped_column(index=True)
quantity: Mapped[int]
unit_price: Mapped[Decimal] = mapped_column(Numeric(12, 2))
@hybrid_property
def subtotal(self) -> Decimal:
return self.quantity * self.unit_price
@subtotal.inplace.expression
@classmethod
def _subtotal_expression(cls):
return cls.quantity * cls.unit_price
class Product(Base):
__tablename__ = "product"
id: Mapped[int] = mapped_column(primary_key=True)
sku: Mapped[str] = mapped_column(String(64), unique=True)
attributes: Mapped[dict[str, object]] = mapped_column(
MutableDict.as_mutable(JSON), default=dict
)
version_id: Mapped[int] = mapped_column(nullable=False, default=1)
__mapper_args__ = {"version_id_col": version_id}
class Payment(Base):
__tablename__ = "payment"
__table_args__ = (
UniqueConstraint("provider", "idempotency_key", name="uq_provider_idem"),
)
id: Mapped[int] = mapped_column(primary_key=True)
order_id: Mapped[int]
provider: Mapped[str] = mapped_column(String(32))
idempotency_key: Mapped[str] = mapped_column(String(128))
status: Mapped[str] = mapped_column(String(16))
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
@pytest.fixture
def engine(tmp_path) -> Iterator:
test_engine = create_engine(
URL.create("sqlite+pysqlite", database=str(tmp_path / "test.db"))
)
Base.metadata.create_all(test_engine)
yield test_engine
test_engine.dispose()
def test_hybrid_matches_python(engine) -> None:
with Session(engine) as session, session.begin():
session.add(OrderItem(order_id=1, quantity=3, unit_price=Decimal("9.90")))
with Session(engine) as session:
item = session.scalars(select(OrderItem)).one()
assert item.subtotal == Decimal("29.70")
sql_value = session.scalar(
select(OrderItem.subtotal).where(OrderItem.id == item.id))
assert sql_value == item.subtotal
def test_json_in_place_change_is_detected(engine) -> None:
with Session(engine) as session, session.begin():
session.add(Product(sku="A-1", attributes={"warranty_months": 12}))
with Session(engine) as session:
product = session.scalars(select(Product)).one()
product.attributes["warranty_months"] = 24
assert product in session.dirty
session.commit()
with Session(engine) as session:
product = session.scalars(select(Product)).one()
assert product.attributes["warranty_months"] == 24
def test_upsert_is_idempotent(engine) -> None:
def call(key: str) -> int | None:
with Session(engine) as session:
stmt = (
sqlite_insert(Payment)
.values(order_id=1, provider="stripe", idempotency_key=key,
status="SUCCEEDED", amount=Decimal("9.90"))
.on_conflict_do_nothing(
index_elements=["provider", "idempotency_key"])
.returning(Payment.id)
)
return session.execute(stmt).scalar_one_or_none()
assert call("evt-1") is not None
assert call("evt-1") is None # 重复回调幂等
assert call("evt-2") is not None # 新 key 正常插入
def test_version_conflict_raises_stale(engine) -> None:
with Session(engine) as session, session.begin():
session.add(Product(sku="B-1"))
with Session(engine) as session_a, Session(engine) as session_b:
a = session_a.scalars(select(Product).where(Product.sku == "B-1")).one()
b = session_b.scalars(select(Product).where(Product.sku == "B-1")).one()
a.attributes["color"] = "red"
session_a.commit()
b.attributes["color"] = "blue"
with pytest.raises(StaleDataError):
session_b.flush()
session_b.rollback()
执行 python -m pytest tests/test_part7.py -q,预期 4 passed。upsert 测试使用 SQLite 的 on_conflict_do_nothing + RETURNING(SQLite 3.35+ 与 PostgreSQL 支持);在旧 SQLite 或 MySQL 上运行需按方言调整------这本身就是在验证 7.5.2 的方言差异。bulk INSERT 的 Session 状态语义(无对象、无事件)在 7.4.3 与 7.11 基准中验证。
7.15 小结
本篇所有工具都在回答同一件事:基础 CRUD 之外,「性能」「幂等」「复杂建模」如何与 Unit of Work 和解。
四个必须记住的结论:
- 继承映射先算数据库账:Single Table 的简单来自空列与弱约束,Joined Table 的规范来自 JOIN,Concrete Table 的独立来自 UNION------先问「是行为差异还是数据差异」,再决定要不要类继承;
- 能查到、能检测才算数:hybrid 的 Python/SQL 两侧必须一致;JSON 原地修改必须靠 Mutable(且只跟踪顶层);
- 绕过 Unit of Work 有明确账单:bulk INSERT 少了对象、事件与级联,换来回往返与内存的减少;upsert 的幂等来自唯一约束;bulk UPDATE 不带版本检查------并发安全要么写进 SQL 的 WHERE,要么交给数据库约束;
- 方言差异是设计约束:upsert 语法、RETURNING 支持度、JSONB 运算、insertmanyvalues 行为都不同,用方言隔离层收口,在目标数据库复验,用自动化测试固化。
下一篇进入异步世界:AsyncSession 与 Web 生命周期,届时会看到 Session 生命周期与事务边界如何与并发任务纠缠。