核心目标:建立真实数据库测试、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 上线三个月后三个问题同时爆发:
- 测试全绿,上线就挂。本地与 CI 都用 SQLite 跑测试:JSONB「看起来能存」、唯一约束「看起来会报错」、并发写入「看起来没问题」。切到 PostgreSQL 后,锁等待、序列化失败、执行计划全部现出原形。
- 慢请求无法定位。某接口从 20ms 涨到 2s,运维截图只有「数据库慢」。应用与数据库之间是黑盒:不知道等了多久连接池、执行了多少条 SQL、哪条拖了多久、返回了多少行。
- 故障只能靠重启 。
DetachedInstanceError、PendingRollbackError、QueuePool超时轮番出现,每次「重启一下就好」,却没人能回答下次怎么救。
三者共同的根源是测试、观测、决策没有形成闭环 。本篇给出三件套:测试隔离 (外部事务 + SAVEPOINT 让每个测试跑在自己的世界里)、SQL 观测 (事件钩子统计 SQL 数量、耗时与连接池状态)、故障决策树(按「现象 → 根因 → 处置」执行,而不是靠猜)。
示例统一围绕 order-lab:Customer 1---N Order 1---N OrderItem N---1 Product;Order 1---1 Payment;Product 1---N InventoryMovement;Order 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 负责业务对象 (用最少参数造出合法的 Customer、Product、Order,处理必填字段与唯一约束)。反模式是测试里散落 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.items 加 selectinload(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_execute 到 after_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 会真实执行语句 ,写操作慎用,可用不带 ANALYZE 的 EXPLAIN 先看计划;估算行数与实际行数 差一个数量级以上说明统计信息过期,先 ANALYZE 表;扫描方式 上 Seq Scan 对大数据集通常是问题信号,但小表全表扫描反而快------结合 rows 判断;排序 出现在 WHERE 之外可能缺覆盖索引;Buffers 的 read vs hit 反映是否走了磁盘;锁等待 行揭示被其它事务阻塞。SQLite 的 EXPLAIN QUERY PLAN 信息量远小于此,执行计划结论必须在 PostgreSQL 上做。
10.4.4 索引、选择性与统计信息
- 选择性(selectivity) :
distinct值越多选择性越高。status只有 3 个值时单列索引收益有限;customer_id、created_at选择性高,索引收益大; - 统计信息 :PostgreSQL 依据
pg_statistic估行数,数据量剧变后不ANALYZE会用过期分布做错决策; - 复合索引 :
(customer_id, created_at)比两个单列索引更能服务「某客户按时间排序」。order-lab 的ix_orders_customer_created正是为此设计。
修索引流程:EXPLAIN (ANALYZE, BUFFERS) 记录当前计划 → ANALYZE 确认统计新鲜 → 设计/调整索引 → 重跑 EXPLAIN 对比 Execution Time、Buffers 与实际行数 → 写进 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_at,Buffers: shared read=40,Execution 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 的全部事件还包括 connect、close、detach、invalidate、soft_invalidate、reset,排查「连接老化、被回收、被失效」时逐个核对。生产最低要求:记录 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 实验。
处置 :① 统一锁顺序 :所有代码路径按同一顺序加锁(如先 product 后 orders);② 缩短事务 :事务里不做 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)
Customer、Product、Order、OrderItem 沿用 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")
payload 用 Text 存 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 落地):
- 为接口设置 SQL 数量上限 :给
load_order_page写入门禁测试,故意去掉一个selectinload让测试失败,再恢复; - 制造连接泄漏并通过池事件定位:写一个不关闭 Connection 的循环,用 checkout/checkin 事件找出泄漏点,再修复为上下文管理器;
- 制造死锁/序列化失败并实现有限重试 :在 PostgreSQL 上用两事务反序更新复现 deadlock,接入
retry_with_backoff并验证最终一致性; - 对一个慢查询完成「现象 → SQL → 计划 → 索引/查询修改 → 复测」闭环:按 10.4.5 流程产出 10.9 格式的性能报告;
- 证明测试隔离 :在
db_session中插入一行不提交,写第二个测试断言「看不到」,说明外部事务回滚生效; - 验证 SAVEPOINT 行为:在 SQLite 与 PostgreSQL 上分别跑 10.2.9 的失败实验,记录两者在「错误后继续 flush」行为上的差异。
10.14 本篇小结
从「能跑」到「可运营」,本篇建立了一条可执行的链:
text
测试隔离(外部事务 + SAVEPOINT)
→ SQL 数量与耗时观测(事件钩子 + 连接池事件)
→ 性能定位漏斗(等连接 / 执行 / 返回 / ORM / 应用)
→ 执行计划(EXPLAIN + 统计信息 + 索引)
→ 故障决策树(Detached / MissingGreenlet / PendingRollback /
QueuePool timeout / deadlock / stale data / N+1)
→ 安全重试(幂等 + 整体重放 + 退避)
四个必须牢记的结论:
- 测试隔离靠事务回滚,而不是清库 :外部事务 +
create_savepoint让每个测试拥有独立世界,且保留真实数据库行为; - SQL 条数是性能的第一指标:QueryCounter 把 N+1 变成测试失败,SQL 门禁是防回归的底线;
- 先分段计时再优化:漏斗告诉你瓶颈在连接池、数据库、数据还是应用;EXPLAIN 告诉你数据库内部怎么走;
- 重试是有前提的:幂等性 + 完整事务重放 + 指数退避,缺一不可;故障处置要有决策树,而不是重启碰运气。
下一篇进入 Part 11,把视角转向存量项目:如何从 SQLAlchemy 1.x 渐进迁移到 2.x,并借迁移建立长期可维护的数据访问边界。