K 的一隅

Python FastAPI 后端实战

长事务还是短事务:边界怎么画

短事务包住单次原子写;长事务跨多步业务要拆边界;请求级 Session 不等于业务事务;锁持有时间与回滚范围如何权衡。

11 分钟阅读 更新于 2026-07-28
本文目录

下单接口里:扣库存、写订单、记流水、发消息——若从路由进来到返回,全程共用一个未 提交 的数据库 事务,中间还要调支付网关等外部 HTTP,连接上的行锁可能握几十秒。别的用户改同一 SKU 库存会排队超时;连接池被占满后,无关接口也开始 503。问题往往不是「要不要 事务」,而是 边界 画在哪:哪些步骤必须在同一 提交 里原子完成,哪些应拆成多个短 事务,失败时 回滚 范围又该多大。

事务直觉:原子性与隔离

关系型数据库的 事务 把多条 SQL 包成「全成功或全失败」。session.commit() 提交 持久化;session.rollback() 回滚 撤销未提交改动。SQLAlchemy 默认 autocommit 关闭:第一次写操作开启 事务,直到 commit 或 rollback。

短事务:边界紧,只做数据库内的原子变更,毫秒到百毫秒级结束,尽快 提交 释放锁。

长事务:跨越多次用户交互、外部 API、消息队列或人工审批,若全程占着同一 DB 事务,隔离级别下的锁与 MVCC 版本链都会拖垮并发。

Web API 的默认模式是:一个 HTTP 请求对应一个请求级 Sessionyield 依赖在 finally 里 close)。这不自动等于「一个请求一个业务 事务」——你可以在 Service 里多次 commit,也可以在请求末尾才 commit 一次;选错粒度才是线上锁等待的根源。

线上排查「数据库连接池耗尽」时,优先查是否存在跨外部 HTTP 的长 事务、是否在只读 API 里忘记 提交 导致连接挂起、是否 batch job 与 Web 共用同一套 Service 却用了不同 边界 约定。把 事务 画清楚,这类事故会少一大半。

隔离级别带来的「长」感

即使代码里没写 BEGIN,数据库也会为写操作加锁。Repeatable Read 下只读 事务 可能因 MVCC 看不到新行,但写 事务 长时间不 提交 时,其他 事务 修改同一行会阻塞。PostgreSQL 的 idle in transaction 会话是 DBA 常见排查项——往往是应用忘了 commit 或异常路径没 rollback。画 边界 时要问:这条连接从哪一行 SQL 开始算「还在 事务 里」?

请求级 Session vs 业务事务单元

python
from collections.abc import Generator

from fastapi import Depends, FastAPI
from sqlalchemy.orm import Session


def get_db() -> Generator[Session, None, None]:
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()


app = FastAPI()


@app.post("/orders")
def create_order(payload: OrderCreate, db: Session = Depends(get_db)) -> OrderOut:
    order = order_service.place_order(db, payload)
    db.commit()
    return OrderOut.model_validate(order)

上面在路由层 提交 是常见简化写法。更干净的分层是把 事务 边界 放进 Service,Repository 只执行 SQL、不决定何时 commit:

python
class OrderService:
    def __init__(self, repo: OrderRepository, inventory: InventoryService) -> None:
        self._repo = repo
        self._inventory = inventory

    def place_order(self, db: Session, payload: OrderCreate) -> Order:
        with db.begin():  # 短事务:扣库存 + 插订单 + 写流水
            self._inventory.reserve(db, payload.items)
            order = self._repo.insert(db, payload)
            self._repo.insert_ledger(db, order.id, "created")
        return order

with db.begin() 块结束自动 提交;块内异常自动 回滚。这是典型的**短 事务**:边界 与「下单这一业务单元」对齐,不包含发邮件、调支付。

Repository 层只接收 Session 执行语句,不调用 commit(),这样 边界 在 Service 一眼可见。若 Repository 偷偷 提交,上层 with db.begin() 会行为异常,集成测试也难以断言 回滚

路由层 提交 的利弊

@app.postdb.commit() 对小项目够用;团队变大后,同一 Service 可能被 CLI、消息消费者、定时任务复用——它们没有 FastAPI 的 get_db 依赖,更需要 Service 内自包含 事务 边界。统一约定:只有 Service(或明确的 UnitOfWork 类)能 commit,路由只负责 HTTP 与 DTO。

何时必须短:持锁成本

下列操作应尽量留在短 事务 内,且避免块内 await 外部慢调用:

操作持锁风险
UPDATE inventory SET qty = qty - 1 WHERE sku = ?行锁直到 commit
唯一索引冲突重试短事务 + 应用层重试
账户余额变动锁范围大,更要短

反例:在 with db.begin()await payment_client.charge(...)。支付网络往返 2–30 秒,数据库连接与行锁全程不释放。边界 应划在「本地状态已就绪、外部调用之前」:提交 本地预占,再调支付;支付失败用补偿 事务 或状态机 回滚 业务含义(见下)。

课件同款场景:读系统设置 → 再调 OSS

