angle-uparrow-clockwisearrow-counterclockwisearrow-down-uparrow-leftatcalendarcard-listchatcheckenvelopefolderhouseinfo-circlepencilpeoplepersonperson-fillperson-plusphoneplusquestion-circlesearchtagtrashx

FastAPI + SQLAlchemy: Asynchrone IO en Back Pressure

Ik wilde een asynchrone SQLAlchemy API maar bouwde uiteindelijk een synchrone SQLAlchemy API.

4 juni 2021
post main image
pexels.com

APIs worden steeds belangrijker. Bedrijven willen hun gegevens delen met klanten. Of willen derden in staat stellen toepassingen te maken op basis van hun APIs.

Een paar maanden geleden heb ik een API gemaakt met Flask, SQLAlchemy, Marshmallow en APISpec, het was niet echt moeilijk, en werkt prima. Toen las ik meer over FastAPI, een API framework die ook Python async out of the box ondersteunt. Het is gebaseerd op Starlette, een lichtgewicht ASGI framework voor het bouwen van high performance asyncio services.

Deze post gaat over waarom ik een FastAPI synchrone SQLAlchemy demo API applicatie bouw en niet een asynchrone SQLAlchemy API. Het onderwerp is complex. Hieronder citeer ik enkele teksten van de FastAPI website en Github om u niet het verkeerde verhaal te vertellen.

Als u geïnteresseerd bent, kunt u de demo-applicatie hier bekijken:

https://fastapifriends.peterspython.com

API met asynchrone databasetoegang

In het algemeen zijn asynchrone verzoeken niet sneller dan synchrone verzoeken, ze doen hetzelfde. Een synchroon verzoek stopt de uitvoering van de aanroeper en u moet wachten op de voltooiing van de taak. Bij asynchrone verzoeken wordt het verzoek in een wachtrij geplaatst en enige tijd later verwerkt, je wacht niet op het resultaat. Dit geeft je de mogelijkheid om andere dingen te doen terwijl je wacht op de voltooiing van de taak.

Asynchrone werking en Back Pressure

Ik ben altijd heel voorzichtig geweest met asynchrone operaties. Een triviaal voorbeeld is een webpagina met een knop die een AJAX aanroep gebruikt. Ik stond een nieuw verzoek pas toe als het vorige verzoek was voltooid. Erg conservatief, maar ik wil gewoon problemen voorkomen.

Armin Ronacher, maker van het Flask web framework, schreef een interessant artikel over Back Pressure, zie onderstaande links, graag lezen. In het synchrone geval wordt de toegang tot onze database geblokkeerd als de databasesessies op zijn, nieuwe verzoeken worden geblokkeerd totdat er weer sessies beschikbaar komen.

In het asynchrone geval worden alle verzoeken in een wachtrij geplaatst, wachtend om verwerkt te worden. Wanneer het binnenkomende aantal groter is dan de capaciteit van het systeem om ze te verwerken, zal de server zonder geheugen komen te zitten en crashen. Je kunt dit voorkomen door te kijken naar het maximum aantal verzoeken dat in een wachtrij kan worden geplaatst. Dat klinkt eenvoudig, maar dat is het niet.

Voorkomen van Back Pressure problemen

FastAPI Github issue #857 - @dmontagu schreef:

Hoewel het beter zou zijn als er een eenvoudige manier was om dit in de applicatielaag af te handelen, zelfs als je zeer zware belasting hebt, kun je in de praktijk meestal Back Pressure problemen voorkomen door gewoon een rate-limiting load balancer te gebruiken en een goed auto-scaling beleid. Dit zal niet alle gevallen oplossen, en zal je niet behoeden voor bijzonder slechte ontwerpkeuzes, maar voor 99% van de geïmplementeerde python applicaties zou dit al overkill zijn.

Starlette Github issue #802 - @tomchristie schreef:

Uvicorn staat je momenteel toe om een --limit-concurrency <int> in te stellen die het maximum aantal toegestane taken dat mag draaien hard limiteert voordat 503's worden geretourneerd. In het meest beperkte geval zou je dit gewoon kunnen instellen op basis van het aantal beschikbare database
connecties. Eigenlijk zouden we het daar beter kunnen doen, tho - het zou mooier zijn als Uvicorn verzoeken boven die limiet in een wachtrij zou zetten, en pas na een timeout periode zou reageren met een 503.

Het andere ding dat we zouden kunnen doen dat slim zou zijn, is time-out uitzonderingen standaard op 503 antwoorden. Dus... stel je hebt een resourcelimiet op: databaseverbindingen verkrijgen, verbinding met de cache, uitgaande HTTP-verzoeken verzenden, e-mails verzenden.

Dat soort componenten zou altijd een soort van pool limiet beschikbaar moeten hebben, en zou timeouts moeten genereren als de bron niet kan worden verkregen voor een periode van tijd. Als Starlette is geconfigureerd om die uitzonderingen op 503 responses te mappen, dan krijg je graceful failure gedrag als een server overbelast is.

Synchrone vs asynchrone SQLAlchemy

Ik gebruik SQLAlchemy ORM waar mogelijk. SQLAlchemy 1.4 is onlangs uitgebracht en heeft ondersteuning voor Python asyncio. Maar er zijn beperkingen. Een daarvan is dat u geen lazy loading kunt gebruiken omdat gerelateerde objecten kunnen verlopen en een asyncio exceptie kunnen oproepen. Een manier om hier onderuit te komen is het gebruik van de functie "run_sync()" die het mogelijk maakt om synchrone functies onder asyncio uit te voeren.

