SQLAlchemy 系列(十):测试、性能、观测与生产排障——从「能跑」到「可运营」

核心目标:建立真实数据库测试、SQL 数量门禁、连接池观测和「现象到执行计划」的排障闭环,让 order-lab 从「本地能跑」进化到「生产可运营」。

前置知识:理解 Part 1 的 Engine/Connection/Session 执行链路、Part 2 的事务与连接池、Part 3 的声明式模型、Part 6 的关系加载与 N+1;能读懂 pytest fixture。

验证环境:Python 3.11、SQLAlchemy 2.0.51;锁、执行计划与连接容量结论以 PostgreSQL 复验。最后复核日期:2026-07-31。


0. 问题场景:为什么「测试全绿」和「线上稳定」是两回事

order-lab 上线三个月后三个问题同时爆发:

  1. 测试全绿,上线就挂。本地与 CI 都用 SQLite 跑测试:JSONB「看起来能存」、唯一约束「看起来会报错」、并发写入「看起来没问题」。切到 PostgreSQL 后,锁等待、序列化失败、执行计划全部现出原形。
  2. 慢请求无法定位。某接口从 20ms 涨到 2s,运维截图只有「数据库慢」。应用与数据库之间是黑盒:不知道等了多久连接池、执行了多少条 SQL、哪条拖了多久、返回了多少行。
  3. 故障只能靠重启DetachedInstanceErrorPendingRollbackErrorQueuePool 超时轮番出现,每次「重启一下就好」,却没人能回答下次怎么救。

三者共同的根源是测试、观测、决策没有形成闭环 。本篇给出三件套:测试隔离 (外部事务 + SAVEPOINT 让每个测试跑在自己的世界里)、SQL 观测 (事件钩子统计 SQL 数量、耗时与连接池状态)、故障决策树(按「现象 → 根因 → 处置」执行,而不是靠猜)。

示例统一围绕 order-lab:Customer 1---N Order 1---N OrderItem N---1 ProductOrder 1---1 PaymentProduct 1---N InventoryMovementOrder 1---N OutboxEvent。业务规则:客户邮箱唯一;支付回调按外部幂等键去重;订单状态满足有限状态机约束;领域事件写入 outbox。


10.1 测试金字塔:先明确每一层测什么、用什么数据库

10.1.1 四层金字塔

骨架的分层表格可以作为团队共识,补上「示例」与「成本」两列:

测什么 是否需要数据库 典型示例 运行成本
领域单元 金额、状态机、不变量 OrderStatus 状态迁移、价格计算 毫秒级
repository 集成 SQL、映射、约束、事务 会话 CRUD、级联、SQL 数量门禁 秒级
API 集成 请求级 Session、序列化 FastAPI TestClient + 真实 Session 秒级
迁移测试 历史 Schema 到 head Alembic upgrade / downgrade 在快照库上跑 分钟级

每一层对应不同的失败代价:领域单元层发现问题只改几行纯函数;repository 层发现问题牵连接口与事务边界;迁移层代价最高,历史库升级失败可能阻塞发布。常见错误是把数据库断言全部堆在 API 层------只有最外层黑盒用例,任何映射或 SQL 变化都要从头跑完整接口链,慢且难定位。

10.1.2 SQLite 通过 ≠ PostgreSQL 通过

「SQLite 适合快速单测,但不能替代 PostgreSQL」要落到具体差异上:

能力 SQLite PostgreSQL 影响面
JSON / JSONB 无 JSONB,JSON 只是文本列 JSONB 原生 + GIN 索引 JSON 查询表达式与执行计划
锁与并发 单写者、文件/表级锁,写阻塞读 MVCC、行级锁、FOR UPDATE 死锁、lost update 无法在 SQLite 复现
隔离级别 简化语义 READ COMMITTED 起,可配 SERIALIZABLE 可见性与冲突重试结论
执行计划 EXPLAIN QUERY PLAN 信息有限 EXPLAIN (ANALYZE, BUFFERS) 索引与扫描方式定位
类型与精度 动态类型,NUMERIC 语义宽松 强类型、timestamptz Decimal、时区、截断
DDL 有限的 ALTER 丰富的变更能力 迁移测试覆盖度

因此测试分两层:SQLite 快速层 跑纯逻辑与大部分 repository 回归;PostgreSQL 方言层跑锁、隔离、JSONB、执行计划相关结论,且必须在真实 PostgreSQL 上执行。10.10 给出「同一套测试、按环境变量切换数据库」的套件骨架。


10.2 外部事务隔离:让每个测试跑在自己的世界里

10.2.1 为什么「每测试回滚」而不是「每测试重建库」

「每个测试结束清空所有表」要处理外键顺序、自增计数器与并行 worker 互踩;「每个测试重建库」的 DDL 秒级起步。更快的方案是事务级隔离 :每个测试借用一个 Connection、开启外部事务,让 Session 加入其中;测试结束回滚外部事务,数据自然回到测试前状态。回滚是数据库原生能力,比清表快几个数量级,也不受外键顺序影响。

10.2.2 外部事务 + SAVEPOINT 的 fixture 模板

骨架模板补上注释与它解决的问题:

python 复制代码
from collections.abc import Iterator

import pytest
from sqlalchemy import Engine
from sqlalchemy.orm import Session


@pytest.fixture
def db_session(engine: Engine) -> Iterator[Session]:
    connection = engine.connect()              # ① checkout 一条连接
    transaction = connection.begin()           # ② 开启真实外部事务,全程不提交
    session = Session(
        bind=connection,                       # ③ 绑定连接而非 Engine
        join_transaction_mode="create_savepoint",  # ④ Session 用 SAVEPOINT 嵌套
    )

    try:
        yield session
    finally:
        session.close()                        # ⑤ 顺序不能反
        transaction.rollback()
        connection.close()

① 从池中 checkout 连接供测试全程使用;② 在数据库层开启外部事务;③ Session 绑定这条连接,所有 SQL 都运行在外部事务内部;④ create_savepoint 让 Session 用 SAVEPOINT 建立自己的嵌套事务;⑤ 先 session.close() 释放 Session 状态,再回滚外部事务,最后归还连接。

10.2.3 join_transaction_mode 的版本与方言边界

join_transaction_mode 是 SQLAlchemy 2.0 引入Session 参数。取值:"conditional_savepoint"(默认,仅在已处于外部事务时用 SAVEPOINT)、"create_savepoint"(始终用 SAVEPOINT,行为可预期,本文采用)、"rollback_only"(Session 的 rollback 把整个外部事务标为回滚)、"control_fully"(Session 完全控制外部事务,慎用)。

必须验证三件事 ,且不要在 SQLite 观察完就外推到 PostgreSQL:① 版本 ------需要 SQLAlchemy 2.0+,1.x 的测试隔离写法完全不同;② 方言 ------SQLite 与 PostgreSQL 都支持 SAVEPOINT 语法,但错误后的行为有差异(PostgreSQL 中 savepoint 内的错误会使该 savepoint 失效,必须先 ROLLBACK TO SAVEPOINT 才能继续);③ 行为 ------不同模式下「Session.commit() 是否提交外部事务」表现不同(见 10.2.4),应写一条测试固定当前版本的实际行为。

10.2.4 测试里的提交行为

create_savepoint 模式下,测试代码里的 session.commit() 不会 提交外部事务,只释放当前 SAVEPOINT;最终数据是否落库由 transaction.rollback() 决定。因此:测试可以放心调用会 commit 的业务代码而不污染其它测试;但「测试里 commit 成功」不能证明「数据已持久化」,需要验证持久化时用独立 Session 读取。

10.2.5 测试数据:factory 与 fixture 的职责

fixture 负责环境 (引擎、会话、计数器、临时目录与清理),factory 负责业务对象 (用最少参数造出合法的 CustomerProductOrder,处理必填字段与唯一约束)。反模式是测试里散落 Customer(email="alice@example.com", ...) 魔法值,第二个测试改了邮箱后缀、第三个用了相同 sku,于是互相踩唯一约束。正确做法是把生成规则收敛进 factory(完整实现见 10.10.3 的 conftest),例如:

python 复制代码
from uuid import uuid4


def make_customer(session: Session, *, email: str | None = None, name: str = "Alice") -> Customer:
    customer = Customer(
        email=email or f"user-{uuid4().hex[:12]}@example.com",
        name=name,
    )
    session.add(customer)
    session.flush()          # 立即获得主键,供下游对象引用
    return customer

factory 不承担清理职责,清理由外部事务回滚统一完成。

10.2.6 固定时钟、随机种子与确定性

时钟server_default=func.now() 由数据库写入,应用层 monkeypatch 无效------做法是断言存在性与顺序而非精确值;业务中需要「当前时间」的地方通过依赖注入传入,测试固定为已知时间点(可用 freezegun/time_machine 冻结应用层时钟,数据库时间仍以数据库为准)。随机数 :UUID 后缀这类只要求唯一不要求随机;涉及「随机抽样、随机退避」的业务必须显式传入 random.Random(seed),测试固定种子保证可复现。