上传签发、调对象存储时,常要先从库里读「桶名 / 区域 / 临时密钥策略」(上一篇的库内系统设置)。错误写法是:打开 Session → 读设置 → 在同一 Session 未关闭时 HTTP 打 OSS → 再写上传记录。OSS 一慢,连接池就被占满。

更顺的短事务切分:

python
async def generate_upload_sign(self, filename: str, file_size: int) -> dict:
    # 1) 短事务:只读配置,立刻结束
    config = await self._settings.get_oss_config()  # 内部 commit/close 或 begin 块结束

    # 2) 无 DB 连接:调外部
    credentials = await self._oss.presign(config, filename, file_size)

    # 3) 如需落库审计,再开另一段短事务
    # await self._repo.save_sign_audit(...)
    return credentials

改 SKU、改库存同理:先想「哪些 SQL 必须原子」,把外部 HTTP、大文件、AI 调用全部挪出 begin()。课件留的思考题(update_sku 要不要改)答案通常是——把读配置 / 校验与外部副作用拆开,而不是在一个长事务里做完所有事。

连接池与「长 事务」的连带伤害

连接池大小有限(例如二十)。一个慢 事务 占一条连接直到 提交;十个并发慢请求就能占满池子,第十一个无关查询在 pool.connect() 排队超时。症状是「数据库 CPU 不高,API 却超时」——Profiler 应看连接持有时间,而不只是慢 SQL。短 事务 直接缩短连接占用,比盲目加大 pool 更可持续。

长流程怎么拆:状态机 + 多段短事务

跨外部系统的流程不适合一个 DB 事务 包住全程,而用显式状态 + 多段短 事务

python
class OrderStatus(StrEnum):
    PENDING_PAYMENT = "pending_payment"
    PAID = "paid"
    CANCELLED = "cancelled"


def start_checkout(db: Session, payload: OrderCreate) -> Order:
    with db.begin():
        order = repo.create(db, payload, status=OrderStatus.PENDING_PAYMENT)
        inventory.reserve(db, payload.items)
    return order


def mark_paid(db: Session, order_id: int, payment_ref: str) -> None:
    with db.begin():
        order = repo.get_for_update(db, order_id)
        if order.status != OrderStatus.PENDING_PAYMENT:
            raise DomainError("invalid transition")
        repo.update_status(db, order_id, OrderStatus.PAID, payment_ref=payment_ref)


def cancel_unpaid(db: Session, order_id: int) -> None:
    with db.begin():
        order = repo.get_for_update(db, order_id)
        if order.status != OrderStatus.PENDING_PAYMENT:
            return
        inventory.release(db, order.items)
        repo.update_status(db, order_id, OrderStatus.CANCELLED)

每步是独立短 事务;「长」体现在状态机与时间轴上,而不是一条 SQL 事务 从创建挂到支付完成。失败时 回滚 范围限于当前步——已 提交 的预占订单不会随支付超时 magically 消失,需要定时任务或显式 cancel 补偿。

幂等与重复 提交

支付回调可能重复到达。mark_paid 应在短 事务 内检查当前状态,已是 PAID 则直接返回成功(幂等),而不是再改一次库存。幂等键(payment_ref 唯一索引)是 边界 外的另一道保险:同一外部 提交 多次,数据库只接受一次。

消息队列与 事务 顺序

常见模式:短 事务 提交 订单后,再发 MQ 消息通知仓库。若先发消息再 提交,消费者可能读到尚不存在的订单 id。若要求「库与消息严格一致」,需 Outbox 表:与订单同一 事务 写 outbox 行,独立进程扫 outbox 发 MQ——仍是短 事务,只是多一张本地表,而不是把 MQ 调用放进 事务 块里。

读操作与事务边界

只读列表页通常不需要长 事务SELECT 在默认 READ COMMITTED 下不长期占写锁,但只读 事务 仍占连接。复杂报表可:

  • 只读副本 + 无 事务session.execute(text("SET TRANSACTION READ ONLY"))
  • 或快照导出,不与写路径抢主库连接。

把「读请求 = 整个请求一个写 事务」是常见过度设计。

只读 Service 方法

查询商品详情、列表分页,Repository 执行 select 即可,不必包 with db.begin()。若需要「可重复读快照」做报表,显式开只读 事务 并在完成后 提交(或 rollback 结束),而不是让整个 HTTP 请求挂着只读 事务 去调外部 HTTP。

测试与边界清晰度

事务 边界 放在 Service 后,集成测试可以:

python
def test_place_order_rolls_back_on_inventory_error(db_session: Session) -> None:
    with pytest.raises(OutOfStockError):
        service.place_order(db_session, payload_over_qty)
    assert db_session.query(Order).count() == 0

若 commit 散在路由与 Repository 多层,断言「失败时库内无脏数据」会变难。边界 清晰 → 回滚 可预测 → 测试可重复。

还可以测「两步 提交」:第一步 start_checkout 成功后,模拟支付失败调 cancel_unpaid,断言库存恢复。这类测试依赖明确的 边界 与状态字段,比测「一个大 事务 全有或全无」更贴近真实集成。

选型对照

