SQLAlchemy 2.0 Async実践ノウハウ:MissingGreenletエラーとコネクションプール枯渇の根本対策

Python tutorial - IT technology blog
Python tutorial - IT technology blog

1. 午前2時の障害:コネクションプールが完全に枯渇した瞬間

午前2時15分、PagerDutyのアラートが一斉に鳴り響きました。統計エンドポイントのすべてが504 Gateway Timeoutを返し、アプリケーションのPodは完全にハングアップ。コンテナのメモリ使用率は95%に達し、ログは見慣れたエラーメッセージで埋め尽くされていました。

sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached, connection timed out, timeout 30.00 (Background on this error at: https://sqlalche.me/e/20/3o7r)

システムはFastAPIとSQLAlchemy 2.0を組み合わせた非同期(async)バックエンドで構築されています。日中の負荷が60 req/s程度であれば、非常に軽快に動作していました。しかし午前2時ちょうど、パートナー企業からのWebhookが急増したタイミングで、5万件の注文データを同期するバッチジョブ(cron job)が起動。プール内の全15コネクションはわずか25秒で底をつきました。緊急でSSHログインし、PostgreSQLのpg_stat_activityを確認したところ、10以上のセッションが数分間にわたりidle in transactionのまま放置されていました。

2. なぜ非同期コードでDBコネクションが詰まるのか?

Gitログとコールスタックを調査した結果、チームは3つの致命的な問題点を突き止めました。

原因1:ずさんなライフサイクル管理によるセッションリーク

多くのバックグラウンドタスクがasync withコンテキストマネージャを使わずにAsyncSessionを直接生成していました。関数の途中でネットワークエラーが発生すると、セッションをクローズしないまま処理が抜けてしまいます。コネクションはサーバーのタイムアウト(30秒)を迎えるまで宙に浮いた状態となり、新規APIリクエストが利用できる空きスロットが消失していました。

原因2:Lazy LoadingによるMissingGreenletの罠

これはORMに非同期処理を導入する際の最も典型的な落とし穴です。事前に読み込まれていないリレーション(例:order.userへのアクセス)を参照しようとすると、SQLAlchemyはバックグラウンドで同期クエリを実行しようとします。しかし非同期イベントループ上では、この動作は即座に例外をスローします。

sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called in this context; can't call await_only() here.

以前の開発者はこれを急場しのぎで、クエリ関数をasyncio.to_threadでラップして修正していました。しかしこの場当たり的な対症療法は、非同期ワーカーをブロッキングな同期スレッドに変えてしまいます。その結果、サービスのスループットは1,100 req/sからわずか35 req/sへと激減してしまいました。

原因3:all()による一括取得でメモリが肥大化

バッチ処理のコードでは、result.scalars().all()を使って一度に5万件のレコードを取得していました。Pythonは同時に5万個のモデルインスタンスを生成することを余儀なくされ、わずか数秒で1.8GB以上のメモリを消費。さらに深刻なことに、データ取得後にコネクションをプールへ即座に返却せず、ループ処理を実行している40秒間ずっとコネクションを占有し続けていました。

3. 一時的な対症療法とその代償

セッションのアーキテクチャを再設計する前に、チームではよくあるいくつかの解決策を検討しました。

  • pool_sizeとmax_overflowを限界まで引き上げる: 設定を15から80コネクションに増加させました。しかし、これはサーバーのクラッシュを3分ほど先延ばしにしただけに過ぎませんでした。PostgreSQLのコネクションは1本あたり約5〜10MBのメモリを消費します。接続数を増やしすぎたことでDBクラスターのメモリが圧迫され、max_connectionsの上限に達してしまい、同一DBを利用していた決済サービスまで巻き添えでダウンしました。
  • 処理をThreadPoolExecutorに委譲する: 通常の同期エンジンを使用し、スレッドに分割して処理する方法です。OSスレッド間のコンテキストスイッチに多くのCPUリソースが割かれ、AsyncIOならではの軽量・高スループットという強みが失われてしまいます。
  • asyncpgドライバで素のSQL(Raw SQL)を書く: asyncpgのクエリ速度は非常に優秀です。しかし引き換えに、型ヒント、テーブル間のリレーションロジック、そしてAlembicによるマイグレーションの自動生成機能など、ORMの恩恵をすべて手放すことになります。

4. 根本的な解決策:SQLAlchemy 2.0におけるAsyncSessionとクエリの最適化

ORMのメリットを維持しつつ安定稼働を実現するには、エンジン設定、セッションのライフサイクル、リレーションの読み込みメカニズムの3点を標準化する必要があります。

ステップ1:接続のクリーンアップを担保するEngineとFactoryの構築

非同期ドライバとしてasyncpgを採用し、必ずexpire_on_commit=Falseフラグを設定します。これによりコミット後にオブジェクトデータが破棄されず、セッション外でLazy Loadが意図せずトリガーされるリスクを根本から防ぎます。

from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
    create_async_engine,
    async_sessionmaker,
    AsyncSession,
)