10.2.7 并行测试与数据库命名空间

并行测试不能共享一组会互相修改的全局 fixture 。两条隔离路线:SQLite 每个 worker 用独立临时文件库或独立内存库(配合 StaticPool,见 10.10.3);PostgreSQL 每个 worker 用独立 schema,并通过 connect 事件让每条新连接执行 SET search_path TO "test_<worker>"search_path 是会话级设置,必须挂在 connect 事件上)。schema 创建与 create_all() 也要按 worker 分别执行。至少要做到数据库实例独立,绝不能让两个并发测试写同一个 customer 表。

10.2.8 测试隔离层次

#mermaid-svg-UXXNQ7nfkooLB1m3{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-UXXNQ7nfkooLB1m3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UXXNQ7nfkooLB1m3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UXXNQ7nfkooLB1m3 .error-icon{fill:#552222;}#mermaid-svg-UXXNQ7nfkooLB1m3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UXXNQ7nfkooLB1m3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UXXNQ7nfkooLB1m3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UXXNQ7nfkooLB1m3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UXXNQ7nfkooLB1m3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UXXNQ7nfkooLB1m3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UXXNQ7nfkooLB1m3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UXXNQ7nfkooLB1m3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UXXNQ7nfkooLB1m3 .marker.cross{stroke:#333333;}#mermaid-svg-UXXNQ7nfkooLB1m3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UXXNQ7nfkooLB1m3 p{margin:0;}#mermaid-svg-UXXNQ7nfkooLB1m3 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-UXXNQ7nfkooLB1m3 .cluster-label text{fill:#333;}#mermaid-svg-UXXNQ7nfkooLB1m3 .cluster-label span{color:#333;}#mermaid-svg-UXXNQ7nfkooLB1m3 .cluster-label span p{background-color:transparent;}#mermaid-svg-UXXNQ7nfkooLB1m3 .label text,#mermaid-svg-UXXNQ7nfkooLB1m3 span{fill:#333;color:#333;}#mermaid-svg-UXXNQ7nfkooLB1m3 .node rect,#mermaid-svg-UXXNQ7nfkooLB1m3 .node circle,#mermaid-svg-UXXNQ7nfkooLB1m3 .node ellipse,#mermaid-svg-UXXNQ7nfkooLB1m3 .node polygon,#mermaid-svg-UXXNQ7nfkooLB1m3 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UXXNQ7nfkooLB1m3 .rough-node .label text,#mermaid-svg-UXXNQ7nfkooLB1m3 .node .label text,#mermaid-svg-UXXNQ7nfkooLB1m3 .image-shape .label,#mermaid-svg-UXXNQ7nfkooLB1m3 .icon-shape .label{text-anchor:middle;}#mermaid-svg-UXXNQ7nfkooLB1m3 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-UXXNQ7nfkooLB1m3 .rough-node .label,#mermaid-svg-UXXNQ7nfkooLB1m3 .node .label,#mermaid-svg-UXXNQ7nfkooLB1m3 .image-shape .label,#mermaid-svg-UXXNQ7nfkooLB1m3 .icon-shape .label{text-align:center;}#mermaid-svg-UXXNQ7nfkooLB1m3 .node.clickable{cursor:pointer;}#mermaid-svg-UXXNQ7nfkooLB1m3 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-UXXNQ7nfkooLB1m3 .arrowheadPath{fill:#333333;}#mermaid-svg-UXXNQ7nfkooLB1m3 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-UXXNQ7nfkooLB1m3 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-UXXNQ7nfkooLB1m3 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UXXNQ7nfkooLB1m3 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-UXXNQ7nfkooLB1m3 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UXXNQ7nfkooLB1m3 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-UXXNQ7nfkooLB1m3 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-UXXNQ7nfkooLB1m3 .cluster text{fill:#333;}#mermaid-svg-UXXNQ7nfkooLB1m3 .cluster span{color:#333;}#mermaid-svg-UXXNQ7nfkooLB1m3 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-UXXNQ7nfkooLB1m3 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-UXXNQ7nfkooLB1m3 rect.text{fill:none;stroke-width:0;}#mermaid-svg-UXXNQ7nfkooLB1m3 .icon-shape,#mermaid-svg-UXXNQ7nfkooLB1m3 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UXXNQ7nfkooLB1m3 .icon-shape p,#mermaid-svg-UXXNQ7nfkooLB1m3 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-UXXNQ7nfkooLB1m3 .icon-shape .label rect,#mermaid-svg-UXXNQ7nfkooLB1m3 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UXXNQ7nfkooLB1m3 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-UXXNQ7nfkooLB1m3 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-UXXNQ7nfkooLB1m3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-UXXNQ7nfkooLB1m3 .py>*{fill:#2563eb!important;color:#fff!important;}#mermaid-svg-UXXNQ7nfkooLB1m3 .py span{fill:#2563eb!important;color:#fff!important;}#mermaid-svg-UXXNQ7nfkooLB1m3 .py tspan{fill:#fff!important;}#mermaid-svg-UXXNQ7nfkooLB1m3 .sess>*{fill:#7c3aed!important;color:#fff!important;}#mermaid-svg-UXXNQ7nfkooLB1m3 .sess span{fill:#7c3aed!important;color:#fff!important;}#mermaid-svg-UXXNQ7nfkooLB1m3 .sess tspan{fill:#fff!important;}#mermaid-svg-UXXNQ7nfkooLB1m3 .conn>*{fill:#f97316!important;color:#fff!important;}#mermaid-svg-UXXNQ7nfkooLB1m3 .conn span{fill:#f97316!important;color:#fff!important;}#mermaid-svg-UXXNQ7nfkooLB1m3 .conn tspan{fill:#fff!important;}#mermaid-svg-UXXNQ7nfkooLB1m3 .db>*{fill:#16a34a!important;color:#fff!important;}#mermaid-svg-UXXNQ7nfkooLB1m3 .db span{fill:#16a34a!important;color:#fff!important;}#mermaid-svg-UXXNQ7nfkooLB1m3 .db tspan{fill:#fff!important;}#mermaid-svg-UXXNQ7nfkooLB1m3 .err>*{fill:#dc2626!important;color:#fff!important;}#mermaid-svg-UXXNQ7nfkooLB1m3 .err span{fill:#dc2626!important;color:#fff!important;}#mermaid-svg-UXXNQ7nfkooLB1m3 .err tspan{fill:#fff!important;} finally 统一清理
每个测试结束回到基线
测试用例(Python 断言与 factory)
Session(Unit of Work + Identity Map)
SAVEPOINT(测试内可回滚的嵌套事务)
Connection + 外部事务(全程不提交)
PostgreSQL 测试 schema / SQLite 独立库
transaction.rollback()
Pool checkin 归还连接
数据库回到测试前快照

核心不变量:任何测试的写入,无论成功失败,最终都只发生在可整体回滚的外部事务内,数据库在每个测试后回到基线。

10.2.9 失败实验:SAVEPOINT 内未处理的约束冲突

即使有了 SAVEPOINT,错误发生后也必须显式回滚到 savepoint,否则 Session 进入「待回滚」状态:

python 复制代码
from sqlalchemy.exc import IntegrityError, PendingRollbackError


def test_savepoint_conflict_requires_rollback(db_session: Session) -> None:
    db_session.add(Customer(email="dup@example.com", name="A"))
    db_session.flush()

    db_session.add(Customer(email="dup@example.com", name="B"))
    try:
        db_session.flush()
    except IntegrityError:
        pass  # 只捕获错误,没有回滚到 savepoint

    db_session.add(Customer(email="ok@example.com", name="C"))
    with pytest.raises(PendingRollbackError):
        db_session.flush()

唯一约束冲突把当前 SAVEPOINT 标记为失效;不 rollback() 就继续 flush,只会得到 PendingRollbackError,把真正的错误掩盖在后面。原异常通常更早,先找唯一/外键/类型错误。


10.3 查询数量回归:把「SQL 太多」变成测试失败

10.3.1 QueryCounter:before_cursor_execute 事件

性能问题里最常见也最隐蔽的是「条数多 」:一次请求几十条 SQL,每一条都快,整体却慢。其核心是 before_cursor_execute 事件------它在每条 SQL 真正发送给数据库之前触发:

python 复制代码
from sqlalchemy import Engine, event


class QueryCounter:
    def __init__(self, engine: Engine) -> None:
        self.engine = engine
        self.count = 0
        self.statements: list[str] = []
        self._handler = self._on_before_cursor_execute
        event.listen(engine, "before_cursor_execute", self._handler)

    def _on_before_cursor_execute(
        self, conn, cursor, statement, parameters, context, executemany
    ) -> None:
        normalized = statement.lstrip().upper()
        if normalized.startswith(("SAVEPOINT", "RELEASE", "ROLLBACK TO SAVEPOINT")):
            return  # 忽略测试隔离机制产生的语句
        self.count += 1
        self.statements.append(statement)

    def __enter__(self) -> QueryCounter:
        self.count = 0
        self.statements.clear()
        return self

    def __exit__(self, *exc_info: object) -> None:
        return None

    def close(self) -> None:
        event.remove(self.engine, "before_cursor_execute", self._handler)