Pydantic's orm_mode

Van de FastAPI "SQL (Relational) Databases" pagina:

Zonder orm_mode zou u, indien u een SQLAlchemy model van uw padbewerking retourneerde, de relatiegegevens niet bevatten. Zelfs als u die relaties in uw Pydantic modellen zou declareren. Maar met ORM -modus, aangezien Pydantic zelf zal proberen toegang te krijgen tot de gegevens die het nodig heeft van attributen (in plaats van uit te gaan van een dict), kunt u de specifieke gegevens opgeven die u wilt retourneren en zal het in staat zijn om die te gaan halen, zelfs van ORMs.

Dit zou betekenen dat als u een Pydantic model gebruikt voor de respons, u ook de relatiegegevens zult krijgen. Ik weet niet of dit altijd de relatiegegevens betrouwbaar zal verkrijgen, moet dit proberen.

Ik gebruik (nog) geen asynchrone IO

Kortom, async IO introduceert extra complexiteit en, in mijn geval, zal de performance waarschijnlijk niet verbeteren. Er zijn veel toepassingen die hier baat bij kunnen hebben, maar een standaard database gestuurde API doet dat niet. Waarom zou ik dan nog steeds FastAPI gebruiken? De belangrijkste reden voor mij is dat ik in de nabije toekomst wellicht meer functionaliteit wil toevoegen die kan profiteren van asyncio.

Synchroon SQLAlchemy: één database sessie-object tijdens een verzoek

Ik gebruik de methode en code beschreven in de FastAPI sectie 'Alternatieve DB sessie met middleware': 'De middleware die we zullen toevoegen (gewoon een functie) zal een nieuwe SQLAlchemy SessionLocal aanmaken voor elk verzoek, deze toevoegen aan het verzoek en deze vervolgens sluiten zodra het verzoek is voltooid.'

De code die in dit gedeelte wordt getoond is:

@app.middleware("http")
async def db_session_middleware(request: Request, call_next):
    response = Response("Internal server error", status_code=500)
    try:
        request.state.db = SessionLocal()
        response = await call_next(request)
    finally:
        request.state.db.close()
    return response


def get_db(request: Request):
    return request.state.db

Alle verzoeken krijgen een databasesessieobject toegewezen. Als we ooit enkele asynchrone functie(s) willen toevoegen, kunnen we deze routes uitsluiten van db_session_middleware().

Afhankelijkheden die databaseobjecten teruggeven

Indien u synchrone SQLAlchemy gebruikt en Dependency Injection gebruikt dat een databaseobject teruggeeft, MOET u een databasesessie-object gebruiken dat de hele aanvraag geldig is. Anders kan het opgehaalde object verlopen. Dat klinkt voor de hand liggend, maar de FastAPI documentatie is hier niet erg duidelijk over. Stel bijvoorbeeld dat we een functie "read_items()" hebben met een afhankelijkheid get_current_user():

def get_current_user(
    db: Session = Depends(get_db)):
):
    # get  User  object by  user_id in JWT
    ...
    return db_user


@app.get("/items")
def read_items(
        db_user: schemas.User  = Depends(get_current_user)
        db: Session = Depends(get_db),
):
    # do something
    ...
    items = crud.get_items(db, db_user)
    return  users

Veronderstel dat wij de functie db_session_middleware() niet gebruiken zoals hierboven vermeld, maar in plaats daarvan zouden gebruiken:

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

In dit geval kunnen wij meerdere databasesessies hebben tijdens een verzoek en kan het object db_user vervallen in de functie read_items() , omdat de databasesessie wordt gesloten na het verwerken van get_current_user().

Als we asynchroon SQLAlchemy zouden gebruiken, wordt het geretourneerde db_user object losgekoppeld van de sessie en blijft het beschikbaar. Tenminste, dat is wat ik zou verwachten.

Samenvatting

Ik wilde een API maken op basis van FastAPI en SQLAlchemy en PostgreSQL. Het eerste waar ik mee werd geconfronteerd was de vraag of ik synchrone of asynchrone SQLAlchemy moest gebruiken. Ik wist al dat asynchrone operaties problemen kunnen veroorzaken als ze niet goed worden uitgevoerd. Het was informatief om te lezen wat anderen zeiden over asynchrone IO (en FastAPI). Om problemen te voorkomen besloot ik om eerst voor synchrone SQLAlchemy te gaan.

Links / credits

Alternative DB session with middleware
https://fastapi.tiangolo.com/tutorial/sql-databases/

Asynchronous I/O (asyncio)
https://docs.sqlalchemy.org/en/14/orm/extensions/asyncio.html

Asynchronous Python and Databases
https://techspot.zzzeek.org/2015/02/15/asynchronous-python-and-databases/

asyncio support for SQLAlchemy (and Flask, and any other blocking-IO library)
https://gist.github.com/zzzeek/2a8d94b03e46b8676a063a32f78140f1

Back pressure? #802
https://github.com/encode/starlette/issues/802#issuecomment-574606003

Backpressure explained — the resisted flow of data through software
https://medium.com/@jayphelps/backpressure-explained-the-flow-of-data-through-software-2350b3e77ce7

I'm not feeling the async pressure
https://lucumr.pocoo.org/2020/1/1/async-pressure/

Starlette
https://www.starlette.io/

Laat een reactie achter

Reageer anoniem of log in om commentaar te geven.

Opmerkingen

Laat een antwoord achter

Antwoord anoniem of log in om te antwoorden.