FastAPI często wybiera się ze względu na szybkość, wygodę i asynchroniczność. Ale w jednym z naszych projektów API przy obciążeniu zaledwie 150 RPS zaczęło odpowiadać po 3–5 sekundach, a czasami kończyło się timeoutami.

Jednocześnie po stronie infrastruktury wszystko wyglądało całkiem normalnie:

  • CPU utrzymywało się na poziomie 35–40%
  • PostgreSQL nie pokazywał długich zapytań
  • sieć również nie wyglądała na wąskie gardło

Na pierwszy rzut oka nie było żadnych wyraźnych problemów. A jednak użytkownicy czekali na odpowiedź całymi sekundami. Sprawdźmy, co się wydarzyło.

Architektura serwisu

Stack był całkiem typowy:

  • FastAPI
  • PostgreSQL
  • SQLAlchemy 2.0 (async)
  • Redis
  • Docker + Kubernetes
  • Uvicorn + Gunicorn

Jeden z endpointów w uproszczonej formie wyglądał tak:

@router.get("/orders/{order_id}")
async def get_order(order_id: int, db: AsyncSession = Depends(get_db)):
    order = await order_service.get_order(order_id, db)
    return order

Wewnątrz handlera działy się trzy rzeczy:

  • zapytanie do PostgreSQL
  • zapytanie do Redis
  • zapytanie HTTP do zewnętrznego serwisu

I wszystko to przez async. Wydawałoby się, że nie ma tu skąd brać się opóźnieniom.

Objawy pod obciążeniem

Obraz degradacji był bardzo charakterystyczny:

RPS Avg latency P95
50 120 ms 200 ms
100 800 ms 1.6 s
150 3.5 s 5 s

Gdy latency rośnie właśnie w taki schodkowy sposób, a baza danych w tym czasie “milczy”, zwykle warto patrzeć w dwóch kierunkach:

  • gdzieś dobijamy do ograniczenia CPU, choć nie widać tego po ogólnym wykorzystaniu
  • albo istnieje wąskie gardło w event loop, przez które serwis zaczyna działać jak kolejka

Profilowanie

Najpierw uruchomiliśmy py-spy:

py-spy top --pid <pid>

Wynik okazał się zaskakujący:

  • około 40% czasu trafiało w json.dumps
  • około 30% w serializację Pydantic
  • około 20% w przetwarzanie wyników SQLAlchemy

To znaczy, że event loop poświęcał znaczną część czasu nie na bazę i nie na sieć, tylko na budowanie i serializację odpowiedzi.

Problem nr 1: kosztowna serializacja przez Pydantic

Endpoint zwracał dość duży obiekt:

class OrderResponse(BaseModel):
    id: int
    items: list[Item]
    customer: Customer

Niektóre zamówienia miały ponad 200 pozycji. W efekcie dla każdego requestu zachodził taki łańcuch:

obiekty ORM → modele Pydantic → JSON

To wygodne, bezpieczne i estetyczne. Ale kosztowne, szczególnie jeśli odpowiedź jest duża, requestów jest dużo, a serializacja odbywa się przy każdym z nich.

Problem nr 2: klasyczne N+1 w SQLAlchemy

Drugi problem okazał się jeszcze bardziej banalny.

order = await session.get(Order, order_id)
items = order.items

Relacja items ładowała się leniwie. Jeśli zamówienie miało 200 pozycji, bardzo łatwo oznaczało to 200 dodatkowych zapytań SQL.

Przy pojedynczym requestcie takie zachowanie może być mało zauważalne. Ale pod obciążeniem matematyka bardzo szybko staje się nieprzyjemna.

Co działo się przy każdym zapytaniu

W praktyce serwis robił mniej więcej to:

  • 1 zapytanie po Order
  • do 200 zapytań po Items
  • tworzenie setek obiektów Pydantic
  • serializację odpowiedzi do JSON

Osobno każda z tych operacji wyglądała “niewinnie”. Razem zamieniały serwis w kolejkę, a latency uciekało w sekundy.