要点:事件挂在 Engine 上,Session 与 Connection 经由它的所有 SQL 都会计数;跳过 SAVEPOINT/RELEASE/ROLLBACK TO SAVEPOINT 避免测试隔离机制干扰(前缀按实际方言核对);保留 statements 列表,失败时能打印「到底多了哪些 SQL」;计数随实例走,可并行、可复用。

10.3.2 SQL 数量门禁:数量与内容一起断言

骨架强调:必须同时断言返回内容,否则为了减少 SQL 而错误漏加载数据,测试照样通过。

python 复制代码
def test_order_page_sql_budget(db_session: Session, query_counter: QueryCounter) -> None:
    order = seed_order(db_session)

    with query_counter:
        loaded = load_order_page(db_session, order.id)

    assert query_counter.count <= 4          # 1 订单 + 1 items + 1 products + 1 payment
    assert loaded.customer.email             # 内容不能丢
    assert {i.product.sku for i in loaded.items}

门禁阈值应写注释说明构成(如 1 + 1 + 1 + 1),阈值变化时注释与断言同步更新。load_order_page 的完整实现见 10.10.2。

10.3.3 失败实验:lazy load 悄悄多发 SQL

最典型的回归是「新增了一个关系访问」:

python 复制代码
def test_orders_list_emits_one_sql(db_session: Session, query_counter: QueryCounter) -> None:
    orders = seed_orders(db_session, count=3)  # 3 个订单,各 2 个 item

    with query_counter:
        loaded = db_session.scalars(select(Order)).all()
        total_items = sum(len(o.items) for o in loaded)  # ← lazy load

    assert query_counter.count == 1  # 预期 1 条 ------ 实际 1 + 3 = 4,测试失败

sum(len(o.items) ...) 每访问一个订单的 items 就发一条 SQL,statements 里会出现 4 条 SELECT order_item ...,一眼定位触发点。修复是给 Order.itemsselectinload(Part 6),门禁改为 == 2 并注释构成。这就是 SQL 数量门禁的价值:把 N+1 变成提交即失败的测试。


10.4 性能定位漏斗:先分段计时,再动手优化

10.4.1 漏斗五层

骨架的文本树转成漏斗图更直观。核心是逐层排除:先确认是「等连接」还是「执行 SQL」还是「应用处理」,再深入下一层。
#mermaid-svg-NH9HVLY4sSriAnjk{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-NH9HVLY4sSriAnjk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-NH9HVLY4sSriAnjk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-NH9HVLY4sSriAnjk .error-icon{fill:#552222;}#mermaid-svg-NH9HVLY4sSriAnjk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-NH9HVLY4sSriAnjk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-NH9HVLY4sSriAnjk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-NH9HVLY4sSriAnjk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-NH9HVLY4sSriAnjk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-NH9HVLY4sSriAnjk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-NH9HVLY4sSriAnjk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-NH9HVLY4sSriAnjk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-NH9HVLY4sSriAnjk .marker.cross{stroke:#333333;}#mermaid-svg-NH9HVLY4sSriAnjk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-NH9HVLY4sSriAnjk p{margin:0;}#mermaid-svg-NH9HVLY4sSriAnjk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-NH9HVLY4sSriAnjk .cluster-label text{fill:#333;}#mermaid-svg-NH9HVLY4sSriAnjk .cluster-label span{color:#333;}#mermaid-svg-NH9HVLY4sSriAnjk .cluster-label span p{background-color:transparent;}#mermaid-svg-NH9HVLY4sSriAnjk .label text,#mermaid-svg-NH9HVLY4sSriAnjk span{fill:#333;color:#333;}#mermaid-svg-NH9HVLY4sSriAnjk .node rect,#mermaid-svg-NH9HVLY4sSriAnjk .node circle,#mermaid-svg-NH9HVLY4sSriAnjk .node ellipse,#mermaid-svg-NH9HVLY4sSriAnjk .node polygon,#mermaid-svg-NH9HVLY4sSriAnjk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-NH9HVLY4sSriAnjk .rough-node .label text,#mermaid-svg-NH9HVLY4sSriAnjk .node .label text,#mermaid-svg-NH9HVLY4sSriAnjk .image-shape .label,#mermaid-svg-NH9HVLY4sSriAnjk .icon-shape .label{text-anchor:middle;}#mermaid-svg-NH9HVLY4sSriAnjk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-NH9HVLY4sSriAnjk .rough-node .label,#mermaid-svg-NH9HVLY4sSriAnjk .node .label,#mermaid-svg-NH9HVLY4sSriAnjk .image-shape .label,#mermaid-svg-NH9HVLY4sSriAnjk .icon-shape .label{text-align:center;}#mermaid-svg-NH9HVLY4sSriAnjk .node.clickable{cursor:pointer;}#mermaid-svg-NH9HVLY4sSriAnjk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-NH9HVLY4sSriAnjk .arrowheadPath{fill:#333333;}#mermaid-svg-NH9HVLY4sSriAnjk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-NH9HVLY4sSriAnjk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-NH9HVLY4sSriAnjk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NH9HVLY4sSriAnjk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-NH9HVLY4sSriAnjk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NH9HVLY4sSriAnjk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-NH9HVLY4sSriAnjk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-NH9HVLY4sSriAnjk .cluster text{fill:#333;}#mermaid-svg-NH9HVLY4sSriAnjk .cluster span{color:#333;}#mermaid-svg-NH9HVLY4sSriAnjk 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-NH9HVLY4sSriAnjk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-NH9HVLY4sSriAnjk rect.text{fill:none;stroke-width:0;}#mermaid-svg-NH9HVLY4sSriAnjk .icon-shape,#mermaid-svg-NH9HVLY4sSriAnjk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NH9HVLY4sSriAnjk .icon-shape p,#mermaid-svg-NH9HVLY4sSriAnjk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-NH9HVLY4sSriAnjk .icon-shape .label rect,#mermaid-svg-NH9HVLY4sSriAnjk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NH9HVLY4sSriAnjk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-NH9HVLY4sSriAnjk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-NH9HVLY4sSriAnjk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-NH9HVLY4sSriAnjk .py>*{fill:#2563eb!important;color:#fff!important;}#mermaid-svg-NH9HVLY4sSriAnjk .py span{fill:#2563eb!important;color:#fff!important;}#mermaid-svg-NH9HVLY4sSriAnjk .py tspan{fill:#fff!important;}#mermaid-svg-NH9HVLY4sSriAnjk .sess>*{fill:#7c3aed!important;color:#fff!important;}#mermaid-svg-NH9HVLY4sSriAnjk .sess span{fill:#7c3aed!important;color:#fff!important;}#mermaid-svg-NH9HVLY4sSriAnjk .sess tspan{fill:#fff!important;}#mermaid-svg-NH9HVLY4sSriAnjk .conn>*{fill:#f97316!important;color:#fff!important;}#mermaid-svg-NH9HVLY4sSriAnjk .conn span{fill:#f97316!important;color:#fff!important;}#mermaid-svg-NH9HVLY4sSriAnjk .conn tspan{fill:#fff!important;}#mermaid-svg-NH9HVLY4sSriAnjk .db>*{fill:#16a34a!important;color:#fff!important;}#mermaid-svg-NH9HVLY4sSriAnjk .db span{fill:#16a34a!important;color:#fff!important;}#mermaid-svg-NH9HVLY4sSriAnjk .db tspan{fill:#fff!important;}#mermaid-svg-NH9HVLY4sSriAnjk .err>*{fill:#dc2626!important;color:#fff!important;}#mermaid-svg-NH9HVLY4sSriAnjk .err span{fill:#dc2626!important;color:#fff!important;}#mermaid-svg-NH9HVLY4sSriAnjk .err tspan{fill:#fff!important;} 请求慢
① 等连接池
② SQL 执行
③ 返回数据
④ ORM 构造
⑤ 应用处理
连接泄漏 / 池容量 / 长事务
执行计划 / 索引 / 锁等待 / 统计信息
行数 / 网络 / LOB
对象数 / 列数 / 类型转换
序列化 / 业务循环 / 外部 I/O

每层的典型证据:① pool_timeout 日志与 checkout 等待时间(10.5.2);② after_cursor_execute 单条耗时、数据库慢查询日志、锁等待;③ 结果集行数与字节数、LOB、跨机房网络;④ 实例化对象数、Numeric/DateTime 转换;⑤ JSON 序列化、业务循环、外部 HTTP。

10.4.2 分段计时而不是「拍脑袋」