场景建议 边界
单表 CRUD一个 Service 方法内 begin()–commit
多表本地一致性同一短 事务
含外部 HTTP / 消息本地短 事务 + 状态字段 + 异步补偿
批量导入分批 commit(如每 500 行),失败批次 回滚
Saga 跨服务各服务短 事务 + 幂等 + 对账,非分布式两阶段强 事务(除非基础设施专门支持)

常见误解

误解 1:「一个请求只能 commit 一次。」框架不禁止多次 commit;禁止的是无意识地在持锁 事务 里混慢 IO。

误解 2:「get_db yield 结束会自动 commit。」不会——未 commit 的改动在 close 时被 回滚(或丢弃),取决于 Session 配置。必须在明确 边界 处 commit。

误解 3:「异步 SQLAlchemy 就可以在长 事务 里 await。」await 只释放事件循环,不释放数据库锁;边界 规则与同步 Session 相同。

和 FastAPI 异步路由的关系

async def 路由里调用同步 Session 时,阻塞 SQL 会占线程池;异步 Session 配合 await session.execute 更合适,但 事务 边界 规则不变:块内仍不要 await 慢外部 API。若业务必须「边等支付边 hold 连接」,问题在 边界 设计,不在 async/sync 选型。

实战心法:默认短,显式长

边界 时可以记三问:

  1. 若这一步失败,库内哪些表必须一起 回滚?——它们应在同一短 事务 里。
  2. 这一步是否等待公司外部的网络?——若是,提交 当前短 事务 后再做,失败走补偿。
  3. 这一步持锁会阻塞谁?——库存、余额类写路径尽量毫秒级 提交

「长 事务」若指业务周期长,用状态字段表达;若指 SQL 事务 长,几乎都是坏味道。代码审查看到 with db.begin() 包 HTTP 调用,应直接打回。

FastAPI 路由里 commit 放哪一层

团队可约定表格,减少争论:

层级是否 commit说明
Repository只 flush 如需拿自增 id
Service是(默认)边界 与用例对齐
Router尽量避免除非极简 CRUD 原型

当 CLI 命令与 HTTP 共用 Service 时,Service 内 提交 保证两条入口行为一致。FastAPI 的 get_db 在 finally close Session;未 commit 的改动会被丢弃,因此 Service 必须在成功路径 提交,在捕获的业务异常路径 回滚 或依赖 context manager 自动 回滚

乐观锁与短 事务

高并发改同一行时,除短 事务 外还可加版本号:UPDATE ... WHERE id=? AND version=?,影响行数为 0 则重读重试。乐观锁把冲突检测放在 提交 瞬间,仍要求 事务 尽可能短——否则 version 检查虽无长锁,连接占用问题仍在。悲观锁 SELECT FOR UPDATE 则必须在极短 边界提交,否则行锁危害更大。

与消息消费者的 边界

Celery、RQ 等 worker 处理任务时,同样应「一个任务方法内画 事务 边界」,而不是 worker 进程级长 事务。HTTP 请求结束与消息处理结束都是 natural 的 scope;把 提交 写在 Service,HTTP 与 worker 复用,架构 对称。

回顾:一句话 边界

短事务 = 本地、多表、快;长流程 = 状态机 + 多段短 事务 + 补偿;请求级 Session = 借连接,不等于一个 业务 事务。把这三句贴在团队 wiki 上,比争论「到底要不要 事务」更能统一代码风格。

死锁与 边界

两个短 事务 若按不同顺序锁同一批行,仍可能死锁——数据库会 回滚 其中一个。边界 缩小持锁时间有助于降低死锁概率;应用层对 OperationalError 重试也是常见补丁。但根本仍是:能短则短,锁序一致,避免在 事务 里调用不可控的外部代码。

嵌套 事务 与 savepoint

SQLAlchemy 支持 savepoint(begin_nested),在大 事务 内局部 回滚 而不 提交 外层——仍拉长连接占用,只适用于必须批量处理且偶发单行失败的特殊导入。Web API 默认路径仍是扁平短 事务;savepoint 不是「把长 事务 变合法」的银弹。

与 Unit of Work 模式

部分团队引入显式 UnitOfWork 类封装 begin/commit/rollback,Service 注入 UoW 而非裸 Session。边界 仍在 UoW 方法里;FastAPI 的 get_db yield Session 或 UoW 均可,关键是团队只保留一种 提交 入口,避免 Repository 与 Service 抢着 commit。

回顾 事务 篇核心:边界 决定 提交回滚 范围;请求级 Session 只是借连接;跨系统流程用状态机加多段短 事务。把外部 HTTP 移出 with db.begin(),多数锁等待问题会消失。

线上指标可观察 idle in transaction 会话数与连接池等待时间;边界 收窄后这两曲线应同步下降,比单纯加大 pool 更能说明 事务 设计合理。短 事务 是默认答案,长流程用状态机拆段。

小结

事务 保证本地多步写法的原子性;提交回滚 的范围由你画的 边界 决定。请求级 Session 是资源作用域,不是业务 事务 单元。默认倾向**短 事务**:数据库内快做快 提交;跨系统流程用状态机串联多段短 事务,用补偿而非一条长 事务 硬扛外部延迟。锁持有时间下来了,并发与连接池才撑得住真实流量。