本文目录
下单接口里:扣库存、写订单、记流水、发消息——若从路由进来到返回,全程共用一个未 提交 的数据库 事务,中间还要调支付网关等外部 HTTP,连接上的行锁可能握几十秒。别的用户改同一 SKU 库存会排队超时;连接池被占满后,无关接口也开始 503。问题往往不是「要不要 事务」,而是 边界 画在哪:哪些步骤必须在同一 提交 里原子完成,哪些应拆成多个短 事务,失败时 回滚 范围又该多大。
事务直觉:原子性与隔离
关系型数据库的 事务 把多条 SQL 包成「全成功或全失败」。session.commit() 提交 持久化;session.rollback() 回滚 撤销未提交改动。SQLAlchemy 默认 autocommit 关闭:第一次写操作开启 事务,直到 commit 或 rollback。
短事务:边界紧,只做数据库内的原子变更,毫秒到百毫秒级结束,尽快 提交 释放锁。
长事务:跨越多次用户交互、外部 API、消息队列或人工审批,若全程占着同一 DB 事务,隔离级别下的锁与 MVCC 版本链都会拖垮并发。
Web API 的默认模式是:一个 HTTP 请求对应一个请求级 Session(yield 依赖在 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 业务事务单元
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:
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 orderwith db.begin() 块结束自动 提交;块内异常自动 回滚。这是典型的**短 事务**:边界 与「下单这一业务单元」对齐,不包含发邮件、调支付。
Repository 层只接收 Session 执行语句,不调用 commit(),这样 边界 在 Service 一眼可见。若 Repository 偷偷 提交,上层 with db.begin() 会行为异常,集成测试也难以断言 回滚。
路由层 提交 的利弊
在 @app.post 里 db.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 一慢,连接池就被占满。
更顺的短事务切分:
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 事务 包住全程,而用显式状态 + 多段短 事务:
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 后,集成测试可以:
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 选型。
实战心法:默认短,显式长
画 边界 时可以记三问:
- 若这一步失败,库内哪些表必须一起 回滚?——它们应在同一短 事务 里。
- 这一步是否等待公司外部的网络?——若是,提交 当前短 事务 后再做,失败走补偿。
- 这一步持锁会阻塞谁?——库存、余额类写路径尽量毫秒级 提交。
「长 事务」若指业务周期长,用状态字段表达;若指 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 是资源作用域,不是业务 事务 单元。默认倾向**短 事务**:数据库内快做快 提交;跨系统流程用状态机串联多段短 事务,用补偿而非一条长 事务 硬扛外部延迟。锁持有时间下来了,并发与连接池才撑得住真实流量。