至少拆成四段时间:① pool checkout 等待 (请求开始到拿到 DBAPI 连接);② SQL 执行before_cursor_executeafter_cursor_execute);③ ORM 构造Result 到 ORM 对象,含类型转换);④ 应用处理(业务计算、序列化、外部 I/O)。

常见误区是只观测第 2 段:SQL 全部 2ms,接口却 2s------真相往往在第 1 段(连接被占满)或第 5 段(序列化递归对象图同时触发 N+1)。先分段计时,再优化;不要看到 ORM 就立即改成手写 SQL。 同时记录体量指标:SQL 条数、返回行数(含 JOIN 放大)、ORM 实例化对象数、峰值内存------这些数字能解释很多「玄学慢」;若瓶颈在锁等待或执行计划,手写 SQL 毫无帮助,若在连接池甚至绕过池的统计。

10.4.3 EXPLAIN (ANALYZE, BUFFERS):从现象到执行计划

当漏斗确认瓶颈在「② SQL 执行」,下一步拿执行计划。PostgreSQL 的标准姿势:

sql 复制代码
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.status, c.email
FROM orders o
JOIN customer c ON c.id = o.customer_id
WHERE o.created_at >= '2026-07-01'
ORDER BY o.created_at DESC
LIMIT 50;

要点:ANALYZE 会真实执行语句 ,写操作慎用,可用不带 ANALYZEEXPLAIN 先看计划;估算行数与实际行数 差一个数量级以上说明统计信息过期,先 ANALYZE 表;扫描方式Seq Scan 对大数据集通常是问题信号,但小表全表扫描反而快------结合 rows 判断;排序 出现在 WHERE 之外可能缺覆盖索引;Buffersread vs hit 反映是否走了磁盘;锁等待 行揭示被其它事务阻塞。SQLite 的 EXPLAIN QUERY PLAN 信息量远小于此,执行计划结论必须在 PostgreSQL 上做。

10.4.4 索引、选择性与统计信息

  • 选择性(selectivity)distinct 值越多选择性越高。status 只有 3 个值时单列索引收益有限;customer_idcreated_at 选择性高,索引收益大;
  • 统计信息 :PostgreSQL 依据 pg_statistic 估行数,数据量剧变后不 ANALYZE 会用过期分布做错决策;
  • 复合索引(customer_id, created_at) 比两个单列索引更能服务「某客户按时间排序」。order-lab 的 ix_orders_customer_created 正是为此设计。

修索引流程:EXPLAIN (ANALYZE, BUFFERS) 记录当前计划 → ANALYZE 确认统计新鲜 → 设计/调整索引 → 重跑 EXPLAIN 对比 Execution TimeBuffers 与实际行数 → 写进 10.9 报告。

10.4.5 order-lab 实战:一条慢订单查询的完整闭环

场景:运营报表接口查「近 30 天订单列表」从 80ms 涨到 1.8s。

现象 → 分段计时:pool 等待 5ms,SQL 执行 1.7s,ORM 构造 40ms,应用处理 20ms------瓶颈在②。

SQL → 计划

text 复制代码
Seq Scan on orders o  (cost=... rows=350000 ...) (actual time=... rows=180000 ...)
  Filter: (created_at >= '2026-07-01')
  Buffers: shared read=12000
  Execution Time: 1700 ms

估算 350000 与实际 180000 接近(统计正常),问题在全表扫描 + 大量磁盘读。

索引/查询修改 → 复测ix_orders_customer_created 列序以 customer_id 开头,无法服务本查询,为 created_at 建独立索引:

sql 复制代码
CREATE INDEX ix_orders_created_at ON orders (created_at DESC);
ANALYZE orders;

重跑 EXPLAIN:Index Scan using ix_orders_created_atBuffers: shared read=40Execution Time: 60 ms

回归门禁 :把报表 SQL 放入 10.11 的自动化测试,断言关键指标(禁止 Seq Scan on orders),防止未来改动悄悄退回全表扫描。


10.5 安全观测:生产环境的 SQL 与连接池观测

10.5.1 before/after_cursor_execute 计时

骨架的事件计时模板是生产观测的最小单元,补成「异常也计时」的版本:

python 复制代码
import time

from sqlalchemy import Engine, event


def attach_sql_timer(engine: Engine, metrics) -> None:
    @event.listens_for(engine, "before_cursor_execute")
    def before(conn, cursor, statement, parameters, context, executemany):
        context._sql_started = time.perf_counter()

    @event.listens_for(engine, "after_cursor_execute")
    def after(conn, cursor, statement, parameters, context, executemany):
        elapsed = time.perf_counter() - context._sql_started
        metrics.observe("db.sql.seconds", elapsed)

    @event.listens_for(engine, "handle_error")
    def on_error(context):
        if started := getattr(context.execution_context, "_sql_started", None):
            metrics.observe("db.sql.failed_seconds", time.perf_counter() - started)

metrics 为示意对象,生产上接入 Prometheus / OpenTelemetry 等指标后端。)handle_error 保证 SQL 抛错时也有耗时记录。

10.5.2 连接池观测:checkout/checkin、pool.status() 与 Pool events

SQL 耗时只覆盖漏斗第②层;第①层「等连接池」要靠池事件。在 Part 2 计时基础上补「占用时长」:

python 复制代码
from sqlalchemy import Engine, event


def attach_pool_observers(engine: Engine, metrics) -> None:
    @event.listens_for(engine, "checkout")
    def on_checkout(dbapi_connection, connection_record, connection_proxy):
        connection_record.info["checked_out_at"] = time.perf_counter()

    @event.listens_for(engine, "checkin")
    def on_checkin(dbapi_connection, connection_record):
        started = connection_record.info.pop("checked_out_at", None)
        if started is not None:
            metrics.observe("db.pool.held_seconds", time.perf_counter() - started)

随时读取池状态快照:

python 复制代码
print(engine.pool.status())
# Pool size: 5 Connections in pool: 3 Current Overflow: 2 Current Checked out connections: 4

status() 周期性采样得到四个关键数字:池内连接数、溢出数、正在使用数、可用数。结合 checkout 等待时间判断:池参数真的不够,还是连接被长期占用 。Pool 的全部事件还包括 connectclosedetachinvalidatesoft_invalidatereset,排查「连接老化、被回收、被失效」时逐个核对。生产最低要求:记录 checkout 等待时间(第①层唯一证据)、连接占用时长(占用远超 SQL 总耗时即说明「事务里做了别的事」)、pool_timeout 次数并告警。

10.5.3 生产日志红线:脱敏、指纹、采样与 trace

指标可以聚合、可以全量;日志必须脱敏、必须采样。

  • 参数脱敏parameters 可能含个人数据与凭据,按绑定参数 key 打码:
python 复制代码
SENSITIVE_KEYS = {"password", "token", "secret", "card_number"}


def sanitize_parameters(parameters: object) -> object:
    if isinstance(parameters, dict):
        return {
            key: ("***" if str(key).lower() in SENSITIVE_KEYS else value)
            for key, value in parameters.items()
        }
    return parameters

顺序绑定参数(tuple)无法按名识别敏感字段,生产查询应尽量使用具名绑定。

  • SQL 指纹聚合:把参数替换成占位符后聚合,才能统计「同一类 SQL 的 P50/P99」:
python 复制代码
import re

DIGITS = re.compile(r"\b\d+\b")


def fingerprint(statement: str) -> str:
    return DIGITS.sub("?", statement)

这是演示级实现;生产推荐专门的 SQL 归一化工具(sqlparse 或观测产品的 SQL 解析器)。

  • 慢查询采样:超过阈值(如 200ms)的 SQL 记录完整语句与参数,正常 SQL 只记指标,采样率可控避免日志洪泛;
  • 关联 request/trace ID :把当前请求 ID 挂到 context 或连接记录上,失败时能拼出「哪个请求、哪条 SQL、哪段等待」;
  • 红线 :日志永不记录数据库密码、token 和个人数据;echo=True 只用于开发环境,不能直接用于生产

10.6 常见故障决策树

10.6.1 决策树总览

