Async FastAPI + asyncpg: rzeczywiste pułapki
Pięć błędów, które zatrzymały release'y polskich fintechów.
autor: Admin

Wstęp
FastAPI w wersji 0.115 plus asyncpg 0.30 to dziś standardowy stack w polskich fintechach. Wygląda prosto: async def, await, profit. W rzeczywistości pierwsze cztery tygodnie w produkcji to seria nieoczywistych problemów, o których nie pisze się w tutorialach.
Zebrałem pięć pułapek, które realnie zatrzymały release'y projektów, w których brałem udział w ciągu ostatniego roku.
1. Connection pool wycieka pod presją
asyncpg.create_pool(min_size=10, max_size=20) brzmi dobrze, dopóki nie zobaczysz w Datadogu, że pool ma 20 aktywnych połączeń i wszystkie czekają. Najczęstsza przyczyna:
async def get_user(user_id: int):
async with pool.acquire() as conn:
user = await conn.fetchrow("SELECT ...", user_id)
# jakaś logika
await some_external_api_call() # HTTP! 2 sekundy!
return user
Trzymanie połączenia podczas wywołania zewnętrznego API to klasyk. Połączenie z bazą zwalniaj najszybciej jak się da - najlepiej tuż po ostatnim fetch:
async def get_user(user_id: int):
async with pool.acquire() as conn:
user = await conn.fetchrow("SELECT ...", user_id)
await some_external_api_call()
return user
2. Depends() i async: zagnieżdżone transakcje
FastAPI dependency injection jest kuszące - robisz db: AsyncSession = Depends(get_db) i piszesz "service layer". Problem: jeśli serwis A wywołuje serwis B, a oba dostają tę samą sesję przez Depends, dostajesz jedną długą transakcję.
W trakcie tej transakcji blokujesz wiersze, generujesz long-running query, a Postgres zaczyna logować idle in transaction na 30+ sekund.
Reguła kciuka: dependency wstrzykuje sesję, ale commit/rollback robi > warstwa najwyższa - najlepiej endpoint lub explicit
unit_of_work.
3. expire_on_commit=False to nie jest opcja
SQLAlchemy domyślnie po commit unieważnia wszystkie obiekty - przy następnym dostępie do atrybutu robi SELECT. W async świecie ten lazy load rzuca MissingGreenlet, bo jest poza kontekstem async.
SessionLocal = async_sessionmaker(
engine,
expire_on_commit=False,
class_=AsyncSession,
)
Bez tego jednego ustawienia połowa endpointów wybucha przy pierwszej prawdziwej refaktoryzacji.
4. pgbouncer w trybie transaction = goodbye prepared statements
asyncpg domyślnie używa prepared statements. pgbouncer w trybie transaction (czyli ten najczęściej używany na produkcji) nie utrzymuje sesyjnego kontekstu - kolejne zapytanie trafia na inny backend, a prepared statement zniknął.
Rozwiązanie:
engine = create_async_engine(
DATABASE_URL,
connect_args={"statement_cache_size": 0},
pool_pre_ping=True,
)
Tracisz mikro-optymalizację, zyskujesz to, że aplikacja w ogóle działa pod pgbouncerem.
5. Tasks po return - Background Tasks vs prawdziwe queue
@app.post("/email")
async def send(bg: BackgroundTasks):
bg.add_task(send_marketing_email, ...)
return {"ok": True}
To wygląda jak kolejka. To nie jest kolejka. Worker uvicorna nadal musi wykonać tę funkcję - jeśli proces dostanie SIGTERM (deploy!), zadanie przepada bez śladu. Do prawdziwych jobów: arq, Dramatiq albo Celery.
Bonus: profiling
pip install py-spy
sudo py-spy top --pid $(pgrep -f uvicorn | head -1)
W asynchronicznym świecie cProfile kłamie - py-spy z flagą --threads pokazuje co naprawdę dzieje się w event loopie.
Podsumowanie
- pool acquire trzymaj tak krótko jak się da
expire_on_commit=Falseto nie opcja, to wymaganiestatement_cache_size=0jeśli używasz pgbouncer transaction- BackgroundTasks to nie kolejka
Każdy z tych punktów zapłaciliśmy incydentem na produkcji. Mam nadzieję, że wam się to oszczędzi.