FastAPI + SQLAlchemy 2.0:非同期DB操作という「修羅の道」を攻略する
「なぜ俺のAPIは、リクエストが増えると途端にレスポンスが鈍くなるんだ?」
その答えはシンプルだ。同期的なDBドライバが、I/O待ちのたびにイベントループを窒息させているからだ。FastAPIの真価である「非同期I/O」を最大限に引き出すには、SQLAlchemy 2.0の非同期対応を避けて通れない。しかし、この移行は単なるコードの書き換えではない。同期処理の直感的な思考を捨て、イベントループの挙動を理解するという「設計思想のアップデート」を強いる過酷な道のりだ。
この記事では、僕が幾度となく「RuntimeError」に絶望し、深夜のデバッグでようやく辿り着いた、本番環境で生き残るための非同期DB実装の最適解を共有する。
1. なぜ「非同期」が必要なのか?
同期的なDB操作は、SQLを発行してから結果が返ってくるまで、そのスレッドを完全に占有する。リクエストが10件ならまだいい。だが100件、1000件と並行してくると、スレッドプールは枯渇し、APIは悲鳴を上げる。SQLAlchemy 2.0が非同期対応に舵を切ったのは、この「I/Oブロッキングによるスループット低下」を物理的に解決するためだ。ただし、非同期化は複雑性の増大と引き換えである。これから紹介する実装は、その複雑さを「制御可能な設計」に落とし込むための構成だ。
2. 実装:AsyncSessionの正しい構築
`async_sessionmaker` を使った、本番運用を想定した実装例を見てほしい。from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import declarative_base
# 接続プールサイズを明示的に設定。デフォルトのままだと高負荷時に即死する
DATABASE_URL = "postgresql+asyncpg://user:password@localhost/dbname"
engine = create_async_engine(
DATABASE_URL,
echo=False,
pool_size=20, # 同時接続のベース数
max_overflow=10, # スパイク時に許容する最大接続数
pool_pre_ping=True # 接続切断を検知して自動再接続する(必須)
)
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False, # ここが最重要
)
Base = declarative_base()
async def get_db():
"""リクエストスコープでセッションを管理する"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
なぜ `expire_on_commit=False` なのか?
デフォルトの `True` だと、コミットした瞬間にセッション内の全オブジェクトが「期限切れ」とマークされる。その後で属性にアクセスしようとすると、DBへの再クエリが走り、非同期コンテキスト外で実行されると `DetachedInstanceError` を吐く。 本番環境でこれをやると、「たまに落ちる」という最悪の再現性デバッグに追い込まれる。 潔く `False` にし、必要なら明示的に `session.refresh()` を呼ぶのが鉄則だ。3. Alembic移行:迷宮からの脱出
Alembicの非同期マイグレーションは、`env.py` の設定がすべてだ。async def run_async_migrations():
# 非同期エンジンを構築
connectable = create_async_engine(config.get_main_option("sqlalchemy.url"))
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def do_run_migrations(connection):
# 同期的なマイグレーション実行をラップする
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
ここで `postgresql+asyncpg://` を忘れれば、接続エラーの無限ループが待っている。マイグレーションはアプリの土台だ。ここだけは疎かにしてはいけない。
4. 現場で直面する「壁」と解決策
① 同期ライブラリとの共存
「どうしても同期処理のライブラリを使いたい」という場面は必ず来る。その場合は、`anyio` や `loop.run_in_executor` を使って、メインのイベントループを止めずに別スレッドで実行させるのが正攻法だ。import asyncio
from functools import partial
# イベントループをブロックせずに同期関数を実行
result = await asyncio.get_running_loop().run_in_executor(
None, partial(some_sync_function, arg1)
)
② セッションの使い回しは「死」
`Depends` で取得したセッションを、別タスクやバックグラウンド処理に渡してはいけない。セッションはあくまで「そのリクエストの寿命」に従うべきだ。バックグラウンドでDBを触るなら、必ずそのタスク内で別途セッションを生成せよ。最後に:沼を楽しめるか?
SQLAlchemy 2.0の非同期対応は、正直言ってまだ荒削りだ。トレースバックは複雑さを増し、少しのミスが致命的なリークを招く。しかし、非同期で駆け抜けるAPIのスループットを目の当たりにすれば、その苦労が「無駄ではなかった」と確信できるはずだ。
技術は常に「楽をするため」ではなく「より高い要求に応えるため」に進化する。この沼にハマり、泥をすすりながら実装する経験こそが、君をただのエンジニアから、圧倒的な成果を出せるエンジニアへと変える。
さあ、コーヒーを淹れ直して、もう一度コードを書き始めよう。その一行が、爆速APIの礎になる。