面对异常不要逐个试错,先按「现象归属」分支:
#mermaid-svg-59xjYwgKAa4873YC{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-59xjYwgKAa4873YC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-59xjYwgKAa4873YC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-59xjYwgKAa4873YC .error-icon{fill:#552222;}#mermaid-svg-59xjYwgKAa4873YC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-59xjYwgKAa4873YC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-59xjYwgKAa4873YC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-59xjYwgKAa4873YC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-59xjYwgKAa4873YC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-59xjYwgKAa4873YC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-59xjYwgKAa4873YC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-59xjYwgKAa4873YC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-59xjYwgKAa4873YC .marker.cross{stroke:#333333;}#mermaid-svg-59xjYwgKAa4873YC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-59xjYwgKAa4873YC p{margin:0;}#mermaid-svg-59xjYwgKAa4873YC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-59xjYwgKAa4873YC .cluster-label text{fill:#333;}#mermaid-svg-59xjYwgKAa4873YC .cluster-label span{color:#333;}#mermaid-svg-59xjYwgKAa4873YC .cluster-label span p{background-color:transparent;}#mermaid-svg-59xjYwgKAa4873YC .label text,#mermaid-svg-59xjYwgKAa4873YC span{fill:#333;color:#333;}#mermaid-svg-59xjYwgKAa4873YC .node rect,#mermaid-svg-59xjYwgKAa4873YC .node circle,#mermaid-svg-59xjYwgKAa4873YC .node ellipse,#mermaid-svg-59xjYwgKAa4873YC .node polygon,#mermaid-svg-59xjYwgKAa4873YC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-59xjYwgKAa4873YC .rough-node .label text,#mermaid-svg-59xjYwgKAa4873YC .node .label text,#mermaid-svg-59xjYwgKAa4873YC .image-shape .label,#mermaid-svg-59xjYwgKAa4873YC .icon-shape .label{text-anchor:middle;}#mermaid-svg-59xjYwgKAa4873YC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-59xjYwgKAa4873YC .rough-node .label,#mermaid-svg-59xjYwgKAa4873YC .node .label,#mermaid-svg-59xjYwgKAa4873YC .image-shape .label,#mermaid-svg-59xjYwgKAa4873YC .icon-shape .label{text-align:center;}#mermaid-svg-59xjYwgKAa4873YC .node.clickable{cursor:pointer;}#mermaid-svg-59xjYwgKAa4873YC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-59xjYwgKAa4873YC .arrowheadPath{fill:#333333;}#mermaid-svg-59xjYwgKAa4873YC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-59xjYwgKAa4873YC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-59xjYwgKAa4873YC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-59xjYwgKAa4873YC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-59xjYwgKAa4873YC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-59xjYwgKAa4873YC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-59xjYwgKAa4873YC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-59xjYwgKAa4873YC .cluster text{fill:#333;}#mermaid-svg-59xjYwgKAa4873YC .cluster span{color:#333;}#mermaid-svg-59xjYwgKAa4873YC 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-59xjYwgKAa4873YC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-59xjYwgKAa4873YC rect.text{fill:none;stroke-width:0;}#mermaid-svg-59xjYwgKAa4873YC .icon-shape,#mermaid-svg-59xjYwgKAa4873YC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-59xjYwgKAa4873YC .icon-shape p,#mermaid-svg-59xjYwgKAa4873YC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-59xjYwgKAa4873YC .icon-shape .label rect,#mermaid-svg-59xjYwgKAa4873YC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-59xjYwgKAa4873YC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-59xjYwgKAa4873YC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-59xjYwgKAa4873YC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-59xjYwgKAa4873YC .py>*{fill:#2563eb!important;color:#fff!important;}#mermaid-svg-59xjYwgKAa4873YC .py span{fill:#2563eb!important;color:#fff!important;}#mermaid-svg-59xjYwgKAa4873YC .py tspan{fill:#fff!important;}#mermaid-svg-59xjYwgKAa4873YC .sess>*{fill:#7c3aed!important;color:#fff!important;}#mermaid-svg-59xjYwgKAa4873YC .sess span{fill:#7c3aed!important;color:#fff!important;}#mermaid-svg-59xjYwgKAa4873YC .sess tspan{fill:#fff!important;}#mermaid-svg-59xjYwgKAa4873YC .conn>*{fill:#f97316!important;color:#fff!important;}#mermaid-svg-59xjYwgKAa4873YC .conn span{fill:#f97316!important;color:#fff!important;}#mermaid-svg-59xjYwgKAa4873YC .conn tspan{fill:#fff!important;}#mermaid-svg-59xjYwgKAa4873YC .db>*{fill:#16a34a!important;color:#fff!important;}#mermaid-svg-59xjYwgKAa4873YC .db span{fill:#16a34a!important;color:#fff!important;}#mermaid-svg-59xjYwgKAa4873YC .db tspan{fill:#fff!important;}#mermaid-svg-59xjYwgKAa4873YC .err>*{fill:#dc2626!important;color:#fff!important;}#mermaid-svg-59xjYwgKAa4873YC .err span{fill:#dc2626!important;color:#fff!important;}#mermaid-svg-59xjYwgKAa4873YC .err tspan{fill:#fff!important;} 请求报错或变慢
ORM 对象已脱离 Session
异步环境隐式 I/O
flush/commit 失败后未回滚
拿不到数据库连接
数据库主动中止事务
读到旧值 / 覆盖更新
SQL 数量爆炸
连接已失效
DetachedInstanceError
MissingGreenlet
PendingRollbackError
QueuePool timeout
deadlock / serialization failure
stale data / lost update
N+1 与意外 lazy load
DisconnectionError / 断连

10.6.2 DetachedInstanceError

现象sqlalchemy.orm.exc.DetachedInstanceError: Parent instance <Order ...> is not bound to a Session; lazy load operation of attribute 'customer' cannot proceed

根因 :对象已脱离 Session(session.close()session.expunge()、或跨线程),却访问未加载/已过期的属性,ORM 想发 SQL 但找不到会话。

失败实验

python 复制代码
from sqlalchemy.orm.exc import DetachedInstanceError


def test_detached_access_fails(session_factory) -> None:
    session = session_factory()
    order = session.scalars(select(Order)).first()
    session.close()

    with pytest.raises(DetachedInstanceError):
        _ = order.customer  # 未加载的关系属性

处置:① 明确数据加载边界:视图/序列化前把对象图加载完整(eager loading)或转成 DTO(Part 6);② 不要用「永久 Session」掩盖------生命周期拉长只会把错误变成过期数据与状态泄漏;③ 跨线程只传 ID 或 DTO,不传 ORM 实例。

10.6.3 MissingGreenlet

现象sqlalchemy.exc.MissingGreenlet,消息形如 greenlet_spawn has not been called; can't call await_only() here,官方错误链接 https://sqlalche.me/e/20/xd2s

根因 :异步环境(AsyncSession)中,同步代码访问未加载关系属性触发隐式 I/O,async 没有可用的同步上下文执行这次加载。

python 复制代码
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

engine = create_async_engine("postgresql+asyncpg://app:secret@localhost/order_lab")
session_factory = async_sessionmaker(engine)


async def broken(order_id: int) -> str:
    async with session_factory() as session:
        order = (
            await session.scalars(select(Order).where(Order.id == order_id))
        ).one()
        return order.customer.email  # ← 隐式同步 I/O → MissingGreenlet

处置 :① 用 eager loading 显式加载:select(Order).options(selectinload(Order.customer));② 或显式 await session.refresh(order, ["customer"]);③ 确保属性访问发生在正确的异步上下文内。测试防线 :给关系配 lazy="raise",让隐式加载在测试期直接抛错。

10.6.4 PendingRollback

现象sqlalchemy.exc.PendingRollbackError: This Session's transaction has been rolled back due to a previous exception during flush...

根因 :flush 或 commit 失败后(唯一约束、外键、类型错误),事务已被数据库中止;没有 session.rollback() 就继续使用该 Session。

处置 (呼应 10.2.9):① 捕获 IntegrityError 后必须先 session.rollback() 再继续;② 原异常通常更早,先查唯一/外键/类型错误;③ with session.begin(): 可自动回滚失败事务(Part 1),但手工管理事务时仍需显式 rollback。

10.6.5 QueuePool timeout

现象sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached, connection timed out, timeout 30

根因:checkout 等不到可用连接。决策树(参考 Part 2 的 2.8):

text 复制代码
QueuePool timeout
├─ checkout 多、checkin 少 → 连接泄漏 / Result 未释放 / Session 未关闭
├─ 每条请求占用很久 → 慢 SQL / 长事务 / 事务内做外部 I/O
├─ 突发并发超过设计 → 限流、队列或容量调整
└─ 数据库连接上限已满 → 核对所有进程和服务总量

处置 :① 先看池事件------checkout/checkin 次数与占用时长(10.5.2)直接回答「泄漏还是占用久」;② 检查流式 Result 是否被提前丢弃(stream_results 的 Result 未关闭会一直占着连接);③ 检查 Session 是否每个工作单元后关闭;④ 计算多进程容量 进程数 × (pool_size + max_overflow) 并给数据库留余量;⑤ 全部确认后才考虑调大池参数------池参数不是慢查询的止痛药。复现实验见 10.10.5。

10.6.6 deadlock / serialization failure

现象 :PostgreSQL 报 deadlock detected(SQLSTATE 40P01)或 could not serialize access...40001)。数据库主动中止其中一个事务。

根因 :两个事务按相反顺序持锁(deadlock),或串行化冲突。这是数据库的防御性行为------事务必须重做,但数据没有损坏。SQLite 文件锁不会稳定复现,必须用 PostgreSQL 实验。