DATABASE_URL = "postgresql+asyncpg://app_user:[email protected]:5432/production_db"

engine = create_async_engine(
    DATABASE_URL,
    pool_size=10,
    max_overflow=15,
    pool_timeout=30,
    pool_recycle=1800,  # サイレントなソケット切断を防ぐため30分ごとにコネクションを再接続
    pool_pre_ping=True,  # コネクション払い出し時に生存確認を実施
    echo=False,
)

AsyncSessionFactory = async_sessionmaker(
    bind=engine,
    class_=AsyncSession,
    autoflush=False,
    expire_on_commit=False,
)

# FastAPIのDependencyやバックグラウンドワーカーで使用する標準コンテキストマネージャ
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
    async with AsyncSessionFactory() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise

ステップ2:明示的なEager LoadingによるMissingGreenletの根絶

非同期環境では、参照するすべてのリレーションシップを事前に明示的に読み込む必要があります。1対1(または多対1)のリレーションにはjoinedloadを、1対多のリレーションにはselectinloadを使用します。

from sqlalchemy import select
from sqlalchemy.orm import selectinload, joinedload
from models import User, Order, Profile

async def get_user_with_details(session: AsyncSession, user_id: int):
    stmt = (
        select(User)
        .options(
            joinedload(User.profile),       # 1対1リレーション:同一クエリ内でLEFT JOINを使用
            selectinload(User.orders)       # 1対多リレーション:個別のSELECT ... WHERE id IN (...)クエリを発行し高速にロード
        )
        .where(User.id == user_id)
    )
    result = await session.execute(stmt)
    return result.scalar_one_or_none()

データは1度のawaitで漏れなくまとめて取得されます。バックグラウンドで暗黙的な同期I/Oが発生しなくなるため、MissingGreenletエラーは完全に解消されます。

ステップ3:yield_perによるデータのストリーミング処理

数万件規模のレコードを処理するタスクには、yield_per()オプションを指定したstream_scalars()に切り替えます。

async def process_batch_orders(session: AsyncSession):
    stmt = (
        select(Order)
        .where(Order.status == "PENDING")
        .execution_options(yield_per=1000)  # サーバーサイドカーソルから1,000行ずつチャンク取得
    )
    
    result = await session.stream_scalars(stmt)
    async for order in result:
        order.status = "PROCESSED"
    
    await session.commit()

リファクタリング後、監視ダッシュボードで以下の劇的な改善が確認されました。

  • ピーク時でもコネクションプールの稼働数は4〜6本と極めて安定。
  • 大規模ファイル処理時のワーカーのメモリ消費量が1.8GBから260MB未満に減少。
  • APIのP99レイテンシが2,800msから72msへと大幅に短縮。
  • TimeoutErrorが完全に沈静化。
Share: