FastAPIの非同期DB接続入門:async/awaitの仕組みと実装上の注意点

shallow focus of clear hourglass
Photo by Jordan Benton on Pexels.com

FastAPIでデータベースへアクセスするとき、非同期接続は有力な選択肢ですが、async defに書き換えるだけで処理が速くなるわけではありません。

効果が生まれるのは、データベースの応答を待つ間にイベントループが別の処理へ進める構成になっている場合です。

非同期DB接続を理解するための用語

  • 非同期I/O:データベースや外部APIの応答待ちに入った処理を一時停止し、その間に同じプロセスが別の処理を進められる仕組みです。
  • async def:途中で待機できるコルーチン関数を定義する構文です。
  • await:コルーチンなどの完了を待ち、その待機中にイベントループへ制御を戻すための構文です。
  • ブロッキングI/O:処理が終わるまで実行中のスレッドを占有するI/Oです。
  • I/Oバウンド:計算よりも、ネットワークやストレージからの応答待ちが実行時間の大部分を占める処理です。
  • コネクションプール:データベース接続を再利用するために、一定数の接続を保持して貸し出す仕組みです。

async defの中でも、同期ドライバによるDBアクセスを直接呼び出せば、その処理はイベントループをブロックします。

非同期ルートには、awaitに対応したDBドライバやライブラリを組み合わせる必要があります。

非同期化で改善しやすいもの

非同期DB接続が改善しやすいのは、同時リクエストを処理する能力、つまりスループットです。

あるリクエストがDBの応答を待っている間に別のリクエストを進められるため、待ち時間の多いAPIではサーバー資源を活用しやすくなります。

ただし、非同期化が一件のSQL実行時間を短縮するわけではなく、レスポンスタイムが必ず改善するとも限りません。

遅いSQL、索引不足、ロック競合、過大な結果セットが原因なら、クエリやスキーマの改善が先です。

DBアクセス方式を選ぶときの目安
状況選択の目安確認事項
非同期ドライバを利用でき、同時リクエストが多い非同期接続を検討する接続プール、タイムアウト、負荷試験
利用中のDBライブラリが同期APIのみを提供する通常のdefまたは同期処理の分離を検討するイベントループ上で同期I/Oを直接実行しない
小規模な管理APIなどで同時実行が少ない同期構成も比較する実装と運用の複雑さに見合うか
CPU計算が主なボトルネックである非同期DB接続とは別に対策する計測結果を基にワーカー構成などを見直す

ライブラリ選定の基準

ORM、クエリ構築ライブラリ、DBドライバは役割が異なるため、同じ評価軸だけでは比較できません。

候補にはSQLAlchemyのasyncio拡張、Databases、asyncpgなどがありますが、提供する抽象度と対応するデータベースが異なります。

  • 利用するデータベースと非同期ドライバの組み合わせが公式に対応しているか
  • ORMが必要か、SQLやクエリビルダーを直接扱うか
  • トランザクション、接続プール、タイムアウトを管理できるか
  • テストとスキーマ変更の手順をチームで維持できるか
  • 採用するバージョンの保守状況と公式ドキュメントを確認できるか

ライブラリ名だけで決めず、アプリケーションが必要とする機能と運用方法を先に整理すると、選択理由が明確になります。

DatabasesとSQLiteを使った最小例

次の例は、FastAPIの起動時に接続プールを準備し、終了時に解放する流れを示します。

SQLite用の非同期ドライバを含めてインストールします。

pip install "databases[aiosqlite]"
from contextlib import asynccontextmanager

from databases import Database
from fastapi import FastAPI

DATABASE_URL = "sqlite+aiosqlite:///./test.db"
database = Database(DATABASE_URL)


@asynccontextmanager
async def lifespan(app: FastAPI):
    await database.connect()
    try:
        yield
    finally:
        await database.disconnect()


app = FastAPI(lifespan=lifespan)


@app.get("/users")
async def get_users():
    rows = await database.fetch_all(
        query="SELECT id, name FROM users ORDER BY id"
    )
    return [dict(row._mapping) for row in rows]

この例は、usersテーブルがすでに存在する前提です。

lifespanyieldより前が起動処理、後が終了処理になり、DB接続の準備と後片付けを一つの流れで管理できます。

共有リソースの初期化と解放を詳しく確認したい場合は、FastAPIのlifespanによる共有リソース管理も参照してください。

実装時に起きやすい問題

同期DBアクセスでイベントループを止める

async defの中から同期版のSessionや同期ドライバを直接使うと、DBの応答を待つ間もイベントループが先へ進めません。

非同期構成を選ぶなら、ドライバからセッションまで非同期対応の経路を揃えます。

awaitを付け忘れる

async defで定義した関数を呼ぶだけでは処理は実行されず、コルーチンオブジェクトが返ります。

必要な箇所でawaitし、型検査、テスト、実行時警告も使って呼び忘れを検出します。

セッションを同時タスクで共有する

SQLAlchemyのAsyncSessionは状態を持つため、一つのインスタンスを複数の同時タスクで使い回す構成には向きません。

リクエストやトランザクションの境界に合わせてセッションを作り、処理後に確実に閉じます。

設定の不整合でMissingGreenletが発生した場合は、FastAPIでMissingGreenletを直す確認手順が参考になります。

接続プールを増やしすぎる

APIが同時に処理できる件数を増やしても、データベースが受け入れられる接続数と処理量には上限があります。

アプリケーションのワーカー数、各ワーカーのプール設定、DB側の上限をまとめて見積もり、待ち時間とタイムアウトを監視します。

例外時の後片付けを忘れる

途中で例外が起きても、トランザクションのロールバックとセッションや接続の解放が行われる構成にします。

async withや依存性注入のyieldを使うと、処理の開始と終了を同じ場所で管理しやすくなります。

導入を判断する手順

  1. 計測によって、DB待ちがボトルネックかを確認します。
  2. 利用するDBと非同期ドライバの対応状況を確認します。
  3. セッションとトランザクションの境界を決めます。
  4. 接続プール、タイムアウト、再試行の方針を決めます。
  5. 同時実行を含むテストと負荷試験で、同期構成との差を比較します。

非同期DB接続は、待ち時間の多いAPIで同時処理能力を高める手段です。

その効果を得るには、非同期ドライバ、適切なawait、処理単位のセッション、DB容量に合う接続プールを一つの設計として揃える必要があります。

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)