处置 :① 统一锁顺序 :所有代码路径按同一顺序加锁(如先 productorders);② 缩短事务 :事务里不做 HTTP 调用、不等用户输入;③ 串行化冲突只能用有限重试 :rollback 后整体重放完整事务,退避 + 抖动 + 次数上限(10.7);④ deadlock 后连接可能失效,重试必须用新 Session/新连接

10.6.7 stale data / lost update

现象:两个并发事务读取同一行、各自修改、先后提交,后提交者覆盖先提交者的结果。

失败实验(模拟「读-改-写」竞争):

python 复制代码
def test_lost_update_requires_guarded_write(db_session) -> None:
    product = make_product(db_session)          # stock = 10

    s1 = Session(bind=db_session.get_bind())
    s2 = Session(bind=db_session.get_bind())
    try:
        p1 = s1.get(Product, product.id)
        p2 = s2.get(Product, product.id)
        assert p1.stock == 10 and p2.stock == 10

        p1.stock = 7                            # 扣 3
        s1.commit()
        p2.stock = 8                            # 基于旧值 10
        s2.commit()                             # 覆盖 p1 的 7 → lost update
    finally:
        s1.close()
        s2.close()

    assert db_session.get(Product, product.id).stock == 8  # 本应是 7

处置 :① 条件 UPDATE ------把旧值写进 WHERE,行数不匹配即冲突(Part 2 的扣库存写法);② 版本列(乐观锁) ------__mapper_args__ = {"version_id_col": "version_id"},UPDATE 自动带版本条件,冲突抛 StaleDataError;③ 悲观锁 ------select(Product).where(...).with_for_update()(PostgreSQL 行锁;SQLite 行为不同,需方言验证);④ 原子表达式 ------UPDATE ... SET stock = stock - :q WHERE stock >= :q 单条完成。条件 UPDATE 示例:

python 复制代码
from sqlalchemy import update


def deduct_stock_guarded(
    session: Session,
    product_id: int,
    expected_stock: int,
    quantity: int,
) -> bool:
    result = session.execute(
        update(Product)
        .where(Product.id == product_id, Product.stock == expected_stock)
        .values(stock=expected_stock - quantity)
    )
    return result.rowcount == 1

10.6.8 N+1 与意外 lazy load

现象 :请求慢,SQL 日志出现「1 条主查询 + N 条重复子查询」。参考 Part 6 的完整分析,决策要点:① 用 10.3 的 QueryCounter 把门禁加进回归测试;② 按基数选加载策略:集合用 selectinload,链式路径逐层 selectinload,需要单条 JOIN 时用 joinedload;③ 对「绝不该隐式加载」的关系配置 lazy="raise"(如 payment: Mapped[Payment | None] = relationship(..., lazy="raise"));④ 序列化边界(DTO)不要递归遍历对象图(Part 6 的 6.9.3)。

10.6.9 断连与连接失效

现象sqlalchemy.exc.DisconnectionError,或数据库侧主动断开(防火墙空闲超时、数据库重启、负载均衡漂移)。

处置 :① pool_pre_ping=True 在 checkout 前探测连接、剔除失效连接(注意预检本身多一次往返);② pool_recycle 控制在连接被网络设备回收前主动更换;③ 断连发生在事务中间时,当前事务状态已不可靠 ------pool_pre_ping 只防止「下次 checkout 拿到坏连接」,不能重放失败事务;④ 只有具备幂等性和完整事务重放策略时才对断连重试(10.7)。


10.7 安全重试:不是所有 OperationalError 都值得重试

10.7.1 为什么不能无脑重试

四个不能重试的理由:① commit 结果可能未知 ------网络在提交确认前断开,重试可能造成重复提交;② 外部副作用可能已发生 ------事务里的业务逻辑可能已调用外部 API,重放导致副作用重复;③ Session/Connection 可能已失效 ------重试必须换新 Session;④ 只重试 SQL 片段会破坏业务原子性------事务是多条语句的组合,片段重放要么重复已成功的语句,要么漏掉失败后的语句。

10.7.2 安全重试模式:幂等 + 整体重放 + 退避

text 复制代码
识别可重试数据库错误
→ rollback/丢弃旧 Session
→ 新 Session 重放完整幂等命令
→ 指数退避 + 抖动 + 次数上限
→ 记录最终失败

「幂等」是前提:命令本身能安全重复执行(同一 idempotency_key 只生效一次),或整个操作可整体重放。

python 复制代码
import random
import time
from collections.abc import Callable
from typing import TypeVar

from sqlalchemy.exc import OperationalError

T = TypeVar("T")

# PostgreSQL: 40001 serialization failure, 40P01 deadlock
RETRYABLE_SQLSTATES = {"40001", "40P01"}


def is_retryable(exc: BaseException) -> bool:
    if not isinstance(exc, OperationalError):
        return False
    sqlstate = getattr(getattr(exc, "orig", None), "sqlstate", None)
    return sqlstate in RETRYABLE_SQLSTATES


def retry_with_backoff(
    fn: Callable[[], T],
    *,
    max_attempts: int = 3,
    base_delay: float = 0.05,
    max_delay: float = 1.0,
    rng: random.Random | None = None,
) -> T:
    rng = rng or random.Random()
    last_error: OperationalError | None = None
    for attempt in range(max_attempts):
        try:
            return fn()
        except OperationalError as exc:
            if not is_retryable(exc):
                raise
            last_error = exc
            delay = min(max_delay, base_delay * (2**attempt))
            sleep = delay + rng.uniform(0, delay)  # 指数退避 + 抖动
            time.sleep(sleep)
    assert last_error is not None
    raise last_error

要点:只对明确可重试的错误重试,其余直接上抛;重试单位是「完整业务函数」,内部必须新建 Session、整体执行;max_attempts 硬上限防止无限重试放大故障;退避加随机抖动避免惊群;最终失败要记录(告警、重试队列、人工介入),不能静默。

10.7.3 order-lab 实战:支付回调的幂等重试

支付回调规则是「按外部幂等键去重」,处理天然幂等,可以安全整体重试:

python 复制代码
from sqlalchemy import select


def apply_payment_callback(
    session_factory,
    *,
    idempotency_key: str,
    order_id: int,
    amount: Decimal,
) -> Payment:
    def run_once() -> Payment:
        with session_factory() as session:
            existing = session.scalars(
                select(Payment).where(Payment.idempotency_key == idempotency_key)
            ).one_or_none()
            if existing is not None:
                return existing  # 幂等命中:不重复处理

            order = session.get(Order, order_id)
            if order is None:
                raise ValueError(f"order {order_id} not found")
            payment = Payment(order=order, idempotency_key=idempotency_key, amount=amount)
            session.add(payment)
            session.commit()
            return payment

    return retry_with_backoff(run_once, max_attempts=3)

可重试成立的原因很清楚:idempotency_key 唯一约束保证重放不产生第二条支付记录;每次 run_once 使用全新 Session、事务边界完整。先有幂等性,才谈得上重试。


10.8 生产检查清单

  • Engine 每进程创建并复用 ------Engine 持有 Pool 与 Dialect,每请求新建会失去连接复用。检查:进程启动时 create_engine 一次。
  • Session 每工作单元关闭 ------Session 持有事务与对象图,生命周期越长越易泄漏。检查:with Session(engine): 或服务层统一创建/关闭。
  • 事务内没有慢外部 I/O------事务持连接与锁,里面发 HTTP/等用户会放大并发问题。检查:连接占用时长 vs SQL 总耗时(10.5.2)。
  • pool 总量低于数据库容量 ------进程数 × (pool_size + max_overflow) 要留余量给迁移与运维。检查:对比池快照与数据库 max_connections
  • 慢查询能追到 SQL 指纹与计划------只有聚合指纹才能发现「同一类 SQL 变慢」。检查:观测面板能按指纹查 P99 并一键导出 EXPLAIN。
  • 请求序列化不触发隐藏 SQL------DTO 序列化递归对象图会触发 N+1。检查:10.3 门禁覆盖序列化路径。
  • 迁移经过历史库测试 ------alembic upgrade head 在历史快照上通过。检查:CI 的迁移测试 job 存在且非空。
  • 约束冲突、死锁、断连有明确处理------不能只有 traceback。检查:错误码 → 处理策略映射表已评审。
  • 备份恢复演练真实完成------「有备份」不等于「能恢复」。检查:最近恢复演练记录与 RTO/RPO 数字。

10.9 性能报告模板

一次性能问题的处置必须产出报告,否则无法复现与沉淀:

