SQLAlchemy 系列(七):高级建模与高效写入——批量 DML、方言与扩展

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,子类 StripeConfigapi_secret)与 PaypalConfigclient_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_configpaypal_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 里遍历计算,不能在 WHEREORDER 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_methodcolumn_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() 自动包装。列表同理用 MutableListMutableList.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) 会把参数列表聚合为单条多值 INSERTVALUES (?, ?), (?, ?), ...),官方称 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 幂等回调的数据库解法

「先查后写」有竞态,幂等必须由唯一约束裁决。Paymentprovider + 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 / MariaDBsqlalchemy.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.StaleDataErrorUPDATE 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_executeafter_cursor_execute SQL 耗时、慢 SQL 采样、trace
PoolEvents engine.pool checkoutcheckin 连接池观测、泄漏定位(参考 Part 2)
SessionEvents session_factory before_flushafter_commitdo_orm_execute 事务边界观测、全局查询拦截
MapperEvents 映射类 before_insertafter_update 对象级审计元数据
AttributeEvents 映射属性 setappend 字段级变化钩子

骨架的 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_idquantityunit_price,订单按每 100 行一组切分------模型与 7.14 测试文件中的 OrderItem 同构,不再重复列出。

数据生成、清理与基准(build_rows 保证同一 order_idproduct_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 过长,收益不再线性

测量要点:

  1. SQL 条数看 page size,不看批大小insertmanyvalues 默认 page 1000,10 万行固定 100 条多值 INSERT;批大小只影响 session.execute 调用次数(1k 是 100 次、5k 是 20 次)与峰值内存;
  2. 不是越大越好:批 10k 单条 SQL 达万组参数,网络包与解析成本上升;
  3. 事务长度:整个基准在一个事务内;改成每批 commit 会让耗时显著上升(fsync),那是另一组对照;
  4. 对照 :同样的 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.inserton_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 和解

四个必须记住的结论:

  1. 继承映射先算数据库账:Single Table 的简单来自空列与弱约束,Joined Table 的规范来自 JOIN,Concrete Table 的独立来自 UNION------先问「是行为差异还是数据差异」,再决定要不要类继承;
  2. 能查到、能检测才算数:hybrid 的 Python/SQL 两侧必须一致;JSON 原地修改必须靠 Mutable(且只跟踪顶层);
  3. 绕过 Unit of Work 有明确账单:bulk INSERT 少了对象、事件与级联,换来回往返与内存的减少;upsert 的幂等来自唯一约束;bulk UPDATE 不带版本检查------并发安全要么写进 SQL 的 WHERE,要么交给数据库约束;
  4. 方言差异是设计约束:upsert 语法、RETURNING 支持度、JSONB 运算、insertmanyvalues 行为都不同,用方言隔离层收口,在目标数据库复验,用自动化测试固化。

下一篇进入异步世界:AsyncSession 与 Web 生命周期,届时会看到 Session 生命周期与事务边界如何与并发任务纠缠。

官方参考

相关推荐
Zane19942 小时前
@property 到底是怎么把方法伪装成属性的?一文吃透 property、staticmethod、classmethod
后端·python
qq_316411032 小时前
AI 情感陪伴智能潮玩软硬件一体化开发案例
人工智能·python
废弃的小码农2 小时前
功能测试--Day07--Python编程基础
开发语言·python
zx1154502 小时前
大模型工具调用次数限制
人工智能·python
Lethehong3 小时前
双擎并驱·全链路并行:KFS让TB级异构增量同步秒级到达
数据库
MC皮蛋侠客3 小时前
SQLAlchemy 系列(八):AsyncIO、并发与 Web 生命周期——让每个并发任务持有自己的 Session
数据库·python
一水3 小时前
Redis实战:一个AI工作流系统里的五个应用场景,从传参到限流的完整链路
数据库·redis·wpf
大数据魔法师4 小时前
Python 网络请求库 curl_cffi:从入门到实战,如何规避网站指纹检测
python·数据分析
中电华星4 小时前
专业的工业电源公司
网络·python