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 生命周期与事务边界如何与并发任务纠缠。

官方参考

相关推荐
泡泡鱼(敲代码中)2 小时前
MySQL基础学习笔记:从数据模型到DDL全掌握
开发语言·数据库·笔记·学习·mysql
Ticnix3 小时前
MCP 实战:把工具层从 Agent 里彻底解耦
python·mcp
l1t3 小时前
DeepSeek总结的chdb-core v26.7.3发版说明
数据库·clickhouse·oracle
XZ-0700014 小时前
week4-1-figure画布
python
冰暮流星4 小时前
mysql之表子查询
数据库·mysql
Full Stack Developme4 小时前
CRM相关库表设计
数据库
步行cgn4 小时前
Spring 注入 Map 集合详解
数据库·python·spring
今儿敲了吗4 小时前
03停用词过滤
笔记·python
李可以量化5 小时前
Tornado 部署公域网络安全与防护(上)
python
风跟我说过她5 小时前
SQL 一键转经典 Chen 风格 ER 图:开源 CLI + 在线工具 + Agent Skill
数据库·python·sql·开源·开源软件