text 复制代码
现象与 SLO:     例如:订单列表接口 P99 从 200ms → 2s,违反 SLO(P99 < 500ms)。
时间范围/trace:  起止时间、相关 trace ID 或请求样本。
Pool 等待:      checkout 等待 P50/P99、pool 状态快照、timeout 次数。
SQL 指纹与调用点: 最慢的 3 个指纹、耗时、调用栈/路由。
调用次数/返回行数: 每请求 SQL 条数、返回行数、ORM 对象数。
执行计划:        EXPLAIN (ANALYZE, BUFFERS) 原文与关键数字(估算/实际行数、扫描方式、Buffers、Execution Time)。
锁与数据库资源:    pg_locks/pg_stat_activity 观察到的等待事件、连接占用。
根因:            一句话结论(例如:缺 created_at 索引导致 Seq Scan + 大量磁盘读)。
修改:            具体变更(DDL、查询改写、加载策略)与 PR 链接。
同环境复测:       修改前后的 P99/Execution Time/Buffers 对比。
回归门禁:        新增的自动化测试(SQL 门禁 / EXPLAIN 断言),以及为什么它能防住。

对应 10.4.5 的 order-lab 示例:

text 复制代码
现象与 SLO: 报表接口 P99 1.8s,SLO 500ms。
时间范围/trace: 2026-07-20 14:00-14:30,trace-7788。
Pool 等待: checkout P99 5ms,无 timeout ------ 排除①。
SQL 指纹: SELECT ... FROM orders WHERE created_at >= ? ORDER BY created_at DESC ------ 1.7s。
调用次数/返回行数: 1 条 SQL / 返回 180000 行。
执行计划: Seq Scan on orders,估算 350000 实际 180000,Buffers shared read=12000。
锁与数据库资源: 无锁等待,连接 12 条/池 15 条。
根因: created_at 缺少覆盖排序的索引,全表扫描。
修改: 建 ix_orders_created_at (created_at DESC);ANALYZE orders。
同环境复测: Execution Time 1700ms → 60ms,Buffers read 12000 → 40。
回归门禁: 报表 SQL 加入自动化 EXPLAIN 断言(禁止 Seq Scan on orders)。

10.10 order-lab 集成测试套件搭建

把前面的结论组装成可运行的 order-lab 套件,文件布局:

text 复制代码
order-lab/
├── src/order_lab/
│   ├── models.py        # 完整声明式模型
│   └── services.py      # 订单创建服务
└── tests/
    ├── conftest.py      # engine / db_session / factory / query_counter
    ├── test_order_flow.py
    └── test_pool.py

10.10.1 模型(order_lab/models.py)

CustomerProductOrderOrderItem 沿用 Part 3 的声明式模型,本文件在 Order 上追加两个关系,并新增 Payment(幂等键)与 OutboxEvent(领域事件):

python 复制代码
from datetime import datetime
from decimal import Decimal

from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship

from ._base import Base  # Part 3 的 Base 与既有模型


class Order(Base):  # 覆盖定义:追加关系
    payment: Mapped[Payment | None] = relationship(
        back_populates="order", cascade="all, delete-orphan"
    )
    outbox_events: Mapped[list[OutboxEvent]] = relationship(
        back_populates="order", cascade="all, delete-orphan"
    )


class Payment(Base):
    __tablename__ = "payment"

    id: Mapped[int] = mapped_column(primary_key=True)
    order_id: Mapped[int] = mapped_column(
        ForeignKey("orders.id", ondelete="RESTRICT"), unique=True
    )
    idempotency_key: Mapped[str] = mapped_column(String(128), unique=True)
    amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
    paid_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now()
    )
    order: Mapped[Order] = relationship(back_populates="payment")


class OutboxEvent(Base):
    __tablename__ = "outbox_event"

    id: Mapped[int] = mapped_column(primary_key=True)
    order_id: Mapped[int] = mapped_column(
        ForeignKey("orders.id", ondelete="CASCADE")
    )
    event_type: Mapped[str] = mapped_column(String(64))
    payload: Mapped[str] = mapped_column(Text, default="{}")
    published: Mapped[bool] = mapped_column(default=False)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now()
    )
    order: Mapped[Order] = relationship(back_populates="outbox_events")

payloadText 存 JSON 字符串保持跨库可移植------若改用 PostgreSQL JSONB,参考 10.1.2 差异表单独验证查询行为,方言层测试放到 PostgreSQL 上执行。

10.10.2 服务(order_lab/services.py)

python 复制代码
from __future__ import annotations

from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload

from order_lab.models import Order, OrderItem, OrderStatus, OutboxEvent, Product


class InsufficientStockError(ValueError):
    """库存不足(演示用;生产并发场景使用条件 UPDATE,见 10.6.7)。"""


def create_order(
    session: Session, *, customer_id: int, lines: list[tuple[Product, int]]
) -> Order:
    order = Order(customer_id=customer_id, status=OrderStatus.PENDING.value)
    session.add(order)

    for product, quantity in lines:
        if product.stock < quantity:
            raise InsufficientStockError(
                f"product {product.sku} stock {product.stock} < {quantity}"
            )
        product.stock -= quantity
        order.items.append(
            OrderItem(product=product, quantity=quantity, unit_price=product.price)
        )

    session.add(OutboxEvent(order=order, event_type="ORDER_CREATED", payload="{}"))
    return order


def load_order_page(session: Session, order_id: int) -> Order:
    return session.scalars(
        select(Order)
        .where(Order.id == order_id)
        .options(
            selectinload(Order.items).selectinload(OrderItem.product),
            selectinload(Order.payment),
        )
    ).one()

10.10.3 测试基础设施(tests/conftest.py)

python 复制代码
from __future__ import annotations

from collections.abc import Iterator
from decimal import Decimal
from uuid import uuid4

import pytest
from sqlalchemy import Engine, create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool

from order_lab.models import Base, Customer, Product
from tests.query_counter import QueryCounter  # 完整实现见 10.3.1


@pytest.fixture
def engine() -> Iterator[Engine]:
    test_engine = create_engine("sqlite+pysqlite:///:memory:", poolclass=StaticPool)

    @event.listens_for(test_engine, "connect")
    def enable_foreign_keys(dbapi_connection, connection_record) -> None:
        cursor = dbapi_connection.cursor()
        cursor.execute("PRAGMA foreign_keys=ON")
        cursor.close()

    Base.metadata.create_all(test_engine)
    yield test_engine
    test_engine.dispose()


@pytest.fixture
def db_session(engine: Engine) -> Iterator[Session]:
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection, join_transaction_mode="create_savepoint")
    try:
        yield session
    finally:
        session.close()
        transaction.rollback()
        connection.close()


@pytest.fixture
def session_factory(engine: Engine) -> sessionmaker[Session]:
    return sessionmaker(bind=engine, expire_on_commit=False)


def make_customer(
    session: Session, *, email: str | None = None, name: str = "Alice"
) -> Customer:
    customer = Customer(
        email=email or f"user-{uuid4().hex[:12]}@example.com", name=name
    )
    session.add(customer)
    session.flush()
    return customer


def make_product(
    session: Session,
    *,
    sku: str | None = None,
    price: Decimal = Decimal("99.00"),
    stock: int = 10,
) -> Product:
    product = Product(
        sku=sku or f"SKU-{uuid4().hex[:12].upper()}",
        name="T-Shirt",
        price=price,
        stock=stock,
    )
    session.add(product)
    session.flush()
    return product


@pytest.fixture
def query_counter(engine: Engine) -> Iterator[QueryCounter]:
    counter = QueryCounter(engine)
    yield counter
    counter.close()

(切换 PostgreSQL 时把 engine fixture 改为按环境变量选择 postgresql+psycopg://...,方言验证类测试标记为仅在 PostgreSQL 下运行,避免在 SQLite 上给出虚假结论。)

10.10.4 业务测试与 SQL 门禁(tests/test_order_flow.py)

python 复制代码
from __future__ import annotations

from sqlalchemy import select
from sqlalchemy.orm import Session

from order_lab.models import Order, OrderStatus, OutboxEvent
from order_lab.services import create_order, load_order_page
from tests.conftest import make_customer, make_product


def test_create_order_writes_items_and_outbox(db_session: Session) -> None:
    customer = make_customer(db_session)
    product = make_product(db_session, stock=5)

    order = create_order(db_session, customer_id=customer.id, lines=[(product, 2)])
    db_session.commit()  # 只释放 SAVEPOINT,外部事务随后回滚

    assert order.status == OrderStatus.PENDING.value
    assert order.items[0].quantity == 2
    assert product.stock == 3

    outbox = db_session.scalars(
        select(OutboxEvent).where(OutboxEvent.order_id == order.id)
    ).one()
    assert outbox.event_type == "ORDER_CREATED"


def test_order_page_sql_budget(db_session: Session, query_counter) -> None:
    customer = make_customer(db_session)
    product = make_product(db_session)
    order = create_order(db_session, customer_id=customer.id, lines=[(product, 1)])
    db_session.flush()

    with query_counter:
        loaded = load_order_page(db_session, order.id)

    # 1(orders)+ 1(order_item IN)+ 1(product IN)+ 1(payment IN)
    assert query_counter.count <= 4
    assert loaded.customer.email == customer.email
    assert {item.product.sku for item in loaded.items} == {product.sku}

db_session.commit() 故意展示 10.2.4 的结论:create_savepoint 模式下提交的只是 SAVEPOINT,测试结束后外部事务回滚,数据不污染其它测试。

10.10.5 连接池耗尽复现实验(tests/test_pool.py)

python 复制代码
from __future__ import annotations

import pytest
from sqlalchemy import URL, create_engine
from sqlalchemy.exc import TimeoutError as SQLAlchemyTimeoutError
from sqlalchemy.pool import QueuePool


def test_pool_exhaustion_times_out(tmp_path) -> None:
    database_url = URL.create("sqlite+pysqlite", database=str(tmp_path / "pool.db"))
    tiny_engine = create_engine(
        database_url,
        poolclass=QueuePool,
        pool_size=1,
        max_overflow=0,
        pool_timeout=0.5,
    )

    first = tiny_engine.connect()
    try:
        with pytest.raises(SQLAlchemyTimeoutError):
            tiny_engine.connect()  # 唯一连接未归还,等待 0.5s 后超时
    finally:
        first.close()

SQLAlchemy 2.x 的 sqlalchemy.exc.TimeoutError 同时是内建 TimeoutError 的子类,用别名显式断言语义更清晰。tmp_path 是 pytest 内建 fixture,每个测试独立目录,天然满足 10.2.7 的命名空间隔离。


10.11 自动化测试:把本篇结论固化成可重复验证的事实

10.11.1 失败实验测试化

10.6 的失败实验必须变成测试,否则「修好了」无法证明、也防不住回归:

python 复制代码
from __future__ import annotations

import pytest
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError, PendingRollbackError
from sqlalchemy.orm.exc import DetachedInstanceError

from order_lab.models import Customer
from tests.conftest import make_customer


def test_detached_instance_error_after_close(session_factory) -> None:
    session = session_factory()
    customer = make_customer(session)
    session.close()

    with pytest.raises(DetachedInstanceError):
        _ = customer.orders  # 未加载的关系属性


def test_pending_rollback_requires_explicit_rollback(session_factory) -> None:
    session = session_factory()
    session.add(Customer(email="dup@example.com", name="A"))
    session.add(Customer(email="dup@example.com", name="B"))
    try:
        session.commit()
    except IntegrityError:
        pass  # 故意不 rollback

    with pytest.raises(PendingRollbackError):
        session.execute(text("SELECT 1"))

    session.rollback()  # 显式回滚后 Session 恢复正常
    assert session.execute(text("SELECT 1")).scalar_one() == 1

分别把 10.6.2(DetachedInstanceError)、10.6.4(PendingRollback)的结论固定下来;10.6.7 的 lost update 用条件 UPDATE 守卫测试覆盖;10.2.9 的 SAVEPOINT 冲突实验同样测试化。连同 10.10 的四个用例,套件共 8 个测试。

10.11.2 运行与预期

powershell 复制代码
python -m pytest -q
text 复制代码
8 passed

运行通过说明:外部事务 + SAVEPOINT 隔离成立(测试间数据互不泄漏);SQL 数量门禁生效(页面加载条数被固定);连接池在异常与耗尽场景下的行为符合预期;失败实验的异常路径被明确捕获并断言。把这 8 个测试放进 CI,作为本篇所有结论的回归防线。


10.12 常见误区

  • 测试里必须 commit() 才算真跑了数据库 :错。外部事务隔离下,commit() 只释放 SAVEPOINT,最终回滚由 fixture 统一完成;需要验证持久化时用独立 Session 读取。
  • SQLite 全绿 = PostgreSQL 一定没问题:错。JSONB、锁、隔离、执行计划、类型精度都不同(10.1.2),方言级结论必须在真实 PostgreSQL 上验证。
  • SQL 数量不重要,功能对就行:错。1+N 条「都很快」的 SQL 加起来就是慢请求,且随数据量恶化;SQL 数量门禁(10.3)是唯一能防 N+1 回归的机制。
  • 慢查询一定是 ORM 的锅:错。先在漏斗(10.4)里定位:可能是连接池等待、执行计划、返回数据量或应用序列化;手写 SQL 无法解决锁等待和池容量问题。
  • EXPLAIN 跑一遍就能下结论 :错。EXPLAIN (ANALYZE, BUFFERS) 是真实执行,结果受统计信息影响;先 ANALYZE 排除统计过期,再比较估算/实际行数,最后谈索引(10.4.3/10.4.4)。
  • 所有 OperationalError 都可以重试:错。commit 结果可能未知、副作用可能已发生、Session 可能已失效(10.7);只有幂等 + 整体重放 + 退避上限才是安全重试。
  • 观测 = 打开 echo=True :错。echo 会把 SQL 连同参数打进日志,生产会泄漏数据并产生海量日志;生产观测要用事件钩子 + 脱敏 + 指纹 + 采样(10.5)。
  • pool 参数越大越好:错。池参数必须与数据库容量、进程数、事务时长整体设计(10.6.5);调大池子只是掩盖「连接没有及时归还」或「事务过长」。

10.13 本篇验收实验

完成以下实验,才算真正掌握本篇(每项都应在 order-lab 落地):

  1. 为接口设置 SQL 数量上限 :给 load_order_page 写入门禁测试,故意去掉一个 selectinload 让测试失败,再恢复;
  2. 制造连接泄漏并通过池事件定位:写一个不关闭 Connection 的循环,用 checkout/checkin 事件找出泄漏点,再修复为上下文管理器;
  3. 制造死锁/序列化失败并实现有限重试 :在 PostgreSQL 上用两事务反序更新复现 deadlock,接入 retry_with_backoff 并验证最终一致性;
  4. 对一个慢查询完成「现象 → SQL → 计划 → 索引/查询修改 → 复测」闭环:按 10.4.5 流程产出 10.9 格式的性能报告;
  5. 证明测试隔离 :在 db_session 中插入一行不提交,写第二个测试断言「看不到」,说明外部事务回滚生效;
  6. 验证 SAVEPOINT 行为:在 SQLite 与 PostgreSQL 上分别跑 10.2.9 的失败实验,记录两者在「错误后继续 flush」行为上的差异。

10.14 本篇小结

从「能跑」到「可运营」,本篇建立了一条可执行的链:

text 复制代码
测试隔离(外部事务 + SAVEPOINT)
   → SQL 数量与耗时观测(事件钩子 + 连接池事件)
     → 性能定位漏斗(等连接 / 执行 / 返回 / ORM / 应用)
       → 执行计划(EXPLAIN + 统计信息 + 索引)
         → 故障决策树(Detached / MissingGreenlet / PendingRollback /
                       QueuePool timeout / deadlock / stale data / N+1)
           → 安全重试(幂等 + 整体重放 + 退避)

四个必须牢记的结论:

  1. 测试隔离靠事务回滚,而不是清库 :外部事务 + create_savepoint 让每个测试拥有独立世界,且保留真实数据库行为;
  2. SQL 条数是性能的第一指标:QueryCounter 把 N+1 变成测试失败,SQL 门禁是防回归的底线;
  3. 先分段计时再优化:漏斗告诉你瓶颈在连接池、数据库、数据还是应用;EXPLAIN 告诉你数据库内部怎么走;
  4. 重试是有前提的:幂等性 + 完整事务重放 + 指数退避,缺一不可;故障处置要有决策树,而不是重启碰运气。

下一篇进入 Part 11,把视角转向存量项目:如何从 SQLAlchemy 1.x 渐进迁移到 2.x,并借迁移建立长期可维护的数据访问边界。

官方参考

相关推荐
杨云龙UP2 小时前
MySQL 数据库常用备份工具对比:mysqldump、mydumper 与 XtraBackup
linux·运维·服务器·数据库·mysql·dba·备份恢复
ZENERGY-众壹3 小时前
500个工商业电站压垮数据库?海量光伏时序数据存储的架构取舍
数据库·架构·influxdb·光伏·光伏运维
傻啦嘿哟3 小时前
某短视频平台视频爬虫实战:抓取推荐流视频信息,绕过反爬的3种技巧
开发语言·爬虫·python
迷迭香yy3 小时前
集合竞价数据挖掘实战:用Python构建开盘信号识别系统
人工智能·python·数据挖掘
mubei-1233 小时前
SpringDAO的用法
java·开发语言·数据库
李昊哲小课6 小时前
fastapi sse websocket 奶茶店实时订单看板
人工智能·python·websocket·网络协议·fastapi·sse
星蓝_starblue6 小时前
零服务器、零数据库!开源growth-board,利用GitHub自动管理刷题/学习/求职全流程
服务器·数据库·程序人生·系统架构·node.js·github·改行学it
2401_844582958 小时前
工具包:软件架构设计的实用技巧与经验分享
python
RFID固定资产管理系统8 小时前
适配媒体行业的固定资产管理软件有哪些功能与核心优势
大数据·人工智能·python·媒体
三8448 小时前
sqli-labs1-10通关笔记
数据库