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が完全に沈静化。