Co zrobiliśmy

1. Usunęliśmy N+1 przez eager loading

Przenieśliśmy ładowanie powiązanych encji na selectinload:

stmt = (
    select(Order)
    .options(selectinload(Order.items))
    .where(Order.id == order_id)
)

result = await session.execute(stmt)
order = result.scalar_one()

Po tej zmianie schemat wyglądał tak:

  • 1 zapytanie po Order
  • 1 zapytanie po Items

Już samo to mocno odciążyło serwis.

2. Zmniejszyliśmy narzut Pydantic tam, gdzie miało to sens

Było:

return OrderResponse.model_validate(order)

Zmieniliśmy na:

return {
    "id": order.id,
    "items": [{"id": i.id, "price": i.price} for i in order.items],
}

W naszym przypadku niemal dwukrotnie zmniejszyło to latency.

Ważne: nie zrezygnowaliśmy z Pydantic całkowicie. Zostawiliśmy go tam, gdzie naprawdę jest potrzebny, czyli na granicach systemu, do walidacji danych wejściowych i tam, gdzie istotny jest ścisły schemat. Ale przestaliśmy bez potrzeby przepuszczać przez niego w całości duże, zagnieżdżone obiekty.

3. Podłączyliśmy szybszy encoder JSON, czyli orjson

Dodaliśmy ORJSONResponse:

from fastapi.responses import ORJSONResponse

app = FastAPI(default_response_class=ORJSONResponse)

Przy dużych odpowiedziach zysk był bardzo wyraźny: serializacja stała się około 3–5 razy szybsza. Konkretna liczba zależy od struktury danych, ale w naszym przypadku efekt był zdecydowanie odczuwalny.

4. Zrównolegliliśmy zewnętrzne zapytania

Wcześniej zewnętrzne zapytania HTTP były wykonywane sekwencyjnie:

data1 = await client.get(url1)
data2 = await client.get(url2)
data3 = await client.get(url3)

Przerobiliśmy to na wykonanie równoległe:

data1, data2, data3 = await asyncio.gather(
    client.get(url1),
    client.get(url2),
    client.get(url3),
)

Jeśli requesty są od siebie niezależne, daje to bardzo wyraźny zysk w czasie odpowiedzi.

Efekt po poprawkach

RPS Avg latency P95
50 80 ms 120 ms
150 140 ms 220 ms
500 350 ms 600 ms

Po optymalizacjach serwis zaczął wytrzymywać 3–4 razy większe obciążenie.

Wnioski

  1. Async nie znaczy “szybko”. Asynchroniczność pomaga przeczekać I/O, ale nie ratuje przed ciężką serializacją i dodatkową pracą na CPU.
  2. N+1 długo pozostaje niewidoczne. Przy małym obciążeniu taki kod może wyglądać całkiem poprawnie. Pod ruchem bardzo szybko zamienia się w poważne wąskie gardło.
  3. Duże odpowiedzi są kosztowne. Im większy obiekt odpowiedzi, tym wyższy koszt serializacji, walidacji i transformacji.
  4. Najpierw profilowanie, potem optymalizacja. Inaczej łatwo stracić czas na poprawianie nie tego fragmentu systemu, który naprawdę spowalnia całość.

Co sprawdzić, jeśli FastAPI nagle zwolniło

  • czy nie ma blokującego kodu: requests, synchronicznych klientów albo ciężkich operacji w Pythonie
  • czy nie ma N+1 w ORM
  • ile czasu zajmuje serializacja w Pydantic
  • jakiego JSON-encodera używasz
  • czy workers, timeouty i limity serwera są dobrane sensownie

Podsumowanie

Czasem problem nie leży w bazie. Czasem nie leży w sieci. Czasem nie chodzi nawet o zewnętrzne serwisy.

Czasem wszystko sprowadza się do tego, jak dokładnie budujesz i serializujesz odpowiedź. I szczerze mówiąc, to jedno z częstszych i bardziej nieprzyjemnych produkcyjnych zaskoczeń w serwisach pisanych w Pythonie.