FastAPI + Tortoise ORM 异步数据库实践
引言
去年双十一前,我接手了一个用 FastAPI 写的订单查询服务。压测时 QPS 刚到 800 就上不去了,CPU 却只跑了 30%。打开火焰图一看,asyncio 事件循环里躺着大量 psycopg2 的同步阻塞调用——await db.execute(...) 表面上是异步的,底下却在用线程池硬扛,线程池一满,整个事件循环就开始排队。这就是典型的“伪异步”:你以为自己写的是 async,实际上只是把阻塞操作从主线程挪到了 thread pool。
这个问题的根子不在 FastAPI,而在 ORM 选型。FastAPI 本身是纯异步的 ASGI 框架,但如果你配的是 SQLAlchemy 1.4 的同步 Session,或者 Django ORM,那异步链路就在数据库这一层断掉了。Tortoise ORM 正是为填补这块空缺而生的:它是原生 async 的 ORM,API 设计上高度致敬 Django ORM,让熟悉 Django 的工程师几乎零成本迁移。
这篇文章不讲“怎么装包、怎么定义模型”这种入门内容。我们要往下钻:Tortoise 的连接池是怎么在事件循环里工作的?await Model.get() 背后发生了什么?它和 SQLAlchemy 2.0 async、asyncpg 裸用相比,各自的边界在哪?以及最关键的——生产环境里踩过的那些坑。
核心概念:从“餐厅后厨”理解异步 ORM
先把 asyncio 想象成一家只有一个传菜员的餐厅。
- 同步阻塞模型:传菜员把菜端到后厨窗口,然后站在那儿等厨师做完。等的时候他什么也干不了。这就是
psycopg2的行为。
- 线程池伪异步:老板看传菜员老等着,就雇了 20 个传菜员(线程池)。每个传菜员照样站着等,但至少能同时等 20 单。问题是:传菜员是有成本的(8MB 栈 + 上下文切换),而且 20 个传菜员全占满的时候,第 21 单照样卡住。这就是 SQLAlchemy 1.4 同步 Session 在 async 环境下的真相。
- 真异步:传菜员把订单贴到窗口就转身走人,厨师做完按铃,传菜员再回来取。一个传菜员能同时处理几百单。这就是 Tortoise + asyncpg 的模式——数据库驱动本身是非阻塞的,等待期间事件循环可以调度其他协程。
所以判断一个 ORM 是不是“真异步”,关键看底层的数据库驱动是不是非阻塞的。Tortoise 在 PostgreSQL 下用 asyncpg,MySQL 下用 aiomysql,SQLite 下用 aiosqlite——全部是原生异步驱动,没有线程池兜底。
Tortoise 的另一个设计取舍是放弃 Data Mapper、拥抱 Active Record。SQLAlchemy 是 Data Mapper:模型是纯 Python 对象,和 Session 解耦,Session 负责跟踪状态、生成 SQL。Tortoise 是 Active Record:模型类自带 save()、delete()、filter(),像 Django 一样。这个取舍在异步场景下其实更自然——因为异步里你本来就该“用完即走”,不需要长生命周期的 Session 去维护身份映射(identity map)。这一点后面会细说。
源码/原理深度分析
1. 连接池:ConnectionHandler 如何管理事件循环中的连接
Tortoise 的连接管理核心在 tortoise/connection.py 的 ConnectionHandler 类。简化后的关键结构如下:
class ConnectionHandler:
def __init__(self, connections: dict[str, dict]):
self._connections: dict[str, ConnectionWrapper] = {}
self._db_config = connections
async def _get_connection(self, connection_name: str):
# 每个连接名对应一个 ConnectionWrapper,
# 内部持有一个由驱动提供的 pool
wrapper = self._connections.get(connection_name)
if wrapper is None:
# 懒初始化:第一次访问时才建池
wrapper = ConnectionWrapper(...)
await wrapper.init(self._db_config[connection_name])
self._connections[connection_name] = wrapper
return wrapper这里有个容易被忽略的细节:连接池是绑定到事件循环的。asyncpg 的 Pool 对象内部持有 asyncio.Queue 和 Loop 引用,如果跨事件循环使用(比如在 uvicorn --workers 4 的多进程里,或者测试中反复 asyncio.run()),就会报 attached to a different loop。我在一个项目里就因为 pytest 每个测试用例都 asyncio.run() 而踩过这个坑。
看 ConnectionWrapper 的 get_connection 实现(tortoise/backends/base/client.py 附近):
async def get_connection(self):
if self._pool is None:
async with self._lock:
if self._pool is None:
self._pool = await self.create_pool()
return await self._pool.acquire() # 从池里借一个注意这里用了双检锁(double-checked locking)来保证池只建一次。self._lock 是 asyncio.Lock,在单事件循环里是安全的。这也是为什么 Tortoise 官方明确说不要在多个事件循环之间共享 Tortoise 实例——锁和池都是 loop-bound 的。
2. await Model.get() 的完整生命周期
假设我们执行:
user = await User.get(id=1)这行代码背后发生的事,可以拆成下面这个流程图:
解析字段、生成 SQL 与参数"] C --> D["executor.execute_select_query()"] D --> E["ConnectionHandler.get_connection()"] E --> F{"连接池有空闲连接?"} F -->|是| G["acquire() 借出连接"] F -->|否| H["await 等待池中归还
事件循环挂起当前协程"] H --> G G --> I["asyncpg 执行 SQL
期间协程让出 CPU"] I --> J["结果集映射为 model 实例
_init_from_db()"] J --> K["finally: release() 归还连接"] K --> L["返回 User 实例"]
关键点在第 I 步:asyncpg 把 SQL 发给 PostgreSQL 后,当前协程在 await 处挂起,事件循环立刻去跑别的请求。等 PostgreSQL 返回数据,IO 就绪,事件循环再唤醒这个协程继续走 J 和 K。整个过程没有线程参与,也就没有线程池耗尽的问题。
再往深一层看结果集映射。Tortoise 在 Model._init_from_db(tortoise/models.py)里做字段赋值:
@classmethod
def _init_from_db(cls, **kwargs):
instance = cls.__new__(cls)
instance._partial = False
instance._saved_in_db = True
for key, value in kwargs.items():
# 直接塞进 __dict__,不做 setter 校验
instance.__dict__[key] = value
return instance注意它绕过了 __setattr__,直接写 __dict__。因为 ORM 的字段描述符(fields.py 里的 Field.__set__)会做类型转换和脏检查,而从数据库读出来的数据已经是可信的,没必要再校验一遍。这是个性能优化,但也意味着——如果你在模型上定义了自定义 __setattr__ 做业务逻辑,从 DB 加载时不会触发。这个坑我见过有人用来做“字段变更审计”,结果读数据时审计日志一条没有。
3. 事务:in_transaction() 与连接的绑定关系
异步事务最容易出错的地方是连接泄漏。Tortoise 的 in_transaction() 是个异步上下文管理器:
async with in_transaction() as conn:
await User.create(name="a", using_db=conn)
await Order.create(user_id=..., using_db=conn)源码层面(tortoise/transactions.py),in_transaction 会从当前连接的池里 acquire 一个连接,BEGIN,然后把事务上下文存进一个 ContextVar:
class _TransactionContext:
__slots__ = ("connection", "token", "lock")
...
async def in_transaction(connection_name=None):
ctx = _get_context(connection_name)
await ctx.lock.acquire()
try:
connection = await ctx.connection_handler.get_connection()
await connection.start()
token = _current_transaction.set(connection) # ContextVar
yield connection
finally:
await connection.rollback()
_current_transaction.reset(token)
ctx.lock.release()用 ContextVar 而不是 threading.local,是因为 asyncio 的任务切换不换线程。但这也带来一个陷阱:如果你在事务里 await asyncio.create_task(...) 开了子任务,子任务默认继承父任务的 ContextVar 值(Python 3.7+ 的行为),于是子任务里的 ORM 操作会“以为”自己也在同一个事务里,实际上用的是同一个连接——并发执行时会互相踩。正确的做法是子任务里显式传 using_db=None,或者干脆别在事务里开并发子任务。
实战代码
下面三个示例都是可运行的完整脚本,聚焦不同层面的实战问题。
示例一:完整 CRUD + 事务 + 连接池配置
# main.py
# 运行:uvicorn main:app --reload
# 依赖:pip install fastapi uvicorn tortoise-orm asyncpg
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from tortoise import Tortoise, fields, run_async
from tortoise.models import Model
from tortoise.transactions import in_transaction
class User(Model):
id = fields.IntField(pk=True)
name = fields.CharField(max_length=64, index=True)
balance = fields.DecimalField(max_digits=12, decimal_places=2, default=0)
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "users"
class Order(Model):
id = fields.IntField(pk=True)
user = fields.ForeignKeyField("models.User", related_name="orders")
amount = fields.DecimalField(max_digits=12, decimal_places=2)
status = fields.CharEnumField(
enum_type=type("OrderStatus", (), {"PAID": "paid", "REFUNDED": "refunded"}),
default="paid",
)
class Meta:
table = "orders"
@asynccontextmanager
async def lifespan(app: FastAPI):
# 关键:连接池参数直接决定高并发下的表现
await Tortoise.init(
db_url="postgres://postgres:postgres@localhost:5432/demo",
modules={"models": ["main"]},
# 下面这些参数会透传给 asyncpg 的 create_pool
connections={
"default": {
"engine": "tortoise.backends.asyncpg",
"credentials": {
"host": "localhost",
"port": 5432,
"user": "postgres",
"password": "postgres",
"database": "demo",
"min_size": 5, # 池最小连接数
"max_size": 20, # 池上限,别超过 PG 的 max_connections 除以 worker 数
"max_queries": 50000, # 单连接执行多少次查询后回收,防内存泄漏
"max_inactive_connection_lifetime": 300,
},
}
},
)
await Tortoise.generate_schemas()
yield
await Tortoise.close_connections() # 必须显式关闭,否则测试会挂
app = FastAPI(lifespan=lifespan)
class TransferReq(BaseModel):
from_user: int
to_user: int
amount: float
@app.post("/transfer")
async def transfer(req: TransferReq):
"""转账:演示事务 + 行锁 + 正确回滚"""
async with in_transaction() as conn:
# 用 using_db 把两个操作绑到同一连接,
# 否则 ORM 会从池里再借一个连接,事务就分裂了
sender = await User.select_for_update().using_db(conn).get_or_none(id=req.from_user)
if sender is None:
raise HTTPException(404, "sender not found")
if sender.balance < req.amount:
# 抛异常会自动触发 rollback
raise HTTPException(400, "insufficient balance")
await User.filter(id=req.from_user).using_db(conn).update(
balance=sender.balance - req.amount
)
await User.filter(id=req.to_user).using_db(conn).update(
balance=fields.F("balance") + req.amount # F 表达式,避免读改写竞态
)
return {"ok": True}
@app.get("/users/{uid}")
async def get_user(uid: int):
# prefetch_related 一次查询带出订单,避免 N+1
user = await User.filter(id=uid).prefetch_related("orders").first()
if not user:
raise HTTPException(404)
return {
"id": user.id,
"name": user.name,
"orders": [{"id": o.id, "amount": str(o.amount)} for o in user.orders],
}这段代码里有三个细节值得注意:select_for_update() 要配合 using_db(conn) 才在事务内生效;fields.F() 用于原子更新避免竞态;prefetch_related 是 Tortoise 里避免 N+1 的标准手段。
示例二:批量操作与 bulk_create 的正确姿势
# bulk.py
import asyncio
from tortoise import Tortoise, run_async
from tortoise.expressions import Q
from main import User, Order
async def seed():
await Tortoise.init(
db_url="postgres://postgres:postgres@localhost:5432/demo",
modules={"models": ["main"]},
)
await Tortoise.generate_schemas()
# 反例:循环 create,每次一个 RTT,1 万条要几十秒
# for i in range(10000):
# await User.create(name=f"u{i}")
# 正例:bulk_create 生成单条 INSERT ... VALUES (...),(...),一次 RTT
users = [User(name=f"u{i}") for i in range(10000)]
await User.bulk_create(users, batch_size=1000)
# batch_size 控制单条 SQL 的 VALUES 数量,
# 太大可能撞 PostgreSQL 的 65535 参数上限(每行 N 个字段 × 行数)
# 批量更新:Tortoise 没有原生 bulk_update,
# 用 CASE WHEN 或分批 filter().update() 更实际
await User.filter(name__startswith="u1").update(balance=100)
# 复杂条件查询:Q 对象组合
rows = await User.filter(
Q(name__startswith="u1") & Q(balance__gte=0)
).order_by("-created_at").limit(10)
print(f"matched {len(rows)}")
await Tortoise.close_connections()
if __name__ == "__main__":
run_async(seed())bulk_create 的 batch_size 参数是个双刃剑:调大能减少 RTT,但 PostgreSQL 单条 SQL 最多 65535 个绑定参数,假设 User 有 5 个字段,那 batch_size 超过约 13000 就会报错。默认值 None 表示一次性全塞进去,生产环境务必显式设置。
示例三:并发查询与事件循环陷阱
# concurrency.py
import asyncio
from tortoise import Tortoise
from main import User
async def bad_pattern():
"""反例:在事务里开并发子任务,连接互相踩踏"""
# 伪代码示意,实际会报 "another operation is in progress"
# async with in_transaction() as conn:
# await asyncio.gather(
# User.create(name="a", using_db=conn),
# User.create(name="b", using_db=conn),
# )
pass
async def good_pattern():
"""正例:并发查询不共享连接,各自从池里借"""
await Tortoise.init(
db_url="postgres://postgres:postgres@localhost:5432/demo",
modules={"models": ["main"]},
)
# 每个查询独立 borrow 连接,事件循环并行调度
results = await asyncio.gather(
User.filter(name__startswith="u1").count(),
User.filter(name__startswith="u2").count(),
User.filter(balance__gte=50).count(),
)
print("counts:", results)
# 大数据量分页:别用 offset 深翻页,用 keyset pagination
last_id = 0
while True:
page = await User.filter(id__gt=last_id).order_by("id").limit(100)
if not page:
break
for u in page:
# 处理...
pass
last_id = page[-1].id
await Tortoise.close_connections()
async def main():
await good_pattern()
# 注意:不要在一个进程里反复 Tortoise.init / close 后又 init,
# 池是 loop-bound 的,跨 loop 复用会炸
if __name__ == "__main__":
asyncio.run(main())重点在 good_pattern:并发查询必须各自借连接,不能共享。而 bad_pattern 里在事务中 gather 多个写操作,asyncpg 的连接是单工通信,同一时刻只能跑一条语句,会直接抛 another operation is in progress。这是异步 ORM 最经典的坑之一。
方案对比:Tortoise vs SQLAlchemy 2.0 async vs asyncpg 裸用
| 维度 | Tortoise ORM | SQLAlchemy 2.0 async | asyncpg 裸用 |
|---|---|---|---|
| 异步原生性 | 原生 async 驱动 | Session 基于 AsyncSession,底层 asyncpg/aiomysql |
原生 async |
| API 风格 | Active Record(类 Django) | Data Mapper(Session 跟踪) | 纯 SQL |
| 学习曲线 | Django 用户几乎零成本 | 心智模型重,Session 生命周期管理复杂 | 最低,但 SQL 全手写 |
| 关系加载 | prefetch_related 显式 |
selectinload / joinedload 显式 |
手写 JOIN |
| 迁移工具 | Aerich(功能较基础) | Alembic(成熟) | 无 |
| 事务隔离 | ContextVar 绑定连接 |
AsyncSession 绑定 |
手动管理 |
| 生态成熟度 | 中等 | 极高 | 高(驱动层) |
| 适用场景 | 中小型服务、快速迭代、Django 迁移 | 大型复杂领域模型、多数据库 | 极致性能、复杂 SQL、只读查询 |
怎么选?我的经验是分三档:
- 业务 CRUD 为主、团队有 Django 背景:Tortoise 最省心。它的 API 让你几乎不用思考“连接从哪来”,异步链路干净。
- 领域模型复杂、需要多数据源、要成熟迁移:SQLAlchemy 2.0 async。它的
AsyncSession虽然重,但selectinload控制加载策略的能力远超 Tortoise,大型项目后期会庆幸当初选对了。
- 报表、批量导入、复杂聚合:直接 asyncpg。ORM 生成的 SQL 在这种场景下往往是负担,手写 SQL +
conn.fetch()反而更清晰、更快。
还有一个常被忽略的对比维度:Tortoise 的迁移工具 Aerich 相比 Alembic 是明显短板。它不支持自动检测所有 schema 变更(比如索引增删、约束修改),复杂迁移经常要手写 SQL。如果你的项目 schema 会频繁演进,这点要提前评估。
最佳实践与避坑指南
1. 连接池参数必须和 PG 的 max_connections 联动算
这是最容易出事的地方。假设 uvicorn --workers 4,每个 worker 一个进程一个事件循环一个池,池 max_size=20,那峰值就是 4×20=80 个连接。PostgreSQL 默认 max_connections=100,再算上运维连接,很快就 too many clients。经验公式:
池 max_size ≤ (PG max_connections - 预留 10) / worker 数宁可把 max_size 调小(比如 10),靠事件循环的并发能力扛,也别把连接数堆爆。
2. using_db(conn) 一个都不能漏
事务内所有 ORM 操作都必须显式传 using_db=conn。漏一个,那个操作就会从池里另借一个连接,跑在事务之外,回滚时它不跟着回滚——数据就脏了。这是我在 code review 里抓得最多的 bug。
3. 别在事务里 asyncio.gather
前面说过,asyncpg 连接单工,事务内并发写会直接崩。要并发,就在事务外并发,或者拆成多个独立事务。
4. 测试里的 asyncio.run() 是连接池杀手
pytest 每个用例 asyncio.run() 会建新 loop,而 Tortoise 的池绑在旧 loop 上。解决方案是全局一个 loop(pytest-asyncio 的 event_loop fixture),或者在 fixture 里每个用例 Tortoise.init + close_connections。我推荐后者,隔离干净。
5. N+1 查询一定要用 prefetch_related
Tortoise 不会自动预加载。user.orders 第一次访问会触发一次查询,循环里就是 N+1。养成习惯:列表接口返回关联数据时,先 filter(...).prefetch_related("orders")。
6. 深分页用 keyset,别用 offset
offset 100000 limit 20 会让 PG 扫描并丢弃 10 万行。用 filter(id__gt=last_id).order_by("id").limit(20),走索引,O(1) 定位。
7. Decimal 字段别用 float 中转
balance 是 DecimalField,从 DB 读出来是 Decimal。如果经过 Pydantic 时声明成 float,精度就丢了。序列化时统一 str(amount),或者 Pydantic 里用 condecimal。
总结
回到开头那个 QPS 卡在 800 的服务。换成 Tortoise + asyncpg 后,同样的硬件 QPS 上到 4000+,CPU 也跑满了——因为事件循环终于不用等线程池了。这个提升的本质,不是 ORM 换了个名字,而是数据库 IO 从“线程阻塞”变成了“协程挂起”,一个事件循环能同时挂起成百上千个等待中的查询。
几个要点回顾:
- 异步 ORM 的“异步”取决于底层驱动,Tortoise 用 asyncpg/aiomysql/aiosqlite,是原生异步,没有线程池兜底。
- 连接池是 loop-bound 的,跨事件循环复用会炸,测试和部署都要注意。
- 事务靠
ContextVar绑定连接,事务内所有操作必须using_db(conn),且不能并发共享连接。
- 选型看场景:Tortoise 胜在 Django 式 API 和干净异步链路,SQLAlchemy 2.0 async 胜在复杂模型和成熟迁移,asyncpg 裸用胜在极致控制。
延伸思考:异步 ORM 的下一个战场其实是连接池的可观测性。Tortoise 目前对池的监控指标(等待队列长度、借出/归还速率、慢查询)支持很弱,生产环境经常要靠 pg_stat_activity 反推。如果你的服务规模到了一定量级,考虑在 ConnectionHandler 外面包一层埋点,或者干脆上 PgBouncer 做连接复用——把池的管理从应用层下沉到中间件层,这往往是异步 ORM 高并发场景下的最终形态。